This commit is contained in:
2026-01-06 16:19:29 -08:00
parent 41c11d39fd
commit edc33f4528
10 changed files with 1192 additions and 881 deletions

110
SYS/json_table.py Normal file
View File

@@ -0,0 +1,110 @@
"""Helper utilities for normalizing JSON result tables.
This mirrors the intent of the existing `SYS.html_table` helper but operates on
JSON payloads (API responses, JSON APIs, etc.). It exposes:
- `extract_records` for locating and normalizing the first list of record dicts
from a JSON document.
- `normalize_record` for coercing arbitrary values into printable strings.
These helpers make it easy for providers that consume JSON to populate
`ResultModel` metadata without hand-writing ad-hoc sanitizers.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence, Tuple
_DEFAULT_LIST_KEYS: Tuple[str, ...] = ("results", "items", "docs", "records")
def _coerce_value(value: Any) -> str:
"""Convert a JSON value into a compact string representation."""
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (list, tuple, set)):
parts = [_coerce_value(v) for v in value]
cleaned = [part for part in parts if part]
return ", ".join(cleaned)
if isinstance(value, dict):
parts: List[str] = []
for subkey, subvalue in value.items():
part = _coerce_value(subvalue)
if part:
parts.append(f"{subkey}:{part}")
return ", ".join(parts)
try:
return str(value).strip()
except Exception:
return ""
def normalize_record(record: Dict[str, Any]) -> Dict[str, str]:
"""Return a copy of ``record`` with keys lowered and values coerced to strings."""
out: Dict[str, str] = {}
if not isinstance(record, dict):
return out
for key, value in record.items():
normalized_key = str(key or "").strip().lower()
if not normalized_key:
continue
normalized_value = _coerce_value(value)
if normalized_value:
out[normalized_key] = normalized_value
return out
def _traverse(data: Any, path: Sequence[str]) -> Optional[Any]:
current = data
for key in path:
if not isinstance(current, dict):
return None
current = current.get(key)
return current
def extract_records(
data: Any,
*,
path: Optional[Sequence[str]] = None,
list_keys: Optional[Sequence[str]] = None,
) -> Tuple[List[Dict[str, str]], Optional[str]]:
"""Extract normalized record dicts from ``data``.
Args:
data: JSON document (dict/list) that may contain tabular records.
path: optional key path to traverse before looking for a list.
list_keys: candidate keys to inspect when ``path`` is not provided.
Returns:
(records, chosen_path) where ``records`` is the list of normalized dicts
and ``chosen_path`` is either the traversed path or the key that matched.
"""
list_keys = list_keys or _DEFAULT_LIST_KEYS
chosen_path: Optional[str] = None
candidates: List[Any] = []
if path:
found = _traverse(data, path)
if isinstance(found, list):
candidates = found
chosen_path = ".".join(path)
if not candidates and isinstance(data, dict):
for key in list_keys:
found = data.get(key)
if isinstance(found, list):
candidates = found
chosen_path = key
break
if not candidates and isinstance(data, list):
candidates = data
chosen_path = ""
records: List[Dict[str, str]] = []
for entry in candidates:
if isinstance(entry, dict):
records.append(normalize_record(entry))
return records, chosen_path

View File

@@ -783,56 +783,56 @@ class ResultTable:
def _add_search_result(self, row: ResultRow, result: Any) -> None:
"""Extract and add SearchResult fields to row."""
# If provider supplied explicit columns, render those and skip legacy defaults
cols = getattr(result, "columns", None)
used_explicit_columns = False
if cols:
used_explicit_columns = True
for name, value in cols:
row.add_column(name, value)
return
else:
# Core fields (legacy fallback)
title = getattr(result, "title", "")
table = str(getattr(result, "table", "") or "").lower()
# Core fields (legacy fallback)
title = getattr(result, "title", "")
table = str(getattr(result, "table", "") or "").lower()
# Handle extension separation for local files
extension = ""
if title and table == "local":
path_obj = Path(title)
if path_obj.suffix:
extension = path_obj.suffix.lstrip(".")
title = path_obj.stem
# Handle extension separation for local files
extension = ""
if title and table == "local":
path_obj = Path(title)
if path_obj.suffix:
extension = path_obj.suffix.lstrip(".")
title = path_obj.stem
if title:
row.add_column("Title", title)
if title:
row.add_column("Title", title)
# Extension column
row.add_column("Ext", extension)
# Extension column
row.add_column("Ext", extension)
if hasattr(result, "table") and getattr(result, "table", None):
row.add_column("Source", str(getattr(result, "table")))
if hasattr(result, "table") and getattr(result, "table", None):
row.add_column("Source", str(getattr(result, "table")))
if hasattr(result, "detail") and result.detail:
row.add_column("Detail", result.detail)
if hasattr(result, "detail") and result.detail:
row.add_column("Detail", result.detail)
if hasattr(result, "media_kind") and result.media_kind:
row.add_column("Type", result.media_kind)
if hasattr(result, "media_kind") and result.media_kind:
row.add_column("Type", result.media_kind)
# Tag summary
if hasattr(result, "tag_summary") and result.tag_summary:
row.add_column("Tag", str(result.tag_summary))
# Tag summary
if hasattr(result, "tag_summary") and result.tag_summary:
row.add_column("Tag", str(result.tag_summary))
# Duration (for media)
if hasattr(result, "duration_seconds") and result.duration_seconds:
dur = _format_duration_hms(result.duration_seconds)
row.add_column("Duration", dur or str(result.duration_seconds))
# Duration (for media)
if hasattr(result, "duration_seconds") and result.duration_seconds:
dur = _format_duration_hms(result.duration_seconds)
row.add_column("Duration", dur or str(result.duration_seconds))
# Size (for files)
if hasattr(result, "size_bytes") and result.size_bytes:
row.add_column("Size", _format_size(result.size_bytes, integer_only=False))
# Size (for files)
if hasattr(result, "size_bytes") and result.size_bytes:
row.add_column("Size", _format_size(result.size_bytes, integer_only=False))
# Annotations
if hasattr(result, "annotations") and result.annotations:
row.add_column("Annotations", ", ".join(str(a) for a in result.annotations))
# Annotations
if hasattr(result, "annotations") and result.annotations:
row.add_column("Annotations", ", ".join(str(a) for a in result.annotations))
try:
md = getattr(result, "full_metadata", None)

View File

@@ -61,15 +61,22 @@ class Provider:
def serialize_row(self, row: ResultModel) -> Dict[str, Any]:
r = ensure_result_model(row)
return {
metadata = r.metadata or {}
out: Dict[str, Any] = {
"title": r.title,
"path": r.path,
"ext": r.ext,
"size_bytes": r.size_bytes,
"metadata": r.metadata or {},
"metadata": metadata,
"source": r.source or self.name,
"_selection_args": self.selection_args(r),
}
selection_action = metadata.get("_selection_action") or metadata.get("selection_action")
if selection_action:
out["_selection_action"] = [
str(x) for x in selection_action if x is not None
]
return out
def serialize_rows(self, rows: Iterable[ResultModel]) -> List[Dict[str, Any]]:
return [self.serialize_row(r) for r in rows]