diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4297c9b..f89e576 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -79,3 +79,23 @@ jobs:
rustc --version | grep -q "^rustc ${RUSTUP_TOOLCHAIN}[. ]" \
|| { echo "::error::expected rustc ${RUSTUP_TOOLCHAIN}, got $(rustc --version)"; exit 1; }
cargo check --locked --all-targets
+
+ i18n:
+ name: translations up to date
+ runs-on: ubuntu-latest
+ steps:
+ # No `fetch-depth` needed, and that is the point: the check compares the
+ # BLOB HASH of each English source against the stamp its translations
+ # carry, which is computable from the working tree alone. The obvious
+ # alternative, `git log` on the source, reports a single commit on the
+ # depth-1 clone Actions makes by default, so every comparison would come
+ # out equal and this job would pass green forever while translations rot.
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Check every translation against its English source
+ run: ./scripts/i18n-check.sh
+
+ - name: Check every link and anchor resolves
+ run: ./scripts/i18n-links.py
diff --git a/README.md b/README.md
index f1a5b4e..eda50a5 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,18 @@ already sets it, and removes every key from `config.toml`.
- [docs/filebot-parity.md](docs/filebot-parity.md): which FileBot features
KayaBot has today and which it does not.
+## Language
+
+This README and the pages above are also in French. **English is canonical**:
+when a translation disagrees with it, the English file is right.
+
+- **Français** - [README](docs/i18n/fr/README.md) · [architecture](docs/i18n/fr/docs/architecture.md) · [metadata sources](docs/i18n/fr/docs/metadata-sources.md) · [FileBot parity](docs/i18n/fr/docs/filebot-parity.md)
+
+Each translation carries the hash of the English file it was made from, and CI
+fails when the English moves ahead: see [`scripts/i18n-check.sh`](scripts/i18n-check.sh).
+`docs/i18n/fr/` mirrors the repository root, so `docs/i18n/fr/docs/architecture.md`
+is the French `docs/architecture.md`.
+
## Code signing policy
Every release asset is signed by the Project Colony organisation in CI, never on
diff --git a/docs/architecture.md b/docs/architecture.md
index 6f3f94c..bb161dc 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -107,8 +107,8 @@ When a token has no value, it is dropped together with the separators around
it, so a movie without a year becomes `Title`, not `Title ()`. Titles are
cleaned before use: every character that is not a letter or a digit becomes a
space (so `Grey's` becomes `Grey s`, and no character a file name cannot hold
-survives), technical tags (`1080p`, `BluRay` and so on) are removed, and runs
-of spaces collapse to one.
+survives), technical tags (`1080p`, `BluRay`, `VF`, `VOSTFR` and so on) are
+removed, and runs of spaces collapse to one.
## Rename engine
diff --git a/docs/filebot-parity.md b/docs/filebot-parity.md
index 0e59358..4001428 100644
--- a/docs/filebot-parity.md
+++ b/docs/filebot-parity.md
@@ -15,8 +15,10 @@ version exists. **Not yet** means there is no code for it.
| Keep the extension | **Has it** |
| Handle name collisions | **Has it.** Never overwrites; a taken name gets ` (1)`, ` (2)` and so on, and two files of a batch never collide |
| Preview and dry run | **Has it.** Every new name is shown first, and a dry run renames nothing |
-| Move files into series, season or movie folders | **Not yet.** Files are renamed in their own folder |
+| Move files into series, season, movie or collection folders | **Not yet.** Files are renamed in their own folder |
| Rename subtitles with their video | **Not yet** |
+| Normalise subtitle language suffixes (`.fr`, `.en`, `.multi`) | **Not yet** |
+| Download subtitles | **Not yet** |
| Write `.nfo` files or file tags | **Not yet** |
| Check SFV or other checksums | **Not yet** |
diff --git a/docs/i18n/fr/README.md b/docs/i18n/fr/README.md
new file mode 100644
index 0000000..1376345
--- /dev/null
+++ b/docs/i18n/fr/README.md
@@ -0,0 +1,289 @@
+
+
+
+
+**Un renommeur de médias de bureau pour les bibliothèques de films et de séries : il cherche vos fichiers dans TMDB, TheTVDB, TVmaze et OMDb et leur donne des noms propres et cohérents.**
+
+
+
+[](../../../LICENSE)
+[](https://github.com/Project-Colony/Colony)
+[](#installation)
+
+Les épisodes et films téléchargés arrivent avec des noms comme
+`show.name.s01e02.1080p.web.x264-grp.mkv`. Un renommeur de masse peut
+nettoyer la ponctuation, mais il ne connaît pas le titre de l'épisode, et
+FileBot, l'outil habituel pour ce travail, demande une licence payante.
+KayaBot analyse les noms de fichiers, interroge plusieurs bases de données en
+ligne à la fois et affiche chaque nouveau nom avant de toucher au moindre
+fichier.
+
+> **État :** prototype précoce, et aucune version n'a encore été publiée. La
+> vue Renommer fonctionne de bout en bout : analyse, correspondance avec TMDB,
+> TheTVDB, TVmaze et OMDb, aperçu, simulation avec une confirmation avant un
+> vrai renommage, le renommage lui-même et un résumé en CSV ou JSON.
+> L'interface est construite avec iced et le système de design Colony, en
+> anglais et en français ; les Préférences regroupent le thème,
+> l'accessibilité, la langue, les clés d'API et les options de renommage.
+> L'analyseur, les modèles de noms, le moteur de renommage, les quatre clients
+> de sources et la gestion d'état de l'interface ont des tests automatisés,
+> les clients face à des réponses enregistrées ; l'apparence de la fenêtre
+> n'est vérifiée qu'à la main.
+
+## Pourquoi KayaBot
+
+- **Un renommeur de masse ou une regex** range la ponctuation, mais vous
+ tapez encore chaque titre d'épisode. KayaBot récupère les titres et l'année.
+- **FileBot** demande une licence payante. KayaBot est un logiciel libre sous
+ GPL-3.0-or-later.
+- **Aucune base de données n'est jamais complète.** KayaBot interroge
+ plusieurs sources en parallèle et fusionne leurs réponses par identifiants
+ communs (IMDb, TMDB, TheTVDB, TVmaze) et similarité des titres : une série
+ absente d'une source est quand même trouvée.
+- **Le renommage est l'étape risquée.** KayaBot n'écrase jamais un fichier :
+ une simulation montre d'abord chaque résultat, et un nom déjà pris reçoit un
+ suffixe numéroté.
+
+```
+show.name.s01e02.1080p.web.x264-grp.mkv
+ | analyse : "show name", saison 1, épisode 2
+ v
+TheTVDB + TVmaze (séries) ou TMDB + OMDb (films), interrogés en parallèle
+ | meilleur titre, puis sa liste d'épisodes
+ v
+Show Name 1x2 - Episode Title.mkv aperçu, simulation, renommage
+```
+
+## Ce qu'il fait
+
+- Analyse les noms de fichiers : `S01E02`, `S01 E02`, `1x02`, `102`,
+ `Season 1 Episode 2` (ou `Saison`), une année de sortie, et retire les tags
+ de qualité, de codec, d'audio et de groupe de diffusion.
+- Devine si chaque fichier est un épisode ou un film. Vous pouvez changer le
+ type.
+- Cherche les séries dans TheTVDB et TVmaze (TMDB sur demande) et les films
+ dans TMDB et OMDb. La source choisie et son partenaire sont interrogés en
+ parallèle ; l'option « Utiliser cette source uniquement » limite une
+ recherche à la source choisie. Les recherches tournent en arrière-plan : la
+ fenêtre reste réactive.
+- Classe les résultats fusionnés selon la confiance accordée à la source, la
+ similarité des titres et la note propre à la source, puis choisit l'épisode
+ par saison et numéro.
+- Construit les noms à partir de modèles. Par défaut, `{n} {s}x{e} - {t}` pour
+ les épisodes et `{n} ({y})` pour les films ; des modèles prédéfinis et
+ personnalisés sont disponibles, et une valeur manquante disparaît avec son
+ séparateur.
+- Affiche chaque nouveau nom, propose une simulation, demande confirmation
+ avant un vrai renommage (vous pouvez le désactiver) et renomme sur place. Il
+ conserve l'extension, n'écrase jamais un fichier (un nom pris reçoit ` (1)`,
+ ` (2)` et ainsi de suite) et gère les renommages qui ne changent que la
+ casse.
+- Exporte le résultat d'un passage en CSV ou en JSON.
+
+## Installation
+
+### Via Colony (recommandé)
+
+Cherchez **KayaBot** dans [Colony](https://github.com/Project-Colony/Colony) et
+installez-le. Les mises à jour arrivent par le lanceur. KayaBot y apparaîtra
+dès la publication de sa première version.
+
+### Téléchargement direct du binaire
+
+Aucune version n'a encore été publiée. Chaque version portera ces fichiers sur
+la page de la [dernière version](https://github.com/Project-Colony/KayaBot/releases/latest) :
+
+| Plateforme | Fichier |
+|---|---|
+| Linux | `kayabot-linux` |
+| Windows | `kayabot-windows.exe` |
+| macOS (Apple Silicon) | `kayabot-macos` |
+| macOS (Intel) | `kayabot-macos-x86` |
+
+```bash
+chmod +x kayabot-linux && ./kayabot-linux
+```
+
+### Compiler depuis les sources
+
+```bash
+git clone https://github.com/Project-Colony/KayaBot
+cd KayaBot
+cargo build --release
+./target/release/kayabot
+```
+
+Nécessite Rust 1.88 ou plus récent. `kayabot --version` affiche la version et
+quitte sans ouvrir de fenêtre.
+
+## Configuration
+
+KayaBot garde ses fichiers dans un seul dossier :
+
+| Plateforme | Dossier |
+|---|---|
+| Linux | `~/.config/Colony/KayaBot/` |
+| Windows | `%LOCALAPPDATA%\Colony\KayaBot\` |
+| macOS | `~/Library/Application Support/Colony/KayaBot/` |
+
+| Fichier | Contenu |
+|---|---|
+| `preferences.json` | les choix faits dans les Préférences, enregistrés au fur et à mesure |
+| `api_keys.toml` | vos clés d'API, saisies dans les Préférences, rubrique Connexions, ou écrites à la main |
+| `format_options.toml` | les modèles de noms, et si les noms portent l'année et le titre de l'épisode |
+
+Les anciennes versions gardaient ces fichiers dans `~/.kayabot/` et, sous
+Windows, dans le profil itinérant, `%APPDATA%\Colony\KayaBot\`. Tant que le
+dossier ci-dessus est vide, le premier démarrage les y copie et laisse un
+marqueur `.migrated`. L'ancien dossier reste tel quel, à supprimer à la main ;
+si la copie échoue, KayaBot continue d'utiliser l'ancien dossier et réessaie
+au démarrage suivant. Un `preferences.toml` d'une ancienne version est lu une
+fois et enregistré en `preferences.json`.
+
+TVmaze n'a pas besoin de clé. TMDB accepte soit un jeton d'accès en lecture
+v4, soit une clé API v3, et TheTVDB et OMDb ont chacun besoin de leur propre
+clé :
+
+```toml
+tmdb_bearer_token = "..." # ou : tmdb_api_key = "..."
+tvdb_api_key = "..."
+omdb_api_key = "..."
+tvmaze_user_agent = "KayaBot"
+```
+
+Les variables d'environnement ont priorité sur le fichier. Elles sont listées
+dans [`.env.example`](../../../.env.example) : `KAYABOT_TMDB_BEARER_TOKEN` (ou
+`KAYABOT_TMDB_API_KEY`), `KAYABOT_TVDB_API_KEY`, `KAYABOT_OMDB_API_KEY` et
+`KAYABOT_TVMAZE_USER_AGENT`. `KAYABOT_TVMAZE_API_KEY` est un ancien nom de
+l'agent utilisateur TVmaze, lu seulement quand `KAYABOT_TVMAZE_USER_AGENT`
+n'est pas défini ; TVmaze n'a pas de clé d'API.
+
+Les anciennes versions lisaient aussi les clés dans `config.toml`, dans le
+même dossier. Au démarrage, KayaBot déplace chaque clé qu'il y trouve vers
+`api_keys.toml`, sauf si ce fichier la définit déjà, et retire toutes les clés
+de `config.toml`.
+
+## Documentation
+
+- [docs/architecture.md](docs/architecture.md) : l'organisation du code et le
+ chemin d'un fichier de son ancien nom à son nouveau nom.
+- [docs/metadata-sources.md](docs/metadata-sources.md) : ce que couvre chaque
+ source de métadonnées, comment KayaBot s'authentifie et ce qu'il envoie.
+- [docs/filebot-parity.md](docs/filebot-parity.md) : les fonctionnalités de
+ FileBot que KayaBot a aujourd'hui et celles qu'il n'a pas.
+
+## Langue
+
+Ce README et les pages ci-dessus existent aussi en français. **L'anglais fait
+foi** : quand une traduction le contredit, c'est le fichier anglais qui a
+raison.
+
+- **Français** - [README](README.md) · [architecture](docs/architecture.md) · [sources de métadonnées](docs/metadata-sources.md) · [parité avec FileBot](docs/filebot-parity.md)
+
+Chaque traduction porte l'empreinte du fichier anglais dont elle est issue, et
+la CI échoue quand l'anglais avance : voir [`scripts/i18n-check.sh`](../../../scripts/i18n-check.sh).
+`docs/i18n/fr/` reproduit la racine du dépôt : `docs/i18n/fr/docs/architecture.md`
+est la version française de `docs/architecture.md`.
+
+## Politique de signature du code
+
+Chaque fichier de version est signé par l'organisation Project Colony dans la
+CI, jamais sur la machine d'un développeur. À côté de chaque fichier, sur la
+page de la version :
+
+| Fichier | Ce que c'est |
+|---|---|
+| `.sig` | une signature ed25519 du fichier, faite avec la clé de publication de l'organisation |
+| `.meta` | trois lignes qui lient le fichier à son nom, à son sha256 et au numéro de version |
+| `.meta.sig` | une signature ed25519 du `.meta` |
+
+La clé privée est un secret de l'organisation, utilisé uniquement par le
+[workflow sign-and-publish](https://github.com/Project-Colony/Project-Colony-Resources/blob/main/.github/workflows/sign-and-publish.yml)
+partagé, dans un job qui ne compile rien ; les jobs qui compilent KayaBot ne
+la voient jamais. Colony vérifie les trois fichiers avant d'installer ou de
+mettre à jour KayaBot, et refuse une version plus ancienne que celle qui est
+installée. Pour vérifier vous-même un téléchargement avec OpenSSL 3 (les mêmes
+commandes valent pour chaque fichier) :
+
+```bash
+cat > colony-release.pub <<'EOF'
+-----BEGIN PUBLIC KEY-----
+MCowBQYDK2VwAyEARNjg3Nn8H6/aBg1unwGjkUTcrdTxERNefVaqU8cFu0s=
+-----END PUBLIC KEY-----
+EOF
+a=kayabot-linux
+openssl pkeyutl -verify -pubin -inkey colony-release.pub -rawin -in "$a" -sigfile "$a.sig"
+openssl pkeyutl -verify -pubin -inkey colony-release.pub -rawin -in "$a.meta" -sigfile "$a.meta.sig"
+cat "$a.meta" # version=, asset=, sha256=
+sha256sum "$a" # le condensé doit être égal à la ligne sha256
+```
+
+Comment les versions sont compilées, signées et publiées :
+[design/releases.md](https://github.com/Project-Colony/Project-Colony-Resources/blob/main/design/releases.md#5-signing)
+(en anglais).
+
+
+
+## Confidentialité
+
+KayaBot n'envoie ni télémétrie, ni statistiques d'usage, ni rapports de
+plantage, et il n'a ni compte ni vérification de mises à jour.
+
+| Donnée | Conservée ou envoyée | Où, et pourquoi |
+|---|---|---|
+| Préférences | conservées | `preferences.json` et `format_options.toml` dans le dossier indiqué sous [Configuration](#configuration) |
+| Fichiers des anciennes versions | copiés | une fois, depuis `~/.kayabot/` ou le profil itinérant de Windows vers le dossier indiqué sous [Configuration](#configuration). Rien ne quitte l'ordinateur, et les anciennes copies, clés d'API comprises, restent où elles étaient jusqu'à ce que vous les supprimiez |
+| Clés d'API | conservées | `api_keys.toml` dans le même dossier, en clair. Sous Linux et macOS, KayaBot l'écrit lisible par votre seul utilisateur (mode 0600) et resserre à ce mode un fichier existant quand il le lit. Les clés fournies par variables d'environnement ne sont jamais écrites sur le disque |
+| Clés laissées dans `config.toml` par les anciennes versions | déplacées | vers `api_keys.toml` au démarrage, puis retirées de `config.toml` |
+| Titres à rechercher | envoyés | quand vous lancez une association : le titre tiré du nom de fichier, ou celui que vous avez saisi, plus l'année pour un film, aux sources actives ci-dessous. Le nom du fichier, son dossier et son contenu ne sont jamais envoyés, pas plus que les numéros de saison ou d'épisode : KayaBot télécharge la liste des épisodes de la série et choisit l'épisode localement |
+| Identifiants de titres | envoyés | l'identifiant de la série ou du film choisi, à la source qui l'a renvoyé, pour récupérer sa liste d'épisodes ou ses détails |
+| Langue et région des métadonnées | envoyées | à TMDB (en `language` et `region`, `fr-FR` quand les Préférences laissent les deux vides) et à TheTVDB |
+| Clé TMDB | envoyée | à `api.themoviedb.org` uniquement : un jeton v4 dans l'en-tête `Authorization`, une clé v3 en paramètre `api_key` dans l'URL de la requête |
+| Clé TheTVDB | envoyée | à `api4.thetvdb.com` uniquement, dans le corps de sa requête de connexion ; le jeton de session qu'il renvoie est gardé en mémoire et jamais enregistré |
+| Clé OMDb | envoyée | à `www.omdbapi.com` uniquement, en paramètre `apikey` dans l'URL de la requête |
+| Agent utilisateur | envoyé | à `api.tvmaze.com`, `KayaBot` sauf si vous définissez `tvmaze_user_agent`. TVmaze n'a pas besoin de clé : il est donc interrogé pour les séries sans aucune configuration |
+| Résumé du renommage | conservé | seulement quand vous l'exportez, en CSV ou JSON, au chemin que vous choisissez |
+| Vos fichiers médias | renommés | sur place, dans leur propre dossier. Rien n'est téléversé |
+
+KayaBot ne garde aucun cache de métadonnées : chaque association interroge de
+nouveau les sources. Il ne se connecte à aucun serveur en dehors des quatre
+ci-dessus, et seulement quand vous lancez une association. Une source sans clé
+est ignorée sans aucune requête. Les clés envoyées dans une URL (la clé OMDb
+et une clé TMDB v3) peuvent apparaître dans les journaux d'un proxy situé
+entre vous et le service ; un jeton TMDB v4 évite cela. Comme toute requête
+HTTPS, chacune montre aussi votre adresse IP au service.
+
+### Services tiers
+
+Chaque service traite ce qu'il reçoit selon sa propre politique :
+
+- **TMDB** : [politique de confidentialité](https://www.themoviedb.org/privacy-policy).
+ Ce produit utilise l'API TMDB mais n'est ni approuvé ni certifié par
+ [TMDB](https://www.themoviedb.org/)
+ ([règles de logo et d'attribution](https://www.themoviedb.org/about/logos-attribution)).
+- **TheTVDB** : [politique de confidentialité](https://thetvdb.com/privacy-policy).
+ Métadonnées fournies par [TheTVDB](https://thetvdb.com/). Pensez à y ajouter
+ les informations manquantes ou à vous abonner.
+- **TVmaze** : [politique de confidentialité](https://www.tvmaze.com/site/privacy).
+ Données TV de [TVmaze](https://www.tvmaze.com/), sous licence
+ [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
+- **OMDb** : [conditions](https://www.omdbapi.com/legal.htm). Données de films
+ de l'[API OMDb](https://www.omdbapi.com/), sous licence
+ [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/). OMDb n'est
+ ni approuvé par IMDb.com ni affilié à IMDb.com.
+
+## Licence
+
+GPL-3.0-or-later. Voir [LICENSE](../../../LICENSE).
diff --git a/docs/i18n/fr/docs/architecture.md b/docs/i18n/fr/docs/architecture.md
new file mode 100644
index 0000000..b15d1a8
--- /dev/null
+++ b/docs/i18n/fr/docs/architecture.md
@@ -0,0 +1,193 @@
+
+
+# Architecture
+
+KayaBot est une seule crate Rust, avec une bibliothèque (`kayabot`,
+`src/lib.rs`) et un programme de bureau (`src/main.rs`). La bibliothèque
+contient tout, interface comprise ; `main.rs` se contente de répondre à
+`--version`, de choisir le dossier de configuration et d'ouvrir la fenêtre.
+
+## Organisation du code
+
+| Chemin | Rôle |
+|---|---|
+| `src/main.rs` | le point d'entrée : `--version`, le dossier de configuration, puis `ui::run` |
+| `src/ui/mod.rs` | `run` : applique les préférences, enregistre les polices et démarre iced |
+| `src/ui/app.rs` | `App`, chaque `Message` qu'il traite, le cadre autour des vues, le dépôt de fichiers et les raccourcis clavier |
+| `src/ui/sidebar.rs` | la barre latérale : le bouton KayaBot qui ouvre les Préférences, puis l'entrée Renommer |
+| `src/ui/preferences.rs` | la page Préférences : Général, Apparence et Accessibilité venant de colony-ui, puis Connexions, Renommage et À propos |
+| `src/ui/rename/mod.rs` | l'état et les messages de la vue Renommer, le renommage lui-même et l'aperçu des noms |
+| `src/ui/rename/files.rs`, `preview.rs`, `details.rs`, `summary.rs` | la liste des fichiers, les noms proposés avec les options de recherche et de nommage, la fenêtre de correspondance, et le résumé avec son export et la fenêtre de confirmation |
+| `src/ui/widgets.rs` | les boutons, cartes, champs et fenêtres propres à KayaBot, stylés d'après la palette, et ses glyphes Nerd Font |
+| `src/i18n/` | `t` et `tf`, avec `en.json` et `fr.json` ; le squelette des Préférences et les libellés des thèmes viennent de `colony_ui::i18n` |
+| `src/config.rs` | `Config`, enregistré dans `preferences.json`, et la conversion de l'ancien `preferences.toml` |
+| `src/api_keys.rs` | les clés venant de l'environnement et de `api_keys.toml`, `write_private_file` et `migrate_config_credentials` |
+| `src/lookup.rs` | quelles sources une recherche interroge, ce qu'elle leur demande, et les recherches que l'interface lance hors de son fil d'exécution |
+| `src/export.rs` | le résumé du renommage en CSV ou JSON |
+| `src/paths.rs` | le dossier de configuration, venant de `colony_ui::paths`, et la copie unique depuis les anciens emplacements |
+| `src/matching/parsing.rs` | `parse_filename` : découpe un nom de fichier en jetons et en tire un titre, une saison et un épisode, et une année |
+| `src/matching/mod.rs` | `match_files` : transforme chaque nom analysé en un `MatchResult` avec ses candidats (épisode, film ou série), un statut et une confiance |
+| `src/matching/advanced.rs` | `normalize_name` et `rank_candidates`, un score Jaro-Winkler et Levenshtein. La chaîne de traitement s'en sert pour la similarité de titre de chaque résultat |
+| `src/metadata/provider.rs` | le trait `MetadataProvider` (`search_title`, `fetch_episode_list`, `fetch_movie_details`) et l'énumération `MetadataSource` |
+| `src/metadata/providers/` | un client par source : `tmdb.rs`, `thetvdb.rs`, `tvmaze.rs`, `omdb.rs` |
+| `src/metadata/aggregate.rs` | `MetadataPipeline` : interroge les sources actives en parallèle, fusionne et classe leurs réponses |
+| `src/metadata/models.rs` | `TitleMatch`, `EpisodeMatch`, `MovieMatch` et les `ExternalIds` et informations complémentaires qu'ils portent |
+| `src/metadata/locale.rs` | `MetadataLocale`, la langue et la région envoyées aux sources |
+| `src/metadata/error.rs` | `MetadataError`, les erreurs qu'un client signale à la fenêtre |
+| `src/formatting.rs` | les modèles de noms, le nettoyage des titres et `format_options.toml` |
+| `src/rename.rs` | le moteur de renommage |
+
+## L'interface
+
+La fenêtre est construite avec [iced](https://iced.rs) 0.14 et
+[colony-ui](https://crates.io/crates/colony-ui), le système de design que
+partagent tous les programmes Colony. Les couleurs viennent de la palette
+Colony active, le texte des trois polices Colony via `Typography::current()`
+(ainsi la police pour la dyslexie et les deux tailles de texte atteignent
+chaque widget), et les tailles passent par `sz()`. Les widgets intégrés d'iced
+reçoivent la palette via `App::theme`.
+
+`App` suit le modèle d'iced : `update` reçoit un `Message` et modifie l'état,
+`view` le dessine. La vue Renommer est un composant à part entière
+(`ui::rename::Rename`) avec son propre `Message`, enveloppé dans
+`Message::Rename`. Les Préférences remplacent la zone de contenu, jamais une
+fenêtre ni une boîte de dialogue, et le bouton KayaBot comme le bouton Fermer
+de la page envoient le même `Message::TogglePreferences`. Une modification
+dans les Préférences s'applique tout de suite et est enregistrée ; il n'y a
+pas de bouton Enregistrer.
+
+## D'un ancien nom à un nouveau
+
+1. **Ajouter des fichiers.** La vue Renommer accepte des fichiers ou un
+ dossier choisis dans la boîte de dialogue du système (`rfd`), ou des
+ fichiers et dossiers déposés sur la fenêtre.
+2. **Analyser.** `matching::match_files` lit chaque nom. `S01E02`, `S01 E02`,
+ `1x02`, un nombre à trois chiffres `102` et `Season 1 Episode 2` (ou
+ `Saison`) donnent une saison et un épisode ; un nombre à quatre chiffres de
+ 1900 à 2099 donne une année. Les tags de qualité, de codec, d'audio et de
+ groupe de diffusion sont retirés du titre. Une saison et un épisode font du
+ fichier un épisode ; une année sans eux en fait un film. Vous pouvez changer
+ le type dans la vue.
+3. **Choisir les sources.** Par défaut, les séries utilisent TheTVDB avec
+ TVmaze pour partenaire, et les films TMDB avec OMDb. La liste active est la
+ source choisie plus ses partenaires (TMDB choisi pour une série amène à la
+ fois TheTVDB et TVmaze). La case « Utiliser cette source uniquement » ne
+ garde que la source choisie.
+4. **Chercher.** `MetadataPipeline::search_title` envoie le titre (plus
+ l'année pour un film) à toutes les sources actives en même temps, sur des
+ threads à portée limitée (scoped threads), et attend qu'elles aient toutes
+ répondu. Une source qui échoue ou n'a pas de clé est écartée ; la recherche
+ n'échoue que si toutes les sources échouent.
+5. **Fusionner et classer.** Les résultats qui partagent un identifiant externe
+ (IMDb, TMDB, TheTVDB, TVmaze, OMDb), ou dont les titres sont similaires à au
+ moins 0.9 avec des années compatibles, ne forment qu'une entrée. Chaque
+ entrée reçoit le score
+ `0.55 * trust + 0.35 * title similarity + 0.10 * source rating` (confiance,
+ similarité du titre, note de la source), où la confiance vaut 0.9 pour TMDB
+ et TheTVDB, 0.75 pour TVmaze et 0.7 pour OMDb.
+6. **Épisode.** Pour un épisode, la liste des épisodes du meilleur titre est
+ récupérée auprès de la source qui l'a renvoyé, et l'entrée qui porte la
+ saison et le numéro analysés est retenue.
+7. **Mettre en forme.** `formatting` applique le modèle à chaque fichier (voir
+ plus bas).
+8. **Aperçu, simulation, renommage.** `rename::apply` construit la cible à
+ côté de l'original et conserve l'extension. En mode simulation, il résout le
+ nom et ne touche à rien ; sinon, une fois que l'utilisateur a confirmé (sauf
+ si c'est désactivé dans les Préférences), il renomme, puis la liste se
+ recharge à partir des nouveaux noms. Le résultat de chaque fichier alimente
+ le résumé, qui peut être exporté.
+
+Les clients des sources utilisent du HTTP bloquant avec un délai d'expiration
+de 10 secondes. L'interface ne les appelle jamais elle-même : `Rename::run`
+confie la recherche à un thread de travail et en reçoit la réponse sous forme
+de message, et Associer et Rechercher restent désactivés pendant ce temps. Une
+association lancée avant que la liste de fichiers ne change est abandonnée à
+son arrivée.
+
+## Modèles de noms
+
+| Jeton | Valeur |
+|---|---|
+| `{n}` | le titre de la série ou du film (aussi `{name}`, `{title}`) |
+| `{t}` | le titre de l'épisode (aussi `{episodetitle}`) |
+| `{s}` | le numéro de saison (aussi `{season}`) |
+| `{e}` | le numéro d'épisode (aussi `{episode}`) |
+| `{y}` | l'année (aussi `{year}`) |
+
+Des chiffres après un jeton complètent le nombre par des zéros : `{s00}` donne
+`01`. Les modèles par défaut sont `{n} {s}x{e} - {t}` pour les épisodes et
+`{n} ({y})` pour les films ; désactiver le titre de l'épisode ou l'année dans
+la vue les fait passer à `{n} {s}x{e}` et `{n}`. Quand un jeton n'a pas de
+valeur, il disparaît avec les séparateurs qui l'entourent : un film sans année
+devient `Title`, pas `Title ()`. Les titres sont nettoyés avant usage : tout
+caractère qui n'est ni une lettre ni un chiffre devient une espace (ainsi
+`Grey's` devient `Grey s`, et aucun caractère interdit dans un nom de fichier
+ne subsiste), les tags techniques (`1080p`, `BluRay`, `VF`, `VOSTFR`, etc.)
+sont supprimés, et les espaces multiples sont réduites à une seule.
+
+## Moteur de renommage
+
+`src/rename.rs` construit le chemin cible à côté de l'original, choisit le
+premier nom libre (` (1)`, ` (2)` et ainsi de suite, jusqu'à 9999) et renomme
+le fichier. Il n'écrase jamais un fichier existant, y compris un lien physique
+vers l'original. Deux fichiers d'un même lot ne reçoivent jamais des noms qui
+ne diffèrent que par la casse. Un renommage qui ne change que la casse passe
+par un nom temporaire ; un déplacement entre deux périphériques copie dans un
+nouveau fichier, le synchronise sur le disque, puis supprime l'original.
+
+## Fichiers de configuration
+
+`paths::config_dir` vaut `colony_ui::paths::config_dir("KayaBot")` :
+`~/.config/Colony/KayaBot` sous Linux, `%LOCALAPPDATA%\Colony\KayaBot` sous
+Windows et `~/Library/Application Support/Colony/KayaBot` sous macOS.
+
+Avant toute lecture, `paths::init` vérifie ce dossier. S'il est vide et qu'un
+ancien emplacement contient des fichiers (d'abord le profil itinérant de
+Windows, `%APPDATA%\Colony\KayaBot`, puis `~/.kayabot`), il les copie et écrit
+un marqueur `.migrated`. L'ancien dossier n'est jamais modifié. Une copie qui
+échoue est annulée, et l'ancien dossier est utilisé pour cette exécution.
+
+- `preferences.json` : `Config`, c'est-à-dire les `StandardPreferences` de
+ colony-ui (thème, couleur d'accent, tailles de texte, contraste élevé,
+ police pour la dyslexie, animations réduites), plus la langue de
+ l'interface, la langue et la région des métadonnées, les deux confirmations
+ et le mode simulation par défaut. Chaque champ est lu séparément : une valeur
+ invalide ne réinitialise que ce champ. Écrit à chaque changement de
+ préférence. S'il manque, le `preferences.toml` d'une ancienne version, à plat
+ ou en sections, est lu une fois et enregistré au nouveau format ; ses
+ réglages qui n'ont jamais rien fait sont abandonnés.
+- `api_keys.toml` : lu au démarrage ; écrit au fil de la saisie des clés dans
+ Préférences, Connexions, via `write_private_file`, qui écrit un fichier
+ temporaire avec le mode 0600 sous Unix et le renomme à sa place. Les
+ variables d'environnement priment sur chaque clé.
+- `format_options.toml` : les modèles de noms et les choix concernant l'année
+ et le titre de l'épisode.
+- `config.toml` : seulement pour les anciennes versions. Avant l'ouverture de
+ la fenêtre, `migrate_config_credentials` déplace ses clés dans
+ `api_keys.toml` et les en retire, identifiants AniDB compris.
+
+## Tests
+
+- Les tests unitaires se trouvent à côté du code : les modèles, le moteur de
+ renommage, la chaîne de traitement, chaque client de source face aux
+ réponses enregistrées dans `tests/fixtures/`, la migration des clés et les
+ écritures de fichiers privés, l'export, les préférences et leur conversion,
+ la copie depuis les anciens dossiers, et la fonction `update` de l'interface
+ avec une source simulée (`ui/app.rs`, `ui/rename/tests.rs`). Les tests
+ unitaires écrivent dans un dossier temporaire, jamais dans le vrai profil.
+- `i18n` vérifie que `en.json` et `fr.json` ont les mêmes clés et les mêmes
+ paramètres, et que chaque clé demandée par l'interface existe ; `ui/widgets.rs`
+ vérifie que chaque glyphe existe dans la Nerd Font.
+- `tests/matching_snapshots.rs` passe l'analyseur sur un épisode, un film et
+ un titre seul et compare le résultat à un instantané `insta`. Les fichiers
+ vides de `tests/fixtures/media_samples` alimentent les tests du renommage et
+ de la vue Renommer.
+- `tests/integration_api.rs` appelle les vrais services. Ses tests sont
+ ignorés par défaut et ont besoin des clés de `.env.example` :
+
+ ```bash
+ KAYABOT_TMDB_API_KEY=... KAYABOT_OMDB_API_KEY=... KAYABOT_TVDB_API_KEY=... \
+ cargo test --test integration_api -- --ignored
+ ```
+
+ `KAYABOT_TMDB_BEARER_TOKEN` fonctionne à la place de `KAYABOT_TMDB_API_KEY`.
diff --git a/docs/i18n/fr/docs/filebot-parity.md b/docs/i18n/fr/docs/filebot-parity.md
new file mode 100644
index 0000000..5b5be29
--- /dev/null
+++ b/docs/i18n/fr/docs/filebot-parity.md
@@ -0,0 +1,65 @@
+
+
+# Parité avec FileBot
+
+FileBot est la référence de ce qu'un renommeur de médias doit savoir faire.
+Cette page liste les fonctionnalités de FileBot que KayaBot vise à couvrir et
+où chacune en est aujourd'hui, pour que personne n'ait à lire le code pour le
+savoir.
+
+**Oui** signifie que cela fonctionne dans le programme actuel. **En partie**
+signifie qu'une version réduite existe. **Pas encore** signifie qu'aucun code
+n'existe pour cela.
+
+## Renommage et organisation
+
+| Fonctionnalité | KayaBot aujourd'hui |
+|---|---|
+| Renommer selon un modèle | **Oui.** Modèles prédéfinis et personnalisés pour les épisodes et les films |
+| Conserver l'extension | **Oui** |
+| Gérer les collisions de noms | **Oui.** N'écrase jamais rien ; un nom déjà pris reçoit ` (1)`, ` (2)` et ainsi de suite, et deux fichiers d'un même lot n'entrent jamais en collision |
+| Prévisualisation et simulation | **Oui.** Chaque nouveau nom est affiché d'abord, et une simulation (dry run) ne renomme rien |
+| Déplacer les fichiers dans des dossiers par série, saison, film ou collection | **Pas encore.** Les fichiers sont renommés dans leur propre dossier |
+| Renommer les sous-titres avec leur vidéo | **Pas encore** |
+| Normaliser les suffixes de langue des sous-titres (`.fr`, `.en`, `.multi`) | **Pas encore** |
+| Télécharger des sous-titres | **Pas encore** |
+| Écrire des fichiers `.nfo` ou des tags dans les fichiers | **Pas encore** |
+| Vérifier les SFV ou d'autres sommes de contrôle | **Pas encore** |
+
+## Correspondance
+
+| Fonctionnalité | KayaBot aujourd'hui |
+|---|---|
+| Lire la saison, l'épisode et l'année dans un nom de fichier | **Oui.** `S01E02`, `S01 E02`, `1x02`, `102`, `Season 1 Episode 2`, une année de 1900 à 2099 |
+| Distinguer un épisode d'un film | **Oui**, d'après le nom analysé ; vous pouvez changer le type |
+| Identifier les séries et les épisodes | **Oui.** Recherche par titre, puis l'épisode par saison et numéro |
+| Identifier les films | **Oui.** Titre et année |
+| Choisir entre des résultats ambigus | **En partie.** Les candidats sont listés avec leur score et vous pouvez en choisir un autre, ou corriger à la main le titre, la saison et l'épisode d'un fichier. Il n'y a pas de question interactive pendant un lot |
+| Identifier par empreinte (hash) du fichier | **Pas encore** |
+| Éditions et versions (cuts), fichiers multi-épisodes, épisodes spéciaux | **Pas encore** |
+| Filtrer les fichiers par règles | **Pas encore** |
+
+## Métadonnées
+
+| Fonctionnalité | KayaBot aujourd'hui |
+|---|---|
+| Plusieurs sources avec une priorité par type de contenu | **Oui.** TheTVDB et TVmaze pour les séries, TMDB et OMDb pour les films ; voir [metadata-sources.md](metadata-sources.md) |
+| Fusionner les résultats de plusieurs sources | **Oui.** Par identifiants communs et similarité des titres |
+| Se rabattre sur une autre source quand l'une échoue | **Oui.** Les partenaires sont interrogés en même temps et une source en échec est écartée |
+| Titre officiel, année et titre d'épisode | **Oui** |
+| Synopsis et genres | **En partie.** Affichés à côté d'une correspondance, pas utilisés dans les noms |
+| Langue et région des titres | **Oui** pour TMDB et TheTVDB |
+| Distribution, illustrations, collections | **Pas encore** |
+
+## Modèles
+
+KayaBot lit des jetons au format FileBot, un petit sous-ensemble du langage de
+format de FileBot :
+
+| Jeton | KayaBot aujourd'hui |
+|---|---|
+| `{n}` titre, `{t}` titre d'épisode, `{y}` année | **Oui** |
+| `{s}`, `{e}` et leurs formes complétées par des zéros comme `{s00}`, `{e00}` | **Oui** |
+| Supprimer une valeur manquante avec son séparateur | **Oui** |
+| `{airdate}` et les autres variables de FileBot | **Pas encore** |
+| Expressions et conditions dans un modèle | **Pas encore** |
diff --git a/docs/i18n/fr/docs/metadata-sources.md b/docs/i18n/fr/docs/metadata-sources.md
new file mode 100644
index 0000000..e1cdf3c
--- /dev/null
+++ b/docs/i18n/fr/docs/metadata-sources.md
@@ -0,0 +1,137 @@
+
+
+# Sources de métadonnées
+
+KayaBot cherche les titres dans quatre bases de données en ligne. Cette page
+indique à quoi sert chacune, comment KayaBot l'interroge et ce que le service
+demande à un programme qui l'utilise. Ce qui quitte votre machine est résumé
+dans la section [Confidentialité](../README.md#confidentialité) du README.
+
+## En un coup d'œil
+
+| Source | Utilisée pour | Clé | Confiance |
+|---|---|---|---|
+| TMDB | les films par défaut ; les séries si vous la choisissez | un jeton d'accès en lecture v4 ou une clé API v3 | 0.9 |
+| TheTVDB | les séries par défaut | une clé API | 0.9 |
+| TVmaze | les séries, en partenaire de TheTVDB | aucune | 0.75 |
+| OMDb | les films, en partenaire de TMDB | une clé API | 0.7 |
+
+La confiance est le poids fixe que chaque client donne à sa source quand les
+résultats sont classés (voir
+[architecture.md](architecture.md#dun-ancien-nom-à-un-nouveau)). Les libellés
+affichés dans le programme proviennent de `MetadataSource::label` : TheMovieDB,
+TheTVDB, TVmaze et OMDb.
+
+## Comment KayaBot choisit les sources
+
+- La liste Source de la carte Recherche, dans la vue Renommer, propose
+ TheMovieDB, TheTVDB et TVmaze quand le type est Série, et TheMovieDB et OMDb
+ quand c'est Film.
+- Au démarrage, les séries utilisent TheTVDB et les films TheMovieDB. Votre
+ dernier choix pour chaque type est conservé jusqu'à la fermeture du
+ programme.
+- Une recherche interroge ensemble la source choisie et ses partenaires :
+ TheTVDB avec TVmaze, TVmaze avec TheTVDB, TMDB (pour les séries) avec les
+ deux, et TMDB avec OMDb ou OMDb avec TMDB pour les films. Leurs réponses sont
+ fusionnées : les partenaires ne sont donc pas un repli essayé après un échec,
+ mais un second avis demandé en même temps.
+- L'option « Utiliser cette source uniquement » n'interroge que la source
+ choisie.
+- Une source sans clé renvoie une erreur avant toute requête et est écartée de
+ la fusion. TVmaze n'a pas besoin de clé : une recherche de série l'atteint
+ donc toujours.
+
+## Chaque source
+
+### TMDB
+
+- Hôte : `https://api.themoviedb.org/3`.
+- Requêtes : `/search/multi` avec `query` et `include_adult=false` ;
+ `/tv/{id}` puis `/tv/{id}/season/{n}` pour chaque saison afin de construire
+ la liste des épisodes ; `/movie/{id}` pour un film.
+- Authentification : une valeur qui commence par `eyJ` est un jeton d'accès en
+ lecture v4 et part dans l'en-tête `Authorization: Bearer` ; toute autre
+ valeur est une clé v3 et part dans le paramètre de requête `api_key`.
+- Langue et région : `language` (par exemple `en-US`) sur chaque requête et
+ `region` sur les recherches. Quand la langue et la région des métadonnées
+ sont toutes deux vides dans les Préférences, KayaBot envoie `fr-FR`. Une
+ langue seule reçoit sa région habituelle (`en` reçoit `US`, `fr` reçoit `FR`)
+ et une région seule sa langue habituelle.
+- Ce que KayaBot conserve : le titre localisé, avec le titre original comme
+ alias quand il diffère ; l'année tirée de `release_date` (films) ou de
+ `first_air_date` (séries) ; et `vote_average` divisé par 10 comme note de la
+ source.
+- Quota : plus de quota fixe depuis décembre 2019, mais TMDB répond encore par
+ un `429` aux alentours de 40 requêtes par seconde
+ ([limitation de débit](https://developer.themoviedb.org/docs/rate-limiting)).
+- Conditions : les [conditions d'utilisation de l'API](https://www.themoviedb.org/api-terms-of-use)
+ exigent la mention « This product uses the TMDB API but is not endorsed or
+ certified by TMDB » et le respect des
+ [règles de logo et d'attribution](https://www.themoviedb.org/about/logos-attribution).
+
+### TheTVDB
+
+- Hôte : `https://api4.thetvdb.com/v4`.
+- Requêtes : `/search` avec `query` et `type=series` ;
+ `/series/{id}/episodes/default` pour une liste d'épisodes ; `/movies/{id}`
+ pour un film.
+- Authentification : la clé est envoyée à `/login`, qui renvoie un jeton
+ transmis en `Authorization: Bearer` sur les autres requêtes. KayaBot le
+ renouvelle via `/refresh_token` et le garde en mémoire uniquement.
+- Langue et région : la langue en paramètre de requête `language` et, avec la
+ région, en `Accept-Language`.
+- Ce que KayaBot conserve : le nom de la série, son `year`, et le `score` du
+ résultat de recherche comme note de la source.
+- Conditions : TheTVDB est gratuit pour les projets qui gagnent moins de
+ 50 000 $ par an, en échange d'une attribution avec un lien direct vers
+ TheTVDB.com, visible par les personnes qui voient ses données
+ ([informations sur l'API](https://thetvdb.com/api-information)). La
+ formulation suggérée est « Metadata provided by TheTVDB. Please consider
+ adding missing information or subscribing. »
+
+### TVmaze
+
+- Hôte : `https://api.tvmaze.com`.
+- Requêtes : `/search/shows` avec `q` ; `/shows/{id}/episodes` pour une liste
+ d'épisodes.
+- Authentification : aucune. TVmaze demande aux clients d'envoyer un
+ User-Agent qui les identifie ; KayaBot envoie `KayaBot`, sauf si
+ `tvmaze_user_agent` ou `KAYABOT_TVMAZE_USER_AGENT` en définit un autre.
+- Langue et région : aucune.
+- Ce que KayaBot conserve : le nom de la série, l'année tirée de `premiered`,
+ et le `score` du résultat de recherche (le score propre de la série quand il
+ vaut 0) comme note de la source.
+- Quota : au moins 20 appels toutes les 10 secondes par adresse IP.
+- Conditions : l'[API](https://www.tvmaze.com/api) est sous licence
+ [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) : TVmaze doit
+ être cité comme source, et les données partagées suivent les conditions
+ ShareAlike (partage dans les mêmes conditions).
+
+### OMDb
+
+- Hôte : `https://www.omdbapi.com`.
+- Requêtes : `?s=` pour chercher et `?i=` pour les
+ détails d'un film.
+- Authentification : la clé part dans le paramètre de requête `apikey`.
+- Langue et région : aucune.
+- Ce que KayaBot conserve : le titre, les quatre premiers chiffres de `Year`,
+ et l'identifiant IMDb, qui sert aussi d'identifiant au résultat. OMDb ne
+ fournit aucune note que KayaBot utilise : chaque résultat reçoit une note de
+ source fixe de 0.7.
+- Quota : une clé gratuite permet 1 000 requêtes par jour
+ ([clés API](https://www.omdbapi.com/apikey.aspx)).
+- Conditions : le contenu d'OMDb est sous licence
+ [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/), et OMDb
+ n'est ni approuvé par IMDb.com ni affilié à IMDb.com
+ ([conditions](https://www.omdbapi.com/legal.htm)).
+
+## Limites et erreurs
+
+Chaque requête a un délai d'expiration de 10 secondes. Une réponse
+`429 Too Many Requests` est signalée comme une limite de débit, et une source
+qui expire ou échoue est écartée de cette recherche sans arrêter les autres.
+Les messages d'erreur n'incluent jamais l'URL de la requête : une clé envoyée
+dans la chaîne de requête n'atteint donc pas la fenêtre.
+
+Les quotas et les conditions changent : consultez les pages de chaque service
+avant de vous fier à un chiffre.
diff --git a/docs/metadata-sources.md b/docs/metadata-sources.md
index 053f4f6..8e251e1 100644
--- a/docs/metadata-sources.md
+++ b/docs/metadata-sources.md
@@ -50,6 +50,12 @@ TheTVDB, TVmaze and OMDb.
searches. When the metadata language and region in Preferences are both
unset, KayaBot sends `fr-FR`. A language alone gets its usual region (`en`
gets `US`, `fr` gets `FR`) and a region alone its usual language.
+- What KayaBot keeps: the localised title, with the original title as an alias
+ when it differs; the year from `release_date` (movies) or `first_air_date`
+ (series); and `vote_average` divided by 10 as the source rating.
+- Quota: no fixed quota since December 2019, but TMDB still answers with a
+ `429` somewhere around 40 requests per second
+ ([rate limiting](https://developer.themoviedb.org/docs/rate-limiting)).
- Terms: the [API terms of use](https://www.themoviedb.org/api-terms-of-use)
require the notice "This product uses the TMDB API but is not endorsed or
certified by TMDB" and follow the
@@ -66,6 +72,8 @@ TheTVDB, TVmaze and OMDb.
`/refresh_token` and keeps it in memory only.
- Locale: the language as a `language` query parameter and, with the region, as
`Accept-Language`.
+- What KayaBot keeps: the series name, its `year`, and the `score` of the
+ search result as the source rating.
- Terms: TheTVDB is free for projects earning less than $50k a year, against
attribution with a direct link to TheTVDB.com, shown to the people who see
its data ([API information](https://thetvdb.com/api-information)). The
@@ -81,6 +89,10 @@ TheTVDB, TVmaze and OMDb.
identifies them; KayaBot sends `KayaBot` unless `tvmaze_user_agent` or
`KAYABOT_TVMAZE_USER_AGENT` sets another one.
- Locale: none.
+- What KayaBot keeps: the show name, the year from `premiered`, and the
+ `score` of the search result (the show's own score when that is 0) as the
+ source rating.
+- Quota: at least 20 calls every 10 seconds per IP address.
- Terms: the [API](https://www.tvmaze.com/api) is licensed under
[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/): TVmaze must
be credited as the source, and shared data follows the ShareAlike terms.
@@ -91,6 +103,11 @@ TheTVDB, TVmaze and OMDb.
- Requests: `?s=` to search and `?i=` for a movie's details.
- Authentication: the key goes in the `apikey` query parameter.
- Locale: none.
+- What KayaBot keeps: the title, the first four digits of `Year`, and the IMDb
+ ID, which is also the result's ID. OMDb gives no rating KayaBot uses: every
+ result gets a fixed source rating of 0.7.
+- Quota: a free key allows 1,000 requests a day
+ ([API keys](https://www.omdbapi.com/apikey.aspx)).
- Terms: OMDb content is licensed under
[CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/), and OMDb is
not endorsed by or affiliated with IMDb.com
diff --git a/scripts/i18n-check.sh b/scripts/i18n-check.sh
new file mode 100755
index 0000000..e603e2c
--- /dev/null
+++ b/scripts/i18n-check.sh
@@ -0,0 +1,132 @@
+#!/usr/bin/env bash
+# Whether every translated document still matches the English it was made from.
+#
+# Each translation carries the BLOB HASH of its source, not a commit sha:
+#
+#
+#
+# Translations live at docs/i18n//, so a translation is
+# a mirror of the repo root: docs/i18n/fr/docs/guide/install.md translates
+# docs/guide/install.md. That shape is what makes a link between two translated
+# pages the SAME string as the link between their English originals.
+#
+# The hash is `git hash-object `, computable from the working tree with
+# no history at all. That is deliberate. The obvious design - record the last
+# commit that touched the source and compare with `git log` - needs history this
+# repo's CI does not fetch: `actions/checkout` clones with depth 1 unless told
+# otherwise, `git log` on a shallow clone reports one commit, every comparison
+# comes out equal, and the check passes green forever while translations rot.
+# A freshness check that cannot fail is worse than none: it is a badge asserting
+# something nobody verified.
+#
+# Exit 1 if any translation is stale or malformed. `--fix` restamps instead,
+# for when you have just re-translated.
+set -uo pipefail
+cd "$(dirname "$0")/.."
+
+fix=0
+stamp=0
+case "${1:-}" in
+ --fix) fix=1 ;;
+ # Adds a missing header to a translated file. An authoring convenience, kept
+ # separate from --fix on purpose: CI must keep FAILING on an unstamped file,
+ # because a translation with no stamp is one nothing can ever tell is stale.
+ --stamp) stamp=1 ;;
+esac
+
+stale=0
+missing=0
+checked=0
+
+# A translation's source is its path with the docs/i18n// prefix removed.
+while IFS= read -r t; do
+ checked=$((checked + 1))
+ # Strip `find`'s leading `./`: the stamp inside a file names its source the
+ # way a human would write it, repo-relative, and a path that does not match
+ # the stamp byte for byte makes --fix silently substitute nothing.
+ t="${t#./}"
+ src="$(printf '%s' "$t" | sed -E 's|^docs/i18n/[^/]+/||')"
+ if [ ! -f "$src" ]; then
+ printf 'ORPHAN %s\n its English source %s does not exist\n' "$t" "$src"
+ missing=$((missing + 1))
+ continue
+ fi
+ want="$(git hash-object "$src")"
+ have="$(sed -n 's/.*eidos-i18n: source=[^ ]* sha=\([0-9a-f]*\).*/\1/p' "$t" | head -1)"
+ if [ -z "$have" ]; then
+ if [ "$stamp" = 1 ]; then
+ printf '\n\n%s\n' "$src" "$want" "$(cat "$t")" > "$t.tmp"
+ mv "$t.tmp" "$t"
+ printf 'STAMPED %s\n' "$t"
+ continue
+ fi
+ printf 'NO STAMP %s\n add: \n' "$t" "$src" "$want"
+ missing=$((missing + 1))
+ continue
+ fi
+ if [ "$have" != "$want" ]; then
+ if [ "$fix" = 1 ]; then
+ sed -i "s|eidos-i18n: source=$src sha=$have|eidos-i18n: source=$src sha=$want|" "$t"
+ # Confirm the substitution LANDED before saying so. The first version
+ # of this script printed RESTAMPED unconditionally, and because a
+ # path mismatch made the pattern match nothing, it announced success
+ # while changing not one byte - the exact failure this whole check
+ # exists to prevent, inside the check itself.
+ if [ "$(sed -n 's/.*eidos-i18n: source=[^ ]* sha=\([0-9a-f]*\).*/\1/p' "$t" | head -1)" = "$want" ]; then
+ printf 'RESTAMPED %s\n' "$t"
+ else
+ printf 'COULD NOT RESTAMP %s\n its stamp does not name %s\n' "$t" "$src"
+ stale=$((stale + 1))
+ fi
+ else
+ n="$(git log --oneline "$src" 2>/dev/null | wc -l)"
+ printf 'STALE %s\n %s changed since this was translated (stamped %s, now %s)\n' \
+ "$t" "$src" "${have:0:8}" "${want:0:8}"
+ [ "$n" -gt 1 ] && printf ' history: %s commits on the source\n' "$n"
+ stale=$((stale + 1))
+ fi
+ fi
+done < <(find docs/i18n -name '*.md' 2>/dev/null | sort)
+
+# Structure. Nobody here reads fifteen languages, so the honest check on a
+# translation's BODY is not its prose but its shape: a page with the source's
+# headings, code blocks and table rows is a page somebody actually translated,
+# and one with half of them is a page that was summarised, truncated, or had its
+# examples "helpfully" merged. This catches the failure a reviewer cannot.
+shape() {
+ # headings | fenced code blocks | table rows, for one file
+ printf '%s %s %s' \
+ "$(grep -c '^#\{1,6\} ' "$1")" \
+ "$(grep -c '^```' "$1")" \
+ "$(grep -c '^|' "$1")"
+}
+
+for t in $(find docs/i18n -name '*.md' 2>/dev/null | sort); do
+ t="${t#./}"
+ src="$(printf '%s' "$t" | sed -E 's|^docs/i18n/[^/]+/||')"
+ [ -f "$src" ] || continue
+ # The stamp line is one extra line in the translation and nothing else.
+ a="$(shape "$src")"
+ b="$(shape "$t")"
+ if [ "$a" != "$b" ]; then
+ printf 'SHAPE %s\n headings/code-fences/table-rows: source %s, translation %s\n' \
+ "$t" "$a" "$b"
+ stale=$((stale + 1))
+ fi
+done
+
+printf '\n%s translation(s) checked' "$checked"
+[ "$stale" -gt 0 ] && printf ', %s STALE' "$stale"
+[ "$missing" -gt 0 ] && printf ', %s unusable' "$missing"
+printf '\n'
+
+if [ "$stale" -gt 0 ] || [ "$missing" -gt 0 ]; then
+ cat <<'MSG'
+
+A stale translation is worse than a missing one: it looks authoritative and
+gives last month's instructions. Either re-translate it and run this with
+--fix, or delete it and let the reader fall back to English.
+MSG
+ exit 1
+fi
+exit 0
diff --git a/scripts/i18n-links.py b/scripts/i18n-links.py
new file mode 100755
index 0000000..e036a55
--- /dev/null
+++ b/scripts/i18n-links.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+"""Every markdown link and anchor in the docs resolves.
+
+Split from i18n-check.sh because it needs a real slug function: an anchor is
+GitHub's slug of a heading, and a translated page's headings are - obviously -
+not the English ones. The failure this exists to catch is quiet and specific:
+a translation that correctly rewrote `usage.md` to `usage.de.md` and correctly
+left `#why-passthrough-is-off-by-default` alone, producing a link that lands on
+the right page at the wrong place, or nowhere at all. Twenty-nine of those
+shipped in one batch before this existed.
+"""
+import glob
+import os
+import re
+import sys
+
+ANCHOR = re.compile(r"\]\(([^)\s#]*\.md)(?:#([^)\s]+))?\)")
+
+
+def slug(heading: str) -> str:
+ s = heading.strip().lower()
+ s = re.sub(r"`([^`]*)`", r"\1", s)
+ s = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", s)
+ s = re.sub(r"[*_]", "", s)
+ s = "".join(c for c in s if c.isalnum() or c in " -_")
+ return s.strip().replace(" ", "-")
+
+
+def headings(path: str) -> set:
+ with open(path, encoding="utf-8") as fh:
+ return {slug(l.lstrip("#").strip()) for l in fh if l.startswith("#")}
+
+
+def main() -> int:
+ bad = 0
+ files = sorted(f for f in glob.glob("**/*.md", recursive=True) if not f.startswith("target/"))
+ for f in files:
+ d = os.path.dirname(f)
+ with open(f, encoding="utf-8") as fh:
+ text = fh.read()
+ for m in ANCHOR.finditer(text):
+ target, anchor = m.group(1), m.group(2)
+ if target.startswith("http"):
+ continue
+ p = os.path.normpath(os.path.join(d, target))
+ if not os.path.exists(p):
+ print(f"MISSING FILE {f}\n -> {target}")
+ bad += 1
+ continue
+ if anchor and anchor not in headings(p):
+ print(f"MISSING ANCHOR {f}\n -> {target}#{anchor}")
+ bad += 1
+ print(f"\n{len(files)} markdown file(s) checked", end="")
+ print(f", {bad} broken link(s)" if bad else ", every link resolves")
+ return 1 if bad else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())