dfdsf
This commit is contained in:
268
result_table.py
268
result_table.py
@@ -48,6 +48,177 @@ def _sanitize_cell_text(value: Any) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _format_duration_hms(duration: Any) -> str:
|
||||
"""Format a duration in seconds into a compact h/m/s string.
|
||||
|
||||
Examples:
|
||||
3150 -> "52m30s"
|
||||
59 -> "59s"
|
||||
3600 -> "1h0m0s"
|
||||
|
||||
If the value is not numeric, returns an empty string.
|
||||
"""
|
||||
if duration is None:
|
||||
return ""
|
||||
try:
|
||||
if isinstance(duration, str):
|
||||
s = duration.strip()
|
||||
if not s:
|
||||
return ""
|
||||
# If it's already formatted (contains letters/colon), leave it to caller.
|
||||
if any(ch.isalpha() for ch in s) or ":" in s:
|
||||
return ""
|
||||
seconds = float(s)
|
||||
else:
|
||||
seconds = float(duration)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if seconds < 0:
|
||||
return ""
|
||||
|
||||
total_seconds = int(seconds)
|
||||
minutes, secs = divmod(total_seconds, 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
|
||||
parts: List[str] = []
|
||||
if hours > 0:
|
||||
parts.append(f"{hours}h")
|
||||
if minutes > 0 or hours > 0:
|
||||
parts.append(f"{minutes}m")
|
||||
parts.append(f"{secs}s")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TableColumn:
|
||||
"""Reusable column specification.
|
||||
|
||||
This is intentionally separate from `ResultColumn`:
|
||||
- `ResultColumn` is a rendered (name,value) pair attached to a single row.
|
||||
- `TableColumn` is a reusable extractor/formatter used to build rows consistently
|
||||
across cmdlets and stores.
|
||||
"""
|
||||
|
||||
key: str
|
||||
header: str
|
||||
extractor: Callable[[Any], Any]
|
||||
|
||||
def extract(self, item: Any) -> Any:
|
||||
try:
|
||||
return self.extractor(item)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_first_dict_value(data: Dict[str, Any], keys: List[str]) -> Any:
|
||||
for k in keys:
|
||||
if k in data:
|
||||
v = data.get(k)
|
||||
if v is not None and str(v).strip() != "":
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _as_dict(item: Any) -> Optional[Dict[str, Any]]:
|
||||
if isinstance(item, dict):
|
||||
return item
|
||||
try:
|
||||
if hasattr(item, "__dict__"):
|
||||
return dict(getattr(item, "__dict__"))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def extract_store_value(item: Any) -> str:
|
||||
data = _as_dict(item) or {}
|
||||
store = _get_first_dict_value(data, ["store", "table", "source", "storage"]) # storage is legacy
|
||||
return str(store or "").strip()
|
||||
|
||||
|
||||
def extract_hash_value(item: Any) -> str:
|
||||
data = _as_dict(item) or {}
|
||||
hv = _get_first_dict_value(data, ["hash", "hash_hex", "file_hash", "sha256"])
|
||||
return str(hv or "").strip()
|
||||
|
||||
|
||||
def extract_title_value(item: Any) -> str:
|
||||
data = _as_dict(item) or {}
|
||||
title = _get_first_dict_value(data, ["title", "name", "filename"])
|
||||
if not title:
|
||||
title = _get_first_dict_value(data, ["target", "path", "url"]) # last resort display
|
||||
return str(title or "").strip()
|
||||
|
||||
|
||||
def extract_ext_value(item: Any) -> str:
|
||||
data = _as_dict(item) or {}
|
||||
|
||||
meta = data.get("metadata") if isinstance(data.get("metadata"), dict) else {}
|
||||
raw_path = data.get("path") or data.get("target") or data.get("filename") or data.get("title")
|
||||
|
||||
ext = (
|
||||
_get_first_dict_value(data, ["ext", "file_ext", "extension"])
|
||||
or _get_first_dict_value(meta, ["ext", "file_ext", "extension"])
|
||||
)
|
||||
|
||||
if (not ext) and raw_path:
|
||||
try:
|
||||
suf = Path(str(raw_path)).suffix
|
||||
if suf:
|
||||
ext = suf.lstrip(".")
|
||||
except Exception:
|
||||
ext = ""
|
||||
|
||||
ext_str = str(ext or "").strip().lstrip(".")
|
||||
for idx, ch in enumerate(ext_str):
|
||||
if not ch.isalnum():
|
||||
ext_str = ext_str[:idx]
|
||||
break
|
||||
return ext_str[:5]
|
||||
|
||||
|
||||
def extract_size_bytes_value(item: Any) -> Optional[int]:
|
||||
data = _as_dict(item) or {}
|
||||
meta = data.get("metadata") if isinstance(data.get("metadata"), dict) else {}
|
||||
|
||||
size_val = (
|
||||
_get_first_dict_value(data, ["size_bytes", "size", "file_size", "bytes", "filesize"])
|
||||
or _get_first_dict_value(meta, ["size_bytes", "size", "file_size", "bytes", "filesize"])
|
||||
)
|
||||
if size_val is None:
|
||||
return None
|
||||
try:
|
||||
s = str(size_val).strip()
|
||||
if not s:
|
||||
return None
|
||||
# Some sources might provide floats or numeric strings
|
||||
return int(float(s))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
COMMON_COLUMNS: Dict[str, TableColumn] = {
|
||||
"title": TableColumn("title", "Title", extract_title_value),
|
||||
"store": TableColumn("store", "Store", extract_store_value),
|
||||
"hash": TableColumn("hash", "Hash", extract_hash_value),
|
||||
"ext": TableColumn("ext", "Ext", extract_ext_value),
|
||||
"size": TableColumn("size", "Size", extract_size_bytes_value),
|
||||
}
|
||||
|
||||
|
||||
def build_display_row(item: Any, *, keys: List[str]) -> Dict[str, Any]:
|
||||
"""Build a dict suitable for `ResultTable.add_result()` using shared column specs."""
|
||||
out: Dict[str, Any] = {}
|
||||
for k in keys:
|
||||
spec = COMMON_COLUMNS.get(k)
|
||||
if spec is None:
|
||||
continue
|
||||
val = spec.extract(item)
|
||||
out[spec.key] = val
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class InputOption:
|
||||
"""Represents an interactive input option (cmdlet argument) in a table.
|
||||
@@ -159,6 +330,12 @@ class ResultRow:
|
||||
break
|
||||
str_value = str_value[:5]
|
||||
|
||||
# Normalize Duration columns: providers often pass raw seconds.
|
||||
if normalized_name.lower() == "duration":
|
||||
formatted = _format_duration_hms(value)
|
||||
if formatted:
|
||||
str_value = formatted
|
||||
|
||||
self.columns.append(ResultColumn(normalized_name, str_value))
|
||||
|
||||
def get_column(self, name: str) -> Optional[str]:
|
||||
@@ -502,16 +679,12 @@ class ResultTable:
|
||||
|
||||
# Tag summary
|
||||
if hasattr(result, 'tag_summary') and result.tag_summary:
|
||||
tag_str = str(result.tag_summary)
|
||||
if len(tag_str) > 60:
|
||||
tag_str = tag_str[:57] + "..."
|
||||
row.add_column("Tag", tag_str)
|
||||
row.add_column("Tag", str(result.tag_summary))
|
||||
|
||||
# Duration (for media)
|
||||
if hasattr(result, 'duration_seconds') and result.duration_seconds:
|
||||
minutes = int(result.duration_seconds // 60)
|
||||
seconds = int(result.duration_seconds % 60)
|
||||
row.add_column("Duration", f"{minutes}m {seconds}s")
|
||||
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:
|
||||
@@ -519,10 +692,7 @@ class ResultTable:
|
||||
|
||||
# Annotations
|
||||
if hasattr(result, 'annotations') and result.annotations:
|
||||
ann_str = ", ".join(str(a) for a in result.annotations)
|
||||
if len(ann_str) > 50:
|
||||
ann_str = ann_str[:47] + "..."
|
||||
row.add_column("Annotations", ann_str)
|
||||
row.add_column("Annotations", ", ".join(str(a) for a in result.annotations))
|
||||
|
||||
def _add_result_item(self, row: ResultRow, item: Any) -> None:
|
||||
"""Extract and add ResultItem fields to row (compact display for search results).
|
||||
@@ -550,7 +720,7 @@ class ResultTable:
|
||||
title = path_obj.stem
|
||||
|
||||
if title:
|
||||
row.add_column("Title", title[:90] + ("..." if len(title) > 90 else ""))
|
||||
row.add_column("Title", title)
|
||||
|
||||
# Extension column - always add to maintain column order
|
||||
row.add_column("Ext", extension)
|
||||
@@ -573,12 +743,9 @@ class ResultTable:
|
||||
All data preserved in TagItem for piping and operations.
|
||||
Tag row selection is handled by the CLI pipeline (e.g. `@N | ...`).
|
||||
"""
|
||||
# Tag name (truncate if too long)
|
||||
# Tag name
|
||||
if hasattr(item, 'tag_name') and item.tag_name:
|
||||
tag_name = item.tag_name
|
||||
if len(tag_name) > 60:
|
||||
tag_name = tag_name[:57] + "..."
|
||||
row.add_column("Tag", tag_name)
|
||||
row.add_column("Tag", item.tag_name)
|
||||
|
||||
# Source/Store (where the tag values come from)
|
||||
if hasattr(item, 'source') and item.source:
|
||||
@@ -593,14 +760,11 @@ class ResultTable:
|
||||
|
||||
# Title
|
||||
if hasattr(obj, 'title') and obj.title:
|
||||
row.add_column("Title", obj.title[:50] + ("..." if len(obj.title) > 50 else ""))
|
||||
row.add_column("Title", obj.title)
|
||||
|
||||
# File info
|
||||
if hasattr(obj, 'path') and obj.path:
|
||||
file_str = str(obj.path)
|
||||
if len(file_str) > 60:
|
||||
file_str = "..." + file_str[-57:]
|
||||
row.add_column("Path", file_str)
|
||||
row.add_column("Path", str(obj.path))
|
||||
|
||||
# Tag
|
||||
if hasattr(obj, 'tag') and obj.tag:
|
||||
@@ -611,7 +775,8 @@ class ResultTable:
|
||||
|
||||
# Duration
|
||||
if hasattr(obj, 'duration') and obj.duration:
|
||||
row.add_column("Duration", f"{obj.duration:.1f}s")
|
||||
dur = _format_duration_hms(obj.duration)
|
||||
row.add_column("Duration", dur or str(obj.duration))
|
||||
|
||||
# Warnings
|
||||
if hasattr(obj, 'warnings') and obj.warnings:
|
||||
@@ -652,6 +817,29 @@ class ResultTable:
|
||||
# Strip out hidden metadata fields (prefixed with __)
|
||||
visible_data = {k: v for k, v in data.items() if not is_hidden_field(k)}
|
||||
|
||||
# Normalize common fields using shared extractors so nested metadata/path values work.
|
||||
# This keeps Ext/Size/Store consistent across all dict-based result sources.
|
||||
try:
|
||||
store_extracted = extract_store_value(data)
|
||||
if store_extracted and "store" not in visible_data and "table" not in visible_data and "source" not in visible_data:
|
||||
visible_data["store"] = store_extracted
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
ext_extracted = extract_ext_value(data)
|
||||
# Always ensure `ext` exists so priority_groups keeps a stable column.
|
||||
visible_data["ext"] = str(ext_extracted or "")
|
||||
except Exception:
|
||||
visible_data.setdefault("ext", "")
|
||||
|
||||
try:
|
||||
size_extracted = extract_size_bytes_value(data)
|
||||
if size_extracted is not None and "size_bytes" not in visible_data and "size" not in visible_data:
|
||||
visible_data["size_bytes"] = size_extracted
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Handle extension separation for local files
|
||||
store_val = str(visible_data.get('store', '') or visible_data.get('table', '') or visible_data.get('source', '')).lower()
|
||||
|
||||
@@ -671,7 +859,7 @@ class ResultTable:
|
||||
# print(f"DEBUG: Split extension. Title: {visible_data[title_field]}, Ext: {extension}")
|
||||
else:
|
||||
visible_data['ext'] = ""
|
||||
|
||||
|
||||
# Ensure 'ext' is present so it gets picked up by priority_groups in correct order
|
||||
if 'ext' not in visible_data:
|
||||
visible_data['ext'] = ""
|
||||
@@ -699,9 +887,26 @@ class ResultTable:
|
||||
continue
|
||||
if column_count >= self.max_columns:
|
||||
break
|
||||
col_value_str = format_value(col_value)
|
||||
if len(col_value_str) > 60:
|
||||
col_value_str = col_value_str[:57] + "..."
|
||||
# When providers supply raw numeric fields, keep formatting consistent.
|
||||
if isinstance(col_name, str) and col_name.strip().lower() == "size":
|
||||
try:
|
||||
if col_value is None or str(col_value).strip() == "":
|
||||
col_value_str = ""
|
||||
else:
|
||||
col_value_str = _format_size(col_value, integer_only=False)
|
||||
except Exception:
|
||||
col_value_str = format_value(col_value)
|
||||
elif isinstance(col_name, str) and col_name.strip().lower() == "duration":
|
||||
try:
|
||||
if col_value is None or str(col_value).strip() == "":
|
||||
col_value_str = ""
|
||||
else:
|
||||
dur = _format_duration_hms(col_value)
|
||||
col_value_str = dur or format_value(col_value)
|
||||
except Exception:
|
||||
col_value_str = format_value(col_value)
|
||||
else:
|
||||
col_value_str = format_value(col_value)
|
||||
row.add_column(col_name, col_value_str)
|
||||
added_fields.add(col_name.lower())
|
||||
column_count += 1
|
||||
@@ -743,9 +948,6 @@ class ResultTable:
|
||||
else:
|
||||
value_str = format_value(visible_data[field])
|
||||
|
||||
if len(value_str) > 60:
|
||||
value_str = value_str[:57] + "..."
|
||||
|
||||
# Map field names to display column names
|
||||
if field in ['store', 'table', 'source']:
|
||||
col_name = "Store"
|
||||
@@ -777,11 +979,7 @@ class ResultTable:
|
||||
if key.startswith('_'): # Skip private attributes
|
||||
continue
|
||||
|
||||
value_str = str(value)
|
||||
if len(value_str) > 60:
|
||||
value_str = value_str[:57] + "..."
|
||||
|
||||
row.add_column(key.replace('_', ' ').title(), value_str)
|
||||
row.add_column(key.replace('_', ' ').title(), str(value))
|
||||
|
||||
def to_rich(self):
|
||||
"""Return a Rich renderable representing this table."""
|
||||
|
||||
Reference in New Issue
Block a user