from __future__ import annotations import re import sys from pathlib import Path from typing import Any, Dict, List, Sequence, Tuple from SYS.cmdlet_spec import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args from SYS.logger import log from SYS.result_table import Column, Table from SYS.rich_display import stdout_console _NUMERIC_NAMESPACE_HINTS = { "track", "disk", "disc", "episode", "season", "chapter", "volume", "part", } _WINDOWS_RESERVED_NAMES = { "con", "prn", "aux", "nul", *(f"com{i}" for i in range(1, 10)), *(f"lpt{i}" for i in range(1, 10)), } _ILLEGAL_FILENAME_CHARS_RE = re.compile(r'[<>:"/\\|?*]') def _normalize_bool(value: Any) -> bool: text = str(value or "").strip().lower() return text in {"1", "true", "yes", "on", "y"} def _parse_table_query(query: Any) -> Dict[str, str]: fields: Dict[str, str] = {} raw = str(query or "").strip() if not raw: return fields for chunk in re.split(r"[;,]+", raw): part = str(chunk or "").strip() if not part: continue sep_index = part.find(":") if sep_index < 0: sep_index = part.find("=") if sep_index <= 0: continue key = part[:sep_index].strip().lower() value = part[sep_index + 1 :].strip().strip('"').strip("'") if key: fields[key] = value return fields def _active_table_bundle(ctx: Any) -> Tuple[Any, str]: display_table = ctx.get_display_table() if hasattr(ctx, "get_display_table") else None if display_table is not None: return display_table, "display" current_stage_table = ctx.get_current_stage_table() if hasattr(ctx, "get_current_stage_table") else None if current_stage_table is not None: return current_stage_table, "stage" last_result_table = ctx.get_last_result_table() if hasattr(ctx, "get_last_result_table") else None if last_result_table is not None: return last_result_table, "last" return None, "" def _clone_table(source: Any) -> Any: if source is None or not isinstance(source, Table): return source cloned = source.copy_with_title(str(getattr(source, "title", "") or "")) for source_row in getattr(source, "rows", []) or []: row = cloned.add_row() row.columns = [ Column(col.name, col.value, getattr(col, "width", None)) for col in getattr(source_row, "columns", []) or [] ] row.selection_args = list(getattr(source_row, "selection_args", []) or []) or None row.selection_action = list(getattr(source_row, "selection_action", []) or []) or None row.source_index = getattr(source_row, "source_index", None) row.payload = getattr(source_row, "payload", None) return cloned def _column_sort_key(value: Any, *, numeric: bool = False) -> Tuple[int, Any, str]: text = str(value or "").strip() if not text: return (1, float("inf") if numeric else "", "") if numeric: match = re.search(r"-?\d+(?:\.\d+)?", text) if match: try: return (0, float(match.group(0)), text.casefold()) except Exception: pass return (0, float("inf"), text.casefold()) return (0, text.casefold(), text.casefold()) def _sort_by_column(table: Any, column_name: str, *, numeric: bool = False, reverse: bool = False) -> None: if table is None or not hasattr(table, "rows"): return wanted = str(column_name or "").strip().lower() if not wanted: return if wanted in {"title", "name"} and hasattr(table, "sort_by_title"): table.sort_by_title() if reverse and hasattr(table, "rows"): table.rows.reverse() return if wanted == "tag" and hasattr(table, "sort_by_title"): table.rows.sort( key=lambda row: _column_sort_key(row.get_column("Tag"), numeric=numeric), reverse=bool(reverse), ) return table.rows.sort( key=lambda row: _column_sort_key(row.get_column(column_name), numeric=numeric), reverse=bool(reverse), ) def _reorder_items_from_table(table: Any, items: List[Any]) -> List[Any]: if not items or table is None or not hasattr(table, "rows"): return list(items or []) payloads: List[Any] = [] for row in getattr(table, "rows", []) or []: payload = getattr(row, "payload", None) if payload is None: payloads = [] break payloads.append(payload) if payloads and len(payloads) == len(getattr(table, "rows", []) or []): return payloads reordered: List[Any] = [] for row in getattr(table, "rows", []) or []: source_index = getattr(row, "source_index", None) if isinstance(source_index, int) and 0 <= source_index < len(items): reordered.append(items[source_index]) if reordered and len(reordered) == len(getattr(table, "rows", []) or []): return reordered return list(items or []) def _render_table(table: Any) -> int: if table is None: log("No active result table", file=sys.stderr) return 1 try: if hasattr(table, "to_rich"): stdout_console().print(table.to_rich()) return 0 except Exception as exc: log(f"Failed to render table: {exc}", file=sys.stderr) return 1 try: print(table) return 0 except Exception as exc: log(f"Failed to print table: {exc}", file=sys.stderr) return 1 def _sanitize_filename_base(text: str) -> str: s = str(text or "").strip() if not s: return "table" s = _ILLEGAL_FILENAME_CHARS_RE.sub(" ", s) s = "".join(ch for ch in s if ch.isprintable()) s = " ".join(s.split()).strip() s = s.rstrip(" .") if not s: s = "table" if s.lower() in _WINDOWS_RESERVED_NAMES: s = f"_{s}" if len(s) > 200: s = s[:200].rstrip(" .") return s or "table" def _resolve_output_path(path_arg: str, *, table_title: str) -> Path: raw = str(path_arg or "").strip() if not raw: raise ValueError("-path is required") ends_with_sep = raw.endswith(("/", "\\")) target = Path(raw) if target.exists() and target.is_dir(): return target / f"{_sanitize_filename_base(table_title)}.svg" if (ends_with_sep or not target.suffix) and not target.exists(): target.mkdir(parents=True, exist_ok=True) return target / f"{_sanitize_filename_base(table_title)}.svg" if not target.suffix: target.parent.mkdir(parents=True, exist_ok=True) return target.with_suffix(".svg") if target.suffix.lower() != ".svg": return target.with_suffix(".svg") return target def _export_table_svg(table: Any, path_arg: str) -> int: if table is None: log("No table available to export", file=sys.stderr) return 1 title_text = str(getattr(table, "title", None) or "table") try: out_path = _resolve_output_path(path_arg, table_title=title_text) out_path.parent.mkdir(parents=True, exist_ok=True) from rich.console import Console console = Console(record=True) renderable = table.to_rich() if hasattr(table, "to_rich") else table console.print(renderable) console.save_svg(str(out_path)) log(f"Saved table SVG: {out_path}") return 0 except Exception as exc: log(f"Failed to save table SVG: {type(exc).__name__}: {exc}", file=sys.stderr) return 1 def _apply_table_sort(table: Any, *, sort_column: str, query_text: str) -> int: query_fields = _parse_table_query(query_text) wanted_column = str(sort_column or query_fields.get("sort") or "").strip() namespace = str(query_fields.get("namespace") or "").strip().rstrip(":") order = str(query_fields.get("format") or query_fields.get("order") or "asc").strip().lower() reverse = order in {"desc", "descending", "reverse", "z-a"} numeric_field = query_fields.get("numeric") if numeric_field is not None: numeric = _normalize_bool(numeric_field) else: numeric = namespace.casefold() in _NUMERIC_NAMESPACE_HINTS if not wanted_column and namespace: wanted_column = "tag" if not wanted_column: wanted_column = "title" try: if str(wanted_column).strip().lower() == "tag" and namespace: if not hasattr(table, "sort_by_tag_namespace"): log("Current table does not support namespace sorting", file=sys.stderr) return 1 table.sort_by_tag_namespace(namespace, numeric=numeric, reverse=reverse) else: _sort_by_column(table, wanted_column, numeric=numeric, reverse=reverse) except Exception as exc: log(f"Failed to sort table: {exc}", file=sys.stderr) return 1 if hasattr(table, "_perseverance"): try: table._perseverance(True) except Exception: pass return 0 def _run(piped_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: _ = piped_result, config try: from SYS import pipeline as ctx except Exception as exc: log(f"Failed to import pipeline context: {exc}") return 1 parsed = parse_cmdlet_args(args, CMDLET) sort_column = str(parsed.get("sort") or "").strip() query_text = str(parsed.get("query") or "").strip() debug_mode = bool(parsed.get("debug", False)) print_mode = bool(parsed.get("print", False)) path_arg = str(parsed.get("path") or "").strip() active_table, _table_kind = _active_table_bundle(ctx) if print_mode or path_arg: if not path_arg: log("Missing required -path for table export", file=sys.stderr) return 1 return _export_table_svg(active_table, path_arg) if not debug_mode and not sort_column and not query_text: return _render_table(active_table) if not debug_mode and (sort_column or query_text): base_table = active_table if base_table is None: log("No active result table to sort", file=sys.stderr) return 1 working_table = _clone_table(base_table) rc = _apply_table_sort(working_table, sort_column=sort_column, query_text=query_text) if rc != 0: return rc items = list(ctx.get_last_result_items() or []) reordered_items = _reorder_items_from_table(working_table, items) subject = ctx.get_last_result_subject() if hasattr(ctx, "get_last_result_subject") else None ctx.set_last_result_table_overlay(working_table, reordered_items, subject) ctx.set_current_stage_table(working_table) return _render_table(working_table) state = None try: state = ctx.get_pipeline_state() if hasattr(ctx, "get_pipeline_state") else None except Exception: state = None def _summarize_table(name: str, t: Any) -> None: if t is None: print(f"{name}: None") return try: table_type = getattr(t, "table", None) except Exception: table_type = None try: title = getattr(t, "title", None) except Exception: title = None try: src_cmd = getattr(t, "source_command", None) except Exception: src_cmd = None try: src_args = getattr(t, "source_args", None) except Exception: src_args = None try: no_choice = bool(getattr(t, "no_choice", False)) except Exception: no_choice = False try: preserve_order = bool(getattr(t, "preserve_order", False)) except Exception: preserve_order = False try: row_count = len(getattr(t, "rows", []) or []) except Exception: row_count = 0 try: meta = ( t.get_table_metadata() if hasattr(t, "get_table_metadata") else getattr(t, "table_metadata", None) ) except Exception: meta = None meta_keys = list(meta.keys()) if isinstance(meta, dict) else [] print( f"{name}: id={id(t)} class={type(t).__name__} title={repr(title)} table={repr(table_type)} rows={row_count} " f"source={repr(src_cmd)} source_args={repr(src_args)} no_choice={no_choice} preserve_order={preserve_order} meta_keys={meta_keys}" ) label = "" try: label = str(args[0]) if args else "" if label: print(f"Table State: {label}") else: print("Table State") except Exception: print("Table State") try: _summarize_table("display_table", getattr(state, "display_table", None) if state is not None else None) _summarize_table("current_stage_table", getattr(state, "current_stage_table", None) if state is not None else None) _summarize_table("last_result_table", getattr(state, "last_result_table", None) if state is not None else None) display_items = getattr(state, "display_items", None) if state is not None else None last_result_items = getattr(state, "last_result_items", None) if state is not None else None hist = getattr(state, "result_table_history", None) if state is not None else None fwd = getattr(state, "result_table_forward", None) if state is not None else None last_sel = getattr(state, "last_selection", None) if state is not None else None print( "buffers: " f"display_items={len(display_items or [])} " f"last_result_items={len(last_result_items or [])} " f"history={len(hist or [])} " f"forward={len(fwd or [])} " f"last_selection={list(last_sel or [])}" ) except Exception as exc: log(f"Failed to summarize table state: {exc}") return 1 # If debug logging is enabled, also emit the richer debug dump. try: if hasattr(ctx, "debug_table_state"): ctx.debug_table_state(label or ".table") except Exception: pass return 0 CMDLET = Cmdlet( name=".table", alias=["table"], summary="Render, inspect, or sort the active result table.", usage='.table [-sort ] [-query "format:asc|desc,namespace:track"] [-print -path ] [-debug [label]]', arg=[ CmdletArg( name="sort", type="string", description="Sort by a visible column name (for namespace tag sorting, use -sort tag with -query namespace:).", required=False, ), CmdletArg( name="query", type="string", description="Table options like format:asc|desc, namespace:track, numeric:true.", required=False, ), CmdletArg( name="print", type="flag", description="Export the active table as an SVG using -path.", required=False, ), SharedArgs.PATH, CmdletArg( name="debug", type="flag", description="Dump pipeline table state for debugging instead of rendering the table.", required=False, ), CmdletArg( name="label", type="string", description="Optional label to include in the debug dump", required=False, ), ], ) CMDLET.exec = _run