diff --git a/script/odoo/migration/reset_stale_cow_tui.py b/script/odoo/migration/reset_stale_cow_tui.py
new file mode 100755
index 0000000..766f90f
--- /dev/null
+++ b/script/odoo/migration/reset_stale_cow_tui.py
@@ -0,0 +1,198 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Full-screen browsing of the COW copies that drifted from their module view.
+
+Two things decide whether to reset a copy, and the text report puts them a
+thousand lines apart: what the copy holds that the module view does not — the
+only thing a reset gives up — and which child no longer finds its anchor,
+which is why anything breaks at all. Space switches between them.
+
+The reset command is on « c »: it takes a key, and copying a key by hand out
+of a scrolled diff is where a character goes missing. A key matching no copy
+is not an error for the tool — it runs and does nothing.
+"""
+
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+)
+
+from reset_stale_cow_views import render_broken, render_diff # noqa: E402
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+CSS = """
+Screen { layout: vertical; }
+#head { height: 3; padding: 0 1; background: $panel; color: $text; }
+#body { height: 1fr; }
+#copies { width: 44; border-right: solid $accent; }
+#pane { width: 1fr; padding: 0 1; }
+"""
+
+
+def weight(cow_view, module_view):
+ """Combien la copie s'écarte, pour trier d'un coup d'œil."""
+ if not module_view:
+ return "—"
+ import difflib
+
+ diff = list(
+ difflib.unified_diff(
+ module_view["arch"].splitlines(),
+ cow_view["arch"].splitlines(),
+ lineterm="",
+ n=0,
+ )
+ )
+ plus = sum(1 for x in diff if x[:1] == "+" and not x.startswith("+++"))
+ minus = sum(1 for x in diff if x[:1] == "-" and not x.startswith("---"))
+ return f"+{plus}/-{minus}"
+
+
+def build_app(lst_finding, database):
+ """Build the application. Textual is imported here, not at module level.
+
+ The module stays importable — and therefore testable — on a machine
+ without Textual, which is also what lets the caller fall back to the text
+ report rather than fail.
+ """
+ from textual.app import App, ComposeResult
+ from textual.containers import Horizontal, VerticalScroll
+ from textual.widgets import DataTable, Footer, Header, Static
+
+ class StaleCowApp(App):
+ CSS = globals()["CSS"]
+ BINDINGS = [
+ ("q,escape", "quit", "Quit"),
+ ("space,tab", "toggle", "Diff / why"),
+ ("c", "copy", "Copy reset command"),
+ ]
+
+ def __init__(self, lst_finding, database):
+ super().__init__()
+ self.lst_finding = lst_finding
+ self.database = database
+ self.why = False
+ self.index = 0
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ yield Static("", id="head")
+ with Horizontal(id="body"):
+ yield DataTable(id="copies", cursor_type="row")
+ with VerticalScroll(id="pane"):
+ yield Static("", id="content")
+ yield Footer()
+
+ def on_mount(self):
+ self.title = "COW copies drifted from their module view"
+ table = self.query_one("#copies", DataTable)
+ table.add_columns("copy", "+/-", "broken")
+ for cow_view, module_view, broken in self.lst_finding:
+ table.add_row(
+ (cow_view["key"] or str(cow_view["id"]))[:30],
+ weight(cow_view, module_view),
+ str(len(broken)),
+ key=str(cow_view["id"]),
+ )
+ self._show()
+
+ def _show(self):
+ if not self.lst_finding:
+ return
+ cow_view, module_view, broken = self.lst_finding[self.index]
+ which = "why it breaks" if self.why else "what the copy holds"
+ self.query_one("#head", Static).update(
+ f"{cow_view['key']} · id={cow_view['id']}"
+ f" · website={cow_view.get('website_id')}\n"
+ f"[{which}] — space to switch, c to copy the reset command"
+ )
+ if self.why:
+ text = render_broken(cow_view, module_view, broken)
+ elif module_view:
+ text = render_diff(module_view, cow_view, indent=" ")
+ else:
+ text = (
+ f" {t('No module view carries this key: nothing to reset')}"
+ f" {t('onto.')}"
+ )
+ self.query_one("#content", Static).update(text)
+
+ def on_data_table_row_highlighted(self, event):
+ if event.data_table.id == "copies" and self.lst_finding:
+ self.index = event.cursor_row
+ self._show()
+
+ def action_toggle(self):
+ self.why = not self.why
+ self._show()
+
+ def action_copy(self):
+ """Mettre la commande de réinitialisation dans le presse-papier.
+
+ Elle prend une CLÉ, et recopier une clé à la main depuis un diff
+ défilé est l'endroit où un caractère se perd — l'outil ne dirait
+ rien, il ne trouverait simplement aucune copie.
+ """
+ cow_view = self.lst_finding[self.index][0]
+ command = (
+ "./script/odoo/migration/reset_stale_cow_views.py"
+ f" -d {self.database} --reset {cow_view['key']} --apply"
+ )
+ try:
+ self.copy_to_clipboard(command)
+ self.notify(t("Reset command copied."))
+ except Exception:
+ self.notify(command, timeout=20)
+
+ return StaleCowApp(lst_finding, database)
+
+
+def run_tui(lst_finding, database, run_app=True):
+ """Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi.
+
+ Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas
+ de terminal, ou Textual absent. Se taire ferait réafficher le rapport
+ texte à la place de l'écran demandé, sans rien qui distingue les deux.
+ """
+ if not lst_finding:
+ 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.available():
+ if not textual_setup.in_venv():
+ print(
+ f"ℹ️ {t('Textual is missing from this interpreter:')}"
+ f" {sys.executable}"
+ )
+ print(f" {t('Run it with')} .venv.erplibre/bin/python3")
+ if textual_setup and not textual_setup.ensure():
+ return False
+ try:
+ app = build_app(lst_finding, database)
+ except ImportError:
+ print(
+ f"ℹ️ {t('Textual is missing from this interpreter:')}"
+ f" {sys.executable}"
+ )
+ return False
+ if not run_app:
+ return app
+ app.run()
+ return True
diff --git a/script/odoo/migration/reset_stale_cow_views.py b/script/odoo/migration/reset_stale_cow_views.py
index f4cc956..62bb2ee 100755
--- a/script/odoo/migration/reset_stale_cow_views.py
+++ b/script/odoo/migration/reset_stale_cow_views.py
@@ -248,8 +248,13 @@ def find_copy_by_key(views, key):
return copy, twin
-def show_diff(module_view, cow_view):
- """Module arch vs copy: what the copy would gain and lose on a reset."""
+def render_diff(module_view, cow_view, indent=" "):
+ """Module arch vs copy : ce qu'une réinitialisation rend et abandonne.
+
+ Rendu en texte plutôt qu'imprimé : la TUI montre exactement le même
+ diff que la ligne de commande. Deux rendus séparés dériveraient sans
+ que rien ne le signale.
+ """
diff = difflib.unified_diff(
module_view["arch"].splitlines(),
cow_view["arch"].splitlines(),
@@ -257,8 +262,44 @@ def show_diff(module_view, cow_view):
tofile=f"cow id={cow_view['id']}",
lineterm="",
)
- for line in diff:
- print(f" {line}")
+ return "\n".join(f"{indent}{line}" for line in diff)
+
+
+def render_broken(cow_view, module_view, broken):
+ """Pourquoi ça casse : les enfants dont l'xpath ne trouve plus son point."""
+ lines = [
+ f"── id={cow_view['id']} {cow_view['key']}"
+ f" (website={cow_view.get('website_id')}) ──",
+ "",
+ ]
+ if not broken:
+ lines.append(
+ f" {t('No child fails on this copy: it drifted without')}"
+ f" {t('breaking anything yet.')}"
+ )
+ else:
+ lines.append(
+ f" {t('These children no longer find their anchor in the copy')}"
+ " :"
+ )
+ for child_id, expr in broken:
+ lines.append(f" {t('child')} {child_id} : {expr}")
+ lines += [
+ "",
+ f" {t('The anchor exists in the module view')}"
+ f" (id={module_view['id'] if module_view else '?'})"
+ f" {t('but not in this copy, frozen on an older version.')}",
+ "",
+ f" {t('Resetting restores the module arch; the customization the')}",
+ f" {t('copy carried is saved first, and is yours to re-apply as an')}",
+ f" {t('INHERITING view with its own key.')}",
+ ]
+ return "\n".join(lines)
+
+
+def show_diff(module_view, cow_view):
+ """Imprimer le diff, pour la ligne de commande."""
+ print(render_diff(module_view, cow_view))
def backup(database, cow_view, directory):
@@ -304,6 +345,11 @@ def main():
)
)
parser.add_argument("-d", "--database", required=True)
+ parser.add_argument(
+ "--tui",
+ action="store_true",
+ help="browse the differences full screen",
+ )
parser.add_argument(
"--list-keys",
action="store_true",
@@ -381,6 +427,13 @@ def main():
)
return 1
+ if config.tui and findings:
+ from reset_stale_cow_tui import run_tui
+
+ # False n'est pas un échec : l'écran a dit pourquoi, et le rapport
+ # texte ci-dessus porte déjà la même information.
+ run_tui(findings, config.database)
+
wanted = set(config.reset)
directory = config.backup_dir or os.path.join(
"private", "odoo", "migration", config.database, "cow_reset"
diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py
index df0712f..6529d4a 100644
--- a/script/todo/todo_i18n.py
+++ b/script/todo/todo_i18n.py
@@ -5094,6 +5094,50 @@ TRANSLATIONS = {
"fr": "après — et ce ne sera pas la migration.",
"en": "after — and that is not the migration.",
},
+ "No child fails on this copy: it drifted without": {
+ "fr": "Aucun enfant n'échoue sur cette copie : elle a dérivé sans",
+ "en": "No child fails on this copy: it drifted without",
+ },
+ "breaking anything yet.": {
+ "fr": "encore rien casser.",
+ "en": "breaking anything yet.",
+ },
+ "These children no longer find their anchor in the copy": {
+ "fr": "Ces enfants ne trouvent plus leur point d'ancrage dans la copie",
+ "en": "These children no longer find their anchor in the copy",
+ },
+ "The anchor exists in the module view": {
+ "fr": "L'ancrage existe dans la vue module",
+ "en": "The anchor exists in the module view",
+ },
+ "but not in this copy, frozen on an older version.": {
+ "fr": "mais pas dans cette copie, figée sur une version antérieure.",
+ "en": "but not in this copy, frozen on an older version.",
+ },
+ "Resetting restores the module arch; the customization the": {
+ "fr": "Réinitialiser rend l'arch du module ; la personnalisation que",
+ "en": "Resetting restores the module arch; the customization the",
+ },
+ "copy carried is saved first, and is yours to re-apply as an": {
+ "fr": "la copie portait est sauvegardée d'abord, à vous de la réappliquer en",
+ "en": "copy carried is saved first, and is yours to re-apply as an",
+ },
+ "INHERITING view with its own key.": {
+ "fr": "vue HÉRITANTE avec sa propre clé.",
+ "en": "INHERITING view with its own key.",
+ },
+ "No module view carries this key: nothing to reset": {
+ "fr": "Aucune vue module ne porte cette clé : rien sur quoi",
+ "en": "No module view carries this key: nothing to reset",
+ },
+ "onto.": {
+ "fr": "réinitialiser.",
+ "en": "onto.",
+ },
+ "Browse the differences full screen": {
+ "fr": "Parcourir les différences en plein écran",
+ "en": "Browse the differences full screen",
+ },
"Nothing to decide yet": {
"fr": "Rien à décider pour l'instant",
"en": "Nothing to decide yet",
diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py
index de79827..260c376 100755
--- a/script/todo/todo_upgrade.py
+++ b/script/todo/todo_upgrade.py
@@ -3412,6 +3412,7 @@ class TodoUpgrade:
f" ({database_name})"
)
print(f"[3] {t('Reset one of them onto its module view')}")
+ print(f"[4] {t('Browse the differences full screen')}")
wait_status = (
input(
f"💬 {t('Error detected, press enter to continue or')}"
@@ -3434,6 +3435,18 @@ class TodoUpgrade:
if wait_status == "3" and database_name:
self.prompt_reset_stale_cow_views(database_name)
continue
+ if wait_status == "4" and database_name:
+ # Sur le VRAI terminal : un plein écran refuse de
+ # s'ouvrir sur un stdout capturé, et retomberait sur le
+ # rapport texte sans que rien ne distingue les deux.
+ self.run_on_terminal(
+ f"{PYTHON_BIN} ./"
+ + os.path.join(
+ PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py"
+ )
+ + f" -d {database_name} --tui"
+ )
+ continue
break
if wait_status == "1":
diff --git a/test/test_reset_stale_cow_tui.py b/test/test_reset_stale_cow_tui.py
new file mode 100755
index 0000000..2b0296b
--- /dev/null
+++ b/test/test_reset_stale_cow_tui.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Lire une copie COW avant d'accepter de la perdre, en plein écran.
+
+Le rapport texte met à mille lignes d'écart les deux choses qui décident :
+ce que la copie porte en propre — la seule chose qu'une réinitialisation
+abandonne — et quel enfant ne trouve plus son ancrage, qui est la raison
+pour laquelle quoi que ce soit casse. L'espace bascule entre les deux.
+
+La commande de réparation prend une CLÉ. La recopier à la main depuis un
+diff défilé est l'endroit où un caractère se perd, et une clé sans
+correspondance n'est pas une erreur pour l'outil : il tourne et ne fait
+rien. D'où « c ».
+"""
+
+import os
+import sys
+import unittest
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
+
+import reset_stale_cow_tui as tui # noqa: E402
+import reset_stale_cow_views as reset # noqa: E402
+
+
+def finding(**override):
+ cow = {
+ "id": 2841,
+ "key": "website_blog.blog_post_complete",
+ "website_id": 1,
+ "arch": "\n \n vieux\n",
+ }
+ module = {
+ "id": 1826,
+ "key": "website_blog.blog_post_complete",
+ "website_id": None,
+ "arch": "\n \n \n",
+ }
+ broken = [(3288, "//section[@id='o_wblog_post_footer']")]
+ if "cow" in override:
+ cow.update(override["cow"])
+ return cow, override.get("module", module), override.get("broken", broken)
+
+
+class TestBothScreens(unittest.TestCase):
+ def setUp(self):
+ from script.todo import todo_i18n
+
+ self.addCleanup(
+ setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
+ )
+ todo_i18n._current_lang = "en"
+
+ def test_the_diff_shows_what_the_copy_holds(self):
+ cow, module, _broken = finding()
+ text = reset.render_diff(module, cow, indent=" ")
+ self.assertIn("vieux", text)
+ self.assertIn("o_wblog_post_footer", text)
+
+ def test_the_why_screen_names_the_failing_child(self):
+ # Sans lui, on lit un diff sans savoir pourquoi il compte.
+ text = tui.render_broken(*finding())
+ self.assertIn("3288", text)
+ self.assertIn("//section[@id='o_wblog_post_footer']", text)
+
+ def test_the_why_screen_says_what_a_reset_costs(self):
+ text = tui.render_broken(*finding())
+ self.assertIn("INHERITING", text)
+
+ def test_a_copy_without_broken_child_is_said_so(self):
+ # Elle a dérivé sans rien casser ENCORE : le taire ferait croire à
+ # une erreur de détection.
+ cow, module, _ = finding()
+ text = tui.render_broken(cow, module, [])
+ self.assertIn("without", text)
+
+ def test_the_two_screens_use_the_same_diff_as_the_cli(self):
+ # Deux rendus séparés dériveraient sans que rien ne le signale.
+ import inspect
+
+ source = inspect.getsource(tui)
+ self.assertIn("from reset_stale_cow_views import", source)
+ self.assertIn("render_diff", source)
+
+
+class TestTheTriageColumn(unittest.TestCase):
+ def test_the_weight_counts_both_directions(self):
+ cow, module, _ = finding()
+ self.assertEqual(tui.weight(cow, module), "+1/-1")
+
+ def test_no_module_twin_has_no_weight(self):
+ cow, _module, _ = finding()
+ self.assertEqual(tui.weight(cow, None), "—")
+
+
+class TestTheRefusalIsNeverSilent(unittest.TestCase):
+ """Un refus muet ferait réafficher le rapport texte sans le dire."""
+
+ def test_a_pipe_is_explained(self):
+ import io
+ from contextlib import redirect_stdout
+
+ out = io.StringIO()
+ with redirect_stdout(out):
+ result = tui.run_tui([finding()], "db")
+ self.assertFalse(result)
+ self.assertTrue(out.getvalue().strip(), "refus muet")
+
+ def test_nothing_to_show_stays_silent(self):
+ import io
+ from contextlib import redirect_stdout
+
+ out = io.StringIO()
+ with redirect_stdout(out):
+ self.assertFalse(tui.run_tui([], "db"))
+ self.assertEqual(out.getvalue(), "")
+
+
+class TestTheToolAndTheMigrationOfferIt(unittest.TestCase):
+ def test_the_tool_has_a_tui_flag(self):
+ import subprocess
+
+ done = subprocess.run(
+ [
+ sys.executable,
+ "./script/odoo/migration/reset_stale_cow_views.py",
+ "--help",
+ ],
+ capture_output=True,
+ text=True,
+ cwd=REPO,
+ )
+ self.assertEqual(done.returncode, 0)
+ self.assertIn("--tui", done.stdout)
+
+ def test_the_error_prompt_offers_it_as_option_four(self):
+ import inspect
+
+ from script.todo.todo_upgrade import TodoUpgrade
+
+ source = inspect.getsource(TodoUpgrade.todo_upgrade_execute)
+ self.assertIn("Browse the differences full screen", source)
+ self.assertIn('wait_status == "4"', source)
+
+ def test_it_gets_a_real_terminal(self):
+ # Un plein écran lancé par l'exécuteur qui capture retomberait sur
+ # le rapport texte, sans que rien ne distingue les deux.
+ import inspect
+
+ from script.todo.todo_upgrade import TodoUpgrade
+
+ source = inspect.getsource(TodoUpgrade.todo_upgrade_execute)
+ start = source.index('wait_status == "4"')
+ window = source[start : start + 620]
+ self.assertIn("run_on_terminal", window)
+ self.assertIn("--tui", window)
+
+
+if __name__ == "__main__":
+ unittest.main()