fix(admin/tables): unescape shell-quoting backslashes in descriptions

This commit is contained in:
ZdenekSrotyr 2026-05-06 10:13:49 +02:00
parent e369d0ed7b
commit 05e535d743
3 changed files with 59 additions and 1 deletions

View file

@ -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

View file

@ -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.

View file

@ -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 += '<td class="col-id" title="' + escapeHtml(table.id) + '">' + escapeHtml(table.id) + '</td>';
html += '<td>' + escapeHtml(table.query_mode || 'local') + '</td>';
html += '<td>' + escapeHtml((table.primary_key || []).join(', ') || '-') + '</td>';
html += '<td class="col-description" title="' + escapeHtml(table.description || '') + '">' + escapeHtml(table.description || '-') + '</td>';
var desc = unescapeShellQuoting(table.description || '');
html += '<td class="col-description" title="' + escapeHtml(desc) + '">' + escapeHtml(desc || '-') + '</td>';
html += '<td class="col-actions"><div style="display:flex; gap:4px; justify-content:flex-end;">';
html += '<button class="btn-icon" title="Edit" onclick=\'openEditModal(' + JSON.stringify(table).replace(/'/g, "\\'") + ')\'>';
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';