huge refactor of plugin system
This commit is contained in:
+8
-3
@@ -28,15 +28,20 @@ def _register_cmdlet_object(cmdlet_obj, registry: Dict[str, CmdletFn]) -> None:
|
||||
registry[alias.replace("_", "-").lower()] = run_fn
|
||||
|
||||
|
||||
def register_native_commands(registry: Dict[str, CmdletFn]) -> None:
|
||||
"""Import native command modules and register their CMDLET exec functions."""
|
||||
def _iter_legacy_native_module_names() -> list[str]:
|
||||
base_dir = os.path.dirname(__file__)
|
||||
module_names: list[str] = []
|
||||
for filename in os.listdir(base_dir):
|
||||
if not (filename.endswith(".py") and not filename.startswith("_")
|
||||
and filename != "__init__.py"):
|
||||
continue
|
||||
module_names.append(filename[:-3])
|
||||
return module_names
|
||||
|
||||
mod_name = filename[:-3]
|
||||
|
||||
def register_native_commands(registry: Dict[str, CmdletFn]) -> None:
|
||||
"""Import legacy local command modules from cmdnat/ and register them."""
|
||||
for mod_name in _iter_legacy_native_module_names():
|
||||
try:
|
||||
module = import_module(f".{mod_name}", __name__)
|
||||
cmdlet_obj = getattr(module, "CMDLET", None)
|
||||
|
||||
-1299
File diff suppressed because it is too large
Load Diff
@@ -1,165 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Sequence, Optional
|
||||
|
||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg
|
||||
from SYS.logger import log
|
||||
from SYS import pipeline as ctx
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name=".out-table",
|
||||
summary="Save the current result table to an SVG file.",
|
||||
usage='.out-table -path "C:\\Path\\To\\Dir"',
|
||||
arg=[
|
||||
CmdletArg(
|
||||
"path",
|
||||
type="string",
|
||||
description="Directory (or file path) to write the SVG to",
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
detail=[
|
||||
"Exports the most recent table (overlay/stage/last) as an SVG using Rich.",
|
||||
"Default filename is derived from the table title (sanitized).",
|
||||
"Examples:",
|
||||
'search-file "ext:mp3" | .out-table -path "C:\\Users\\Admin\\Desktop"',
|
||||
'search-file "ext:mp3" | .out-table -path "C:\\Users\\Admin\\Desktop\\my-table.svg"',
|
||||
],
|
||||
)
|
||||
|
||||
_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 _sanitize_filename_base(text: str) -> str:
|
||||
"""Sanitize a string for use as a Windows-friendly filename (no extension)."""
|
||||
s = str(text or "").strip()
|
||||
if not s:
|
||||
return "table"
|
||||
|
||||
# Replace characters illegal on Windows (and generally unsafe cross-platform).
|
||||
s = _ILLEGAL_FILENAME_CHARS_RE.sub(" ", s)
|
||||
|
||||
# Drop control characters.
|
||||
s = "".join(ch for ch in s if ch.isprintable())
|
||||
|
||||
# Collapse whitespace.
|
||||
s = " ".join(s.split()).strip()
|
||||
|
||||
# Windows disallows trailing space/dot.
|
||||
s = s.rstrip(" .")
|
||||
|
||||
if not s:
|
||||
s = "table"
|
||||
|
||||
# Avoid reserved device names.
|
||||
if s.lower() in _WINDOWS_RESERVED_NAMES:
|
||||
s = f"_{s}"
|
||||
|
||||
# Keep it reasonably short.
|
||||
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")
|
||||
|
||||
# Treat trailing slash as directory intent even if it doesn't exist yet.
|
||||
ends_with_sep = raw.endswith((os.sep, os.altsep or ""))
|
||||
|
||||
target = Path(raw)
|
||||
|
||||
if target.exists() and target.is_dir():
|
||||
base = _sanitize_filename_base(table_title)
|
||||
return target / f"{base}.svg"
|
||||
|
||||
if ends_with_sep and not target.suffix:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
base = _sanitize_filename_base(table_title)
|
||||
return target / f"{base}.svg"
|
||||
|
||||
# File path intent.
|
||||
if not target.suffix:
|
||||
return target.with_suffix(".svg")
|
||||
|
||||
if target.suffix.lower() != ".svg":
|
||||
return target.with_suffix(".svg")
|
||||
|
||||
return target
|
||||
|
||||
|
||||
def _get_active_table(piped_result: Any) -> Optional[Any]:
|
||||
# Prefer an explicit ResultTable passed through the pipe, but normally `.out-table`
|
||||
# is used after `@` which pipes item selections (not the table itself).
|
||||
if piped_result is not None and hasattr(piped_result, "__rich__"):
|
||||
# Avoid mistakenly treating a dict/list as a renderable.
|
||||
if piped_result.__class__.__name__ == "ResultTable":
|
||||
return piped_result
|
||||
|
||||
return ctx.get_display_table() or ctx.get_current_stage_table(
|
||||
) or ctx.get_last_result_table()
|
||||
|
||||
|
||||
def _run(piped_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
args_list = [str(a) for a in (args or [])]
|
||||
|
||||
# Simple flag parsing: `.out-table -path <value>`
|
||||
path_arg: Optional[str] = None
|
||||
i = 0
|
||||
while i < len(args_list):
|
||||
low = args_list[i].strip().lower()
|
||||
if low in {"-path",
|
||||
"--path"} and i + 1 < len(args_list):
|
||||
path_arg = args_list[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
if not args_list[i].startswith("-") and path_arg is None:
|
||||
# Allow `.out-table <path>` as a convenience.
|
||||
path_arg = args_list[i]
|
||||
i += 1
|
||||
|
||||
if not path_arg:
|
||||
log("Missing required -path", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
table = _get_active_table(piped_result)
|
||||
if table is None:
|
||||
log("No table available to export", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
title = getattr(table, "title", None)
|
||||
title_text = str(title 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)
|
||||
console.print(table)
|
||||
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
|
||||
|
||||
|
||||
CMDLET.exec = _run
|
||||
-2825
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -41,7 +41,7 @@ def _run(result: Any, args: List[str], config: Dict[str, Any]) -> int:
|
||||
try:
|
||||
# MPV check
|
||||
try:
|
||||
from MPV.mpv_ipc import MPV
|
||||
from plugins.mpv.mpv_ipc import MPV
|
||||
MPV()
|
||||
mpv_path = shutil.which("mpv")
|
||||
_add_startup_check(startup_table, "ENABLED", "MPV", detail=mpv_path or "Available")
|
||||
|
||||
+350
-6
@@ -1,17 +1,335 @@
|
||||
from typing import Any, Dict, Sequence
|
||||
from __future__ import annotations
|
||||
|
||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg
|
||||
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:
|
||||
# Debug utility: dump current pipeline table state (display/current/last + buffers)
|
||||
_ = 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
|
||||
@@ -108,13 +426,39 @@ def _run(piped_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name=".table",
|
||||
summary="Dump pipeline table state for debugging",
|
||||
usage=".table [label]",
|
||||
alias=["table"],
|
||||
summary="Render, inspect, or sort the active result table.",
|
||||
usage='.table [-sort <column>] [-query "format:asc|desc,namespace:track"] [-print -path <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:<name>).",
|
||||
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 dump",
|
||||
description="Optional label to include in the debug dump",
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg
|
||||
from SYS.logger import log
|
||||
from SYS.result_table import Table
|
||||
from SYS import pipeline as ctx
|
||||
from ProviderCore.registry import get_plugin
|
||||
from cmdnat._parsing import has_flag as _has_flag, normalize_to_list as _normalize_to_list
|
||||
|
||||
_TELEGRAM_PENDING_ITEMS_KEY = "telegram_pending_items"
|
||||
|
||||
|
||||
def _get_telegram_provider(config: Dict[str, Any]) -> Any:
|
||||
provider = get_plugin("telegram", config)
|
||||
if provider is None:
|
||||
raise RuntimeError("Telegram plugin is not registered")
|
||||
return provider
|
||||
|
||||
def _extract_chat_id(chat_obj: Any) -> Optional[int]:
|
||||
try:
|
||||
if isinstance(chat_obj, dict):
|
||||
maybe_id = chat_obj.get("id")
|
||||
if maybe_id is not None:
|
||||
return int(maybe_id)
|
||||
extra = chat_obj.get("extra")
|
||||
if isinstance(extra, dict):
|
||||
v = extra.get("id")
|
||||
if v is not None:
|
||||
return int(v)
|
||||
v = extra.get("chat_id")
|
||||
if v is not None:
|
||||
return int(v)
|
||||
# PipeObject stores unknown fields in .extra
|
||||
if hasattr(chat_obj, "extra"):
|
||||
extra = getattr(chat_obj, "extra")
|
||||
if isinstance(extra, dict):
|
||||
v = extra.get("id")
|
||||
if v is not None:
|
||||
return int(v)
|
||||
v = extra.get("chat_id")
|
||||
if v is not None:
|
||||
return int(v)
|
||||
if hasattr(chat_obj, "id"):
|
||||
maybe_id = getattr(chat_obj, "id")
|
||||
if maybe_id is not None:
|
||||
return int(maybe_id)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_chat_username(chat_obj: Any) -> str:
|
||||
try:
|
||||
if isinstance(chat_obj, dict):
|
||||
u = chat_obj.get("username")
|
||||
return str(u or "").strip()
|
||||
if hasattr(chat_obj, "extra"):
|
||||
extra = getattr(chat_obj, "extra")
|
||||
if isinstance(extra, dict):
|
||||
u = extra.get("username")
|
||||
if isinstance(u, str) and u.strip():
|
||||
return u.strip()
|
||||
if hasattr(chat_obj, "username"):
|
||||
return str(getattr(chat_obj, "username") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_title(item: Any) -> str:
|
||||
try:
|
||||
if isinstance(item, dict):
|
||||
return str(item.get("title") or "").strip()
|
||||
if hasattr(item, "title"):
|
||||
return str(getattr(item, "title") or "").strip()
|
||||
# PipeObject stores some fields in .extra
|
||||
if hasattr(item, "extra"):
|
||||
extra = getattr(item, "extra")
|
||||
if isinstance(extra, dict):
|
||||
v = extra.get("title")
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_file_path(item: Any) -> Optional[str]:
|
||||
|
||||
def _maybe(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.startswith("http://") or text.startswith("https://"):
|
||||
return None
|
||||
try:
|
||||
p = Path(text).expanduser()
|
||||
if p.exists():
|
||||
return str(p)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
try:
|
||||
if hasattr(item, "path"):
|
||||
found = _maybe(getattr(item, "path"))
|
||||
if found:
|
||||
return found
|
||||
if hasattr(item, "file_path"):
|
||||
found = _maybe(getattr(item, "file_path"))
|
||||
if found:
|
||||
return found
|
||||
if isinstance(item, dict):
|
||||
for key in ("path", "file_path", "target"):
|
||||
found = _maybe(item.get(key))
|
||||
if found:
|
||||
return found
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _run(_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
try:
|
||||
provider = _get_telegram_provider(config)
|
||||
except Exception as exc:
|
||||
log(f"Telegram not available: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if _has_flag(args, "-login"):
|
||||
ok = False
|
||||
try:
|
||||
ok = provider.ensure_session(prompt=True)
|
||||
except Exception:
|
||||
ok = False
|
||||
if not ok:
|
||||
err = getattr(provider, "_last_login_error", None)
|
||||
if isinstance(err, str) and err.strip():
|
||||
log(f"Telegram login failed: {err}", file=sys.stderr)
|
||||
else:
|
||||
log("Telegram login failed (no session created).", file=sys.stderr)
|
||||
return 1
|
||||
log("Telegram login OK (authorized session ready).", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# Internal stage: send previously selected pipeline items to selected chats.
|
||||
if _has_flag(args, "-send"):
|
||||
# Ensure we don't keep showing the picker table on the send stage.
|
||||
try:
|
||||
if hasattr(ctx, "set_last_result_table_overlay"):
|
||||
ctx.set_last_result_table_overlay(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(ctx, "set_current_stage_table"):
|
||||
ctx.set_current_stage_table(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
selected_chats = _normalize_to_list(_result)
|
||||
chat_ids: List[int] = []
|
||||
chat_usernames: List[str] = []
|
||||
for c in selected_chats:
|
||||
cid = _extract_chat_id(c)
|
||||
if cid is not None:
|
||||
chat_ids.append(cid)
|
||||
else:
|
||||
u = _extract_chat_username(c)
|
||||
if u:
|
||||
chat_usernames.append(u)
|
||||
|
||||
# De-dupe chat identifiers (preserve order).
|
||||
try:
|
||||
chat_ids = list(dict.fromkeys([int(x) for x in chat_ids]))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
chat_usernames = list(
|
||||
dict.fromkeys(
|
||||
[str(u).strip() for u in chat_usernames if str(u).strip()]
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not chat_ids and not chat_usernames:
|
||||
log(
|
||||
"No Telegram chat selected (use @N on the Telegram table)",
|
||||
file=sys.stderr
|
||||
)
|
||||
return 1
|
||||
|
||||
pending_items = ctx.load_value(_TELEGRAM_PENDING_ITEMS_KEY, default=[])
|
||||
items = _normalize_to_list(pending_items)
|
||||
if not items:
|
||||
log("No pending items to send (use: @N | .telegram)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
file_jobs: List[Dict[str, str]] = []
|
||||
any_failed = False
|
||||
for item in items:
|
||||
p = _extract_file_path(item)
|
||||
if not p:
|
||||
any_failed = True
|
||||
log(
|
||||
"Telegram send requires local file path(s) on the piped item(s)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
title = _extract_title(item)
|
||||
file_jobs.append({
|
||||
"path": p,
|
||||
"title": title
|
||||
})
|
||||
|
||||
# De-dupe file paths (preserve order).
|
||||
try:
|
||||
seen: set[str] = set()
|
||||
unique_jobs: List[Dict[str, str]] = []
|
||||
for j in file_jobs:
|
||||
k = str(j.get("path") or "").strip().lower()
|
||||
if not k or k in seen:
|
||||
continue
|
||||
seen.add(k)
|
||||
unique_jobs.append(j)
|
||||
file_jobs = unique_jobs
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not file_jobs:
|
||||
return 1
|
||||
|
||||
try:
|
||||
provider.send_files_to_chats(
|
||||
chat_ids=chat_ids,
|
||||
usernames=chat_usernames,
|
||||
files=file_jobs
|
||||
)
|
||||
except Exception as exc:
|
||||
log(f"Telegram send failed: {exc}", file=sys.stderr)
|
||||
any_failed = True
|
||||
|
||||
ctx.store_value(_TELEGRAM_PENDING_ITEMS_KEY, [])
|
||||
return 1 if any_failed else 0
|
||||
|
||||
selected_items = _normalize_to_list(_result)
|
||||
if selected_items:
|
||||
ctx.store_value(_TELEGRAM_PENDING_ITEMS_KEY, selected_items)
|
||||
else:
|
||||
# Avoid stale sends if the user just wants to browse chats.
|
||||
try:
|
||||
ctx.store_value(_TELEGRAM_PENDING_ITEMS_KEY, [])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(ctx, "clear_pending_pipeline_tail"):
|
||||
ctx.clear_pending_pipeline_tail()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Default: list available chats/channels (requires an existing session or bot_token).
|
||||
try:
|
||||
rows = provider.list_chats(limit=200)
|
||||
except Exception as exc:
|
||||
log(f"Failed to list Telegram chats: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Only show dialogs you can typically post to.
|
||||
try:
|
||||
rows = [
|
||||
r for r in (rows or [])
|
||||
if str(r.get("type") or "").strip().lower() in {"group", "user"}
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not rows:
|
||||
log(
|
||||
"No Telegram groups/users available (or not logged in). Run: .telegram -login",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
table = Table("Telegram Chats")
|
||||
table.set_table("telegram")
|
||||
table.set_source_command(".telegram", [])
|
||||
|
||||
chat_items: List[Dict[str, Any]] = []
|
||||
for item in rows:
|
||||
row = table.add_row()
|
||||
title = str(item.get("title") or "").strip()
|
||||
username = str(item.get("username") or "").strip()
|
||||
chat_id = item.get("id")
|
||||
kind = str(item.get("type") or "").strip()
|
||||
row.add_column("Type", kind)
|
||||
row.add_column("Title", title)
|
||||
row.add_column("Username", username)
|
||||
row.add_column("Id", str(chat_id) if chat_id is not None else "")
|
||||
chat_items.append(
|
||||
{
|
||||
**item,
|
||||
"store": "telegram",
|
||||
"title": title or username or str(chat_id) or "Telegram",
|
||||
}
|
||||
)
|
||||
|
||||
# Overlay table: ensures @N selection targets this Telegram picker, not a previous table.
|
||||
ctx.set_last_result_table_overlay(table, chat_items)
|
||||
ctx.set_current_stage_table(table)
|
||||
if selected_items:
|
||||
ctx.set_pending_pipeline_tail([[".telegram", "-send"]], ".telegram")
|
||||
return 0
|
||||
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name=".telegram",
|
||||
alias=["telegram"],
|
||||
summary="Telegram login and chat listing",
|
||||
usage="@N | .telegram (pick a chat, then send piped files)",
|
||||
arg=[
|
||||
CmdletArg(
|
||||
name="login",
|
||||
type="bool",
|
||||
description="Create/refresh a Telegram session (prompts)",
|
||||
required=False,
|
||||
),
|
||||
CmdletArg(
|
||||
name="send",
|
||||
type="bool",
|
||||
description="(internal) Send to selected chat(s)",
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
exec=_run,
|
||||
)
|
||||
Reference in New Issue
Block a user