fix(admin/tables): unescape shell-quoting backslashes in descriptions
This commit is contained in:
parent
e369d0ed7b
commit
05e535d743
3 changed files with 59 additions and 1 deletions
|
|
@ -12,6 +12,8 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
||||||
|
|
||||||
### Fixed
|
### 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**: 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
|
## [0.38.0] — 2026-05-06
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
def _normalize_primary_key(v):
|
||||||
"""Coerce a string primary_key to ``[v]`` for backward compatibility.
|
"""Coerce a string primary_key to ``[v]`` for backward compatibility.
|
||||||
|
|
||||||
|
|
@ -1273,6 +1298,13 @@ class RegisterTableRequest(BaseModel):
|
||||||
def _coerce_primary_key(cls, v):
|
def _coerce_primary_key(cls, v):
|
||||||
return _normalize_primary_key(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")
|
@field_validator("source_type", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validate_source_type(cls, v):
|
def _validate_source_type(cls, v):
|
||||||
|
|
@ -1676,6 +1708,13 @@ class UpdateTableRequest(BaseModel):
|
||||||
def _coerce_primary_key(cls, v):
|
def _coerce_primary_key(cls, v):
|
||||||
return _normalize_primary_key(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
|
# Duplicated from RegisterTableRequest — Pydantic v2 validators don't
|
||||||
# inherit cleanly across unrelated BaseModel classes; a shared mixin
|
# inherit cleanly across unrelated BaseModel classes; a shared mixin
|
||||||
# would be overkill for two fields.
|
# would be overkill for two fields.
|
||||||
|
|
|
||||||
|
|
@ -1558,6 +1558,22 @@
|
||||||
return div.innerHTML;
|
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 +
|
// C3: removed dead Discovery panel JS. The global Discovery card +
|
||||||
// its #discoverBtn / #discoveryResults DOM hooks were removed when
|
// its #discoverBtn / #discoveryResults DOM hooks were removed when
|
||||||
// the per-tab UI landed; per-tab Discover/List datalist helpers live
|
// 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 class="col-id" title="' + escapeHtml(table.id) + '">' + escapeHtml(table.id) + '</td>';
|
||||||
html += '<td>' + escapeHtml(table.query_mode || 'local') + '</td>';
|
html += '<td>' + escapeHtml(table.query_mode || 'local') + '</td>';
|
||||||
html += '<td>' + escapeHtml((table.primary_key || []).join(', ') || '-') + '</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 += '<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 += '<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>';
|
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>';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue