This commit is contained in:
nose
2025-12-21 05:10:09 -08:00
parent 8ca5783970
commit 11a13edb84
15 changed files with 1712 additions and 213 deletions

View File

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