From 05e535d743e320e33ba996462fc6eec1ec584e0f Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Wed, 6 May 2026 10:13:49 +0200 Subject: [PATCH] fix(admin/tables): unescape shell-quoting backslashes in descriptions --- CHANGELOG.md | 2 ++ app/api/admin.py | 39 +++++++++++++++++++++++++++++ app/web/templates/admin_tables.html | 19 +++++++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ae7f0..d729db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C ### Fixed - **Admin / Tables**: long table descriptions no longer push the row's Edit / Manage access / Delete buttons off-screen. The Description column is now clamped to 2 lines with the full text available on hover and in the Edit modal. +- **Admin / Tables**: descriptions stored with shell-quoting backslash-escapes (`Don\'t`, `\n`) now render correctly. The same normalization also runs at register/update time so newly-saved descriptions are never corrupted. +- **Admin / Tables**: `scripts/fix_description_escapes.py` cleans up already-corrupted descriptions in `table_registry` (run with `--dry-run` first, then `--apply`). ## [0.38.0] — 2026-05-06 diff --git a/app/api/admin.py b/app/api/admin.py index 4469299..a697197 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -88,6 +88,31 @@ def _validate_url_not_private(url: str, field_name: str = "url") -> None: ) +def _unescape_shell_quoting(s: str | None) -> str | None: + """Defensive normalization for descriptions arriving via shell-quoting tooling. + + Some operators register tables with bash/curl invocations whose quoting + injects literal backslash escapes into the payload (e.g. ``Don\\'t`` or + embedded ``\\n`` instead of real newlines). The backend would otherwise + persist those bytes verbatim and the UI would render them verbatim too. + Mirrored in JS as ``unescapeShellQuoting`` in + ``app/web/templates/admin_tables.html`` for already-stored rows. + """ + if not s: + return s + # Order matters: protect real backslashes first. + SENTINEL = "\x00" + return ( + s.replace("\\\\", SENTINEL) + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") + .replace("\\'", "'") + .replace('\\"', '"') + .replace(SENTINEL, "\\") + ) + + def _normalize_primary_key(v): """Coerce a string primary_key to ``[v]`` for backward compatibility. @@ -1273,6 +1298,13 @@ class RegisterTableRequest(BaseModel): def _coerce_primary_key(cls, v): return _normalize_primary_key(v) + @field_validator("description", mode="before") + @classmethod + def _normalize_description(cls, v): + # Defensive normalization for descriptions arriving via shell-quoting + # tooling that injects literal backslash escapes (e.g. `Don\'t`, `\n`). + return _unescape_shell_quoting(v) + @field_validator("source_type", mode="before") @classmethod def _validate_source_type(cls, v): @@ -1676,6 +1708,13 @@ class UpdateTableRequest(BaseModel): def _coerce_primary_key(cls, v): return _normalize_primary_key(v) + @field_validator("description", mode="before") + @classmethod + def _normalize_description(cls, v): + # Defensive normalization for descriptions arriving via shell-quoting + # tooling that injects literal backslash escapes (e.g. `Don\'t`, `\n`). + return _unescape_shell_quoting(v) + # Duplicated from RegisterTableRequest — Pydantic v2 validators don't # inherit cleanly across unrelated BaseModel classes; a shared mixin # would be overkill for two fields. diff --git a/app/web/templates/admin_tables.html b/app/web/templates/admin_tables.html index 46e2d53..c0ce1b6 100644 --- a/app/web/templates/admin_tables.html +++ b/app/web/templates/admin_tables.html @@ -1558,6 +1558,22 @@ return div.innerHTML; } + // Defensive normalization for descriptions registered via shell-quoting + // tooling that injected literal backslash escapes (e.g. `Don\'t`, `\n`). + // Mirrors _unescape_shell_quoting in app/api/admin.py — applied at render + // time so already-stored corrupt rows still display readably. + function unescapeShellQuoting(s) { + if (!s) return s; + return s + .replace(/\\\\/g, ' ') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\'/g, "'") + .replace(/\\"/g, '"') + .replace(/ /g, '\\'); + } + // C3: removed dead Discovery panel JS. The global Discovery card + // its #discoverBtn / #discoveryResults DOM hooks were removed when // the per-tab UI landed; per-tab Discover/List datalist helpers live @@ -2620,7 +2636,8 @@ html += '' + escapeHtml(table.id) + ''; html += '' + escapeHtml(table.query_mode || 'local') + ''; html += '' + escapeHtml((table.primary_key || []).join(', ') || '-') + ''; - html += '' + escapeHtml(table.description || '-') + ''; + var desc = unescapeShellQuoting(table.description || ''); + html += '' + escapeHtml(desc || '-') + ''; html += '
'; html += '