diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 683f1f9..665c8ce 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -57,6 +57,38 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - ERPLibre Home Mobile Application, use TODO to compile, deploy it and personalize it - Support Selenium grid from selenium_lib.py - Add addons OnlyOffice, Cetmix, OCA automation, OCA shopfloor +- Deploy ERPLibre VMs with QEMU/KVM from cloud images (Ubuntu, Debian, Fedora, + Arch) on amd64, arm64 and s390x, with a menu to list, test, resize, delete + and clean up +- Textual interfaces: install dashboard, VM deployment form, migration resume + screen; Textual is installed on demand +- Navigation telemetry for TODO, as a tree, a kanban or a list +- Migration tools for the website copy-on-write views: predict, snapshot, + diff, neutralize and reset +- Read-only analysis toolkit for an Odoo database +- SSH configuration with recursive ProxyJump, port forwarding, and + registration of the QEMU hosts in virt-manager +- NTFY self-hosted push notification server +- Generative AI policy, adopting the OCA one +- Claude Code agents and commands +- Local git server to share code between machines +- Unit tests for the configuration, the refactoring and the uncovered + components, with a bilingual test plan +- Read and send email from the TODO CLI, over IMAP and SMTP +- The database analysis reads a backup zip directly, without restoring it +- RTK management menu +- AI assistant tools menu, with the Claude Code commit command +- Deploy menu: clone ERPLibre on a remote host, configure sshfs, and make + targets for SSH deployment +- Database backup and erase commands, and a clearer restore naming +- Git patch, git remote and vim configuration from the menu +- Security check of the Python environment +- Odoo 18 reads STL files (OpenCAD) +- Mobile: whisper.cpp and sentencepiece in the manifest, a mobile test script, + and the Odoo sync API contract +- FAQ entry on wkhtmltopdf for recent distributions +- brin_advisor and brin_cluster: recommend and apply the right PostgreSQL + index for an Odoo model @@ -76,6 +108,40 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Application mobile ERPLibre Home, utiliser TODO pour compiler, déployer et personnaliser - Support de la grille Selenium depuis selenium_lib.py - Ajout des addons OnlyOffice, Cetmix, OCA automation, OCA shopfloor +- Déploiement de VM ERPLibre en QEMU/KVM depuis des images cloud (Ubuntu, + Debian, Fedora, Arch) en amd64, arm64 et s390x, avec un menu pour lister, + tester, redimensionner, supprimer et nettoyer +- Interfaces Textual : tableau de bord d'installation, formulaire de + déploiement de VM, écran de reprise de migration ; Textual s'installe à la + demande +- Télémétrie de navigation pour TODO, en arbre, en kanban ou en liste +- Outils de migration pour les vues copy-on-write du site web : prévoir, + photographier, comparer, neutraliser et réinitialiser +- Boîte à outils d'analyse en lecture seule d'une base Odoo +- Configuration SSH avec ProxyJump récursif, redirection de port et + enregistrement des hôtes QEMU dans virt-manager +- Serveur de notifications NTFY auto-hébergé +- Politique d'IA générative, adoptant celle de l'OCA +- Agents et commandes Claude Code +- Serveur git local pour partager du code entre machines +- Tests unitaires pour la configuration, la refactorisation et les composants + non couverts, avec un plan de test bilingue +- Lecture et envoi de courriel depuis le CLI TODO, en IMAP et SMTP +- L'analyse de base lit un zip de sauvegarde tel quel, sans le restaurer +- Menu de gestion RTK +- Menu d'outils d'assistance IA, avec la commande de commit Claude Code +- Menu de déploiement : cloner ERPLibre sur un hôte distant, configurer sshfs, + et des cibles make pour le déploiement SSH +- Commandes de sauvegarde et d'effacement de base, et un nommage plus clair à + la restauration +- Correctif git, dépôt distant git et configuration vim depuis le menu +- Vérification de sécurité de l'environnement Python +- Odoo 18 lit les fichiers STL (OpenCAD) +- Mobile : whisper.cpp et sentencepiece au manifeste, un script de test mobile, + et le contrat d'API de synchronisation Odoo +- Entrée de FAQ sur wkhtmltopdf pour les distributions récentes +- brin_advisor et brin_cluster : recommander et appliquer le bon index + PostgreSQL pour un modèle Odoo ## Changed @@ -86,12 +152,95 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Docker support postgresql 18 - Format script search diff file into each repository - Support neutralize database from Odoo +- Installation supports Fedora, Debian, Ubuntu and Arch Linux +- Repository sync and poetry install run in parallel, up to 50 % faster on a + slow connection +- CybroOdoo extra modules become opt-in, tracked per Odoo version +- Node.js 22, required by Capacitor 8 for the mobile application +- Poetry and repo are quiet by default; EL_VERBOSE restores the output +- TODO menus grouped into sections with icons, and English text used as the + i18n key +- Documentation is bilingual, generated from the .base.md sources +- A VM inherits the timezone of the host that creates it +- First boot no longer waits on snapd, locale generation or the guest agent +- apt picks the fastest reachable mirror before the official archive +- Selenium: download through a network hub, SVG to PNG, error detection, + multiple clicks and updated drivers +- Odoo can run on a custom database; queue_job setup and SSH forwarding + options in the menu +- Killing a process by port asks before acting, with an interactive menu +- LinuxMint 22.3 supported +- Odoo 18 dependencies: flanker, orjson, python-magic, tldextract, PyYAML +- Copyright year updated to 2026 - Support Docker postgresql 18 - Script de formatage recherche les fichiers diff dans chaque dépôt - Support de la neutralisation de base de données depuis Odoo +- L'installation prend en charge Fedora, Debian, Ubuntu et Arch Linux +- La synchronisation des dépôts et l'installation poetry tournent en + parallèle, jusqu'à 50 % plus rapide sur une connexion lente +- Les modules extra CybroOdoo deviennent optionnels, suivis par version d'Odoo +- Node.js 22, exigé par Capacitor 8 pour l'application mobile +- Poetry et repo sont silencieux par défaut ; EL_VERBOSE rétablit la sortie +- Menus TODO regroupés en sections avec icônes, et texte anglais utilisé comme + clé i18n +- Documentation bilingue, générée depuis les sources .base.md +- Une VM hérite du fuseau horaire de l'hôte qui la crée +- Le premier démarrage n'attend plus snapd, la génération de locales ni l'agent +- apt prend le miroir joignable le plus rapide avant le dépôt officiel +- Selenium : téléchargement via un hub réseau, SVG vers PNG, détection + d'erreurs, clics multiples et pilotes à jour +- Odoo peut tourner sur une base personnalisée ; configuration de queue_job et + options de redirection SSH dans le menu +- Tuer un processus par son port demande confirmation, avec un menu interactif +- LinuxMint 22.3 pris en charge +- Dépendances Odoo 18 : flanker, orjson, python-magic, tldextract, PyYAML +- Année de copyright portée à 2026 + + +## Fixed + +## Corrigé + + +- A failed installation is no longer reported as a success: the exit code is + propagated through the whole chain +- --with_extra now applies to an already-installed environment +- The addons path no longer points at a repository the Odoo 18 manifest never + clones +- repo init receives a branch name, so a fresh install no longer fails +- The install monitor follows a VM whose DHCP lease changes +- Installation on Debian 13, Fedora and Ubuntu 26.04: apt lock, wkhtmltopdf, + SELinux and the missing C compiler +- Documentation accents and the parallel markdown generation + + + +- Une installation en échec n'est plus rapportée comme réussie : le code de + sortie remonte toute la chaîne +- --with_extra s'applique désormais à un environnement déjà installé +- Le chemin d'addons ne pointe plus vers un dépôt que le manifeste Odoo 18 ne + clone jamais +- repo init reçoit un nom de branche, une installation neuve n'échoue plus +- Le suivi d'installation suit une VM dont le bail DHCP change +- Installation sur Debian 13, Fedora et Ubuntu 26.04 : verrou apt, wkhtmltopdf, + SELinux et le compilateur C manquant +- Accents de la documentation et génération markdown en parallèle + + +## Security + +## Sécurité + + +- Passwords and tokens are redacted before a command is displayed or logged + + + +- Les mots de passe et jetons sont caviardés avant l'affichage ou la + journalisation d'une commande diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 00007ae..e93f828 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -31,12 +31,84 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Application mobile ERPLibre Home, utiliser TODO pour compiler, déployer et personnaliser - Support de la grille Selenium depuis selenium_lib.py - Ajout des addons OnlyOffice, Cetmix, OCA automation, OCA shopfloor +- Déploiement de VM ERPLibre en QEMU/KVM depuis des images cloud (Ubuntu, + Debian, Fedora, Arch) en amd64, arm64 et s390x, avec un menu pour lister, + tester, redimensionner, supprimer et nettoyer +- Interfaces Textual : tableau de bord d'installation, formulaire de + déploiement de VM, écran de reprise de migration ; Textual s'installe à la + demande +- Télémétrie de navigation pour TODO, en arbre, en kanban ou en liste +- Outils de migration pour les vues copy-on-write du site web : prévoir, + photographier, comparer, neutraliser et réinitialiser +- Boîte à outils d'analyse en lecture seule d'une base Odoo +- Configuration SSH avec ProxyJump récursif, redirection de port et + enregistrement des hôtes QEMU dans virt-manager +- Serveur de notifications NTFY auto-hébergé +- Politique d'IA générative, adoptant celle de l'OCA +- Agents et commandes Claude Code +- Serveur git local pour partager du code entre machines +- Tests unitaires pour la configuration, la refactorisation et les composants + non couverts, avec un plan de test bilingue +- Lecture et envoi de courriel depuis le CLI TODO, en IMAP et SMTP +- L'analyse de base lit un zip de sauvegarde tel quel, sans le restaurer +- Menu de gestion RTK +- Menu d'outils d'assistance IA, avec la commande de commit Claude Code +- Menu de déploiement : cloner ERPLibre sur un hôte distant, configurer sshfs, + et des cibles make pour le déploiement SSH +- Commandes de sauvegarde et d'effacement de base, et un nommage plus clair à + la restauration +- Correctif git, dépôt distant git et configuration vim depuis le menu +- Vérification de sécurité de l'environnement Python +- Odoo 18 lit les fichiers STL (OpenCAD) +- Mobile : whisper.cpp et sentencepiece au manifeste, un script de test mobile, + et le contrat d'API de synchronisation Odoo +- Entrée de FAQ sur wkhtmltopdf pour les distributions récentes +- brin_advisor et brin_cluster : recommander et appliquer le bon index + PostgreSQL pour un modèle Odoo ## Modifié - Support Docker postgresql 18 - Script de formatage recherche les fichiers diff dans chaque dépôt - Support de la neutralisation de base de données depuis Odoo +- L'installation prend en charge Fedora, Debian, Ubuntu et Arch Linux +- La synchronisation des dépôts et l'installation poetry tournent en + parallèle, jusqu'à 50 % plus rapide sur une connexion lente +- Les modules extra CybroOdoo deviennent optionnels, suivis par version d'Odoo +- Node.js 22, exigé par Capacitor 8 pour l'application mobile +- Poetry et repo sont silencieux par défaut ; EL_VERBOSE rétablit la sortie +- Menus TODO regroupés en sections avec icônes, et texte anglais utilisé comme + clé i18n +- Documentation bilingue, générée depuis les sources .base.md +- Une VM hérite du fuseau horaire de l'hôte qui la crée +- Le premier démarrage n'attend plus snapd, la génération de locales ni l'agent +- apt prend le miroir joignable le plus rapide avant le dépôt officiel +- Selenium : téléchargement via un hub réseau, SVG vers PNG, détection + d'erreurs, clics multiples et pilotes à jour +- Odoo peut tourner sur une base personnalisée ; configuration de queue_job et + options de redirection SSH dans le menu +- Tuer un processus par son port demande confirmation, avec un menu interactif +- LinuxMint 22.3 pris en charge +- Dépendances Odoo 18 : flanker, orjson, python-magic, tldextract, PyYAML +- Année de copyright portée à 2026 + +## Corrigé + +- Une installation en échec n'est plus rapportée comme réussie : le code de + sortie remonte toute la chaîne +- --with_extra s'applique désormais à un environnement déjà installé +- Le chemin d'addons ne pointe plus vers un dépôt que le manifeste Odoo 18 ne + clone jamais +- repo init reçoit un nom de branche, une installation neuve n'échoue plus +- Le suivi d'installation suit une VM dont le bail DHCP change +- Installation sur Debian 13, Fedora et Ubuntu 26.04 : verrou apt, wkhtmltopdf, + SELinux et le compilateur C manquant +- Accents de la documentation et génération markdown en parallèle + +## Sécurité + +- Les mots de passe et jetons sont caviardés avant l'affichage ou la + journalisation d'une commande ## [1.6.0] - 2025-04-25 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e5ef15..103f0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,12 +31,81 @@ Recreating the virtual environment, use installation guide from tool `make`. - ERPLibre Home Mobile Application, use TODO to compile, deploy it and personalize it - Support Selenium grid from selenium_lib.py - Add addons OnlyOffice, Cetmix, OCA automation, OCA shopfloor +- Deploy ERPLibre VMs with QEMU/KVM from cloud images (Ubuntu, Debian, Fedora, + Arch) on amd64, arm64 and s390x, with a menu to list, test, resize, delete + and clean up +- Textual interfaces: install dashboard, VM deployment form, migration resume + screen; Textual is installed on demand +- Navigation telemetry for TODO, as a tree, a kanban or a list +- Migration tools for the website copy-on-write views: predict, snapshot, + diff, neutralize and reset +- Read-only analysis toolkit for an Odoo database +- SSH configuration with recursive ProxyJump, port forwarding, and + registration of the QEMU hosts in virt-manager +- NTFY self-hosted push notification server +- Generative AI policy, adopting the OCA one +- Claude Code agents and commands +- Local git server to share code between machines +- Unit tests for the configuration, the refactoring and the uncovered + components, with a bilingual test plan +- Read and send email from the TODO CLI, over IMAP and SMTP +- The database analysis reads a backup zip directly, without restoring it +- RTK management menu +- AI assistant tools menu, with the Claude Code commit command +- Deploy menu: clone ERPLibre on a remote host, configure sshfs, and make + targets for SSH deployment +- Database backup and erase commands, and a clearer restore naming +- Git patch, git remote and vim configuration from the menu +- Security check of the Python environment +- Odoo 18 reads STL files (OpenCAD) +- Mobile: whisper.cpp and sentencepiece in the manifest, a mobile test script, + and the Odoo sync API contract +- FAQ entry on wkhtmltopdf for recent distributions +- brin_advisor and brin_cluster: recommend and apply the right PostgreSQL + index for an Odoo model ## Changed - Docker support postgresql 18 - Format script search diff file into each repository - Support neutralize database from Odoo +- Installation supports Fedora, Debian, Ubuntu and Arch Linux +- Repository sync and poetry install run in parallel, up to 50 % faster on a + slow connection +- CybroOdoo extra modules become opt-in, tracked per Odoo version +- Node.js 22, required by Capacitor 8 for the mobile application +- Poetry and repo are quiet by default; EL_VERBOSE restores the output +- TODO menus grouped into sections with icons, and English text used as the + i18n key +- Documentation is bilingual, generated from the .base.md sources +- A VM inherits the timezone of the host that creates it +- First boot no longer waits on snapd, locale generation or the guest agent +- apt picks the fastest reachable mirror before the official archive +- Selenium: download through a network hub, SVG to PNG, error detection, + multiple clicks and updated drivers +- Odoo can run on a custom database; queue_job setup and SSH forwarding + options in the menu +- Killing a process by port asks before acting, with an interactive menu +- LinuxMint 22.3 supported +- Odoo 18 dependencies: flanker, orjson, python-magic, tldextract, PyYAML +- Copyright year updated to 2026 + +## Fixed + +- A failed installation is no longer reported as a success: the exit code is + propagated through the whole chain +- --with_extra now applies to an already-installed environment +- The addons path no longer points at a repository the Odoo 18 manifest never + clones +- repo init receives a branch name, so a fresh install no longer fails +- The install monitor follows a VM whose DHCP lease changes +- Installation on Debian 13, Fedora and Ubuntu 26.04: apt lock, wkhtmltopdf, + SELinux and the missing C compiler +- Documentation accents and the parallel markdown generation + +## Security + +- Passwords and tokens are redacted before a command is displayed or logged ## [1.6.0] - 2025-04-25 diff --git a/doc/EMAIL.base.md b/doc/EMAIL.base.md new file mode 100644 index 0000000..339b0b6 --- /dev/null +++ b/doc/EMAIL.base.md @@ -0,0 +1,678 @@ + + + + + + +# Mail client + +A mail client built into the TODO CLI: several accounts, IMAP + SMTP, and a +local cache — so you can read and answer email without leaving +`./script/todo/todo.py`. + +Every `Mail > ...` path below is shorthand for +`TODO > [3] Assistant > [2] Mail - Read and send email > ...` — the full path +is spelled out once, in "Adding an account". + + +# Client courriel + +Un client courriel intégré au CLI TODO : plusieurs comptes, IMAP + SMTP, et +un cache local — pour lire et répondre à son courriel sans quitter +`./script/todo/todo.py`. + +Chaque chemin `Courriel > ...` ci-dessous est un raccourci pour +`TODO > [3] Assistant > [2] Courriel - Lire et envoyer du courriel > ...` — le +chemin complet est écrit une fois, dans « Ajouter un compte ». + + +## Prerequisites + +Four Python packages, already listed in `requirement/erplibre_require-ments.txt` +(the `.venv.erplibre` environment, not an Odoo venv): + +- `cryptography` — seals the local cache in `encrypted` and `ephemeral` mode. +- `keyring` — the system keyring, one of the two places a password can live. +- `pykeepass` — the KDBX vault, the other place, and the one the client tries + first. +- `textual` — the terminal UI itself. Without it, "Open the mail client + (TUI)" prints a message and does nothing; the rest of the menu (accounts, + sync, cache) still works. + +Install them with: + + +## Prérequis + +Quatre paquets Python, déjà listés dans +`requirement/erplibre_require-ments.txt` (l'environnement `.venv.erplibre`, +pas un venv Odoo) : + +- `cryptography` — scelle le cache local en mode `encrypted` et `ephemeral`. +- `keyring` — le trousseau système, l'un des deux endroits où peut vivre un + mot de passe. +- `pykeepass` — le coffre KDBX, l'autre endroit, celui que le client essaie + en premier. +- `textual` — l'interface terminal elle-même. Sans lui, « Ouvrir le client + courriel (TUI) » affiche un message et ne fait rien ; le reste du menu + (comptes, synchronisation, cache) fonctionne quand même. + +Installez-les avec : + + +```bash +.venv.erplibre/bin/pip install -r requirement/erplibre_require-ments.txt +``` + + +### App passwords for Gmail, Outlook and iCloud + +Phase 1 speaks plain IMAP/SMTP login only — no OAuth yet (that is phase 2). +Gmail, Outlook and iCloud have all closed that door to the account's real +password, so each of these three presets requires an **app password** +instead: + +| Provider | Where to generate it | +|---|---| +| Gmail | Enable 2-step verification, then [myaccount.google.com](https://myaccount.google.com/security) > Security > App passwords | +| Outlook / Microsoft 365 | [account.microsoft.com](https://account.microsoft.com/security) > Security > Advanced security options > App passwords | +| iCloud | [account.apple.com](https://account.apple.com/) > Sign-In and Security > App-Specific Passwords | + +Use that generated password when account setup asks for one — never the +account's normal password. The "Standard server" preset (generic IMAP/SMTP) +does not need one. + + +### Mots de passe d'application pour Gmail, Outlook et iCloud + +La phase 1 ne parle qu'IMAP/SMTP en authentification simple — pas encore +OAuth (ça, c'est la phase 2). Gmail, Outlook et iCloud ont tous les trois +fermé cette porte au vrai mot de passe du compte : chacun de ces trois +préréglages exige donc un **mot de passe d'application** à la place : + +| Fournisseur | Où le générer | +|---|---| +| Gmail | Activez la validation en deux étapes, puis [myaccount.google.com](https://myaccount.google.com/security) > Sécurité > Mots de passe des applications | +| Outlook / Microsoft 365 | [account.microsoft.com](https://account.microsoft.com/security) > Sécurité > Options de sécurité avancées > Mots de passe d'application | +| iCloud | [account.apple.com](https://account.apple.com/) > Connexion et sécurité > Mots de passe spécifiques aux applications | + +Utilisez ce mot de passe généré quand la configuration du compte en demande +un — jamais le mot de passe normal du compte. Le préréglage « Serveur +standard » (IMAP/SMTP générique) n'en a pas besoin. + + +## Adding an account + +Menu path: `TODO > [3] Assistant > [2] Mail - Read and send email > [2] +Accounts > [2] Add an account`. + +The prompts, in order: + +1. **Short account name** — becomes both the folder name under + `~/.erplibre/mail/` and the vault reference, so it cannot contain `/` or + start with a dot. +2. **Email address**. +3. **Display name** (optional) — shown in the `From:` header as + `Display Name `. +4. **Provider** — a number from the printed list: Gmail, Outlook, iCloud, or + "Standard server" (generic IMAP/SMTP). +5. If you picked "Standard server", the **IMAP host** and **SMTP host** are + asked next; the other presets fill these in for you. +6. If the preset requires an app password, its note is printed here as a + reminder. +7. **Password** — typed hidden (`getpass`), then stored — never written to + `accounts.json`. + +Where the password goes: at the password step, the client hands off to the +CLI's shared **KDBX manager** — the same one already used for the OpenAI +key and Odoo credentials. It reads `kdbx.path` / `kdbx.password` from the +TODO config (`script/todo/todo.json`, overridable in +`private/todo/todo_override.json` / `private/todo/todo_override_private.json`). +If `kdbx.path` isn't set yet, a graphical file picker pops up asking you to +choose an existing `.kdbx` file — it needs a display, and cancelling it (or +running headless) fails account creation with "le fichier kdbx n'a pas pu +être ouvert" (French — see Troubleshooting). **Set `kdbx.path` (and +`kdbx.password`, to skip the prompt) before adding your first account**, +pointing at a `.kdbx` vault you already have (create one with KeePassXC or +similar). The system keyring is only ever used for an account whose +`secret_ref` already points at one — the menu itself always writes new +accounts into the KDBX vault. + +`accounts.json` (at `~/.erplibre/mail/accounts.json`) only ever holds a +`secret_ref` such as `kdbx:ERPLibre/Mail/perso` — a pointer, never the +secret. It is safe to read, edit by hand, or check into a private backup. + + +## Ajouter un compte + +Chemin de menu : `TODO > [3] Assistant > [2] Courriel - Lire et envoyer du +courriel > [2] Comptes > [2] Ajouter un compte`. + +Les questions, dans l'ordre : + +1. **Nom court du compte** — devient à la fois le nom de dossier sous + `~/.erplibre/mail/` et la référence dans le coffre : il ne peut donc pas + contenir `/` ni commencer par un point. +2. **Adresse courriel**. +3. **Nom affiché** (facultatif) — apparaît dans l'en-tête `De :` comme + `Nom affiché `. +4. **Fournisseur** — un numéro dans la liste affichée : Gmail, Outlook, + iCloud, ou « Serveur standard » (IMAP/SMTP générique). +5. Si vous choisissez « Serveur standard », le **serveur IMAP** puis le + **serveur SMTP** sont demandés ensuite ; les autres préréglages les + remplissent déjà pour vous. +6. Si le préréglage exige un mot de passe d'application, sa note s'affiche + ici en rappel. +7. **Mot de passe** — saisi masqué (`getpass`), puis rangé dans le coffre — + jamais écrit dans `accounts.json`. + +Où va le mot de passe : à l'étape du mot de passe, le client passe par le +**gestionnaire KDBX** partagé du CLI — le même que pour la clé OpenAI et +les identifiants Odoo. Il lit `kdbx.path` / `kdbx.password` dans la +configuration TODO (`script/todo/todo.json`, surchargeable dans +`private/todo/todo_override.json` / `private/todo/todo_override_private.json`). +Si `kdbx.path` n'est pas encore réglé, une fenêtre de sélection de fichier +s'ouvre pour choisir un `.kdbx` existant — il faut un affichage graphique, +et l'annuler (ou lancer le CLI sans affichage) fait échouer la création du +compte avec « le fichier kdbx n'a pas pu être ouvert » (voir Dépannage). +**Réglez `kdbx.path` (et `kdbx.password`, pour éviter l'invite) avant +d'ajouter votre premier compte**, en pointant vers un coffre `.kdbx` que +vous avez déjà (créez-en un avec KeePassXC ou équivalent). Le trousseau +système ne sert que pour un compte dont la `secret_ref` le désigne déjà — +le menu écrit toujours les nouveaux comptes dans le coffre KDBX. + +`accounts.json` (dans `~/.erplibre/mail/accounts.json`) ne contient jamais +qu'une `secret_ref` du genre `kdbx:ERPLibre/Mail/perso` — une référence, +jamais le secret. Il est sans danger à lire, à éditer à la main, ou à +mettre dans une sauvegarde privée. + + +## The three cache modes + +Every account keeps a local cache — a small SQLite database plus one file +per downloaded message — so the inbox stays readable offline. Three modes +control what that cache leaves on disk: + +| Mode | What's on disk | Encryption key | +|---|---|---| +| `clear` (default) | `~/.erplibre/mail//cache.db` and `.eml` files, readable as plain text | none | +| `encrypted` | same location, but sender, recipients, subject, snippet, message-id and message bodies are sealed with AES-256-GCM | generated once, stored in the vault next to the password (`.../cache-key`) | +| `ephemeral` | under `/dev/shm/erplibre-mail-//` (or the system temp dir if `/dev/shm` isn't writable), sealed the same way as `encrypted` | generated fresh in RAM at every run, never written anywhere, and the whole directory is removed when the session closes | + +Even in `clear` mode, the technical fields the SQL needs to sort and +filter — UID, folder, date, flags, size — are always plain; only the +person-identifying fields (and the message body) are ever sealed, and only +in `encrypted`/`ephemeral`. + +Set the **general default** at `Mail > [4] Cache > [1] Default cache mode`; +it is the `mail_cache_mode` preference (default `clear`). **Override it per +account** at `Mail > [4] Cache > [2] Cache mode of one account` — this +writes the account's `cache_mode` field in `accounts.json`; leaving it at +`null` there means "inherit the general default." + +`Mail > [4] Cache > [3] Cache size and purge` lists every account's +effective mode and disk usage, and can erase one account's cache entirely +(after confirmation) — the next sync rebuilds it from scratch. + + +## Les trois modes de cache + +Chaque compte garde un cache local — une petite base SQLite plus un fichier +par message téléchargé — pour que la boîte de réception reste lisible hors +ligne. Trois modes contrôlent ce que ce cache laisse sur le disque : + +| Mode | Ce qui reste sur le disque | Clé de chiffrement | +|---|---|---| +| `clear` (par défaut) | `~/.erplibre/mail//cache.db` et les fichiers `.eml`, lisibles en clair | aucune | +| `encrypted` | même emplacement, mais l'expéditeur, les destinataires, le sujet, l'extrait, le Message-ID et le corps des messages sont scellés en AES-256-GCM | générée une fois, rangée dans le coffre à côté du mot de passe (`.../cache-key`) | +| `ephemeral` | sous `/dev/shm/erplibre-mail-//` (ou le dossier temporaire système si `/dev/shm` n'est pas inscriptible), scellé comme `encrypted` | tirée en RAM à chaque lancement, jamais écrite nulle part, et tout le dossier est effacé à la fermeture de la session | + +Même en mode `clear`, les champs techniques dont le SQL a besoin pour trier +et filtrer — UID, dossier, date, drapeaux, taille — restent toujours en +clair ; seuls les champs qui identifient des personnes (et le corps du +message) sont scellés, et seulement en `encrypted`/`ephemeral`. + +Réglez le **défaut général** dans `Courriel > [4] Cache > [1] Mode de cache +par défaut` ; c'est la préférence `mail_cache_mode` (défaut `clear`). +**Surchargez-le par compte** dans `Courriel > [4] Cache > [2] Mode de cache +d'un compte` — ceci écrit le champ `cache_mode` du compte dans +`accounts.json` ; le laisser à `null` là-bas veut dire « hérite du défaut +général ». + +`Courriel > [4] Cache > [3] Taille du cache et purge` liste le mode +effectif et l'espace disque de chaque compte, et peut effacer entièrement le +cache d'un compte (après confirmation) — la prochaine synchronisation le +reconstruit à partir de zéro. + + +## The TUI + +`Mail > [1] Open the mail client (TUI)` opens a three-pane screen: an +account/folder tree on the left, the message list in the middle, and a +preview pane on the right, with a status line at the bottom. + +| Key | Action | +|---|---| +| `↑` `↓` `Tab` | move within a pane / move focus between panes (Textual defaults) | +| `h` | open the help window: every shortcut plus a few notes, closed with `Escape` | +| `z` | toggle full-screen preview (hides the folder tree and the message list) | +| `Escape` | leave full-screen | +| `v` | cycle the layout: columns, split, stacked | +| `+` / `-` | grow / shrink the pane that has focus | +| `0` | back to the default pane sizes | +| `r` | sync the account of the currently selected folder (all its folders) | +| `Shift+R` | sync every account | +| `/` | open the search field (filters the currently visible list only — locally, over subject/from/to/snippet; it does not search the server) | +| `s` / `u` | mark the selected message seen / unseen | +| `c` | compose a new message | +| `a` / `Shift+A` | reply / reply all | +| `f` | forward | +| `w` | save the message's **first** attachment to `~/Téléchargements` (created if missing) | +| `n` | add an account without leaving the client | +| `l` | show the tail of `~/.erplibre/mail.log` and this session's sync errors | +| `q` | quit | + +This table is written by hand and can fall behind the code; the `h` window +cannot. It builds its list from the application's own key bindings every time +it opens, so it is the reference if the two ever disagree. + +The bars between the panes can also be dragged with the mouse, and pane sizes +are remembered per layout. + +The footer's key hints, like the help window, follow the CLI's chosen +language, as do the account tree, the message list and the preview text. + + +## Le TUI + +`Courriel > [1] Ouvrir le client courriel (TUI)` ouvre un écran en trois +volets : l'arbre comptes/dossiers à gauche, la liste des messages au +centre, et un aperçu à droite, avec une ligne de statut en bas. + +| Touche | Action | +|---|---| +| `↑` `↓` `Tab` | se déplacer dans un volet / changer de volet (comportement par défaut de Textual) | +| `h` | ouvre la fenêtre d'aide : tous les raccourcis et quelques repères, fermée par `Échap` | +| `z` | plein écran sur l'aperçu (masque l'arbre et la liste) | +| `Échap` | quitter le plein écran | +| `v` | change de disposition : colonnes, partagée, empilée | +| `+` / `-` | agrandir / rétrécir le volet qui a le focus | +| `0` | revenir aux tailles de volets par défaut | +| `r` | synchronise le compte du dossier actuellement sélectionné (tous ses dossiers) | +| `Shift+R` | synchronise tous les comptes | +| `/` | ouvre le champ de recherche (filtre seulement la liste déjà affichée — localement, sur sujet/de/à/extrait ; ne cherche pas sur le serveur) | +| `s` / `u` | marquer le message sélectionné lu / non lu | +| `c` | écrire un nouveau message | +| `a` / `Shift+A` | répondre / répondre à tous | +| `f` | transférer | +| `w` | enregistrer la **première** pièce jointe du message dans `~/Téléchargements` (créé s'il n'existe pas) | +| `n` | ajouter un compte sans quitter le client | +| `l` | affiche la fin de `~/.erplibre/mail.log` et les erreurs de synchronisation de la session | +| `q` | quitter | + +Ce tableau est écrit à la main et peut prendre du retard sur le code ; la +fenêtre `h`, elle, ne le peut pas : elle construit sa liste depuis les +liaisons de l'application à chaque ouverture. En cas de désaccord entre les +deux, c'est elle qui a raison. + +Les barres entre les volets se glissent aussi à la souris, et les tailles +sont retenues par disposition. + +Les indices de touches du pied d'écran, comme la fenêtre d'aide, suivent la +langue choisie dans le CLI, tout comme l'arbre des comptes, la liste et le +texte d'aperçu. + + +## Writing a message + +`c` opens the compose form: `To`, `Cc`, `Subject`, an `Attachments` field +(semicolon-separated file paths — a comma is legal in a filename, so only +`;` splits entries; there is no file picker, type the paths), and a +multi-line body. `e` sends the body out to `$EDITOR` (or +`nano` if unset) and reads it back; if the editor is missing or exits with +an error, the body you had is kept untouched. `Ctrl+S` (or the Send button) +delivers the message; `Escape` discards the draft — there is no +save-as-draft. + +`a` (reply) and `Shift+A` (reply all) prefill `To`/`Cc`/`Subject`/ +`In-Reply-To`/`References` and quote the original message in the body. `f` +(forward) prefills the `Fwd:` subject and **attaches the original message** +automatically, as a `message/rfc822` attachment; the body itself starts +empty — write your own note above the attached original. + +Reply, reply-all and forward all need the original message's body +available — from the cache, or fetched live if the account is online; with +neither, you get "No message selected." / "No message to forward." + +Sending requires the account to be online (composing offline fails with +"Account offline: cannot send." — there is no offline outbox). Once sent, a +copy is filed into the account's Sent folder over IMAP; if that filing step +fails, the status line says so, but the message has already left — it is +not resent. + + +## Écrire un message + +`c` ouvre le formulaire : `À`, `Cc`, `Objet`, un champ `Pièces jointes` +(chemins de fichiers séparés par un point-virgule — une virgule est légale +dans un nom de fichier, donc seul `;` sépare les entrées ; il n'y a pas de +sélecteur de fichier, tapez les chemins), et un corps multi-lignes. +`e` envoie le corps vers `$EDITOR` (ou `nano` si non défini) et le relit ; +si l'éditeur manque ou sort en erreur, le texte de départ est conservé tel +quel. `Ctrl+S` (ou le bouton Envoyer) remet le message ; `Échap` abandonne +le brouillon — il n'y a pas d'enregistrement en brouillon. + +`a` (répondre) et `Shift+A` (répondre à tous) préremplissent `À`/`Cc`/ +`Objet`/`In-Reply-To`/`References` et citent le message d'origine dans le +corps. `f` (transférer) préremplit l'objet en `Fwd:` et **rattache le +message d'origine** automatiquement, en pièce jointe `message/rfc822` ; le +corps, lui, part vide — écrivez votre propre mot au-dessus du message +joint. + +Répondre, répondre à tous et transférer ont tous besoin du corps du message +d'origine — depuis le cache, ou récupéré en direct si le compte est en +ligne ; sans l'un ou l'autre, vous obtenez « Aucun message sélectionné. » / +« Aucun message à transférer. ». + +Envoyer exige que le compte soit en ligne (écrire hors ligne échoue avec +« Compte hors ligne : envoi impossible. » — il n'y a pas de file d'attente +hors ligne). Une fois envoyé, une copie est classée dans le dossier +Envoyés du compte par IMAP ; si ce classement échoue, la ligne de statut le +dit, mais le message est déjà parti — il n'est pas renvoyé. + + +## Synchronization + +A sync pass is incremental: only UIDs above the last known one are +fetched, message bodies are never downloaded during a pass (only headers), +and bodies are fetched on demand when you open a message. Flags +(read/unread, etc.) of already-known messages are re-checked on every +pass, so a message read elsewhere shows up correctly here too. + +Sync happens: + +- **At launch** — opening the TUI kicks off one background sync of every + account. +- **On demand** — `r` (current account) / `Shift+R` (all accounts) inside + the TUI, or `Mail > [3] Synchronise now` from the CLI menu (prints a + per-account summary to the terminal). +- **Automatically, every `mail_refresh_sec` seconds** (default 300 = 5 + minutes; 0 disables it) — **but only while the TUI is open**. Close it + and the timer goes with it; nothing syncs in the background afterward. + +If the server reports a changed `UIDVALIDITY` for a folder (its UIDs no +longer mean what they used to — typically after a server-side migration), +that folder's cache is purged and resynced from scratch automatically; +there is currently no on-screen notice when this happens beyond the folder +briefly emptying and refilling. + + +## Synchronisation + +Une passe de synchronisation est incrémentale : seuls les UID supérieurs au +dernier connu sont demandés, le corps des messages n'est jamais téléchargé +pendant une passe (seulement les en-têtes), et les corps sont récupérés à +la demande à l'ouverture d'un message. Les drapeaux (lu/non lu, etc.) des +messages déjà connus sont revérifiés à chaque passe, donc un message lu +ailleurs apparaît correctement lu ici aussi. + +La synchronisation a lieu : + +- **Au lancement** — ouvrir le TUI déclenche une synchronisation de tous + les comptes en arrière-plan. +- **À la demande** — `r` (compte courant) / `Shift+R` (tous les comptes) + dans le TUI, ou `Courriel > [3] Synchroniser maintenant` depuis le menu + CLI (affiche un résumé par compte dans le terminal). +- **Automatiquement, toutes les `mail_refresh_sec` secondes** (défaut 300 = + 5 minutes ; 0 la désactive) — **mais seulement tant que le TUI est + ouvert**. Fermez-le et la minuterie part avec lui ; rien ne se + synchronise en arrière-plan ensuite. + +Si le serveur annonce un `UIDVALIDITY` changé pour un dossier (ses UID ne +veulent plus dire ce qu'ils disaient — typiquement après une migration +côté serveur), le cache de ce dossier est purgé et resynchronisé à partir +de zéro automatiquement ; il n'y a actuellement aucun avis à l'écran +au-delà du dossier qui se vide puis se remplit à nouveau brièvement. + + +## Where the files live + +| Path | Contents | +|---|---| +| `~/.erplibre/mail/accounts.json` | account list — servers, presets, cache mode, and a `secret_ref` pointer; never a password (mode 0600) | +| `~/.erplibre/mail//cache.db` | that account's SQLite cache (mode 0600, parent directory 0700) | +| `~/.erplibre/mail///.eml` (or `.eml.enc` when sealed) | one file per downloaded message body | +| `/dev/shm/erplibre-mail-//` | an `ephemeral` account's cache while the process is alive; removed when it exits (a sweep at every startup also clears directories left behind by a killed process) | + + +## Où sont les fichiers + +| Chemin | Contenu | +|---|---| +| `~/.erplibre/mail/accounts.json` | la liste des comptes — serveurs, préréglages, mode de cache, et une référence `secret_ref` ; jamais un mot de passe (mode 0600) | +| `~/.erplibre/mail//cache.db` | le cache SQLite de ce compte (mode 0600, dossier parent 0700) | +| `~/.erplibre/mail///.eml` (ou `.eml.enc` s'il est scellé) | un fichier par corps de message téléchargé | +| `/dev/shm/erplibre-mail-//` | le cache d'un compte `ephemeral` pendant que le processus vit ; effacé à sa sortie (un balayage au démarrage nettoie aussi ce qu'un processus tué aurait laissé) | + + +## Troubleshooting + +Error messages raised by the mail package itself (`secrets.py`, +`store.py`, `crypto.py`, `accounts.py`, `smtp_send.py`, +`imap_transport.py`, `imap_sync.py`) now go through the CLI's translation +layer, the same as the menu prompts and TUI labels: running the CLI in +English shows them in English. The wording below is quoted in French, this +document's reference language; expect the matching English wording when +`EL_LANG=en`. + +**"Connection failed: ..." when adding or testing an account.** +`Mail > [2] Accounts > [5] Test an account connection` prints the server's +exact error and then asks for the password again — up to 3 attempts. The +password in the vault is only overwritten *after* a successful connection, +so a typo never destroys a working password. If the account is Gmail, +Outlook or iCloud, check first that you used an app password (see +"Prerequisites" above), not the account's normal one. Opening the TUI +itself does not retry automatically: an account with a rejected password +gets a ⚠ marker; if it had synced successfully before, its already-cached +folders stay visible and readable, they just stop refreshing — only a +brand-new account (nothing synced yet) shows no folders at all. Either +way, go run "Test an account connection" to fix it. + +**"le fichier kdbx n'a pas pu être ouvert" when adding an account.** +The shared KDBX vault isn't configured yet, its file picker was cancelled, +or the CLI is running without a display to show that picker. Set +`kdbx.path` (and `kdbx.password`) as described in "Adding an account" +above, then try again. + +**"le trousseau du système écrirait le mot de passe en clair (backend +...)".** +`keyring`'s active backend isn't one of the ones known to actually +encrypt — this happens over SSH, in a container, or on a machine with no +desktop session, where `keyring` silently falls back to a plaintext file +store. The client refuses rather than pretend that's safe. Use the KDBX +vault instead (see above), or run somewhere a real keyring is unlocked. + +**"Install textual for the mail client (pip)."** +`textual` isn't installed. `Mail > [1] Open the mail client (TUI)` just +prints this and returns; every other menu entry (accounts, sync, cache) +still works without it. + +**The folder cache says it changed (`UIDVALIDITY`).** +Nothing to do — the client purges and resyncs that folder by itself the +next time it syncs. Expect the message list to empty briefly and refill. + +**"cache illisible, purgez-le et resynchronisez : ...".** +The account's `cache.db` is corrupt. `Mail > [4] Cache > [3] Cache size and +purge` may itself fail to open the same broken file; if so, delete the +account's cache directory by hand and resync: + + +## Dépannage + +Les messages d'erreur qui viennent du paquet courriel lui-même +(`secrets.py`, `store.py`, `crypto.py`, `accounts.py`, `smtp_send.py`, +`imap_transport.py`, `imap_sync.py`) passent maintenant par la couche de +traduction du CLI, comme les invites de menu et les libellés du TUI : +lancer le CLI en anglais les affiche en anglais. Le libellé ci-dessous est +cité en français, la langue de référence de ce document ; attendez-vous au +libellé anglais correspondant avec `EL_LANG=en`. + +**« Connexion échouée : ... » en ajoutant ou en testant un compte.** +`Courriel > [2] Comptes > [5] Tester la connexion d'un compte` affiche +l'erreur exacte du serveur puis redemande le mot de passe — jusqu'à 3 +tentatives. Le mot de passe dans le coffre n'est écrasé qu'*après* une +connexion réussie, donc une faute de frappe ne détruit jamais un mot de +passe qui fonctionnait. Si le compte est Gmail, Outlook ou iCloud, +vérifiez d'abord que vous avez utilisé un mot de passe d'application (voir +« Prérequis » plus haut), pas le mot de passe normal du compte. Ouvrir le +TUI lui-même ne relance pas cette demande automatiquement : un compte au +mot de passe refusé porte un ⚠ ; s'il avait déjà synchronisé avec succès, +ses dossiers déjà en cache restent visibles et lisibles, ils cessent +seulement de se rafraîchir — seul un compte tout neuf (rien de +synchronisé encore) n'affiche aucun dossier du tout. Dans tous les cas, +passez par « Tester la connexion d'un compte » pour corriger. + +**« le fichier kdbx n'a pas pu être ouvert » en ajoutant un compte.** +Le coffre KDBX partagé n'est pas encore configuré, sa fenêtre de sélection +de fichier a été annulée, ou le CLI tourne sans affichage pour la montrer. +Réglez `kdbx.path` (et `kdbx.password`) comme décrit dans « Ajouter un +compte » plus haut, puis réessayez. + +**« le trousseau du système écrirait le mot de passe en clair (backend +...) ».** +Le backend actif de `keyring` n'est pas de ceux qu'on sait vraiment +chiffrer — ça arrive en SSH, dans un conteneur, ou sur une machine sans +session graphique, où `keyring` retombe silencieusement sur un fichier en +clair. Le client refuse plutôt que de faire semblant que c'est sûr. +Utilisez le coffre KDBX à la place (voir plus haut), ou lancez-le là où un +vrai trousseau est déverrouillé. + +**« Installez textual pour le client courriel (pip). »** +`textual` n'est pas installé. `Courriel > [1] Ouvrir le client courriel +(TUI)` affiche seulement ce message et revient au menu ; tout le reste +(comptes, synchronisation, cache) fonctionne quand même sans lui. + +**Le cache d'un dossier signale qu'il a changé (`UIDVALIDITY`).** +Rien à faire — le client purge et resynchronise ce dossier tout seul à la +prochaine synchronisation. La liste des messages se vide puis se remplit +brièvement. + +**« cache illisible, purgez-le et resynchronisez : ... ».** +Le `cache.db` du compte est corrompu. `Courriel > [4] Cache > [3] Taille +du cache et purge` peut lui-même échouer à ouvrir ce même fichier cassé ; +le cas échéant, effacez à la main le dossier de cache du compte et +resynchronisez : + + +```bash +rm -rf ~/.erplibre/mail// +``` + + +## Testing against a real server + +Almost every mail test uses an in-memory double. A double only produces what +its author imagined, which is how three protocol bugs reached users. So there +is also a **sandbox**: a real IMAP server (Twisted) and a real SMTP server +(aiosmtpd) that a test starts on an ephemeral loopback port, talks to over +real TCP, and kills when it finishes — pass or fail. + +The point is not conformance. A well-behaved server proves little; this one +can **misbehave on purpose**. A test declares the exact bytes a message is +made of — raw 8-bit header bytes, an `unknown-8bit` charset — and can drop the +connection or refuse a command mid-sync. Adding a new hostile behaviour is a +small subclass in `test/mail_sandbox.py`, not a new server. + +These tests do **not** run in the fast loop. Without `twisted` and `aiosmtpd` +the whole file skips visibly. Run them deliberately: + + +## Tester contre un vrai serveur + +Presque tous les tests courriel passent par un double en mémoire. Un double ne +produit que ce que son auteur avait imaginé — c'est par là que trois bugs de +protocole sont arrivés jusqu'aux utilisateurs. D'où un **bac à sable** : un +vrai serveur IMAP (Twisted) et un vrai serveur SMTP (aiosmtpd), qu'un test +démarre sur un port éphémère de la boucle locale, à qui il parle en vrai TCP, +et qu'il tue en terminant — qu'il réussisse ou qu'il échoue. + +Le but n'est pas la conformité. Un serveur poli ne prouve pas grand-chose ; +celui-ci sait **se conduire mal exprès**. Un test déclare les octets exacts +d'un message — en-tête en 8 bits bruts, charset `unknown-8bit` — et peut +couper la connexion ou refuser une commande en pleine synchronisation. +Ajouter une nouvelle méchanceté est une petite sous-classe dans +`test/mail_sandbox.py`, pas un nouveau serveur. + +Ces tests ne tournent **pas** dans la boucle rapide. Sans `twisted` ni +`aiosmtpd`, tout le fichier se saute visiblement. Pour les lancer +volontairement : + + +```bash +.venv.erplibre/bin/python -m unittest discover -s test \ + -p test_mail_live_server.py -v +``` + + +What it does **not** cover, and will not pretend to: + +- **`SPECIAL-USE`** — Twisted announces only `IMAP4REV1 NAMESPACE IDLE`. The + bug where a sent message was filed under a guessed folder name instead of + the one the server announced is therefore out of reach. Implementing the + extension in the sandbox would only test our own assumption about it, which + is the exact failure this sandbox exists to escape. +- **No provider quirk** — Gmail's label-as-folder model, Microsoft's OAuth, + Apple app passwords: none of it is exercised. The sandbox is a plain + RFC 3501 server, not a stand-in for a specific provider. +- **No TLS** — the sandbox talks in the clear on `127.0.0.1`. `starttls` and + `ssl` code paths are not exercised here. +- **Nothing leaves the machine** — no external host, no OS keyring, no + `~/.erplibre`, no real credentials, and never a fixed port. + + +Ce qu'il ne couvre **pas**, et ne fera pas semblant de couvrir : + +- **`SPECIAL-USE`** — Twisted n'annonce que `IMAP4REV1 NAMESPACE IDLE`. Le bug + du message classé sous un nom de dossier deviné plutôt que sous celui + annoncé par le serveur reste donc hors de portée. Implémenter l'extension + dans le bac à sable ne testerait que notre propre supposition à son sujet — + précisément l'erreur que ce bac à sable existe pour éviter. +- **Aucune particularité de fournisseur** — les dossiers-étiquettes de Gmail, + OAuth chez Microsoft, les mots de passe d'application d'Apple : rien de tout + cela n'est exercé. Le bac à sable est un serveur RFC 3501 ordinaire, pas la + doublure d'un fournisseur précis. +- **Pas de TLS** — le bac à sable parle en clair sur `127.0.0.1`. Les chemins + `starttls` et `ssl` ne sont pas exercés ici. +- **Rien ne quitte la machine** — aucun hôte externe, aucun trousseau système, + aucun `~/.erplibre`, aucun identifiant réel, et jamais un port fixe. + + +## Phase 1 limits + +- **No OAuth** — Gmail, Outlook and iCloud need an app password (see + above); OAuth is phase 2. +- **No statistics** — no read/unread counters or activity dashboards beyond + the per-folder unseen count shown in the folder tree. +- **No server-side search** — `/` filters only what's already synced to the + local cache. +- **No offline outbox** — sending requires the account to be online; there + is no queue that flushes once you're back online. + +See the [design spec](../docs/superpowers/specs/2026-08-02-email-tui-design.md) +for what the following phases add. + + +## Limites de la phase 1 + +- **Pas d'OAuth** — Gmail, Outlook et iCloud demandent un mot de passe + d'application (voir plus haut) ; OAuth arrive en phase 2. +- **Pas de statistiques** — aucun compteur lu/non lu global ni tableau de + bord d'activité, au-delà du compte de non-lus par dossier affiché dans + l'arbre. +- **Pas de recherche côté serveur** — `/` ne filtre que ce qui est déjà + synchronisé dans le cache local. +- **Pas de file d'attente hors ligne** — l'envoi exige que le compte soit + en ligne ; rien ne se met en attente pour partir au retour du réseau. + +Voir le [spec de conception](../docs/superpowers/specs/2026-08-02-email-tui-design.md) +pour ce qu'apportent les phases suivantes. diff --git a/doc/EMAIL.fr.md b/doc/EMAIL.fr.md new file mode 100644 index 0000000..c4a4c14 --- /dev/null +++ b/doc/EMAIL.fr.md @@ -0,0 +1,340 @@ + +# Client courriel + +Un client courriel intégré au CLI TODO : plusieurs comptes, IMAP + SMTP, et +un cache local — pour lire et répondre à son courriel sans quitter +`./script/todo/todo.py`. + +Chaque chemin `Courriel > ...` ci-dessous est un raccourci pour +`TODO > [3] Assistant > [2] Courriel - Lire et envoyer du courriel > ...` — le +chemin complet est écrit une fois, dans « Ajouter un compte ». + +## Prérequis + +Quatre paquets Python, déjà listés dans +`requirement/erplibre_require-ments.txt` (l'environnement `.venv.erplibre`, +pas un venv Odoo) : + +- `cryptography` — scelle le cache local en mode `encrypted` et `ephemeral`. +- `keyring` — le trousseau système, l'un des deux endroits où peut vivre un + mot de passe. +- `pykeepass` — le coffre KDBX, l'autre endroit, celui que le client essaie + en premier. +- `textual` — l'interface terminal elle-même. Sans lui, « Ouvrir le client + courriel (TUI) » affiche un message et ne fait rien ; le reste du menu + (comptes, synchronisation, cache) fonctionne quand même. + +Installez-les avec : + +```bash +.venv.erplibre/bin/pip install -r requirement/erplibre_require-ments.txt +``` + +### Mots de passe d'application pour Gmail, Outlook et iCloud + +La phase 1 ne parle qu'IMAP/SMTP en authentification simple — pas encore +OAuth (ça, c'est la phase 2). Gmail, Outlook et iCloud ont tous les trois +fermé cette porte au vrai mot de passe du compte : chacun de ces trois +préréglages exige donc un **mot de passe d'application** à la place : + +| Fournisseur | Où le générer | +|---|---| +| Gmail | Activez la validation en deux étapes, puis [myaccount.google.com](https://myaccount.google.com/security) > Sécurité > Mots de passe des applications | +| Outlook / Microsoft 365 | [account.microsoft.com](https://account.microsoft.com/security) > Sécurité > Options de sécurité avancées > Mots de passe d'application | +| iCloud | [account.apple.com](https://account.apple.com/) > Connexion et sécurité > Mots de passe spécifiques aux applications | + +Utilisez ce mot de passe généré quand la configuration du compte en demande +un — jamais le mot de passe normal du compte. Le préréglage « Serveur +standard » (IMAP/SMTP générique) n'en a pas besoin. + +## Ajouter un compte + +Chemin de menu : `TODO > [3] Assistant > [2] Courriel - Lire et envoyer du +courriel > [2] Comptes > [2] Ajouter un compte`. + +Les questions, dans l'ordre : + +1. **Nom court du compte** — devient à la fois le nom de dossier sous + `~/.erplibre/mail/` et la référence dans le coffre : il ne peut donc pas + contenir `/` ni commencer par un point. +2. **Adresse courriel**. +3. **Nom affiché** (facultatif) — apparaît dans l'en-tête `De :` comme + `Nom affiché `. +4. **Fournisseur** — un numéro dans la liste affichée : Gmail, Outlook, + iCloud, ou « Serveur standard » (IMAP/SMTP générique). +5. Si vous choisissez « Serveur standard », le **serveur IMAP** puis le + **serveur SMTP** sont demandés ensuite ; les autres préréglages les + remplissent déjà pour vous. +6. Si le préréglage exige un mot de passe d'application, sa note s'affiche + ici en rappel. +7. **Mot de passe** — saisi masqué (`getpass`), puis rangé dans le coffre — + jamais écrit dans `accounts.json`. + +Où va le mot de passe : à l'étape du mot de passe, le client passe par le +**gestionnaire KDBX** partagé du CLI — le même que pour la clé OpenAI et +les identifiants Odoo. Il lit `kdbx.path` / `kdbx.password` dans la +configuration TODO (`script/todo/todo.json`, surchargeable dans +`private/todo/todo_override.json` / `private/todo/todo_override_private.json`). +Si `kdbx.path` n'est pas encore réglé, une fenêtre de sélection de fichier +s'ouvre pour choisir un `.kdbx` existant — il faut un affichage graphique, +et l'annuler (ou lancer le CLI sans affichage) fait échouer la création du +compte avec « le fichier kdbx n'a pas pu être ouvert » (voir Dépannage). +**Réglez `kdbx.path` (et `kdbx.password`, pour éviter l'invite) avant +d'ajouter votre premier compte**, en pointant vers un coffre `.kdbx` que +vous avez déjà (créez-en un avec KeePassXC ou équivalent). Le trousseau +système ne sert que pour un compte dont la `secret_ref` le désigne déjà — +le menu écrit toujours les nouveaux comptes dans le coffre KDBX. + +`accounts.json` (dans `~/.erplibre/mail/accounts.json`) ne contient jamais +qu'une `secret_ref` du genre `kdbx:ERPLibre/Mail/perso` — une référence, +jamais le secret. Il est sans danger à lire, à éditer à la main, ou à +mettre dans une sauvegarde privée. + +## Les trois modes de cache + +Chaque compte garde un cache local — une petite base SQLite plus un fichier +par message téléchargé — pour que la boîte de réception reste lisible hors +ligne. Trois modes contrôlent ce que ce cache laisse sur le disque : + +| Mode | Ce qui reste sur le disque | Clé de chiffrement | +|---|---|---| +| `clear` (par défaut) | `~/.erplibre/mail//cache.db` et les fichiers `.eml`, lisibles en clair | aucune | +| `encrypted` | même emplacement, mais l'expéditeur, les destinataires, le sujet, l'extrait, le Message-ID et le corps des messages sont scellés en AES-256-GCM | générée une fois, rangée dans le coffre à côté du mot de passe (`.../cache-key`) | +| `ephemeral` | sous `/dev/shm/erplibre-mail-//` (ou le dossier temporaire système si `/dev/shm` n'est pas inscriptible), scellé comme `encrypted` | tirée en RAM à chaque lancement, jamais écrite nulle part, et tout le dossier est effacé à la fermeture de la session | + +Même en mode `clear`, les champs techniques dont le SQL a besoin pour trier +et filtrer — UID, dossier, date, drapeaux, taille — restent toujours en +clair ; seuls les champs qui identifient des personnes (et le corps du +message) sont scellés, et seulement en `encrypted`/`ephemeral`. + +Réglez le **défaut général** dans `Courriel > [4] Cache > [1] Mode de cache +par défaut` ; c'est la préférence `mail_cache_mode` (défaut `clear`). +**Surchargez-le par compte** dans `Courriel > [4] Cache > [2] Mode de cache +d'un compte` — ceci écrit le champ `cache_mode` du compte dans +`accounts.json` ; le laisser à `null` là-bas veut dire « hérite du défaut +général ». + +`Courriel > [4] Cache > [3] Taille du cache et purge` liste le mode +effectif et l'espace disque de chaque compte, et peut effacer entièrement le +cache d'un compte (après confirmation) — la prochaine synchronisation le +reconstruit à partir de zéro. + +## Le TUI + +`Courriel > [1] Ouvrir le client courriel (TUI)` ouvre un écran en trois +volets : l'arbre comptes/dossiers à gauche, la liste des messages au +centre, et un aperçu à droite, avec une ligne de statut en bas. + +| Touche | Action | +|---|---| +| `↑` `↓` `Tab` | se déplacer dans un volet / changer de volet (comportement par défaut de Textual) | +| `h` | ouvre la fenêtre d'aide : tous les raccourcis et quelques repères, fermée par `Échap` | +| `z` | plein écran sur l'aperçu (masque l'arbre et la liste) | +| `Échap` | quitter le plein écran | +| `v` | change de disposition : colonnes, partagée, empilée | +| `+` / `-` | agrandir / rétrécir le volet qui a le focus | +| `0` | revenir aux tailles de volets par défaut | +| `r` | synchronise le compte du dossier actuellement sélectionné (tous ses dossiers) | +| `Shift+R` | synchronise tous les comptes | +| `/` | ouvre le champ de recherche (filtre seulement la liste déjà affichée — localement, sur sujet/de/à/extrait ; ne cherche pas sur le serveur) | +| `s` / `u` | marquer le message sélectionné lu / non lu | +| `c` | écrire un nouveau message | +| `a` / `Shift+A` | répondre / répondre à tous | +| `f` | transférer | +| `w` | enregistrer la **première** pièce jointe du message dans `~/Téléchargements` (créé s'il n'existe pas) | +| `n` | ajouter un compte sans quitter le client | +| `l` | affiche la fin de `~/.erplibre/mail.log` et les erreurs de synchronisation de la session | +| `q` | quitter | + +Ce tableau est écrit à la main et peut prendre du retard sur le code ; la +fenêtre `h`, elle, ne le peut pas : elle construit sa liste depuis les +liaisons de l'application à chaque ouverture. En cas de désaccord entre les +deux, c'est elle qui a raison. + +Les barres entre les volets se glissent aussi à la souris, et les tailles +sont retenues par disposition. + +Les indices de touches du pied d'écran, comme la fenêtre d'aide, suivent la +langue choisie dans le CLI, tout comme l'arbre des comptes, la liste et le +texte d'aperçu. + +## Écrire un message + +`c` ouvre le formulaire : `À`, `Cc`, `Objet`, un champ `Pièces jointes` +(chemins de fichiers séparés par un point-virgule — une virgule est légale +dans un nom de fichier, donc seul `;` sépare les entrées ; il n'y a pas de +sélecteur de fichier, tapez les chemins), et un corps multi-lignes. +`e` envoie le corps vers `$EDITOR` (ou `nano` si non défini) et le relit ; +si l'éditeur manque ou sort en erreur, le texte de départ est conservé tel +quel. `Ctrl+S` (ou le bouton Envoyer) remet le message ; `Échap` abandonne +le brouillon — il n'y a pas d'enregistrement en brouillon. + +`a` (répondre) et `Shift+A` (répondre à tous) préremplissent `À`/`Cc`/ +`Objet`/`In-Reply-To`/`References` et citent le message d'origine dans le +corps. `f` (transférer) préremplit l'objet en `Fwd:` et **rattache le +message d'origine** automatiquement, en pièce jointe `message/rfc822` ; le +corps, lui, part vide — écrivez votre propre mot au-dessus du message +joint. + +Répondre, répondre à tous et transférer ont tous besoin du corps du message +d'origine — depuis le cache, ou récupéré en direct si le compte est en +ligne ; sans l'un ou l'autre, vous obtenez « Aucun message sélectionné. » / +« Aucun message à transférer. ». + +Envoyer exige que le compte soit en ligne (écrire hors ligne échoue avec +« Compte hors ligne : envoi impossible. » — il n'y a pas de file d'attente +hors ligne). Une fois envoyé, une copie est classée dans le dossier +Envoyés du compte par IMAP ; si ce classement échoue, la ligne de statut le +dit, mais le message est déjà parti — il n'est pas renvoyé. + +## Synchronisation + +Une passe de synchronisation est incrémentale : seuls les UID supérieurs au +dernier connu sont demandés, le corps des messages n'est jamais téléchargé +pendant une passe (seulement les en-têtes), et les corps sont récupérés à +la demande à l'ouverture d'un message. Les drapeaux (lu/non lu, etc.) des +messages déjà connus sont revérifiés à chaque passe, donc un message lu +ailleurs apparaît correctement lu ici aussi. + +La synchronisation a lieu : + +- **Au lancement** — ouvrir le TUI déclenche une synchronisation de tous + les comptes en arrière-plan. +- **À la demande** — `r` (compte courant) / `Shift+R` (tous les comptes) + dans le TUI, ou `Courriel > [3] Synchroniser maintenant` depuis le menu + CLI (affiche un résumé par compte dans le terminal). +- **Automatiquement, toutes les `mail_refresh_sec` secondes** (défaut 300 = + 5 minutes ; 0 la désactive) — **mais seulement tant que le TUI est + ouvert**. Fermez-le et la minuterie part avec lui ; rien ne se + synchronise en arrière-plan ensuite. + +Si le serveur annonce un `UIDVALIDITY` changé pour un dossier (ses UID ne +veulent plus dire ce qu'ils disaient — typiquement après une migration +côté serveur), le cache de ce dossier est purgé et resynchronisé à partir +de zéro automatiquement ; il n'y a actuellement aucun avis à l'écran +au-delà du dossier qui se vide puis se remplit à nouveau brièvement. + +## Où sont les fichiers + +| Chemin | Contenu | +|---|---| +| `~/.erplibre/mail/accounts.json` | la liste des comptes — serveurs, préréglages, mode de cache, et une référence `secret_ref` ; jamais un mot de passe (mode 0600) | +| `~/.erplibre/mail//cache.db` | le cache SQLite de ce compte (mode 0600, dossier parent 0700) | +| `~/.erplibre/mail///.eml` (ou `.eml.enc` s'il est scellé) | un fichier par corps de message téléchargé | +| `/dev/shm/erplibre-mail-//` | le cache d'un compte `ephemeral` pendant que le processus vit ; effacé à sa sortie (un balayage au démarrage nettoie aussi ce qu'un processus tué aurait laissé) | + +## Dépannage + +Les messages d'erreur qui viennent du paquet courriel lui-même +(`secrets.py`, `store.py`, `crypto.py`, `accounts.py`, `smtp_send.py`, +`imap_transport.py`, `imap_sync.py`) passent maintenant par la couche de +traduction du CLI, comme les invites de menu et les libellés du TUI : +lancer le CLI en anglais les affiche en anglais. Le libellé ci-dessous est +cité en français, la langue de référence de ce document ; attendez-vous au +libellé anglais correspondant avec `EL_LANG=en`. + +**« Connexion échouée : ... » en ajoutant ou en testant un compte.** +`Courriel > [2] Comptes > [5] Tester la connexion d'un compte` affiche +l'erreur exacte du serveur puis redemande le mot de passe — jusqu'à 3 +tentatives. Le mot de passe dans le coffre n'est écrasé qu'*après* une +connexion réussie, donc une faute de frappe ne détruit jamais un mot de +passe qui fonctionnait. Si le compte est Gmail, Outlook ou iCloud, +vérifiez d'abord que vous avez utilisé un mot de passe d'application (voir +« Prérequis » plus haut), pas le mot de passe normal du compte. Ouvrir le +TUI lui-même ne relance pas cette demande automatiquement : un compte au +mot de passe refusé porte un ⚠ ; s'il avait déjà synchronisé avec succès, +ses dossiers déjà en cache restent visibles et lisibles, ils cessent +seulement de se rafraîchir — seul un compte tout neuf (rien de +synchronisé encore) n'affiche aucun dossier du tout. Dans tous les cas, +passez par « Tester la connexion d'un compte » pour corriger. + +**« le fichier kdbx n'a pas pu être ouvert » en ajoutant un compte.** +Le coffre KDBX partagé n'est pas encore configuré, sa fenêtre de sélection +de fichier a été annulée, ou le CLI tourne sans affichage pour la montrer. +Réglez `kdbx.path` (et `kdbx.password`) comme décrit dans « Ajouter un +compte » plus haut, puis réessayez. + +**« le trousseau du système écrirait le mot de passe en clair (backend +...) ».** +Le backend actif de `keyring` n'est pas de ceux qu'on sait vraiment +chiffrer — ça arrive en SSH, dans un conteneur, ou sur une machine sans +session graphique, où `keyring` retombe silencieusement sur un fichier en +clair. Le client refuse plutôt que de faire semblant que c'est sûr. +Utilisez le coffre KDBX à la place (voir plus haut), ou lancez-le là où un +vrai trousseau est déverrouillé. + +**« Installez textual pour le client courriel (pip). »** +`textual` n'est pas installé. `Courriel > [1] Ouvrir le client courriel +(TUI)` affiche seulement ce message et revient au menu ; tout le reste +(comptes, synchronisation, cache) fonctionne quand même sans lui. + +**Le cache d'un dossier signale qu'il a changé (`UIDVALIDITY`).** +Rien à faire — le client purge et resynchronise ce dossier tout seul à la +prochaine synchronisation. La liste des messages se vide puis se remplit +brièvement. + +**« cache illisible, purgez-le et resynchronisez : ... ».** +Le `cache.db` du compte est corrompu. `Courriel > [4] Cache > [3] Taille +du cache et purge` peut lui-même échouer à ouvrir ce même fichier cassé ; +le cas échéant, effacez à la main le dossier de cache du compte et +resynchronisez : + +```bash +rm -rf ~/.erplibre/mail// +``` + +## Tester contre un vrai serveur + +Presque tous les tests courriel passent par un double en mémoire. Un double ne +produit que ce que son auteur avait imaginé — c'est par là que trois bugs de +protocole sont arrivés jusqu'aux utilisateurs. D'où un **bac à sable** : un +vrai serveur IMAP (Twisted) et un vrai serveur SMTP (aiosmtpd), qu'un test +démarre sur un port éphémère de la boucle locale, à qui il parle en vrai TCP, +et qu'il tue en terminant — qu'il réussisse ou qu'il échoue. + +Le but n'est pas la conformité. Un serveur poli ne prouve pas grand-chose ; +celui-ci sait **se conduire mal exprès**. Un test déclare les octets exacts +d'un message — en-tête en 8 bits bruts, charset `unknown-8bit` — et peut +couper la connexion ou refuser une commande en pleine synchronisation. +Ajouter une nouvelle méchanceté est une petite sous-classe dans +`test/mail_sandbox.py`, pas un nouveau serveur. + +Ces tests ne tournent **pas** dans la boucle rapide. Sans `twisted` ni +`aiosmtpd`, tout le fichier se saute visiblement. Pour les lancer +volontairement : + +```bash +.venv.erplibre/bin/python -m unittest discover -s test \ + -p test_mail_live_server.py -v +``` + +Ce qu'il ne couvre **pas**, et ne fera pas semblant de couvrir : + +- **`SPECIAL-USE`** — Twisted n'annonce que `IMAP4REV1 NAMESPACE IDLE`. Le bug + du message classé sous un nom de dossier deviné plutôt que sous celui + annoncé par le serveur reste donc hors de portée. Implémenter l'extension + dans le bac à sable ne testerait que notre propre supposition à son sujet — + précisément l'erreur que ce bac à sable existe pour éviter. +- **Aucune particularité de fournisseur** — les dossiers-étiquettes de Gmail, + OAuth chez Microsoft, les mots de passe d'application d'Apple : rien de tout + cela n'est exercé. Le bac à sable est un serveur RFC 3501 ordinaire, pas la + doublure d'un fournisseur précis. +- **Pas de TLS** — le bac à sable parle en clair sur `127.0.0.1`. Les chemins + `starttls` et `ssl` ne sont pas exercés ici. +- **Rien ne quitte la machine** — aucun hôte externe, aucun trousseau système, + aucun `~/.erplibre`, aucun identifiant réel, et jamais un port fixe. + +## Limites de la phase 1 + +- **Pas d'OAuth** — Gmail, Outlook et iCloud demandent un mot de passe + d'application (voir plus haut) ; OAuth arrive en phase 2. +- **Pas de statistiques** — aucun compteur lu/non lu global ni tableau de + bord d'activité, au-delà du compte de non-lus par dossier affiché dans + l'arbre. +- **Pas de recherche côté serveur** — `/` ne filtre que ce qui est déjà + synchronisé dans le cache local. +- **Pas de file d'attente hors ligne** — l'envoi exige que le compte soit + en ligne ; rien ne se met en attente pour partir au retour du réseau. + +Voir le [spec de conception](../docs/superpowers/specs/2026-08-02-email-tui-design.md) +pour ce qu'apportent les phases suivantes. \ No newline at end of file diff --git a/doc/EMAIL.md b/doc/EMAIL.md new file mode 100644 index 0000000..cecb420 --- /dev/null +++ b/doc/EMAIL.md @@ -0,0 +1,318 @@ + +# Mail client + +A mail client built into the TODO CLI: several accounts, IMAP + SMTP, and a +local cache — so you can read and answer email without leaving +`./script/todo/todo.py`. + +Every `Mail > ...` path below is shorthand for +`TODO > [3] Assistant > [2] Mail - Read and send email > ...` — the full path +is spelled out once, in "Adding an account". + +## Prerequisites + +Four Python packages, already listed in `requirement/erplibre_require-ments.txt` +(the `.venv.erplibre` environment, not an Odoo venv): + +- `cryptography` — seals the local cache in `encrypted` and `ephemeral` mode. +- `keyring` — the system keyring, one of the two places a password can live. +- `pykeepass` — the KDBX vault, the other place, and the one the client tries + first. +- `textual` — the terminal UI itself. Without it, "Open the mail client + (TUI)" prints a message and does nothing; the rest of the menu (accounts, + sync, cache) still works. + +Install them with: + +```bash +.venv.erplibre/bin/pip install -r requirement/erplibre_require-ments.txt +``` + +### App passwords for Gmail, Outlook and iCloud + +Phase 1 speaks plain IMAP/SMTP login only — no OAuth yet (that is phase 2). +Gmail, Outlook and iCloud have all closed that door to the account's real +password, so each of these three presets requires an **app password** +instead: + +| Provider | Where to generate it | +|---|---| +| Gmail | Enable 2-step verification, then [myaccount.google.com](https://myaccount.google.com/security) > Security > App passwords | +| Outlook / Microsoft 365 | [account.microsoft.com](https://account.microsoft.com/security) > Security > Advanced security options > App passwords | +| iCloud | [account.apple.com](https://account.apple.com/) > Sign-In and Security > App-Specific Passwords | + +Use that generated password when account setup asks for one — never the +account's normal password. The "Standard server" preset (generic IMAP/SMTP) +does not need one. + +## Adding an account + +Menu path: `TODO > [3] Assistant > [2] Mail - Read and send email > [2] +Accounts > [2] Add an account`. + +The prompts, in order: + +1. **Short account name** — becomes both the folder name under + `~/.erplibre/mail/` and the vault reference, so it cannot contain `/` or + start with a dot. +2. **Email address**. +3. **Display name** (optional) — shown in the `From:` header as + `Display Name `. +4. **Provider** — a number from the printed list: Gmail, Outlook, iCloud, or + "Standard server" (generic IMAP/SMTP). +5. If you picked "Standard server", the **IMAP host** and **SMTP host** are + asked next; the other presets fill these in for you. +6. If the preset requires an app password, its note is printed here as a + reminder. +7. **Password** — typed hidden (`getpass`), then stored — never written to + `accounts.json`. + +Where the password goes: at the password step, the client hands off to the +CLI's shared **KDBX manager** — the same one already used for the OpenAI +key and Odoo credentials. It reads `kdbx.path` / `kdbx.password` from the +TODO config (`script/todo/todo.json`, overridable in +`private/todo/todo_override.json` / `private/todo/todo_override_private.json`). +If `kdbx.path` isn't set yet, a graphical file picker pops up asking you to +choose an existing `.kdbx` file — it needs a display, and cancelling it (or +running headless) fails account creation with "le fichier kdbx n'a pas pu +être ouvert" (French — see Troubleshooting). **Set `kdbx.path` (and +`kdbx.password`, to skip the prompt) before adding your first account**, +pointing at a `.kdbx` vault you already have (create one with KeePassXC or +similar). The system keyring is only ever used for an account whose +`secret_ref` already points at one — the menu itself always writes new +accounts into the KDBX vault. + +`accounts.json` (at `~/.erplibre/mail/accounts.json`) only ever holds a +`secret_ref` such as `kdbx:ERPLibre/Mail/perso` — a pointer, never the +secret. It is safe to read, edit by hand, or check into a private backup. + +## The three cache modes + +Every account keeps a local cache — a small SQLite database plus one file +per downloaded message — so the inbox stays readable offline. Three modes +control what that cache leaves on disk: + +| Mode | What's on disk | Encryption key | +|---|---|---| +| `clear` (default) | `~/.erplibre/mail//cache.db` and `.eml` files, readable as plain text | none | +| `encrypted` | same location, but sender, recipients, subject, snippet, message-id and message bodies are sealed with AES-256-GCM | generated once, stored in the vault next to the password (`.../cache-key`) | +| `ephemeral` | under `/dev/shm/erplibre-mail-//` (or the system temp dir if `/dev/shm` isn't writable), sealed the same way as `encrypted` | generated fresh in RAM at every run, never written anywhere, and the whole directory is removed when the session closes | + +Even in `clear` mode, the technical fields the SQL needs to sort and +filter — UID, folder, date, flags, size — are always plain; only the +person-identifying fields (and the message body) are ever sealed, and only +in `encrypted`/`ephemeral`. + +Set the **general default** at `Mail > [4] Cache > [1] Default cache mode`; +it is the `mail_cache_mode` preference (default `clear`). **Override it per +account** at `Mail > [4] Cache > [2] Cache mode of one account` — this +writes the account's `cache_mode` field in `accounts.json`; leaving it at +`null` there means "inherit the general default." + +`Mail > [4] Cache > [3] Cache size and purge` lists every account's +effective mode and disk usage, and can erase one account's cache entirely +(after confirmation) — the next sync rebuilds it from scratch. + +## The TUI + +`Mail > [1] Open the mail client (TUI)` opens a three-pane screen: an +account/folder tree on the left, the message list in the middle, and a +preview pane on the right, with a status line at the bottom. + +| Key | Action | +|---|---| +| `↑` `↓` `Tab` | move within a pane / move focus between panes (Textual defaults) | +| `h` | open the help window: every shortcut plus a few notes, closed with `Escape` | +| `z` | toggle full-screen preview (hides the folder tree and the message list) | +| `Escape` | leave full-screen | +| `v` | cycle the layout: columns, split, stacked | +| `+` / `-` | grow / shrink the pane that has focus | +| `0` | back to the default pane sizes | +| `r` | sync the account of the currently selected folder (all its folders) | +| `Shift+R` | sync every account | +| `/` | open the search field (filters the currently visible list only — locally, over subject/from/to/snippet; it does not search the server) | +| `s` / `u` | mark the selected message seen / unseen | +| `c` | compose a new message | +| `a` / `Shift+A` | reply / reply all | +| `f` | forward | +| `w` | save the message's **first** attachment to `~/Téléchargements` (created if missing) | +| `n` | add an account without leaving the client | +| `l` | show the tail of `~/.erplibre/mail.log` and this session's sync errors | +| `q` | quit | + +This table is written by hand and can fall behind the code; the `h` window +cannot. It builds its list from the application's own key bindings every time +it opens, so it is the reference if the two ever disagree. + +The bars between the panes can also be dragged with the mouse, and pane sizes +are remembered per layout. + +The footer's key hints, like the help window, follow the CLI's chosen +language, as do the account tree, the message list and the preview text. + +## Writing a message + +`c` opens the compose form: `To`, `Cc`, `Subject`, an `Attachments` field +(semicolon-separated file paths — a comma is legal in a filename, so only +`;` splits entries; there is no file picker, type the paths), and a +multi-line body. `e` sends the body out to `$EDITOR` (or +`nano` if unset) and reads it back; if the editor is missing or exits with +an error, the body you had is kept untouched. `Ctrl+S` (or the Send button) +delivers the message; `Escape` discards the draft — there is no +save-as-draft. + +`a` (reply) and `Shift+A` (reply all) prefill `To`/`Cc`/`Subject`/ +`In-Reply-To`/`References` and quote the original message in the body. `f` +(forward) prefills the `Fwd:` subject and **attaches the original message** +automatically, as a `message/rfc822` attachment; the body itself starts +empty — write your own note above the attached original. + +Reply, reply-all and forward all need the original message's body +available — from the cache, or fetched live if the account is online; with +neither, you get "No message selected." / "No message to forward." + +Sending requires the account to be online (composing offline fails with +"Account offline: cannot send." — there is no offline outbox). Once sent, a +copy is filed into the account's Sent folder over IMAP; if that filing step +fails, the status line says so, but the message has already left — it is +not resent. + +## Synchronization + +A sync pass is incremental: only UIDs above the last known one are +fetched, message bodies are never downloaded during a pass (only headers), +and bodies are fetched on demand when you open a message. Flags +(read/unread, etc.) of already-known messages are re-checked on every +pass, so a message read elsewhere shows up correctly here too. + +Sync happens: + +- **At launch** — opening the TUI kicks off one background sync of every + account. +- **On demand** — `r` (current account) / `Shift+R` (all accounts) inside + the TUI, or `Mail > [3] Synchronise now` from the CLI menu (prints a + per-account summary to the terminal). +- **Automatically, every `mail_refresh_sec` seconds** (default 300 = 5 + minutes; 0 disables it) — **but only while the TUI is open**. Close it + and the timer goes with it; nothing syncs in the background afterward. + +If the server reports a changed `UIDVALIDITY` for a folder (its UIDs no +longer mean what they used to — typically after a server-side migration), +that folder's cache is purged and resynced from scratch automatically; +there is currently no on-screen notice when this happens beyond the folder +briefly emptying and refilling. + +## Where the files live + +| Path | Contents | +|---|---| +| `~/.erplibre/mail/accounts.json` | account list — servers, presets, cache mode, and a `secret_ref` pointer; never a password (mode 0600) | +| `~/.erplibre/mail//cache.db` | that account's SQLite cache (mode 0600, parent directory 0700) | +| `~/.erplibre/mail///.eml` (or `.eml.enc` when sealed) | one file per downloaded message body | +| `/dev/shm/erplibre-mail-//` | an `ephemeral` account's cache while the process is alive; removed when it exits (a sweep at every startup also clears directories left behind by a killed process) | + +## Troubleshooting + +Error messages raised by the mail package itself (`secrets.py`, +`store.py`, `crypto.py`, `accounts.py`, `smtp_send.py`, +`imap_transport.py`, `imap_sync.py`) now go through the CLI's translation +layer, the same as the menu prompts and TUI labels: running the CLI in +English shows them in English. The wording below is quoted in French, this +document's reference language; expect the matching English wording when +`EL_LANG=en`. + +**"Connection failed: ..." when adding or testing an account.** +`Mail > [2] Accounts > [5] Test an account connection` prints the server's +exact error and then asks for the password again — up to 3 attempts. The +password in the vault is only overwritten *after* a successful connection, +so a typo never destroys a working password. If the account is Gmail, +Outlook or iCloud, check first that you used an app password (see +"Prerequisites" above), not the account's normal one. Opening the TUI +itself does not retry automatically: an account with a rejected password +gets a ⚠ marker; if it had synced successfully before, its already-cached +folders stay visible and readable, they just stop refreshing — only a +brand-new account (nothing synced yet) shows no folders at all. Either +way, go run "Test an account connection" to fix it. + +**"le fichier kdbx n'a pas pu être ouvert" when adding an account.** +The shared KDBX vault isn't configured yet, its file picker was cancelled, +or the CLI is running without a display to show that picker. Set +`kdbx.path` (and `kdbx.password`) as described in "Adding an account" +above, then try again. + +**"le trousseau du système écrirait le mot de passe en clair (backend +...)".** +`keyring`'s active backend isn't one of the ones known to actually +encrypt — this happens over SSH, in a container, or on a machine with no +desktop session, where `keyring` silently falls back to a plaintext file +store. The client refuses rather than pretend that's safe. Use the KDBX +vault instead (see above), or run somewhere a real keyring is unlocked. + +**"Install textual for the mail client (pip)."** +`textual` isn't installed. `Mail > [1] Open the mail client (TUI)` just +prints this and returns; every other menu entry (accounts, sync, cache) +still works without it. + +**The folder cache says it changed (`UIDVALIDITY`).** +Nothing to do — the client purges and resyncs that folder by itself the +next time it syncs. Expect the message list to empty briefly and refill. + +**"cache illisible, purgez-le et resynchronisez : ...".** +The account's `cache.db` is corrupt. `Mail > [4] Cache > [3] Cache size and +purge` may itself fail to open the same broken file; if so, delete the +account's cache directory by hand and resync: + +```bash +rm -rf ~/.erplibre/mail// +``` + +## Testing against a real server + +Almost every mail test uses an in-memory double. A double only produces what +its author imagined, which is how three protocol bugs reached users. So there +is also a **sandbox**: a real IMAP server (Twisted) and a real SMTP server +(aiosmtpd) that a test starts on an ephemeral loopback port, talks to over +real TCP, and kills when it finishes — pass or fail. + +The point is not conformance. A well-behaved server proves little; this one +can **misbehave on purpose**. A test declares the exact bytes a message is +made of — raw 8-bit header bytes, an `unknown-8bit` charset — and can drop the +connection or refuse a command mid-sync. Adding a new hostile behaviour is a +small subclass in `test/mail_sandbox.py`, not a new server. + +These tests do **not** run in the fast loop. Without `twisted` and `aiosmtpd` +the whole file skips visibly. Run them deliberately: + +```bash +.venv.erplibre/bin/python -m unittest discover -s test \ + -p test_mail_live_server.py -v +``` + +What it does **not** cover, and will not pretend to: + +- **`SPECIAL-USE`** — Twisted announces only `IMAP4REV1 NAMESPACE IDLE`. The + bug where a sent message was filed under a guessed folder name instead of + the one the server announced is therefore out of reach. Implementing the + extension in the sandbox would only test our own assumption about it, which + is the exact failure this sandbox exists to escape. +- **No provider quirk** — Gmail's label-as-folder model, Microsoft's OAuth, + Apple app passwords: none of it is exercised. The sandbox is a plain + RFC 3501 server, not a stand-in for a specific provider. +- **No TLS** — the sandbox talks in the clear on `127.0.0.1`. `starttls` and + `ssl` code paths are not exercised here. +- **Nothing leaves the machine** — no external host, no OS keyring, no + `~/.erplibre`, no real credentials, and never a fixed port. + +## Phase 1 limits + +- **No OAuth** — Gmail, Outlook and iCloud need an app password (see + above); OAuth is phase 2. +- **No statistics** — no read/unread counters or activity dashboards beyond + the per-folder unseen count shown in the folder tree. +- **No server-side search** — `/` filters only what's already synced to the + local cache. +- **No offline outbox** — sending requires the account to be online; there + is no queue that flushes once you're back online. + +See the [design spec](../docs/superpowers/specs/2026-08-02-email-tui-design.md) +for what the following phases add. diff --git a/doc/TODO.base.md b/doc/TODO.base.md index 2834cb7..ed6885f 100644 --- a/doc/TODO.base.md +++ b/doc/TODO.base.md @@ -33,3 +33,11 @@ TODO: having the DB variable configurable À FAIRE : rendre la variable DB configurable + + +See also: [EMAIL.md](EMAIL.md) — the mail client built into the TODO CLI +(`Assistant > Mail`). + + +Voir aussi : [EMAIL.fr.md](EMAIL.fr.md) — le client courriel intégré au CLI +TODO (`Assistant > Courriel`). diff --git a/doc/TODO.fr.md b/doc/TODO.fr.md index 3b937c3..84b6740 100644 --- a/doc/TODO.fr.md +++ b/doc/TODO.fr.md @@ -11,4 +11,7 @@ Une base de données est installée mais on n'arrive pas à l'exécuter HEALTHCHECK CMD curl --fail http://localhost:8069/web || exit 1 -À FAIRE : rendre la variable DB configurable \ No newline at end of file +À FAIRE : rendre la variable DB configurable + +Voir aussi : [EMAIL.fr.md](EMAIL.fr.md) — le client courriel intégré au CLI +TODO (`Assistant > Courriel`). \ No newline at end of file diff --git a/doc/TODO.md b/doc/TODO.md index 90f0f64..ef2a322 100644 --- a/doc/TODO.md +++ b/doc/TODO.md @@ -12,3 +12,6 @@ A database is installed but cannot be executed HEALTHCHECK CMD curl --fail http://localhost:8069/web || exit 1 TODO: having the DB variable configurable + +See also: [EMAIL.md](EMAIL.md) — the mail client built into the TODO CLI +(`Assistant > Mail`). diff --git a/private/.gitignore b/private/.gitignore index 5e440ed..12cb007 100644 --- a/private/.gitignore +++ b/private/.gitignore @@ -1 +1,6 @@ *.kdbx + +# Per-database migration lists (modules to uninstall/install before a version +# bump). They describe one specific database, never a shared default, so they +# must not be versioned. Shared defaults belong to script/odoo/migration/. +odoo/ diff --git a/requirement/erplibre_require-ments.txt b/requirement/erplibre_require-ments.txt index 9016738..b170dc0 100644 --- a/requirement/erplibre_require-ments.txt +++ b/requirement/erplibre_require-ments.txt @@ -17,6 +17,8 @@ uvloop python-randomword-fr isort pykeepass +cryptography +keyring click aioshutil python-magic @@ -52,6 +54,14 @@ virtualenv==20.36.1 git+https://github.com/psf/black.git@24.8.0 pre-commit +# Bac à sable du client courriel : un VRAI serveur SMTP et un VRAI serveur +# IMAP, sur loopback, pour tester ce qu'un faux transport ne peut pas — +# littéraux IMAP, en-têtes 8 bits, coupures en plein FETCH. Utilisés +# UNIQUEMENT par les tests, jamais par le client lui-même : les tests +# concernés s'ignorent d'eux-mêmes si ces paquets manquent. +aiosmtpd +twisted + odoo-module-migrator # Ignore because need installation system diff --git a/script/analyse/analyse_custom_field.py b/script/analyse/analyse_custom_field.py new file mode 100644 index 0000000..480a180 --- /dev/null +++ b/script/analyse/analyse_custom_field.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Champs et modèles ajoutés hors module : Studio, ou faits à la main. + +Ce que l'outil répond : ce qu'un intégrateur devra reporter à la main lors +d'une montée de version, puisque rien ne le recréera. Un champ ``x_`` n'est +déclaré dans aucun fichier ; il ne vit que dans ``ir_model_fields``, et une +migration qui le perd perd aussi les données de sa colonne. + +Studio n'est pas nécessaire pour lire ça +----------------------------------------- +``web_studio`` est un module Enterprise, absent de ce dépôt. Les champs qu'il +crée restent pourtant de simples lignes de ``ir_model_fields`` avec +``state = 'manual'``, et une base migrée depuis une instance Enterprise garde +ses identifiants externes ``studio_customization``. Tout se lit en SQL. + +Attribuer un champ à Studio demande deux signaux, pas un +--------------------------------------------------------- +Un champ peut porter PLUSIEURS identifiants externes. Une jointure plate n'en +rendrait qu'un, choisi au hasard : Studio passerait inaperçu une fois sur +deux. Les modules sont donc agrégés, et l'appartenance testée sur l'ensemble. + +Le préfixe ``x_studio_`` est un indice de plus, jamais le seul : un champ créé +à la main en mode développeur s'appelle aussi ``x_quelque_chose``, et ce qui +le distingue est qu'il n'a AUCUN identifiant externe. + +Ce qui bloque, et ce qui ne fait que coûter +-------------------------------------------- +Un champ stocké dont la colonne physique manque empêche le registre de +charger : c'est un blocage. Un champ dont la relation pointe vers un modèle +disparu aussi. Le reste — des champs remplis à reporter, des champs vides à +supprimer avant de migrer — est du travail, pas une panne, et le rapport ne +les mélange pas. +""" + +import argparse +import json +import os +import sys +import textwrap + +new_path = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +sys.path.append(new_path) + +from script.analyse.lib_analyse import ( # noqa: E402 + AnalyseError, + backup_version, + column_types, + existing_columns, + json_query, + model_table, + normalise_arch, + public_tables, + read_backup, + require_odoo_database, + scalar_query, + t, + tr_col, +) + +STUDIO_MODULE = "studio_customization" + +# Un champ relationnel « vers plusieurs » n'a pas de colonne : il vit dans une +# table de relation. Ne pas l'exclure ferait rapporter chaque one2many et +# chaque many2many comme une colonne manquante. +NO_COLUMN_TYPES = ("one2many", "many2many") + +TOP_DEFAULT = 30 + + +def wrap_note(prefix, text, width=79): + """Replier une phrase à l'affichage, sans la découper en clés.""" + lines = textwrap.wrap(text, width=width - len(prefix)) or [""] + pad = " " * len(prefix) + return [prefix + lines[0]] + [pad + line for line in lines[1:]] + + +def origin_label(name): + """Libellé traduit d'une provenance. Épelé, pour que le contrôle voie.""" + return { + "studio": t("Studio"), + "handmade": t("Made by hand"), + "module": t("Declared by a module"), + }.get(name, name) + + +def blocker_label(name): + """Libellé traduit d'un blocage. Épelé, comme les provenances.""" + return { + "missing_column": t("stored, but its column is missing"), + "dangling_relation": t("points at a model that no longer exists"), + "model_gone": t("its model no longer exists"), + "table_unknown": t("its table could not be resolved"), + }.get(name, name) + + +def field_origin(row): + """D'où vient ce champ : Studio, fait à la main, ou déclaré par un module. + + Fonction pure, testable sur fixture. + """ + lst_module = row.get("xmlid_modules") or [] + if STUDIO_MODULE in lst_module: + return "studio" + if not lst_module: + return "handmade" + return "module" + + +def _field_rows(database, **kwargs): + """Les champs manuels, avec tout ce qui sert à les juger. + + ``state = 'manual'`` est le critère d'Odoo ; le motif sur le nom l'élargit + aux bases dont la contrainte n'a pas toujours été posée. Les deux, parce + qu'aucun n'est complet seul. + """ + cols = existing_columns(database, "ir_model_fields", **kwargs) + dct_type = column_types(database, "ir_model_fields", **kwargs) + + def col(column, absent): + return f"f.{column}" if column in cols else absent + + label = tr_col("f", "field_description", dct_type) + help_text = tr_col("f", "help", dct_type) + return json_query( + database, + rf""" + SELECT f.id AS id, + f.model AS model, + f.name AS name, + f.ttype AS ttype, + {col("relation", "NULL::text")} AS relation, + {col("related", "NULL::text")} AS related, + {col("required", "false")} AS required, + {col("readonly", "false")} AS readonly, + {col("store", "true")} AS store, + {col("index", "false")} AS indexed, + {col("translate", "false")} AS translate, + {col("company_dependent", "false")} AS company_dependent, + ({col("compute", "NULL")} IS NOT NULL + AND {col("compute", "''")} <> '') AS is_computed, + {label} AS label, + {help_text} AS help, + {col("state", "NULL::text")} AS state, + x.modules AS xmlid_modules, + {col("create_date", "NULL::timestamp")} AS create_date, + {col("write_date", "NULL::timestamp")} AS write_date + FROM ir_model_fields f + LEFT JOIN ( + SELECT res_id, array_agg(DISTINCT module) AS modules + FROM ir_model_data + WHERE model = 'ir.model.fields' + GROUP BY res_id + ) x ON x.res_id = f.id + WHERE {col("state", "''")} = 'manual' OR f.name LIKE 'x\_%' + ORDER BY f.model, f.name + """, + **kwargs, + ) + + +def _model_rows(database, **kwargs): + """Les modèles manuels — ceux que Studio crée comme objets personnalisés.""" + dct_type = column_types(database, "ir_model", **kwargs) + label = tr_col("m", "name", dct_type) + return json_query( + database, + rf""" + SELECT m.model AS model, + {label} AS description, + m.state AS state, + m.transient AS transient, + x.modules AS xmlid_modules + FROM ir_model m + LEFT JOIN ( + SELECT res_id, array_agg(DISTINCT module) AS modules + FROM ir_model_data + WHERE model = 'ir.model' + GROUP BY res_id + ) x ON x.res_id = m.id + WHERE m.state = 'manual' OR m.model LIKE 'x\_%' + ORDER BY m.model + """, + **kwargs, + ) + + +def _selection_rows(database, **kwargs): + """Valeurs de sélection des champs manuels, si la table existe. + + ``ir_model_fields_selection`` est apparue en cours de route : avant, les + valeurs vivaient dans une chaîne du champ. On sonde plutôt que de dater. + """ + if not scalar_query( + database, + "SELECT to_regclass('public.ir_model_fields_selection');", + **kwargs, + ): + return {} + rows = json_query( + database, + """ + SELECT f.model AS model, f.name AS name, s.value AS value + FROM ir_model_fields_selection s + JOIN ir_model_fields f ON f.id = s.field_id + WHERE f.state = 'manual' + ORDER BY f.model, f.name, s.sequence + """, + **kwargs, + ) + dct = {} + for row in rows: + dct.setdefault((row["model"], row["name"]), []).append(row["value"]) + return dct + + +def collect(database, config_path=None, timeout=120): + """Tout le travail. Donnée pure, sérialisable, aucun affichage.""" + kwargs = {"config_path": config_path, "timeout": timeout} + require_odoo_database(database, **kwargs) + odoo_version = scalar_query( + database, + "SELECT latest_version FROM ir_module_module WHERE name = 'base';", + **kwargs, + ) + + lst_field = _field_rows(database, **kwargs) + lst_model = _model_rows(database, **kwargs) + dct_selection = _selection_rows(database, **kwargs) + set_table = public_tables(database, **kwargs) + set_model = { + row["model"] + for row in json_query(database, "SELECT model FROM ir_model", **kwargs) + } + + # Les colonnes réelles, une sonde par table concernée seulement. + dct_columns = {} + for row in lst_field: + table = model_table(row["model"], known_tables=set_table) + row["table"] = table + if table and table not in dct_columns: + dct_columns[table] = existing_columns(database, table, **kwargs) + + lst_blocker = _judge( + lst_field, set_model, set_table, dct_columns, dct_selection + ) + return _result( + database, + odoo_version, + lst_field, + lst_model, + lst_blocker, + source="database", + ) + + +def _judge(lst_field, set_model, set_table, dct_columns, dct_selection): + """Attribuer et juger chaque champ. Renvoie la liste des bloquants. + + Partagé par la lecture d'une base et celle d'une sauvegarde : les deux + doivent conclure la même chose des mêmes faits, sinon le zip et la base + d'où il vient ne diraient pas pareil. + """ + lst_blocker = [] + for row in lst_field: + row["origin"] = field_origin(row) + row["selection"] = dct_selection.get((row["model"], row["name"])) + row["blocker"] = None + + if row["table"] is None: + # Table non résolue : un fait, pas une anomalie. Le modèle peut + # avoir un _table surchargé qu'on ne connaît pas, ou ne plus + # exister du tout — deux choses qu'on ne confond pas ici. + row["blocker"] = ( + "model_gone" + if row["model"] not in set_model + else "table_unknown" + ) + elif ( + row["store"] + and row["ttype"] not in NO_COLUMN_TYPES + and row["name"] not in dct_columns[row["table"]] + ): + # Un champ stocké sans sa colonne empêche le registre de charger. + row["blocker"] = "missing_column" + elif row["relation"] and row["relation"] not in set_model: + row["blocker"] = "dangling_relation" + + if row["blocker"] in ( + "missing_column", + "dangling_relation", + "model_gone", + ): + lst_blocker.append(row) + + return lst_blocker + + +def _result( + database, + odoo_version, + lst_field, + lst_model, + lst_blocker, + source="database", +): + """La donnée de sortie, une seule forme quelle que soit la provenance.""" + dct_origin = {"studio": 0, "handmade": 0, "module": 0} + for row in lst_field: + dct_origin[row["origin"]] += 1 + + return { + "tool": "analyse_custom_field", + "version": 1, + "database": database, + "source": source, + "odoo_version": odoo_version, + "n_fields": len(lst_field), + "n_models": len(lst_model), + "counts": { + **dct_origin, + "blockers": len(lst_blocker), + "models": len(lst_model), + }, + "fields": lst_field, + "models": lst_model, + "blockers": lst_blocker, + } + + +def collect_from_backup(zip_path): + """Même analyse, mais depuis une sauvegarde .zip, sans rien restaurer. + + Pourquoi cela existe : restaurer la sauvegarde d'une instance Enterprise + sur une installation Community échoue — Odoo veut charger des modules + qu'on n'a pas. Les champs Studio, eux, ne sont que des lignes de + `ir_model_fields`, et un `dump.sql` est du texte. On les lit donc là où + ils sont, plutôt que d'exiger une restauration impossible. + + Ce que la sauvegarde permet en moins : rien, pour cet outil. Le dump + contient les `CREATE TABLE`, donc même la colonne physique manquante — le + seul vrai bloquant — se détecte. + """ + manifest, dct_rows, dct_columns, _ = read_backup( + zip_path, + tables=( + "ir_model_fields", + "ir_model", + "ir_model_data", + "ir_module_module", + ), + with_columns=True, + ) + + # Les identifiants externes, agrégés par champ et par modèle — le même + # regroupement que fait le SQL, pour que Studio s'attribue pareil. + dct_xmlid = {} + for row in dct_rows["ir_model_data"]: + key = (row.get("model"), row.get("res_id")) + dct_xmlid.setdefault(key, set()).add(row.get("module")) + + lst_field = [] + for row in dct_rows["ir_model_fields"]: + name = row.get("name") or "" + if row.get("state") != "manual" and not name.startswith("x_"): + continue + lst_field.append( + { + "id": row.get("id"), + "model": row.get("model"), + "name": name, + "ttype": row.get("ttype"), + "relation": row.get("relation"), + "related": row.get("related"), + # Le dump rend « t »/« f » : PostgreSQL écrit les booléens + # ainsi dans un COPY, et « f » est une chaîne vraie en Python. + "store": row.get("store") != "f", + "translate": row.get("translate") == "t", + "state": row.get("state"), + # Un champ traduit est du jsonb à partir de 16.0. Depuis une + # base, tr_col le déballe côté SQL ; depuis un dump, la valeur + # arrive brute, et « {"en_US": "Code client"} » ne se lit pas. + # normalise_arch fait ce déballage, et c'est la même fonction + # des deux côtés — deux implémentations divergeraient. + "label": normalise_arch(row.get("field_description")), + "help": normalise_arch(row.get("help")), + "xmlid_modules": sorted( + dct_xmlid.get(("ir.model.fields", row.get("id"))) or [] + ), + "create_date": row.get("create_date"), + "write_date": row.get("write_date"), + } + ) + + lst_model = [ + { + "model": row.get("model"), + "description": normalise_arch(row.get("name")), + "state": row.get("state"), + "transient": row.get("transient") == "t", + "xmlid_modules": sorted( + dct_xmlid.get(("ir.model", row.get("id"))) or [] + ), + } + for row in dct_rows["ir_model"] + if row.get("state") == "manual" + or (row.get("model") or "").startswith("x_") + ] + + set_model = {row.get("model") for row in dct_rows["ir_model"]} + set_table = set(dct_columns) + for row in lst_field: + row["table"] = model_table(row["model"], known_tables=set_table) + + lst_blocker = _judge(lst_field, set_model, set_table, dct_columns, {}) + data = _result( + os.path.basename(zip_path), + backup_version(dct_rows, manifest), + lst_field, + lst_model, + lst_blocker, + source="backup", + ) + data["backup_path"] = zip_path + data["backup_db_name"] = manifest.get("db_name") + return data + + +def _field_block(lst_row, top): + """Une ligne par champ : modèle, nom, type, provenance.""" + lines = [ + f" {'model':<28}{'field':<30}{'type':<12}{t('origin')}", + ] + for row in lst_row[:top]: + lines.append( + f" {(row['model'] or '')[:27]:<28}{(row['name'] or '')[:29]:<30}" + f"{(row['ttype'] or '')[:11]:<12}{origin_label(row['origin'])}" + ) + if len(lst_row) > top: + lines.append(f" … {len(lst_row) - top} {t('more')}") + return lines + + +def render(data, verbose=False, top=TOP_DEFAULT, hints=True): + """Rapport texte. Fonction pure : donnée -> chaîne, testable sans base.""" + counts = data["counts"] + lines = [ + "", + f"🔬 {t('Fields added outside a module')} — {data['database']}" + f" (Odoo {data.get('odoo_version') or '?'}" + f"{', ' + t('from a backup') if data.get('source') == 'backup' else ''})", + "", + f" {t('Custom fields'):<30}: {data['n_fields']}", + ] + for name in ("studio", "handmade", "module"): + if counts.get(name): + lines.append(f" {origin_label(name):<30}: {counts[name]}") + if data["n_models"]: + lines.append(f" {t('Custom models'):<30}: {data['n_models']}") + + if not data["n_fields"] and not data["n_models"]: + lines += [ + "", + f"✅ {t('No field or model was added outside a module.')}", + ] + return "\n".join(lines) + "\n" + + if data["blockers"]: + lines += ["", f"── ❌ {t('Blocking')} ({len(data['blockers'])}) ──"] + for row in data["blockers"]: + lines.append( + f" {row['model']}.{row['name']} —" + f" {blocker_label(row['blocker'])}" + + (f" → {row['relation']}" if row.get("relation") else "") + ) + lines.append("") + lines += wrap_note( + " ", + t( + "A stored field without its column stops the registry from" + " loading, so the upgrade will not even start. Settle these" + " before anything else." + ), + ) + + lst_show = data["fields"] + if lst_show: + lines += [ + "", + f"── ⚠️ {t('To carry over by hand')} ({len(lst_show)}" + f"{', ' + str(len(data['blockers'])) + ' ' + t('blocking') if data['blockers'] else ''}) ──", + ] + lines += _field_block(lst_show, len(lst_show) if verbose else top) + + if data["models"]: + lines += ["", f"── 🧱 {t('Custom models')} ({len(data['models'])}) ──"] + for row in data["models"][: len(data["models"]) if verbose else top]: + lines.append( + f" {row['model']:<32}{(row.get('description') or '')[:40]}" + ) + + lines.append("") + lines += wrap_note( + " ", + t( + "Nothing declares these in a file, so no module will recreate" + " them. What a version upgrade keeps is what someone carried over." + ), + ) + if hints and not verbose: + lines += wrap_note( + " ℹ️ ", + t("Use -v to list them all, --json for the raw data."), + ) + return "\n".join(lines) + "\n" + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=t( + "List the fields and models added outside a module — Studio or by" + " hand (read-only)." + ) + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("-d", "--database", help=t("database to inspect")) + source.add_argument( + "-z", + "--zip", + dest="backup", + help=t("Odoo backup .zip to inspect, without restoring it"), + ) + parser.add_argument( + "--top", + type=int, + default=TOP_DEFAULT, + help=t("how many to show (default: 30)"), + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help=t("list every one") + ) + parser.add_argument("--json", action="store_true", help=t("output JSON")) + parser.add_argument( + "-c", "--config", default=None, help=t("path to an Odoo config file") + ) + config = parser.parse_args(argv) + + try: + if config.backup: + data = collect_from_backup(config.backup) + else: + data = collect(config.database, config_path=config.config) + except AnalyseError as exc: + print(f"❌ {exc}") + return 2 + except KeyboardInterrupt: + print(f"\n{t('Cancelled.')}") + return 2 + + if config.json: + print(json.dumps(data, indent=2, ensure_ascii=False, default=str)) + else: + print(render(data, verbose=config.verbose, top=config.top)) + return 1 if (data["fields"] or data["models"]) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/analyse_diff_tui.py b/script/analyse/analyse_diff_tui.py new file mode 100644 index 0000000..9fc700c --- /dev/null +++ b/script/analyse/analyse_diff_tui.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Naviguer dans les différences entre une vue en base et celle du module. + +Lecture seule, sans exception. La touche « r » AFFICHE la commande de +réinitialisation, elle ne l'exécute pas : cet écran sert à décider, et décider +suppose d'avoir lu. Une vue personnalisée porte souvent un travail réel que +personne ne veut perdre d'un appui sur une touche. + +Pourquoi un seul DataTable à trois colonnes +-------------------------------------------- +Deux panneaux séparés ne défilent pas ensemble : il faudrait synchroniser +deux barres, et une ligne de gauche finirait en face de la mauvaise ligne de +droite — exactement l'erreur qu'un diff doit rendre impossible. Un seul +tableau porte « gauche | marque | droite » sur la même ligne, donc +l'alignement est structurel et non entretenu. Il donne aussi le curseur, la +sélection et l'événement de survol sans rien écrire. + +``TextArea`` a été écarté : c'est un éditeur, et il n'y a pas de coloration +XML sans tree-sitter, absent du venv. +""" + +import os +import sys + +new_path = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +sys.path.append(new_path) + +from script.analyse.lib_analyse import side_by_side, t # noqa: E402 + +CSS = """ +Screen { layout: vertical; } +#head { height: 3; padding: 0 1; background: $panel; color: $text; } +#body { height: 1fr; } +#views { width: 34; border-right: solid $accent; } +#diff { width: 1fr; } +""" + + +def diff_rows(finding): + """Les lignes en écart d'un constat, sans les lignes identiques. + + Le contexte est utile dans un diff unifié qu'on lit en entier ; ici on + saute d'un écart à l'autre, et les centaines de lignes identiques d'une + vue de site web ne feraient que les éloigner. + """ + return [ + (mark, left, right) + for mark, left, right in side_by_side( + finding.get("arch_ref"), finding.get("arch_db_text") + ) + if mark != " " + ] + + +def build_app(data): + """Construire l'application Textual. Importe Textual seulement ici. + + L'import vit dans la fonction pour que le module reste importable — et + donc testable — sur une machine sans Textual. + """ + from textual.app import App, ComposeResult + from textual.containers import Horizontal + from textual.widgets import DataTable, Footer, Header, Static + + class DiffApp(App): + CSS = globals()["CSS"] + BINDINGS = [ + ("q,escape", "quit", t("Quit")), + ("n", "only_diff", t("Differences only")), + ("w", "ignore_indent", t("Ignore indentation")), + ("c", "copy", t("Copy")), + ("r", "command", t("Reset command")), + ] + + def __init__(self, data): + super().__init__() + self.data = data + self.lst_finding = [ + row for row in data["findings"] if row.get("differs") + ] + self.only_diff = True + self.ignore_indent = False + self.intent = None + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="head") + with Horizontal(id="body"): + yield DataTable(id="views", cursor_type="row") + yield DataTable(id="diff", cursor_type="row") + yield Footer() + + def on_mount(self): + self.title = t("Customised views") + table = self.query_one("#views", DataTable) + table.add_columns(t("view"), "+/-/≠") + for row in self.lst_finding: + stats = row.get("diff_stats") or {} + table.add_row( + (row.get("key") or str(row["id"]))[:26], + f"{stats.get('added', 0)}/{stats.get('removed', 0)}" + f"/{stats.get('changed', 0)}", + key=str(row["id"]), + ) + diff = self.query_one("#diff", DataTable) + diff.add_columns(t("module (file)"), " ", t("database")) + if self.lst_finding: + self._show(0) + + def _show(self, index): + row = self.lst_finding[index] + stats = row.get("diff_stats") or {} + self.query_one("#head", Static).update( + f"{row.get('key') or row['id']} · " + f"{row.get('arch_fs') or '—'}\n" + f"+{stats.get('added', 0)} -{stats.get('removed', 0)} " + f"≠{stats.get('changed', 0)} · " + f"{', '.join(row.get('reason') or []) or '—'}" + ) + diff = self.query_one("#diff", DataTable) + diff.clear() + for mark, left, right in side_by_side( + row.get("arch_ref"), row.get("arch_db_text") + ): + if self.only_diff and mark == " ": + continue + if self.ignore_indent: + left = (left or "").strip() + right = (right or "").strip() + diff.add_row(left or "", mark, right or "") + + def on_data_table_row_highlighted(self, event): + if event.data_table.id == "views" and self.lst_finding: + self._show(event.cursor_row) + + def _refresh(self): + table = self.query_one("#views", DataTable) + if self.lst_finding: + self._show(table.cursor_row) + + def action_only_diff(self): + self.only_diff = not self.only_diff + self._refresh() + + def action_ignore_indent(self): + self.ignore_indent = not self.ignore_indent + self._refresh() + + def action_copy(self): + table = self.query_one("#views", DataTable) + if not self.lst_finding: + return + row = self.lst_finding[table.cursor_row] + text = "\n".join( + f"{mark} {left or ''} | {right or ''}" + for mark, left, right in diff_rows(row) + ) + # Tronqué en gardant la FIN : c'est là que se trouve ce qu'on + # vient d'ajouter, donc ce qu'on cherche le plus souvent. + self.copy_to_clipboard(text[-100_000:]) + self.notify(t("Difference copied.")) + + def action_command(self): + """Rendre l'intention à l'appelant : l'écran n'écrit jamais.""" + table = self.query_one("#views", DataTable) + if not self.lst_finding: + return + self.intent = ("command", self.lst_finding[table.cursor_row]) + self.exit() + + return DiffApp(data) + + +def run_diff_tui(data, run_app=True): + """Ouvrir l'écran. Renvoie l'intention retenue, ou None. + + ``run_app=False`` construit l'application sans la lancer : c'est ce qui + permet de la tester sans terminal. + """ + app = build_app(data) + if not run_app: + return app + app.run() + return app.intent diff --git a/script/analyse/analyse_schema_size.py b/script/analyse/analyse_schema_size.py new file mode 100644 index 0000000..7c4037a --- /dev/null +++ b/script/analyse/analyse_schema_size.py @@ -0,0 +1,562 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Poids d'une base Odoo, et tables qui ne correspondent à plus rien. + +Ce que l'outil répond : combien pèse cette base, où est le gras, et quelles +tables ne sont réclamées par aucun modèle installé. Ces dernières sont les +reliquats de modules désinstallés sans ``DROP TABLE``, que chaque montée de +version recopie et redéploie sans que personne ne les regarde. + +Trois pièges évités, chacun pour une bonne raison +------------------------------------------------- +**Une table m2m n'a aucune ligne dans ``ir_model``.** Les recenser comme +orphelines ferait crier au loup sur ~200 tables d'une base ordinaire. Odoo +tient leur liste dans ``ir_model_relation``, on la lui demande. + +**Une table introuvable n'est pas une anomalie, c'est une inconnue.** Un +modèle dont le ``_table`` est surchargé — ``ir.actions.act_window`` vit dans +``ir_act_window`` — serait classé « sans table » par un +``replace('.', '_')`` naïf. ``lib_analyse.model_table()`` connaît les +surcharges, et ce qu'il ne résout pas est marqué inconnu, jamais orphelin. + +**Les modèles abstraits SONT dans ``ir_model``.** ``registry.py`` appelle +``_reflect_models()`` sur tous les modèles chargés, sans filtre sur +``_abstract`` ; il n'existe d'ailleurs aucune colonne ``abstract``. Des +centaines de modèles sans table sont donc parfaitement normaux : c'est un +fait rapporté, pas un constat. + +Le comptage de lignes est une estimation, et le dit +--------------------------------------------------- +``reltuples`` vient du dernier ``ANALYZE``. PostgreSQL 14 et suivants y +mettent ``-1`` quand la table n'a jamais été analysée — afficher ``0`` ferait +passer une table pleine pour une table vide. On affiche ``?``. Le compte exact +est derrière ``--exact`` parce qu'il coûte un balayage complet par table. +""" + +import argparse +import json +import os +import sys +import textwrap + +new_path = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +sys.path.append(new_path) + +from script.analyse.lib_analyse import ( # noqa: E402 + AnalyseError, + backup_version, + column_types, + json_query, + model_table, + normalise_arch, + quote_literal, + read_backup, + require_odoo_database, + scalar_query, + t, + tr_col, +) + +# Tables réelles qui n'appartiennent pas à Odoo : une extension PostgreSQL les +# pose dans le schéma public. Les compter comme orphelines enverrait +# l'utilisateur supprimer une table dont dépend PostGIS. +SYSTEM_TABLES = { + "spatial_ref_sys", # PostGIS +} + +TOP_DEFAULT = 20 + + +def wrap_note(prefix, text, width=79): + """Replier une phrase à la largeur du terminal, sans la découper en clés. + + Une phrase coupée en trois clés de traduction se replie correctement dans + la langue où elle a été écrite, et n'importe comment dans l'autre : l'ordre + des mots et la longueur diffèrent. La phrase reste donc entière côté + traduction, et c'est l'affichage qui la replie. + """ + lines = textwrap.wrap(text, width=width - len(prefix)) or [""] + pad = " " * len(prefix) + return [prefix + lines[0]] + [pad + line for line in lines[1:]] + + +def fmt_bytes(value): + """Taille lisible, en unités binaires — mêmes symboles en fr et en en.""" + if value is None: + return "?" + size = float(value) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if size < 1024 or unit == "TiB": + return ( + f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" + ) + size /= 1024 + return f"{size:.1f} TiB" + + +def fmt_rows(value): + """Nombre de lignes, ou « ? » si la table n'a jamais été analysée. + + reltuples vaut -1 depuis PostgreSQL 14 quand aucun ANALYZE n'a tourné. + Avant, il valait 0 — indistinguable d'une table vide. On rend « ? » dans + les deux cas plutôt qu'un chiffre auquel personne ne devrait se fier. + """ + if value is None or value < 0: + return "?" + return f"{value:,}".replace(",", " ") + + +def _table_rows(database, **kwargs): + """Une ligne par table réelle du schéma public, avec son poids. + + relkind 'r' pour une table ordinaire, 'p' pour une partitionnée. Odoo n'en + partitionne pas, mais le compte ne doit pas devenir faux en silence le jour + où cela changera. + """ + return json_query( + database, + """ + SELECT c.relname AS table_name, + pg_total_relation_size(c.oid) AS total_bytes, + pg_table_size(c.oid) AS table_bytes, + pg_indexes_size(c.oid) AS index_bytes, + c.reltuples::bigint AS est_rows + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') + ORDER BY pg_total_relation_size(c.oid) DESC + """, + **kwargs, + ) + + +def _model_rows(database, **kwargs): + """Modèles déclarés, avec leur description traduite. + + ir_model.name est un champ traduit : jsonb à partir de 16.0, texte avant. + tr_col décide sur le type réel de la colonne. + """ + dct_type = column_types(database, "ir_model", **kwargs) + label = tr_col("ir_model", "name", dct_type) + return json_query( + database, + f""" + SELECT model AS model, + {label} AS description, + state AS state, + transient AS transient + FROM ir_model + ORDER BY model + """, + **kwargs, + ) + + +def _relation_tables(database, **kwargs): + """Tables m2m qu'Odoo revendique, via ir_model_relation. + + Sans elles, toute table de relation passerait pour orpheline. La table peut + manquer sur une base très ancienne : on sonde plutôt que de supposer. + """ + if not scalar_query( + database, "SELECT to_regclass('public.ir_model_relation');", **kwargs + ): + return set(), False + rows = json_query( + database, "SELECT name AS name FROM ir_model_relation", **kwargs + ) + return {r["name"] for r in rows if r.get("name")}, True + + +def _exact_counts(database, lst_table, **kwargs): + """count(*) réel par table — un balayage complet chacune. + + format('%I') met les identifiants entre guillemets côté PostgreSQL : aucun + nom de table venu du catalogue ne peut casser la requête ni en détourner + le sens. + """ + if not lst_table: + return {} + values = ", ".join(f"({quote_literal(name)})" for name in lst_table) + rows = json_query( + database, + f""" + SELECT v.table_name AS table_name, + (xpath('/row/c/text()', + query_to_xml( + format('SELECT count(*) AS c FROM public.%I', + v.table_name), + false, true, '')))[1]::text::bigint AS exact_rows + FROM (VALUES {values}) AS v(table_name) + """, + **kwargs, + ) + return {r["table_name"]: r["exact_rows"] for r in rows} + + +def collect(database, exact=False, config_path=None, timeout=120): + """Tout le travail. Donnée pure, sérialisable, aucun affichage.""" + kwargs = {"config_path": config_path, "timeout": timeout} + require_odoo_database(database, **kwargs) + + db_bytes = scalar_query( + database, + "SELECT pg_database_size(current_database());", + **kwargs, + ) + odoo_version = scalar_query( + database, + "SELECT latest_version FROM ir_module_module WHERE name = 'base';", + **kwargs, + ) + + lst_table = _table_rows(database, **kwargs) + lst_model = _model_rows(database, **kwargs) + set_relation, has_relation_table = _relation_tables(database, **kwargs) + set_table = {row["table_name"] for row in lst_table} + + # Table -> modèle. Une table peut porter plusieurs modèles : les huit + # ir.actions.* partagent ir_actions. On garde le premier par ordre + # alphabétique, pour que deux exécutions disent la même chose. + dct_table_model = {} + lst_without_table = [] + for row in lst_model: + table = model_table(row["model"], known_tables=set_table) + if table is None: + lst_without_table.append( + { + "model": row["model"], + "description": row.get("description"), + "state": row.get("state"), + "transient": row.get("transient"), + } + ) + continue + dct_table_model.setdefault(table, row["model"]) + + lst_orphan = [] + for row in lst_table: + name = row["table_name"] + if name in dct_table_model: + row["model"] = dct_table_model[name] + row["origin"] = "model" + elif name in set_relation: + row["model"] = None + row["origin"] = "m2m" + elif name in SYSTEM_TABLES: + row["model"] = None + row["origin"] = "system" + else: + row["model"] = None + row["origin"] = "orphan" + lst_orphan.append(row) + row["exact_rows"] = None + + if exact: + dct_exact = _exact_counts( + database, [r["table_name"] for r in lst_table], **kwargs + ) + for row in lst_table: + row["exact_rows"] = dct_exact.get(row["table_name"]) + + return { + "tool": "analyse_schema_size", + "version": 1, + "database": database, + "odoo_version": odoo_version, + "db_bytes": int(db_bytes) if db_bytes else None, + "exact": exact, + "has_relation_table": has_relation_table, + "n_tables": len(lst_table), + "n_models": len(lst_model), + "tables": lst_table, + "orphan_tables": lst_orphan, + "models_without_table": lst_without_table, + "counts": { + "orphan_tables": len(lst_orphan), + "models_without_table": len(lst_without_table), + "m2m_tables": sum(1 for r in lst_table if r["origin"] == "m2m"), + }, + } + + +def collect_from_backup(zip_path): + """Même analyse, depuis une sauvegarde .zip, sans rien restaurer. + + Ce qu'une sauvegarde donne EN MIEUX : le nombre de lignes est exact, + compté dans le dump, là où une base rend l'estimation du dernier ANALYZE. + + Ce qu'elle ne peut pas donner : le poids sur le disque. Un dump ignore les + index et le ballonnement, et présenter le poids de ses données comme une + taille de table tromperait sur ce qui fait grossir une base. La colonne + affichée est donc « poids dans le dump », et elle est nommée ainsi. + """ + manifest, dct_rows, _, dct_census = read_backup( + zip_path, + tables=("ir_model", "ir_model_relation", "ir_module_module"), + census=True, + ) + set_table = set(dct_census) + set_relation = { + row.get("name") for row in dct_rows.get("ir_model_relation") or [] + } + has_relation_table = "ir_model_relation" in dct_census + + dct_table_model = {} + lst_without_table = [] + for row in dct_rows["ir_model"]: + table = model_table(row.get("model") or "", known_tables=set_table) + if table is None: + lst_without_table.append( + { + "model": row.get("model"), + "description": normalise_arch(row.get("name")), + "state": row.get("state"), + "transient": row.get("transient") == "t", + } + ) + continue + dct_table_model.setdefault(table, row.get("model")) + + lst_table, lst_orphan = [], [] + for name, census in sorted( + dct_census.items(), key=lambda kv: -kv[1]["dump_bytes"] + ): + row = { + "table_name": name, + # Aucune de ces trois-là ne se lit dans un dump : les laisser à + # None fait afficher « ? », ce qui est la vérité, plutôt qu'un + # zéro qui se lirait comme « cette table est vide ». + "total_bytes": None, + "table_bytes": None, + "index_bytes": None, + "dump_bytes": census["dump_bytes"], + "est_rows": census["rows"], + "exact_rows": census["rows"], + "model": dct_table_model.get(name), + "origin": "model", + } + if name in dct_table_model: + pass + elif name in set_relation: + row["origin"] = "m2m" + elif name in SYSTEM_TABLES: + row["origin"] = "system" + else: + row["origin"] = "orphan" + lst_orphan.append(row) + lst_table.append(row) + + return { + "tool": "analyse_schema_size", + "version": 1, + "database": os.path.basename(zip_path), + "source": "backup", + "backup_path": zip_path, + "odoo_version": backup_version(dct_rows, manifest), + "db_bytes": None, + "dump_bytes": sum(c["dump_bytes"] for c in dct_census.values()), + "exact": True, + "has_relation_table": has_relation_table, + "n_tables": len(lst_table), + "n_models": len(dct_rows["ir_model"]), + "tables": lst_table, + "orphan_tables": lst_orphan, + "models_without_table": lst_without_table, + "counts": { + "orphan_tables": len(lst_orphan), + "models_without_table": len(lst_without_table), + "m2m_tables": sum(1 for r in lst_table if r["origin"] == "m2m"), + }, + } + + +def _table_block(lst_row, exact, source="database"): + """Tableau aligné : une ligne par table, colonnes de largeur fixe. + + « heap » plutôt que « table » pour pg_table_size : la colonne « table » + porte déjà le nom, et le même mot pour deux choses dans le même tableau se + lit mal. Les quatre en-têtes techniques ne passent pas par t() — ils + s'écrivent pareil en français et en anglais, contrairement à « rows ». + """ + if source == "backup": + # Un dump n'a ni index ni ballonnement : afficher trois colonnes vides + # ferait croire à une mesure manquante plutôt qu'à une mesure qui + # n'existe pas. + lines = [f" {'table':<44}{t('in the dump'):>14}{t('rows'):>14}"] + for row in lst_row: + lines.append( + f" {row['table_name']:<44}" + f"{fmt_bytes(row.get('dump_bytes')):>14}" + f"{fmt_rows(row['exact_rows']):>14}" + ) + return lines + lines = [ + f" {'table':<34}{'total':>10}{'heap':>10}{'index':>10}" + f"{t('rows'):>14}" + ] + for row in lst_row: + count = row["exact_rows"] if exact else row["est_rows"] + lines.append( + f" {row['table_name']:<34}" + f"{fmt_bytes(row['total_bytes']):>10}" + f"{fmt_bytes(row['table_bytes']):>10}" + f"{fmt_bytes(row['index_bytes']):>10}" + f"{fmt_rows(count):>14}" + ) + return lines + + +def render(data, verbose=False, top=TOP_DEFAULT, hints=True): + """Rapport texte. Fonction pure : donnée -> chaîne, testable sans base. + + ``hints`` gouverne les conseils en ligne de commande (« utilisez -v », + « --exact »). Ils aident qui a tapé la commande ; ils insultent qui est + dans un menu, à qui l'on demande de sortir et de retaper autre chose. + L'appel depuis le menu les coupe et offre les mêmes actions comme choix. + """ + version = data.get("odoo_version") or "?" + lines = [ + "", + f"🔬 {t('Schema analysis')} — {data['database']} (Odoo {version}" + f"{', ' + t('from a backup') if data.get('source') == 'backup' else ''})", + "", + ( + f" {t('Weight in the dump'):<22}: " + f"{fmt_bytes(data.get('dump_bytes'))}" + if data.get("source") == "backup" + else f" {t('Database size'):<22}: {fmt_bytes(data['db_bytes'])}" + ), + f" {t('Tables'):<22}: {data['n_tables']}", + f" {t('Models'):<22}: {data['n_models']}", + ] + n_without = data["counts"]["models_without_table"] + if n_without: + lines.append( + f" {t('Models without table'):<22}: {n_without}" + f" ({t('abstract models have none, by design')})" + ) + if not data["has_relation_table"]: + lines += wrap_note( + "⚠️ ", + t( + "ir_model_relation is absent, so m2m tables cannot be told" + " apart from orphans: the list below is unreliable." + ), + ) + + lst_table = data["tables"] + shown = lst_table if verbose else lst_table[:top] + if shown: + label = ( + t("All tables, heaviest first") + if verbose + else f"{t('Heaviest tables')} ({len(shown)}/{len(lst_table)})" + ) + lines += ["", f"── {label} ──"] + lines += _table_block( + shown, data["exact"], data.get("source", "database") + ) + if hints and not verbose and len(lst_table) > len(shown): + lines.append(f" … {t('use -v to list them all')}") + elif not verbose and len(lst_table) > len(shown): + lines.append(f" … {len(lst_table) - len(shown)} {t('more')}") + + lst_orphan = data["orphan_tables"] + if not lst_orphan: + lines += ["", f"✅ {t('Every table belongs to an installed model.')}"] + else: + lines += [ + "", + f"── ⚠️ {t('Orphan tables')} ({len(lst_orphan)}) ──", + ] + lines += _table_block( + lst_orphan, data["exact"], data.get("source", "database") + ) + lines.append("") + lines += wrap_note( + " ", + t( + "No installed model claims these tables. They are usually left" + " over from modules uninstalled without DROP TABLE, and every" + " version upgrade carries them along." + ), + ) + lines += wrap_note( + " 💡 ", t("Check what they hold before dropping anything.") + ) + + if not data["exact"] and hints: + lines.append("") + lines += wrap_note( + " ℹ️ ", + t( + "Row counts are estimates from the last ANALYZE. Use --exact" + " for real counts, at the cost of one full scan per table." + ), + ) + return "\n".join(lines) + "\n" + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=t( + "Report the size of an Odoo database and the tables no installed" + " model claims (read-only)." + ) + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("-d", "--database", help=t("database to inspect")) + source.add_argument( + "-z", + "--zip", + dest="backup", + help=t("Odoo backup .zip to inspect, without restoring it"), + ) + parser.add_argument( + "--exact", + action="store_true", + help=t("count rows exactly: one full scan per table"), + ) + parser.add_argument( + "--top", + type=int, + default=TOP_DEFAULT, + help=t("how many tables to show (default: 20)"), + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help=t("list every table") + ) + parser.add_argument("--json", action="store_true", help=t("output JSON")) + parser.add_argument( + "-c", "--config", default=None, help=t("path to an Odoo config file") + ) + config = parser.parse_args(argv) + + try: + if config.backup: + data = collect_from_backup(config.backup) + else: + data = collect( + config.database, exact=config.exact, config_path=config.config + ) + except AnalyseError as exc: + print(f"❌ {exc}") + return 2 + except KeyboardInterrupt: + print(f"\n{t('Cancelled.')}") + return 2 + + if config.json: + print(json.dumps(data, indent=2, ensure_ascii=False)) + else: + print(render(data, verbose=config.verbose, top=config.top)) + return 1 if data["orphan_tables"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/analyse_view_custom.py b/script/analyse/analyse_view_custom.py new file mode 100644 index 0000000..fe31310 --- /dev/null +++ b/script/analyse/analyse_view_custom.py @@ -0,0 +1,858 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Inventaire des vues personnalisées d'une base Odoo, copies COW comprises. + +Ce que l'outil répond : parmi les milliers de vues d'une base, lesquelles ne +viennent pas telles quelles d'un module. Ce sont elles qu'une montée de version +peut casser, et elles seules qu'un intégrateur doit relire. + +Ce qu'il ne répond PAS encore +----------------------------- +Il ne compare pas l'arch en base à celle que déclare le module. Une vue portant +le drapeau ``arch_updated`` est donc dite **signalée**, pas **modifiée** : le +drapeau vient d'Odoo, mais il est incomplet dans les deux sens — un ``write`` +SQL direct ne l'arme pas, et ``reset_arch(mode='hard')`` l'efface. Conclure +demande de comparer, ce que fera l'outil suivant. Nommer « modifiée » ce qui +n'est que « signalée » serait une affirmation que rien ici ne soutient. + +Les copies COW, et ce qui les distingue de l'outillage existant +--------------------------------------------------------------- +Personnaliser une vue de site web ne la modifie pas : Odoo en fait une copie +liée à un ``website_id``. Quatre outils du dépôt s'en occupent déjà, chacun +pour une question de MIGRATION — ``check_cow_views.py`` prédit lesquelles +casseront à la version suivante, ``reset_stale_cow_views.py`` trouve celles qui +ont dérivé de leur jumelle et sait les réinitialiser, ``neutralize_cow_views.py`` +les met hors circuit, ``snapshot_cow_views.py`` compare un avant et un après. + +Aucun ne fait l'inventaire, et c'est le trou que celui-ci comble : combien de +vues sont personnalisées, par quel chemin, et lesquelles méritent un regard. +Il ne rejuge donc pas les copies COW — il les compte, dit si chacune a une +jumelle module, et renvoie vers l'outil qui tranche. + +Une vue, une seule catégorie +---------------------------- +Une copie COW peut aussi porter ``arch_updated`` ; une vue Studio peut être une +copie COW. Les classer plusieurs fois ferait un total supérieur au nombre de +vues, et un rapport dont les chiffres ne s'additionnent pas ne se lit pas. La +catégorie retenue est donc la plus spécifique, dans l'ordre de ``CATEGORIES``, +et le reste de ce qu'on sait vit dans ``reason``. +""" + +import argparse +import json +import os +import sys +import textwrap + +new_path = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +sys.path.append(new_path) + +from script.analyse.lib_analyse import ( # noqa: E402 + REPO_ROOT, + AnalyseError, + arch_differs, + backup_version, + column_types, + diff_stats, + existing_columns, + json_query, + normalise_arch, + odoo_shell_json, + read_backup, + require_odoo_database, + scalar_query, + side_by_side, + t, + tr_col, +) + +# Le script poussé dans « odoo-bin shell » pour obtenir l'arch de référence. +SHELL_SCRIPT = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "shell", "view_file_arch.py" +) + +# Le registre se charge par lots : la ligne de commande et la sortie restent +# bornées, et une vue cassée n'emporte que son lot. +BATCH = 100 + +# Modules d'identifiants externes qui ne sont pas des modules : Odoo y range ce +# qui vient d'un import, d'un export, ou de Studio. +STUDIO_MODULE = "studio_customization" +NOT_A_MODULE = ("__export__", "__import__", "__custom__") + +# L'ordre EST la précédence : la première catégorie qui s'applique gagne. Du +# plus spécifique au plus général, pour qu'une vue Studio copiée par le site web +# soit comptée comme copie COW — c'est ce qu'un intégrateur ira regarder en +# premier — et non comme une vue Studio de plus. +CATEGORIES = ( + "theme_installed", + "website_cow_copy", + "studio", + "imported_or_exported", + "ui_created", + "module_view_drifted", + "module_view_flagged", + "module_view", +) + +# Les catégories qui demandent un regard. « module_view » n'y est pas : une vue +# qui vient d'un module et que rien ne signale est le cas normal, et il compte +# pour l'écrasante majorité. +ACTIONABLE = ( + "module_view_drifted", + "module_view_flagged", + "ui_created", + "studio", + "imported_or_exported", + "website_cow_copy", + "theme_installed", +) + +TOP_DEFAULT = 20 + + +def wrap_note(prefix, text, width=79): + """Replier une phrase à l'affichage, sans la découper en clés.""" + lines = textwrap.wrap(text, width=width - len(prefix)) or [""] + pad = " " * len(prefix) + return [prefix + lines[0]] + [pad + line for line in lines[1:]] + + +def category_label(name): + """Libellé traduit d'une catégorie. + + Un `t(variable)` serait plus court, mais il rendrait le contrôle de + couverture aveugle : celui-ci relit les sources et ne voit que les appels + à littéral. Une clé manquante repasserait alors en silence, en anglais. + Les catégories sont donc épelées, une par une. + """ + return { + "theme_installed": t("From an installed theme"), + "website_cow_copy": t("Website copy (COW)"), + "studio": t("Made with Studio"), + "imported_or_exported": t("Imported or exported"), + "ui_created": t("Created from the interface"), + "module_view_drifted": t("From a module, silently drifted"), + "module_view_flagged": t("From a module, flagged as touched"), + "module_view": t("Straight from a module"), + }.get(name, name) + + +def classify(row): + """(catégorie, raisons) d'une vue. Fonction pure, testable sur fixture. + + ``raisons`` porte tout ce qu'on sait et que la catégorie ne dit pas : une + copie COW qui est aussi signalée le mentionne, sinon l'information se + perdrait au profit de la seule catégorie retenue. + """ + lst_module = row.get("xmlid_modules") or [] + has_xmlid = bool(lst_module) + lst_reason = [] + + if row.get("arch_updated"): + lst_reason.append("arch_updated") + if row.get("noupdate"): + lst_reason.append("noupdate") + if row.get("has_arch_prev"): + lst_reason.append("has_arch_prev") + if not row.get("active"): + lst_reason.append("inactive") + + if row.get("theme_template_id"): + return "theme_installed", lst_reason + if row.get("website_id"): + if not row.get("has_module_twin"): + lst_reason.append("no_module_twin") + return "website_cow_copy", lst_reason + if STUDIO_MODULE in lst_module: + return "studio", lst_reason + if any(module in NOT_A_MODULE for module in lst_module): + return "imported_or_exported", lst_reason + if not has_xmlid and not row.get("arch_fs"): + return "ui_created", lst_reason + # `arch_updated` SEUL fait basculer une vue de module. `noupdate` reste une + # raison, jamais un motif : toute vue déclarée dans un bloc + # le porte — les données de mail, d'account, de website + # en sont pleines — et rien n'y a été touché. L'y inclure noierait la + # catégorie qui compte sous des centaines de vues parfaitement normales. + if row.get("arch_fs") and row.get("arch_updated"): + return "module_view_flagged", lst_reason + return "module_view", lst_reason + + +def _view_rows(database, **kwargs): + """Une ligne par vue, sans son arch. + + L'arch n'est pas rapatriée : quelques milliers de vues dont certaines + dépassent 100 ko tiendraient dans une seule ligne de sortie psql, dupliquée + par json.loads. La taille et l'empreinte suffisent à cet inventaire ; la + comparaison, qui a besoin du contenu, ira le chercher pour les seules vues + retenues. + + Les identifiants externes sont AGRÉGÉS. Une jointure plate multiplierait + les lignes d'une vue qui en porte plusieurs, et un « premier trouvé » + déciderait au hasard si elle vient de Studio. + """ + cols = existing_columns(database, "ir_ui_view", **kwargs) + dct_type = column_types(database, "ir_ui_view", **kwargs) + + def col(column, absent): + """« v.colonne » si elle existe, sinon un littéral du bon type. + + Toutes les colonnes passent par ici, y compris celles qu'on croit + acquises comme create_uid : sur une base rognée ou anonymisée, une + seule colonne manquante fait échouer la requête entière, et l'outil + rendrait 2 là où il pouvait encore répondre. + """ + return f"v.{column}" if column in cols else absent + + name = tr_col("v", "name", dct_type) + has_website_col = "website_id" in cols + website = col("website_id", "NULL::integer") + # La CTE « twin » n'a pas l'alias « v » : il lui faut la colonne nue. Sans + # le module website, il n'y a aucune copie COW et toute vue à clé est sa + # propre référence — d'où le « TRUE ». + twin_filter = "website_id IS NULL" if has_website_col else "TRUE" + theme = col("theme_template_id", "NULL::integer") + arch_fs = col("arch_fs", "NULL::text") + arch_updated = col("arch_updated", "false") + arch_prev = "(v.arch_prev IS NOT NULL)" if "arch_prev" in cols else "false" + return json_query( + database, + f""" + WITH xid AS ( + SELECT res_id, + array_agg(DISTINCT module) AS modules, + array_agg(module || '.' || name ORDER BY module, name) + AS xmlids, + bool_or(noupdate) AS noupdate + FROM ir_model_data + WHERE model = 'ir.ui.view' + GROUP BY res_id + ), twin AS ( + SELECT DISTINCT key FROM ir_ui_view + WHERE key IS NOT NULL AND {twin_filter} + ) + SELECT v.id AS id, + {name} AS name, + {col("model", "NULL::text")} AS model, + {col("type", "NULL::text")} AS type, + {col("key", "NULL::text")} AS key, + {col("mode", "NULL::text")} AS mode, + {col("active", "true")} AS active, + {col("inherit_id", "NULL::integer")} AS inherit_id, + {arch_fs} AS arch_fs, + {arch_updated} AS arch_updated, + {arch_prev} AS has_arch_prev, + {website} AS website_id, + {theme} AS theme_template_id, + x.modules AS xmlid_modules, + x.xmlids AS xmlids, + COALESCE(x.noupdate, false) AS noupdate, + (v.key IS NOT NULL + AND EXISTS (SELECT 1 FROM twin WHERE twin.key = v.key)) + AS has_module_twin, + {col("create_uid", "NULL::integer")} AS create_uid, + {col("create_date", "NULL::timestamp")} AS create_date, + {col("write_uid", "NULL::integer")} AS write_uid, + {col("write_date", "NULL::timestamp")} AS write_date, + octet_length(v.arch_db::text) AS arch_bytes, + md5(v.arch_db::text) AS arch_md5 + FROM ir_ui_view v + LEFT JOIN xid x ON x.res_id = v.id + ORDER BY v.id + """, + **kwargs, + ) + + +def checkout_odoo_version(): + """Version Odoo de l'ARBRE SOURCE, qui n'est pas celle de la base.""" + try: + with open(os.path.join(REPO_ROOT, ".odoo-version")) as handle: + return handle.read().strip() + except OSError: + return None + + +def same_major(version_a, version_b): + """Deux versions ont-elles la même majeure ? « 18.0.1.3 » vs « 18.0 ».""" + if not version_a or not version_b: + return False + return version_a.split(".")[0] == version_b.split(".")[0] + + +def add_reference_arch(database, lst_finding, config_path=None, timeout=600): + """Compléter les constats avec l'arch que déclare le module. + + Ne s'adresse qu'aux vues qui ont un ``arch_fs`` : les autres n'ont aucune + contrepartie dans les sources, il n'y a rien à comparer. + + Renvoie ``(source, erreur)`` — « orm » si le registre a répondu, « none » + sinon, avec le message. Jamais d'échec silencieux : une comparaison qui n'a + pas eu lieu ne doit pas se lire comme une comparaison sans écart. + """ + lst_todo = [row for row in lst_finding if row.get("arch_fs")] + if not lst_todo: + return "none", None + + dct_by_id = {row["id"]: row for row in lst_todo} + lst_id = sorted(dct_by_id) + try: + for start in range(0, len(lst_id), BATCH): + chunk = lst_id[start : start + BATCH] + for answer in odoo_shell_json( + database, + SHELL_SCRIPT, + env={"VIEW_IDS": ",".join(str(i) for i in chunk)}, + timeout=timeout, + config_path=config_path, + ): + row = dct_by_id.get(answer["id"]) + if row is None: + continue + row["arch_ref"] = answer.get("arch_file") + row["arch_db_text"] = answer.get("arch_db") + row["arch_ref_error"] = answer.get("error") + except AnalyseError as exc: + return "none", str(exc) + + for row in lst_todo: + differs, comparable = arch_differs( + row.get("arch_ref"), row.get("arch_db_text") + ) + row["comparable"] = comparable + row["differs"] = differs + if comparable: + row["diff_stats"] = diff_stats( + side_by_side(row["arch_ref"], row["arch_db_text"]) + ) + return "orm", None + + +def collect( + database, + with_diff=False, + scope="flagged", + config_path=None, + timeout=120, + shell_timeout=600, +): + """Tout le travail. Donnée pure, sérialisable, aucun affichage.""" + kwargs = {"config_path": config_path, "timeout": timeout} + require_odoo_database(database, **kwargs) + odoo_version = scalar_query( + database, + "SELECT latest_version FROM ir_module_module WHERE name = 'base';", + **kwargs, + ) + has_website = bool( + scalar_query( + database, + "SELECT 1 FROM ir_module_module" + " WHERE name = 'website' AND state = 'installed';", + **kwargs, + ) + ) + + lst_view = _view_rows(database, **kwargs) + dct_count = {name: 0 for name in CATEGORIES} + lst_finding = [] + for row in lst_view: + category, lst_reason = classify(row) + row["category"] = category + row["reason"] = lst_reason + dct_count[category] += 1 + if category in ACTIONABLE: + lst_finding.append(row) + + arch_ref_source, arch_ref_error = "none", None + checkout = checkout_odoo_version() + if with_diff: + if not same_major(odoo_version, checkout): + # Le shell charge l'arbre du checkout, pas celui de la base : sur + # une base 13.0 avec un checkout 18.0 le registre ne chargera pas. + # Le dire en une seconde vaut mieux que trente secondes de + # chargement pour aboutir à la même conclusion. + arch_ref_error = ( + f"{t('Database is Odoo')} {odoo_version}," + f" {t('checkout is')} {checkout}" + ) + else: + # « flagged » ne compare que ce qui porte déjà un signe. C'est + # rapide, et aveugle au cas même que les drapeaux ratent : une vue + # réécrite en SQL direct n'arme pas arch_updated. « all » compare + # toute vue ayant un arch_fs et voit cette dérive silencieuse. + # « flagged » ne compare que ce qui porte déjà un signe : rapide, + # et ce qu'il rapporte est fiable. « all » compare toute vue ayant + # un arch_fs, ce qui trouve la dérive qu'aucun drapeau ne signale — + # une vue réécrite en SQL direct — mais au prix d'un plancher de + # bruit MESURÉ : sur une base 18.0 fraîchement installée, 160 des + # 974 vues à arch_fs diffèrent déjà. read_arch_from_file rend le + # XML brut du fichier, alors que la base porte l'arch APRÈS + # traitement au chargement : un attribut « groups » est consommé, + # un est appliqué. En « all », un + # écart est une piste, pas un verdict. + lst_candidate = ( + lst_finding + if scope == "flagged" + else [row for row in lst_view if row.get("arch_fs")] + ) + arch_ref_source, arch_ref_error = add_reference_arch( + database, + lst_candidate, + config_path=config_path, + timeout=shell_timeout, + ) + + # Une vue signalée dont la forme canonique égale celle du module n'a rien + # de modifié : le drapeau disait vrai sur « touchée », faux sur « autre ». + # C'est tout l'intérêt de comparer, alors elle quitte les constats. + n_identical = 0 + if arch_ref_source == "orm": + if scope != "flagged": + # Une vue sans drapeau dont l'arch diffère de son module a été + # réécrite sans passer par Odoo. C'est le constat que seule la + # comparaison peut produire, et le plus intéressant du lot. + known = {row["id"] for row in lst_finding} + for row in lst_view: + if row["id"] in known or not row.get("differs"): + continue + dct_count[row["category"]] -= 1 + row["category"] = "module_view_drifted" + row["reason"] = row["reason"] + ["differs_from_module"] + dct_count["module_view_drifted"] += 1 + lst_finding.append(row) + lst_kept = [] + for row in lst_finding: + if ( + row["category"] == "module_view_flagged" + and row.get("comparable") + and row.get("differs") is False + ): + row["category"] = "module_view" + row["reason"] = row["reason"] + ["identical_after_canonical"] + dct_count["module_view_flagged"] -= 1 + dct_count["module_view"] += 1 + n_identical += 1 + continue + lst_kept.append(row) + lst_finding = lst_kept + + return { + "tool": "analyse_view_custom", + "version": 1, + "database": database, + "odoo_version": odoo_version, + "checkout_version": checkout, + "has_website": has_website, + "compared_with_module_source": arch_ref_source == "orm", + "arch_ref_source": arch_ref_source, + "scope": scope, + "arch_ref_error": arch_ref_error, + "n_identical_after_canonical": n_identical, + "n_views": len(lst_view), + "counts": dct_count, + "findings": lst_finding, + } + + +def collect_from_backup(zip_path): + """Même inventaire, depuis une sauvegarde .zip, sans rien restaurer. + + Le classement est identique : il ne dépend que de colonnes que le dump + porte toutes. Ce qui manque est la COMPARAISON — l'arch de référence vient + de `read_arch_from_file`, donc d'un registre Odoo chargé, et un zip n'en a + pas. Le rapport le dit plutôt que de laisser croire à une absence d'écart. + """ + manifest, dct_rows, _, _ = read_backup( + zip_path, + tables=("ir_ui_view", "ir_model_data", "ir_module_module"), + ) + + dct_xmlid, dct_noupdate = {}, {} + for row in dct_rows["ir_model_data"]: + if row.get("model") != "ir.ui.view": + continue + res_id = row.get("res_id") + dct_xmlid.setdefault(res_id, set()).add(row.get("module")) + if row.get("noupdate") == "t": + dct_noupdate[res_id] = True + + # Une copie COW a une jumelle si une AUTRE vue, sans website_id, porte la + # même clé. Le même appariement que fait la CTE « twin » côté SQL. + set_twin = { + row.get("key") + for row in dct_rows["ir_ui_view"] + if row.get("key") and row.get("website_id") in (None, "") + } + + lst_view = [] + for row in dct_rows["ir_ui_view"]: + res_id = row.get("id") + website = row.get("website_id") + lst_view.append( + { + "id": int(res_id) if (res_id or "").isdigit() else res_id, + "name": normalise_arch(row.get("name")), + "model": row.get("model"), + "type": row.get("type"), + "key": row.get("key"), + "mode": row.get("mode"), + "active": row.get("active") != "f", + "arch_fs": row.get("arch_fs"), + "arch_updated": row.get("arch_updated") == "t", + "has_arch_prev": bool(row.get("arch_prev")), + "website_id": website if website not in (None, "") else None, + "theme_template_id": row.get("theme_template_id") or None, + "xmlid_modules": sorted(dct_xmlid.get(res_id) or []), + "noupdate": dct_noupdate.get(res_id, False), + "has_module_twin": bool( + row.get("key") and row.get("key") in set_twin + ), + "arch_bytes": len(row.get("arch_db") or ""), + } + ) + + dct_count = {name: 0 for name in CATEGORIES} + lst_finding = [] + for row in lst_view: + category, lst_reason = classify(row) + row["category"] = category + row["reason"] = lst_reason + dct_count[category] += 1 + if category in ACTIONABLE: + lst_finding.append(row) + + return { + "tool": "analyse_view_custom", + "version": 1, + "database": os.path.basename(zip_path), + "source": "backup", + "backup_path": zip_path, + "odoo_version": backup_version(dct_rows, manifest), + "checkout_version": checkout_odoo_version(), + "has_website": any(r.get("website_id") for r in lst_view), + "compared_with_module_source": False, + "arch_ref_source": "none", + "arch_ref_error": None, + "from_backup_no_registry": True, + "n_identical_after_canonical": 0, + "scope": "flagged", + "n_views": len(lst_view), + "counts": dct_count, + "findings": lst_finding, + } + + +def _finding_block(lst_row, top, hints=True): + """Une ligne par vue : clé, identifiant externe, poids, raisons.""" + lines = [ + f" {'id':>6} {'key / xml-id':<44}{'size':>9} {t('why')}", + ] + for row in lst_row[:top]: + label = row.get("key") or (row.get("xmlids") or [""])[0] or "—" + size = row.get("arch_bytes") + lines.append( + f" {row['id']:>6} {label[:44]:<44}" + f"{(str(size) + ' B') if size else '?':>9} " + f"{', '.join(row.get('reason') or []) or '—'}" + ) + if len(lst_row) > top: + lines.append(f" … {len(lst_row) - top} {t('more')}") + return lines + + +def render(data, verbose=False, top=TOP_DEFAULT, category=None, hints=True): + """Rapport texte. Fonction pure : donnée -> chaîne, testable sans base.""" + version = data.get("odoo_version") or "?" + counts = data["counts"] + lines = [ + "", + f"🔬 {t('Customised views')} — {data['database']} (Odoo {version}" + f"{', ' + t('from a backup') if data.get('source') == 'backup' else ''})", + "", + f" {t("Views"):<38}: {data['n_views']}", + ] + for name in CATEGORIES: + if counts.get(name): + lines.append(f" {category_label(name):<38}: {counts[name]}") + + n_finding = len(data["findings"]) + if not n_finding: + lines += [ + "", + f"✅ {t('Every view comes straight from a module.')}", + ] + return "\n".join(lines) + "\n" + + lst_show = data["findings"] + if category: + lst_show = [r for r in lst_show if r["category"] == category] + lines += [ + "", + f"── ⚠️ {t('Views that did not come straight from a module')}" + f" ({len(lst_show)}) ──", + ] + lines += _finding_block(lst_show, len(lst_show) if verbose else top) + + if counts.get("website_cow_copy"): + lines.append("") + lines += wrap_note( + " ", + t( + "Website copies are user data: Odoo copies a view instead of" + " editing it. Whether they will survive the next version is" + " another question, and these tools answer it:" + ), + ) + lines += [ + " ./script/odoo/migration/check_cow_views.py" + " -d DB -t odooXX.0", + " ./script/odoo/migration/reset_stale_cow_views.py -d DB", + ] + + lines.append("") + if data.get("compared_with_module_source"): + if data.get("scope") == "all": + lines += wrap_note( + " ⚠️ ", + t( + "In --scope all, a difference is a lead, not a verdict:" + " read_arch_from_file returns the raw file, while the" + " database holds the arch AFTER load-time processing." + " Measured on a freshly installed 18.0 database, 160 of" + " its 974 views already differ this way." + ), + ) + lines.append("") + if data.get("n_identical_after_canonical"): + lines += wrap_note( + " ✅ ", + f"{data['n_identical_after_canonical']} " + + t( + "views were flagged but hold exactly what their module" + " declares: only the comparison could tell." + ), + ) + lines += wrap_note( + " 💡 ", + t( + "To restore a view to what its module declares — this WRITES" + " to the database, so read the difference first:" + ), + ) + lines.append( + " echo \"env['ir.ui.view'].browse(ID).reset_arch('hard');" + ' env.cr.commit()" \\' + ) + lines.append( + f" | ./odoo_bin.sh shell -c ./config.conf" + f" -d {data['database']}" + ) + elif data.get("from_backup_no_registry"): + lines += wrap_note( + " ℹ️ ", + t( + "A backup holds no registry, so nothing was compared with the" + " module source. The classification above needs none; only the" + " differences do. Restore it, or run this on the database." + ), + ) + elif data.get("arch_ref_error"): + lines += wrap_note( + " ⚠️ ", + t("No reference arch, so nothing was compared: ") + + str(data["arch_ref_error"]), + ) + elif hints: + lines += wrap_note( + " ℹ️ ", + t( + "Flags say a view was touched, not how. They are incomplete" + " both ways: a direct SQL write does not set arch_updated," + " and reset_arch clears it. Comparing with the module source" + " is what settles it — add --diff." + ), + ) + else: + lines += wrap_note( + " ℹ️ ", + t( + "Flags say a view was touched, not how: only comparing with" + " the module source settles it." + ), + ) + return "\n".join(lines) + "\n" + + +def render_diff(row, width=78): + """Le diff d'une vue, côte à côte, pour la sortie texte. + + Calculé sur les arch BRUTES, pas sur les formes canoniques : la forme + canonique sert à décider s'il y a un écart, elle ne se relit pas. + """ + lines = [ + "", + f"── id={row['id']} {row.get('key') or '—'} " + f"({row.get('arch_fs') or '—'}) ──", + ] + half = (width - 4) // 2 + for mark, left, right in side_by_side( + row.get("arch_ref"), row.get("arch_db_text") + ): + if mark == " ": + continue + lines.append( + f" {mark} {(left or '')[:half]:<{half}} │ {(right or '')[:half]}" + ) + return lines + + +def open_tui(data): + """Ouvrir l'écran de navigation. False si on n'a pas pu — l'appelant imprime. + + Trois refus, trois raisons distinctes, et aucune n'est une panne : + rien à montrer, pas de terminal, ou Textual absent. Chacune se dit, plutôt + que d'ouvrir un écran vide ou de laisser des codes d'échappement dans un + fichier de sortie. + """ + lst_diff = [row for row in data["findings"] if row.get("differs")] + if not lst_diff: + return False + if not sys.stdout.isatty(): + print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}") + return False + try: + from script.todo import textual_setup + except Exception: + textual_setup = None + if textual_setup and not textual_setup.ensure(): + return False + + from script.analyse.analyse_diff_tui import run_diff_tui + + intent = run_diff_tui(data) + if intent and intent[0] == "command": + row = intent[1] + print(f"\n💡 {t('To restore this view to what its module declares:')}") + print( + f" echo \"env['ir.ui.view'].browse({row['id']})" + f".reset_arch('hard'); env.cr.commit()\" \\" + ) + print( + f" | ./odoo_bin.sh shell -c ./config.conf" + f" -d {data['database']}" + ) + return True + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=t( + "List the views of an Odoo database that did not come straight" + " from a module, website copies included (read-only)." + ) + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("-d", "--database", help=t("database to inspect")) + source.add_argument( + "-z", + "--zip", + dest="backup", + help=t("Odoo backup .zip to inspect, without restoring it"), + ) + parser.add_argument( + "--category", + choices=CATEGORIES, + default=None, + help=t("only show this category"), + ) + parser.add_argument( + "--top", + type=int, + default=TOP_DEFAULT, + help=t("how many views to show (default: 20)"), + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help=t("list every view") + ) + parser.add_argument( + "--diff", + action="store_true", + help=t("compare with the module source (opens an Odoo shell)"), + ) + parser.add_argument( + "--scope", + choices=("flagged", "all"), + default="flagged", + help=t("which views to compare (default: flagged)"), + ) + parser.add_argument( + "--strict", + action="store_true", + help=t("fail if the comparison could not be made"), + ) + parser.add_argument( + "--tui", + action="store_true", + help=t("browse the differences in a full-screen view"), + ) + parser.add_argument("--json", action="store_true", help=t("output JSON")) + parser.add_argument( + "-c", "--config", default=None, help=t("path to an Odoo config file") + ) + config = parser.parse_args(argv) + + try: + if config.backup: + data = collect_from_backup(config.backup) + else: + data = collect( + config.database, + with_diff=config.diff or config.tui, + scope=config.scope, + config_path=config.config, + ) + except AnalyseError as exc: + print(f"❌ {exc}") + return 2 + except KeyboardInterrupt: + print(f"\n{t('Cancelled.')}") + return 2 + + if config.strict and not data["compared_with_module_source"]: + print( + f"❌ {t('No reference arch, so nothing was compared: ')}" + f"{data.get('arch_ref_error') or ''}" + ) + return 2 + + if config.json: + print(json.dumps(data, indent=2, ensure_ascii=False, default=str)) + return 1 if data["findings"] else 0 + + if config.tui and open_tui(data): + return 1 if data["findings"] else 0 + + print( + render( + data, + verbose=config.verbose, + top=config.top, + category=config.category, + ) + ) + if config.verbose and data["compared_with_module_source"]: + for row in data["findings"]: + if row.get("differs"): + print("\n".join(render_diff(row))) + return 1 if data["findings"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/lib_analyse.py b/script/analyse/lib_analyse.py new file mode 100644 index 0000000..fb2c511 --- /dev/null +++ b/script/analyse/lib_analyse.py @@ -0,0 +1,872 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Socle commun des outils d'analyse d'une base Odoo, en lecture seule. + +Pourquoi psql en sous-processus plutôt que psycopg2 +--------------------------------------------------- +La raison est déjà écrite dans le dépôt, dans ``reset_stale_cow_views.py`` : +« Plain psql on purpose: this runs on databases whose Odoo registry does not +load, which is precisely when it is needed ». Une base 12.0 sur un checkout +18.0 ne charge pas son registre, et c'est exactement le moment où on veut +l'analyser. Accessoirement, psycopg2 n'est pas dans ``.venv.erplibre``, qui est +l'interpréteur de ces outils. + +La lecture seule est une garantie, pas une promesse +-------------------------------------------------- +``PGOPTIONS`` porte ``default_transaction_read_only=on`` : c'est **le serveur** +qui refuse toute écriture, pour toutes les transactions de la connexion. Un +``SET`` glissé dans le même ``-c`` ne suffirait pas — ``psql -c`` ouvre une +transaction implicite unique, et ``default_transaction_read_only`` ne vaut que +pour les transactions *suivantes*. + +Ne jamais deviner la forme du schéma +------------------------------------ +Douze versions d'Odoo se partagent ces tables. Les colonnes apparaissent, +changent de type (``text`` puis ``jsonb`` à partir de 16.0), ou n'existent que +si un module est installé. D'où ``existing_columns()`` et ``tr_col()`` : on +sonde avant d'écrire une requête, on ne date pas les colonnes de mémoire. + +Les sondes lisent ``pg_attribute``, pas ``information_schema`` : +``information_schema`` est filtré par les droits. Avec un rôle non +propriétaire, elle renverrait un ensemble vide, et l'analyse concluerait +« aucune colonne website, donc aucune vue COW » sans le moindre avertissement. +""" + +import configparser +import json +import os +import re +import subprocess +import sys + +new_path = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") +) +sys.path.append(new_path) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +REPO_ROOT = new_path + +# Une valeur littérale « False » dans config.conf veut dire « non défini » : +# c'est ainsi qu'Odoo écrit l'absence de valeur dans son fichier de config. +CONFIG_UNSET = ("false", "none", "") + +# Un nom de base voyage jusqu'à une commande shell (`odoo_bin.sh shell -d …`, +# lancée avec shell=True par execute.py). On le valide au lieu de compter sur +# l'échappement : la liste de caractères qu'une base Odoo utilise réellement +# est courte, et tout le reste est soit une erreur de frappe, soit une +# injection. +RE_DATABASE_NAME = re.compile(r"[A-Za-z0-9_.-]+") + +# Modèles dont la table N'EST PAS `_name.replace('.', '_')`. +# +# Dérivé des sources, pas écrit de mémoire : parcours AST de tous les `.py` de +# `odoo18.0/` et `addons/` (27 843 fichiers), en gardant les classes dont le +# `_table` diffère du défaut. Beaucoup de modules déclarent un `_table` égal au +# défaut — ce sont des déclarations sans effet, à ne pas confondre avec une +# surcharge. +# +# Sans cette table, `replace('.', '_')` échoue précisément sur les modèles les +# plus fréquents dans `ir_model_data` : `ir.actions.act_window` chercherait +# `ir_actions_act_window`, qui n'existe pas. +# +# La liste n'est pas la vérité, seulement ce qu'on sait : un modèle absent +# d'ici et dont la table est introuvable est classé « table inconnue » (un +# fait), jamais « table orpheline » (une anomalie). Pour la régénérer, refaire +# le parcours AST sur l'arbre courant. +MODEL_TABLE_OVERRIDE = { + "ir.actions.act_multi": "ir_actions", + "ir.actions.act_url": "ir_act_url", + "ir.actions.act_window": "ir_act_window", + "ir.actions.act_window.message": "ir_actions", + "ir.actions.act_window.view": "ir_act_window_view", + "ir.actions.act_window_close": "ir_actions", + "ir.actions.actions": "ir_actions", + "ir.actions.client": "ir_act_client", + "ir.actions.report": "ir_act_report_xml", + "ir.actions.server": "ir_act_server", + "project.task.stage.personal": "project_task_user_rel", + # `ir.actions.report.xml` est le nom d'avant 11.0 : les lignes + # `ir_model_data` d'une base ancienne le portent encore. + "ir.actions.report.xml": "ir_act_report_xml", +} + + +class AnalyseError(Exception): + """Échec de l'outil, pas un constat d'analyse. + + Distinction qui porte le code de retour : 2 pour « je n'ai pas pu + analyser », réservé à cette exception ; 1 pour « j'ai analysé et j'ai + trouvé des constats ». Les confondre rendrait une analyse en échec + indistinguable d'une base à problèmes. + """ + + +def valid_database_name(name): + """Le nom est-il un nom de base plausible, sûr à mettre dans une commande ?""" + return bool(name) and RE_DATABASE_NAME.fullmatch(name) is not None + + +def read_config(config_path=None): + """Lire config.conf, en repliant sur /etc/odoo/odoo.conf comme run.sh. + + Renvoie un dict des options, vide si aucun fichier n'est trouvé — l'absence + de config n'est pas une erreur : sur une installation native, psql se + connecte très bien par le socket unix sans aucun paramètre. + """ + lst_candidate = ( + [config_path] + if config_path + else [ + os.path.join(REPO_ROOT, "config.conf"), + "/etc/odoo/odoo.conf", + ] + ) + for path in lst_candidate: + if path and os.path.isfile(path): + parser = configparser.RawConfigParser() + try: + parser.read(path) + except configparser.Error: + continue + if parser.has_section("options"): + return dict(parser.items("options")) + return {} + + +def pg_env(config_path=None, timeout=120, overrides=None): + """Variables d'environnement pour psql : connexion + lecture seule. + + Les paramètres viennent de config.conf, pas d'une hypothèse « socket unix + et rôle = utilisateur système » : le dépôt lui-même livre + ``db_user = erplibre`` et un docker-compose.yml avec un mot de passe. + + ``PGOPTIONS`` est ce qui rend l'analyse incapable d'écrire, et borne la + durée d'une requête — un scan qui part en vrille ne bloque pas un menu. + """ + config = read_config(config_path) + my_env = os.environ.copy() + + dct_map = { + "db_host": "PGHOST", + "db_port": "PGPORT", + "db_user": "PGUSER", + "db_password": "PGPASSWORD", + "db_sslmode": "PGSSLMODE", + } + for key, var in dct_map.items(): + value = str(config.get(key, "")).strip() + if value.lower() not in CONFIG_UNSET: + my_env[var] = value + for var, value in (overrides or {}).items(): + if value: + my_env[var] = str(value) + + my_env["PGOPTIONS"] = ( + f"-c default_transaction_read_only=on -c statement_timeout={timeout}s" + ) + # Un ~/.psqlrc avec \timing ou \pset ajoute des lignes à la sortie et casse + # le parsing. -X l'ignore, mais PSQLRC vide protège aussi les appels qui + # oublieraient -X. + my_env["PSQLRC"] = "" + return my_env + + +def run_psql(database, sql, timeout=120, config_path=None, overrides=None): + """Exécuter du SQL et rendre la sortie brute, une ligne par enregistrement. + + ``-X`` ignore ~/.psqlrc, ``-w`` interdit l'invite de mot de passe (sans + lui, un mot de passe manquant bloque le menu TODO sans rien afficher), + ``ON_ERROR_STOP=1`` fait échouer au premier problème plutôt que de rendre + une sortie partielle qu'on prendrait pour un résultat. + """ + if not valid_database_name(database): + raise AnalyseError(f"{t('Invalid database name: ')}{database!r}") + cmd = [ + "psql", + "-X", + "-w", + "-v", + "ON_ERROR_STOP=1", + "-d", + database, + "-tAc", + sql, + ] + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout + 30, + cwd=REPO_ROOT, + env=pg_env(config_path, timeout=timeout, overrides=overrides), + ) + except FileNotFoundError as exc: + raise AnalyseError( + f"{t('psql is not installed or not in PATH.')}" + ) from exc + except subprocess.TimeoutExpired as exc: + raise AnalyseError( + f"{t('Query exceeded the timeout (s): ')}{timeout}" + ) from exc + if result.returncode: + raise AnalyseError( + f"{t('Cannot read from the database: ')}" + f"{result.stderr.strip() or result.returncode}" + ) + return result.stdout + + +def json_query(database, sql, **kwargs): + """Rendre le résultat d'un SELECT comme une liste de dicts. + + Le SQL est enveloppé côté PostgreSQL plutôt que découpé côté Python : une + arch de vue contient des retours de ligne et des « | », donc tout + séparateur maison finirait par couper au mauvais endroit. C'est le même + choix que ``snapshot_cow_views.py``. + + ``sql`` est un SELECT SANS point-virgule final : il devient une + sous-requête. + """ + inner = sql.strip().rstrip(";") + wrapped = ( + "SELECT COALESCE(json_agg(row_to_json(t))::text, '[]')" + f" FROM ({inner}) t;" + ) + raw = run_psql(database, wrapped, **kwargs).strip() + if not raw: + return [] + try: + return json.loads(raw) + except ValueError as exc: + raise AnalyseError(f"{t('Unreadable JSON from psql: ')}{exc}") from exc + + +def scalar_query(database, sql, **kwargs): + """Première valeur de la première ligne, ou None si aucune ligne.""" + raw = run_psql(database, sql, **kwargs).strip() + if not raw: + return None + return raw.splitlines()[0].strip() or None + + +def require_odoo_database(database, **kwargs): + """Refuser tout ce qui n'est pas une base Odoo, avant d'aller plus loin. + + Sans ce contrôle, ``-d postgres`` ou une base vide remonte un + « relation "ir_ui_view" does not exist » brut, qui ressemble à un bogue de + l'outil alors que c'est une erreur de saisie. + """ + found = scalar_query( + database, "SELECT to_regclass('public.ir_module_module');", **kwargs + ) + if not found: + raise AnalyseError(f"'{database}' {t('is not an Odoo database.')}") + return True + + +def database_version(database, **kwargs): + """Version Odoo de la BASE, qui n'est pas celle du checkout. + + Comparer les deux est ce qui évite d'ouvrir un shell Odoo pour rien : sur + une base 13.0 avec un checkout 18.0, le registre ne chargera pas, et mieux + vaut le dire tout de suite qu'après trente secondes de chargement. + """ + return scalar_query( + database, + "SELECT latest_version FROM ir_module_module WHERE name = 'base';", + **kwargs, + ) + + +def existing_columns(database, table, **kwargs): + """Colonnes réellement présentes, via pg_attribute (pas information_schema). + + Renvoie un ensemble vide si la table n'existe pas — les deux cas se + distinguent avec ``to_regclass`` si l'appelant en a besoin. + """ + sql = ( + "SELECT a.attname FROM pg_attribute a" + " JOIN pg_class c ON c.oid = a.attrelid" + " JOIN pg_namespace n ON n.oid = c.relnamespace" + f" WHERE n.nspname = 'public' AND c.relname = {quote_literal(table)}" + " AND a.attnum > 0 AND NOT a.attisdropped;" + ) + return { + line.strip() + for line in run_psql(database, sql, **kwargs).splitlines() + if line.strip() + } + + +def column_types(database, table, **kwargs): + """{colonne: type PostgreSQL} — dit `jsonb` là où 15.0 disait `text`.""" + sql = ( + "SELECT a.attname, format_type(a.atttypid, a.atttypmod)" + " FROM pg_attribute a" + " JOIN pg_class c ON c.oid = a.attrelid" + " JOIN pg_namespace n ON n.oid = c.relnamespace" + f" WHERE n.nspname = 'public' AND c.relname = {quote_literal(table)}" + " AND a.attnum > 0 AND NOT a.attisdropped;" + ) + dct_type = {} + for line in run_psql(database, sql, **kwargs).splitlines(): + if "|" in line: + name, _, kind = line.partition("|") + dct_type[name.strip()] = kind.strip() + return dct_type + + +def tr_col(table, column, dct_type, lang="en_US"): + """Fragment SQL lisant un champ traduit, quelle que soit la version. + + À partir de 16.0 un champ traduit est un ``jsonb`` ``{"en_US": "…"}`` ; + jusqu'à 15.0 c'est du texte. Un seul endroit décide, et il décide sur le + type réel de la colonne — pas sur un numéro de version, qu'il faudrait + connaître et qui mentirait sur une base à moitié migrée. + + ``dct_type`` vient de ``column_types()``. Une colonne inconnue rend NULL + plutôt que du SQL invalide : l'appelant verra un champ vide, pas une + requête qui explose. + """ + kind = (dct_type or {}).get(column) + if kind is None: + return "NULL::text" + qualified = f'"{table}"."{column}"' if table else f'"{column}"' + if kind == "jsonb": + return f"{qualified}->>{quote_literal(lang)}" + return f"{qualified}::text" + + +def quote_literal(value): + """Littéral SQL sûr : les quotes simples sont doublées. + + Nécessaire parce que ces requêtes sont assemblées en texte pour psql, sans + paramètres liés. Les seules valeurs concernées ici sont des noms de tables + et de colonnes venant du catalogue, mais un nom de table hérité peut + parfaitement porter une apostrophe. + """ + return "'" + str(value).replace("'", "''") + "'" + + +def model_table(model, known_tables=None): + """Table d'un modèle, ou None si elle est introuvable. + + None veut dire « je ne sais pas », jamais « il n'y en a pas » : c'est ce + qui empêche de classer un modèle à `_table` surchargé comme une anomalie. + ``known_tables`` est l'ensemble des tables réelles, quand l'appelant l'a. + """ + table = MODEL_TABLE_OVERRIDE.get(model, model.replace(".", "_")) + if known_tables is not None and table not in known_tables: + return None + return table + + +def public_tables(database, **kwargs): + """Tables réelles du schéma public, vues par le catalogue. + + ``relkind IN ('r', 'p')`` : 'r' pour une table ordinaire, 'p' pour une + table partitionnée. Odoo n'en partitionne pas, mais le jour où cela + changera, le compte ne doit pas devenir faux en silence. + """ + sql = ( + "SELECT c.relname FROM pg_class c" + " JOIN pg_namespace n ON n.oid = c.relnamespace" + " WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p');" + ) + return { + line.strip() + for line in run_psql(database, sql, **kwargs).splitlines() + if line.strip() + } + + +JSON_BEGIN = "ANALYSE_JSON_BEGIN" +JSON_END = "ANALYSE_JSON_END" + + +def odoo_config_path(config_path=None): + """Chemin du fichier de configuration Odoo, comme run.sh le résout. + + Sans ``-c``, ``odoo_bin.sh`` ne passe AUCUNE configuration : ``addons_path`` + retombe sur le défaut d'Odoo, et le shell ne voit alors aucun des dépôts de + ``addons/``. ``read_arch_from_file`` ne trouverait plus un seul fichier et + rendrait une arch de référence vide pour toutes les vues — sans erreur. + C'est la panne la plus coûteuse de cet outillage, parce qu'elle est + silencieuse : le rapport dirait « aucun écart » sur une base pleine + d'écarts. + """ + for path in ( + config_path, + os.path.join(REPO_ROOT, "config.conf"), + "/etc/odoo/odoo.conf", + ): + if path and os.path.isfile(path): + return path + raise AnalyseError(t("No Odoo configuration file found.")) + + +def odoo_shell_json( + database, script_path, env=None, timeout=600, config_path=None +): + """Pousser un script dans « odoo-bin shell » et récupérer son JSON. + + Pas de ``shell=True`` : la commande est une liste, le script arrive par + l'entrée standard. Un nom de base n'a donc rien à échapper — il ne traverse + aucun interpréteur de commandes. + + Les journaux d'Odoo se mêlent à la sortie, d'où les sentinelles : on ne + lit que ce qui est entre elles. Leur absence est une erreur franche, pas + une liste vide qu'on prendrait pour « rien à signaler ». + """ + if not valid_database_name(database): + raise AnalyseError(f"{t('Invalid database name: ')}{database!r}") + with open(script_path, "r", encoding="utf-8") as handle: + source = handle.read() + + my_env = os.environ.copy() + my_env.update(env or {}) + cmd = [ + os.path.join(REPO_ROOT, "odoo_bin.sh"), + "shell", + "-c", + odoo_config_path(config_path), + "-d", + database, + "--no-http", + ] + try: + result = subprocess.run( + cmd, + input=source, + capture_output=True, + text=True, + timeout=timeout, + cwd=REPO_ROOT, + env=my_env, + ) + except FileNotFoundError as exc: + raise AnalyseError(t("odoo_bin.sh not found.")) from exc + except subprocess.TimeoutExpired as exc: + raise AnalyseError( + f"{t('The Odoo shell exceeded the timeout (s): ')}{timeout}" + ) from exc + + output = result.stdout or "" + if JSON_BEGIN not in output or JSON_END not in output: + detail = (result.stderr or output).strip().splitlines()[-3:] + raise AnalyseError( + f"{t('The Odoo shell returned no result: ')}{' / '.join(detail)}" + ) + chunk = output.split(JSON_BEGIN, 1)[1].split(JSON_END, 1)[0] + try: + return json.loads(chunk.strip()) + except ValueError as exc: + raise AnalyseError( + f"{t('Unreadable JSON from the Odoo shell: ')}{exc}" + ) from exc + + +def normalise_arch(value): + """L'arch en chaîne, quel que soit le type de la colonne. + + ``arch_db`` est du texte jusqu'à 15.0 et du jsonb à partir de 16.0, avec + une entrée par langue. Reprise de ``reset_stale_cow_views.normalise_arch``, + à l'identique : deux implémentations de cette conversion finiraient par + diverger sur un cas limite, et c'est exactement le genre d'écart qui + ferait conclure « la vue a changé » sur une base qui n'a rien changé. + """ + if not isinstance(value, str): + return "" if value is None else str(value) + text = value.strip() + if text.startswith("{") and '"' in text: + try: + data = json.loads(text) + except ValueError: + return value + if isinstance(data, dict) and data: + for lang in ("en_US", *sorted(data)): + if lang in data and isinstance(data[lang], str): + return data[lang] + return value + + +# Attributs dont la valeur est un chemin ou une expression : l'espace y sépare +# des jetons, il se normalise, mais il ne se supprime pas. « //div[1] /span » +# et « //div[1]/span » ne désignent pas la même chose. +SPACING_ATTRS = ("expr", "position", "groups", "t-call", "class") + + +def canonical(arch): + """Forme canonique d'une arch, pour DÉCIDER s'il y a un écart. + + Sert à répondre « est-ce différent », jamais à afficher : ce qu'un humain + relit, c'est l'arch brute. + + Pourquoi pas ``etree.canonicalize()`` en un appel, alors que lxml est là : + son seul levier sur les espaces, ``strip_text``, s'applique à TOUS les + nœuds texte, y compris le contenu d'un ``t-esc`` ou d'un CDATA — donc il + masquerait des différences de contenu réelles. Et il n'offre aucun levier + sur les espaces à l'intérieur d'une valeur d'attribut, alors que c'est + précisément là que vit le bruit : un ``expr`` réindenté n'est pas une + modification. D'où ce parcours, qui trie les attributs comme le ferait + c14n, replie les espaces là où ils ne portent rien, et laisse le texte + tranquille partout ailleurs. + + Renvoie None si l'arch n'est pas du XML analysable — un écart ne se + conclut pas sur une comparaison qui n'a pas eu lieu. + """ + from lxml import etree + + text = normalise_arch(arch) + if not text.strip(): + return None + try: + parser = etree.XMLParser(remove_blank_text=True, remove_comments=True) + root = etree.fromstring(text.encode("utf-8"), parser=parser) + except etree.XMLSyntaxError: + return None + + def render(node): + lst_attr = [] + for key in sorted(node.attrib): + value = node.attrib[key] + if key in SPACING_ATTRS: + value = " ".join(value.split()) + lst_attr.append(f"{key}={value!r}") + head = node.tag + ("[" + ",".join(lst_attr) + "]" if lst_attr else "") + # Le texte n'est replié que sur ses bords : l'indentation autour d'un + # élément est de la mise en forme, l'espace À L'INTÉRIEUR d'un libellé + # ou d'un t-esc est du contenu. + own = (node.text or "").strip() + parts = [head] + ([f"#{own}" for _ in (1,) if own]) + parts += [render(child) for child in node] + tail = (node.tail or "").strip() + if tail: + parts.append(f"~{tail}") + return "(" + " ".join(parts) + ")" + + return render(root) + + +def arch_differs(left, right): + """(différent ?, comparable ?) entre deux arch. + + Deux réponses parce qu'il y a trois issues : identiques, différentes, et + « je n'ai pas pu comparer ». Confondre la troisième avec la première + ferait répondre « tout va bien » sur une vue au XML cassé, qui est + justement celle qu'il faut regarder. + """ + canon_left = canonical(left) + canon_right = canonical(right) + if canon_left is None or canon_right is None: + return None, False + return canon_left != canon_right, True + + +def side_by_side(left, right): + """[(marque, gauche, droite)] alignés, pour l'affichage côte à côte. + + Marque : ' ' identiques, '≠' remplacées, '-' seulement à gauche, '+' + seulement à droite. Consommé par le TUI ET par le rendu texte, pour que + les deux racontent la même chose. + """ + import difflib + + lst_left = normalise_arch(left).splitlines() + lst_right = normalise_arch(right).splitlines() + lst_row = [] + matcher = difflib.SequenceMatcher(None, lst_left, lst_right) + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + for offset in range(i2 - i1): + lst_row.append( + (" ", lst_left[i1 + offset], lst_right[j1 + offset]) + ) + elif tag == "replace": + for offset in range(max(i2 - i1, j2 - j1)): + lst_row.append( + ( + "≠", + lst_left[i1 + offset] if i1 + offset < i2 else None, + lst_right[j1 + offset] if j1 + offset < j2 else None, + ) + ) + elif tag == "delete": + for offset in range(i1, i2): + lst_row.append(("-", lst_left[offset], None)) + elif tag == "insert": + for offset in range(j1, j2): + lst_row.append(("+", None, lst_right[offset])) + return lst_row + + +def diff_stats(lst_row): + """{added, removed, changed} depuis les lignes de side_by_side().""" + return { + "added": sum(1 for mark, _, _ in lst_row if mark == "+"), + "removed": sum(1 for mark, _, _ in lst_row if mark == "-"), + "changed": sum(1 for mark, _, _ in lst_row if mark == "≠"), + } + + +# --- Lire une sauvegarde .zip sans restaurer quoi que ce soit ---------------- +# +# Une sauvegarde Odoo est un zip contenant `manifest.json`, un `filestore/` et +# un `dump.sql` — un pg_dump TEXTE, avec ses blocs `COPY … FROM stdin;` et ses +# `CREATE TABLE`. Tout se lit donc sans PostgreSQL et sans Odoo. +# +# Ce que ça débloque : analyser la sauvegarde d'une instance Enterprise depuis +# une installation Community. La restaurer y échoue — Odoo veut charger des +# modules qu'on n'a pas — alors que les champs Studio, eux, ne sont que des +# lignes de `ir_model_fields` qu'on peut lire telles quelles. +# +# La lecture est en FLOT, une seule passe : un dump de production pèse des +# gigaoctets, et on n'en veut que trois tables. + +# Échappements de pg_dump dans un bloc COPY. `\N` (NULL) est traité à part : +# c'est une valeur, pas un caractère. +COPY_ESCAPE = { + "b": "\b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", + "v": "\v", + "\\": "\\", +} + +# Lignes d'un CREATE TABLE qui ne déclarent pas une colonne. +NOT_A_COLUMN = ( + "CONSTRAINT", + "PRIMARY", + "UNIQUE", + "CHECK", + "FOREIGN", + "EXCLUDE", +) + + +def unescape_copy(value): + r"""Une valeur d'un bloc COPY, dés-échappée. « \N » devient None.""" + if value == "\\N": + return None + if "\\" not in value: + return value + out, index = [], 0 + while index < len(value): + char = value[index] + if char == "\\" and index + 1 < len(value): + out.append(COPY_ESCAPE.get(value[index + 1], value[index + 1])) + index += 2 + else: + out.append(char) + index += 1 + return "".join(out) + + +def find_member(archive, filename): + """Le membre nommé ``filename``, où qu'il soit dans l'archive. + + Cherché à la racine d'abord, puis n'importe où : selon qui fabrique la + sauvegarde, elle est mise à plat ou rangée sous un dossier. + """ + lst_name = archive.namelist() + if filename in lst_name: + return filename + for name in lst_name: + if name.rsplit("/", 1)[-1] == filename: + return name + return None + + +def backup_manifest(zip_path): + """Le manifest.json d'une sauvegarde, ou {} s'il n'y en a pas. + + Son absence n'est PAS une erreur. Une sauvegarde odoo.sh n'en contient + aucun — seulement ``dump.sql`` et ``filestore/`` — et refuser le fichier + pour cela reviendrait à refuser d'analyser précisément les bases qu'on ne + peut pas restaurer, ce qui est tout l'intérêt de lire un zip. + + Le manifeste ne portait de toute façon qu'une commodité : la version. Elle + se lit dans le dump lui-même, où elle est plus sûre — c'est la base qui + parle, pas un fichier écrit à côté. + """ + import zipfile + + try: + with zipfile.ZipFile(zip_path) as archive: + member = find_member(archive, "manifest.json") + if member is None: + return {} + with archive.open(member) as handle: + return json.load(handle) + except ValueError: + # Un manifeste illisible ne vaut pas mieux qu'un manifeste absent, et + # ne doit pas empêcher de lire le dump qui est à côté. + return {} + except (OSError, zipfile.BadZipFile) as exc: + raise AnalyseError(f"{t('Cannot read the backup: ')}{exc}") from exc + + +def backup_version(dct_rows, manifest=None): + """Version Odoo d'une sauvegarde, lue dans le dump avant le manifeste. + + ``ir_module_module.latest_version`` du module ``base`` est ce que la base + dit d'elle-même ; le manifeste n'est qu'un repli. + """ + for row in dct_rows.get("ir_module_module") or []: + if row.get("name") == "base" and row.get("latest_version"): + return row["latest_version"] + return (manifest or {}).get("version") + + +def read_backup(zip_path, tables=(), with_columns=(), census=False): + """Lire un dump.sql en flot. + + -> (manifest, {table: [lignes]}, {table: colonnes}, {table: recensement}) + + Une seule passe, quelle que soit la taille du dump : on ne garde en mémoire + que les tables demandées. + + ``census`` compte les lignes et pèse chaque table SANS la garder. Le + comptage est alors EXACT, là où une base rend une estimation d'après le + dernier ANALYZE. Le poids est celui des données dans le dump, pas sur le + disque : une sauvegarde ne sait rien des index ni du ballonnement, et + présenter l'un pour l'autre tromperait sur ce qui fait grossir une base. + """ + import io + import zipfile + + manifest = backup_manifest(zip_path) + set_want = set(tables) + # True = toutes les tables. Le dump les déclare toutes de toute façon, et + # on ne sait quelles tables comptent qu'APRÈS avoir lu les champs : les + # garder toutes coûte quelques centaines de kilo-octets et évite une + # seconde passe sur un fichier qui peut peser des gigaoctets. + all_cols = with_columns is True + set_cols = set() if all_cols else set(with_columns) + dct_rows = {name: [] for name in set_want} + dct_columns = {name: set() for name in set_cols} + dct_census = {} + + try: + archive = zipfile.ZipFile(zip_path) + except (OSError, zipfile.BadZipFile) as exc: + raise AnalyseError(f"{t('Cannot read the backup: ')}{exc}") from exc + with archive: + member = find_member(archive, "dump.sql") + if member is None: + # Le dump est la seule pièce indispensable : c'est LUI qui porte + # les données. Son absence est donc la vraie erreur, là où celle + # du manifeste n'en est pas une. + raise AnalyseError( + f"{t('This backup holds no dump.sql: ')}{zip_path}" + ) + with archive.open(member) as raw: + stream = io.TextIOWrapper(raw, encoding="utf-8", errors="replace") + for line in stream: + if line.startswith("COPY public."): + name = line[len("COPY public.") :].split(" ", 1)[0] + keep = name in set_want + if not keep and not census: + continue + header = line[line.index("(") + 1 : line.rindex(")")] + lst_col = [c.strip() for c in header.split(",")] + n_row, n_byte = 0, 0 + for row in stream: + if row.startswith("\\."): + break + n_row += 1 + n_byte += len(row) + if not keep: + continue + values = row.rstrip("\n").split("\t") + dct_rows[name].append( + dict( + zip( + lst_col, [unescape_copy(v) for v in values] + ) + ) + ) + if census: + dct_census[name] = { + "rows": n_row, + "dump_bytes": n_byte, + "columns": lst_col, + } + elif line.startswith("CREATE TABLE public."): + name = line[len("CREATE TABLE public.") :].split(" ", 1)[0] + if census: + dct_census.setdefault( + name, {"rows": 0, "dump_bytes": 0, "columns": []} + ) + for row in stream: + stripped = row.strip() + if stripped.startswith(");"): + break + if not all_cols and name not in set_cols: + continue + if stripped.upper().startswith(NOT_A_COLUMN): + continue + column = stripped.split(" ", 1)[0].strip('",') + if column: + dct_columns.setdefault(name, set()).add(column) + return manifest, dct_rows, dct_columns, dct_census + + +def _describe(): + """Dire ce qu'est ce fichier, et où sont les outils. + + Ce fichier porte un shebang et un nom qui ressemble à celui d'un outil ; + le lancer ne produisait rien du tout, ce qui se lit comme une panne plutôt + que comme « ce n'est pas un exécutable ». Il énumère donc ses voisins qui, + eux, se lancent — la liste vient du disque, elle ne peut pas se périmer + quand un outil s'ajoute. + """ + here = os.path.dirname(os.path.abspath(__file__)) + + def is_runnable(name): + """Ce fichier se lance-t-il vraiment ? + + Le nom ne suffit pas : `analyse_diff_tui.py` commence pareil et n'a + pas de point d'entrée. L'annoncer comme exécutable reproduirait le + défaut même que cette fonction corrige — promettre une commande qui + ne fait rien. On regarde donc s'il y a un bloc `__main__`. + """ + if not (name.startswith("analyse_") and name.endswith(".py")): + return False + try: + with open(os.path.join(here, name), encoding="utf-8") as handle: + return '__name__ == "__main__"' in handle.read() + except OSError: + return False + + lst_tool = sorted(name for name in os.listdir(here) if is_runnable(name)) + print( + f"📚 {os.path.basename(__file__)} — " + f"{t('shared library, nothing to run here.')}" + ) + print() + if lst_tool: + print(t("Runnable tools in this directory:")) + for tool in lst_tool: + print(f" ./script/analyse/{tool} -d ") + else: + print(t("No analysis tool here yet.")) + print() + print(f"{t('From the menu:')} make todo → Execute → Analyse") + + +if __name__ == "__main__": + _describe() diff --git a/script/analyse/shell/view_file_arch.py b/script/analyse/shell/view_file_arch.py new file mode 100644 index 0000000..771b59d --- /dev/null +++ b/script/analyse/shell/view_file_arch.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Arch de référence des vues, telle que le module la déclare. + +Ce fichier ne se lance PAS seul : il est poussé dans l'entrée standard d'un +``odoo-bin shell``, qui lui fournit ``env``. Lancé directement, il ne trouve +aucun ``env`` et ne fait rien. + +Pourquoi passer par l'ORM plutôt que relire le XML +-------------------------------------------------- +La question « à quoi comparer l'arch en base » a une réponse dans le code +d'Odoo : c'est ce que fait son propre bouton « Reset view », mode ``hard`` — +``view.with_context(read_arch_from_file=True, lang=None).arch``. + +Cette seule expression gère ce qu'une relecture du XML devrait réimplémenter : +localiser le fichier par ``arch_fs``, y trouver le bon nœud par identifiant +externe ou par identifiant court, suivre un ```` qui ne fait que +re-pointer, transformer un ``