This commit is contained in:
2025-12-30 04:47:13 -08:00
parent 756b024b76
commit 925a1631bc
15 changed files with 1083 additions and 625 deletions

462
CLI.py
View File

@@ -18,7 +18,7 @@ import time
import uuid
from copy import deepcopy
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, TextIO, cast
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, TextIO, Tuple, cast
import typer
from prompt_toolkit import PromptSession
@@ -135,6 +135,266 @@ class SelectionSyntax:
return indices if indices else None
class SelectionFilterSyntax:
"""Parses and applies @"COL:filter" selection filters.
Notes:
- CLI tokenization (shlex) strips quotes, so a user input of `@"TITLE:foo"`
arrives as `@TITLE:foo`. We support both forms.
- Filters apply to the *current selectable table items* (in-memory), not to
provider searches.
"""
_OP_RE = re.compile(r"^(>=|<=|!=|==|>|<|=)\s*(.+)$")
_DUR_TOKEN_RE = re.compile(r"(?i)(\d+)\s*([hms])")
@staticmethod
def parse(token: str) -> Optional[List[Tuple[str, str]]]:
"""Return list of (column, raw_expression) or None when not a filter token."""
if not token or not str(token).startswith("@"):
return None
if token.strip() == "@*":
return None
# If this is a concrete numeric selection (@2, @1-3, @{1,3}), do not treat it as a filter.
try:
if SelectionSyntax.parse(str(token)) is not None:
return None
except Exception:
pass
raw = str(token)[1:].strip()
if not raw:
return None
# If quotes survived tokenization, strip a single symmetric wrapper.
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ('"', "'"):
raw = raw[1:-1].strip()
# Shorthand: @"foo" means Title contains "foo".
if ":" not in raw:
if raw:
return [("Title", raw)]
return None
parts = [p.strip() for p in raw.split(",") if p.strip()]
conditions: List[Tuple[str, str]] = []
for part in parts:
if ":" not in part:
return None
col, expr = part.split(":", 1)
col = str(col or "").strip()
expr = str(expr or "").strip()
if not col:
return None
conditions.append((col, expr))
return conditions if conditions else None
@staticmethod
def _norm_key(text: str) -> str:
return re.sub(r"\s+", " ", str(text or "").strip().lower())
@staticmethod
def _item_column_map(item: Any) -> Dict[str, str]:
out: Dict[str, str] = {}
def _set(k: Any, v: Any) -> None:
key = SelectionFilterSyntax._norm_key(str(k or ""))
if not key:
return
if v is None:
return
try:
if isinstance(v, (list, tuple, set)):
text = ", ".join(str(x) for x in v if x is not None)
else:
text = str(v)
except Exception:
return
out[key] = text
if isinstance(item, dict):
# Display columns (primary UX surface)
cols = item.get("columns")
if isinstance(cols, list):
for pair in cols:
try:
if isinstance(pair, (list, tuple)) and len(pair) == 2:
_set(pair[0], pair[1])
except Exception:
continue
# Direct keys as fallback
for k, v in item.items():
if k == "columns":
continue
_set(k, v)
else:
cols = getattr(item, "columns", None)
if isinstance(cols, list):
for pair in cols:
try:
if isinstance(pair, (list, tuple)) and len(pair) == 2:
_set(pair[0], pair[1])
except Exception:
continue
for k in ("title", "path", "detail", "provider", "store", "table"):
try:
_set(k, getattr(item, k, None))
except Exception:
pass
return out
@staticmethod
def _parse_duration_seconds(text: str) -> Optional[int]:
s = str(text or "").strip()
if not s:
return None
if s.isdigit():
try:
return max(0, int(s))
except Exception:
return None
# clock format: M:SS or H:MM:SS
if ":" in s:
parts = [p.strip() for p in s.split(":")]
if len(parts) == 2 and all(p.isdigit() for p in parts):
m, sec = parts
return max(0, int(m) * 60 + int(sec))
if len(parts) == 3 and all(p.isdigit() for p in parts):
h, m, sec = parts
return max(0, int(h) * 3600 + int(m) * 60 + int(sec))
# token format: 1h2m3s (tokens can appear in any combination)
total = 0
found = False
for m in SelectionFilterSyntax._DUR_TOKEN_RE.finditer(s):
found = True
n = int(m.group(1))
unit = m.group(2).lower()
if unit == "h":
total += n * 3600
elif unit == "m":
total += n * 60
elif unit == "s":
total += n
if found:
return max(0, int(total))
return None
@staticmethod
def _parse_float(text: str) -> Optional[float]:
s = str(text or "").strip()
if not s:
return None
s = s.replace(",", "")
try:
return float(s)
except Exception:
return None
@staticmethod
def _parse_op(expr: str) -> tuple[Optional[str], str]:
text = str(expr or "").strip()
if not text:
return None, ""
m = SelectionFilterSyntax._OP_RE.match(text)
if not m:
return None, text
return m.group(1), str(m.group(2) or "").strip()
@staticmethod
def matches(item: Any, conditions: List[Tuple[str, str]]) -> bool:
colmap = SelectionFilterSyntax._item_column_map(item)
for col, expr in conditions:
key = SelectionFilterSyntax._norm_key(col)
actual = colmap.get(key)
# Convenience aliases for common UX names.
if actual is None:
if key == "duration":
actual = colmap.get("duration")
elif key == "title":
actual = colmap.get("title")
if actual is None:
return False
op, rhs = SelectionFilterSyntax._parse_op(expr)
left_text = str(actual or "").strip()
right_text = str(rhs or "").strip()
if op is None:
if not right_text:
return False
if right_text.lower() not in left_text.lower():
return False
continue
# Comparator: try duration parsing first when it looks time-like.
prefer_duration = (
key == "duration"
or any(ch in right_text for ch in (":", "h", "m", "s"))
or any(ch in left_text for ch in (":", "h", "m", "s"))
)
left_num: Optional[float] = None
right_num: Optional[float] = None
if prefer_duration:
ldur = SelectionFilterSyntax._parse_duration_seconds(left_text)
rdur = SelectionFilterSyntax._parse_duration_seconds(right_text)
if ldur is not None and rdur is not None:
left_num = float(ldur)
right_num = float(rdur)
if left_num is None or right_num is None:
left_num = SelectionFilterSyntax._parse_float(left_text)
right_num = SelectionFilterSyntax._parse_float(right_text)
if left_num is not None and right_num is not None:
if op in ("=", "=="):
if not (left_num == right_num):
return False
elif op == "!=":
if not (left_num != right_num):
return False
elif op == ">":
if not (left_num > right_num):
return False
elif op == ">=":
if not (left_num >= right_num):
return False
elif op == "<":
if not (left_num < right_num):
return False
elif op == "<=":
if not (left_num <= right_num):
return False
else:
return False
continue
# Fallback to string equality for =/!= when numeric parsing fails.
if op in ("=", "=="):
if left_text.lower() != right_text.lower():
return False
elif op == "!=":
if left_text.lower() == right_text.lower():
return False
else:
return False
return True
class WorkerOutputMirror(io.TextIOBase):
"""Mirror stdout/stderr to worker manager while preserving console output."""
@@ -1325,6 +1585,7 @@ class CmdletExecutor:
filtered_args: List[str] = []
selected_indices: List[int] = []
select_all = False
selection_filters: List[List[Tuple[str, str]]] = []
value_flags: Set[str] = set()
try:
@@ -1357,9 +1618,10 @@ class CmdletExecutor:
filtered_args.append(arg)
continue
if len(arg) >= 2 and arg[1] in {'"',
"'"}:
filtered_args.append(arg[1:].strip("\"'"))
# Universal selection filter: @"COL:expr" (quotes may be stripped by tokenization)
filter_spec = SelectionFilterSyntax.parse(arg)
if filter_spec is not None:
selection_filters.append(filter_spec)
continue
if arg.strip() == "@*":
@@ -1384,15 +1646,27 @@ class CmdletExecutor:
# Piping should require `|` (or an explicit @ selection).
piped_items = ctx.get_last_result_items()
result: Any = None
if piped_items and (select_all or selected_indices):
if select_all:
result = piped_items
else:
result = [
piped_items[idx] for idx in selected_indices
if 0 <= idx < len(piped_items)
effective_selected_indices: List[int] = []
if piped_items and (select_all or selected_indices or selection_filters):
candidate_idxs = list(range(len(piped_items)))
for spec in selection_filters:
candidate_idxs = [
i for i in candidate_idxs
if SelectionFilterSyntax.matches(piped_items[i], spec)
]
if select_all:
effective_selected_indices = list(candidate_idxs)
elif selected_indices:
effective_selected_indices = [
candidate_idxs[i] for i in selected_indices
if 0 <= i < len(candidate_idxs)
]
else:
effective_selected_indices = list(candidate_idxs)
result = [piped_items[i] for i in effective_selected_indices]
worker_manager = WorkerManagerRegistry.ensure(config)
stage_session = WorkerStages.begin_stage(
worker_manager,
@@ -1438,7 +1712,7 @@ class CmdletExecutor:
stage_status = "completed"
stage_error = ""
ctx.set_last_selection(selected_indices)
ctx.set_last_selection(effective_selected_indices)
try:
try:
if hasattr(ctx, "set_current_cmdlet_name"):
@@ -2356,6 +2630,9 @@ class PipelineExecutor:
elif table_type == "internetarchive":
print("Auto-loading Internet Archive item via download-file")
stages.append(["download-file"])
elif table_type == "podcastindex.episodes":
print("Auto-piping selection to download-file")
stages.append(["download-file"])
elif table_type in {"soulseek",
"openlibrary",
"libgen"}:
@@ -2397,6 +2674,14 @@ class PipelineExecutor:
"Auto-inserting download-file after Internet Archive selection"
)
stages.insert(0, ["download-file"])
if table_type == "podcastindex.episodes" and first_cmd not in (
"download-file",
"download-media",
"download_media",
".pipe",
):
print("Auto-inserting download-file after PodcastIndex episode selection")
stages.insert(0, ["download-file"])
if table_type == "libgen" and first_cmd not in (
"download-file",
"download-media",
@@ -2614,7 +2899,8 @@ class PipelineExecutor:
if not stage_tokens:
continue
cmd_name = stage_tokens[0].replace("_", "-").lower()
raw_stage_name = str(stage_tokens[0])
cmd_name = raw_stage_name.replace("_", "-").lower()
stage_args = stage_tokens[1:]
if cmd_name == "@":
@@ -2676,12 +2962,14 @@ class PipelineExecutor:
continue
if cmd_name.startswith("@"): # selection stage
selection = SelectionSyntax.parse(cmd_name)
is_select_all = cmd_name == "@*"
if selection is None and not is_select_all:
print(f"Invalid selection: {cmd_name}\n")
selection_token = raw_stage_name
selection = SelectionSyntax.parse(selection_token)
filter_spec = SelectionFilterSyntax.parse(selection_token)
is_select_all = selection_token.strip() == "@*"
if selection is None and filter_spec is None and not is_select_all:
print(f"Invalid selection: {selection_token}\n")
pipeline_status = "failed"
pipeline_error = f"Invalid selection {cmd_name}"
pipeline_error = f"Invalid selection {selection_token}"
return
selected_indices = []
@@ -2715,6 +3003,11 @@ class PipelineExecutor:
if is_select_all:
selected_indices = list(range(len(items_list)))
elif filter_spec is not None:
selected_indices = [
i for i, item in enumerate(items_list)
if SelectionFilterSyntax.matches(item, filter_spec)
]
else:
selected_indices = sorted(
[i - 1 for i in selection]
@@ -2731,6 +3024,52 @@ class PipelineExecutor:
pipeline_error = "Empty selection"
return
# Filter UX: if the stage token is a filter and it's terminal,
# render a filtered table overlay rather than selecting/auto-downloading.
stage_is_last = (stage_index + 1 >= len(stages))
if filter_spec is not None and stage_is_last:
try:
from SYS.result_table import ResultTable
base_table = stage_table
if base_table is None:
base_table = ctx.get_last_result_table()
if base_table is not None and hasattr(base_table, "copy_with_title"):
new_table = base_table.copy_with_title(getattr(base_table, "title", "") or "Results")
else:
new_table = ResultTable(getattr(base_table, "title", "") if base_table is not None else "Results")
try:
if base_table is not None and getattr(base_table, "table", None):
new_table.set_table(str(getattr(base_table, "table")))
except Exception:
pass
try:
# Attach a one-line header so users see the active filter.
safe = str(selection_token)[1:].strip()
new_table.set_header_line(f'filter: "{safe}"')
except Exception:
pass
for item in filtered:
new_table.add_result(item)
try:
ctx.set_last_result_table_overlay(new_table, items=list(filtered), subject=ctx.get_last_result_subject())
except Exception:
pass
try:
stdout_console().print()
stdout_console().print(new_table)
except Exception:
pass
except Exception:
pass
continue
# UX: selecting a single URL row from get-url tables should open it.
# Only do this when the selection stage is terminal to avoid surprising
# side-effects in pipelines like `@1 | download-file`.
@@ -2747,10 +3086,10 @@ class PipelineExecutor:
pass
if PipelineExecutor._maybe_run_class_selector(
ctx,
config,
filtered,
stage_is_last=(stage_index + 1 >= len(stages))):
ctx,
config,
filtered,
stage_is_last=(stage_index + 1 >= len(stages))):
return
# Special case: selecting multiple tags from get-tag and piping into delete-tag
@@ -2835,9 +3174,82 @@ class PipelineExecutor:
if current_table and hasattr(current_table,
"table") else None
)
if table_type == "youtube" and stage_index + 1 >= len(stages):
print("Auto-running YouTube selection via download-media")
stages.append(["download-media", *stage_args])
def _norm_stage_cmd(name: Any) -> str:
return str(name or "").replace("_", "-").strip().lower()
next_cmd = None
if stage_index + 1 < len(stages) and stages[stage_index + 1]:
next_cmd = _norm_stage_cmd(stages[stage_index + 1][0])
# Auto-insert downloader stages for provider tables.
# IMPORTANT: do not auto-download for filter selections; they may match many rows.
if filter_spec is None:
if stage_index + 1 >= len(stages):
if table_type == "youtube":
print("Auto-running YouTube selection via download-media")
stages.append(["download-media", *stage_args])
elif table_type == "bandcamp":
print("Auto-running Bandcamp selection via download-media")
stages.append(["download-media"])
elif table_type == "internetarchive":
print("Auto-loading Internet Archive item via download-file")
stages.append(["download-file"])
elif table_type == "podcastindex.episodes":
print("Auto-piping selection to download-file")
stages.append(["download-file"])
elif table_type in {"soulseek", "openlibrary", "libgen"}:
print("Auto-piping selection to download-file")
stages.append(["download-file"])
else:
if table_type == "soulseek" and next_cmd not in (
"download-file",
"download-media",
"download_media",
".pipe",
):
debug("Auto-inserting download-file after Soulseek selection")
stages.insert(stage_index + 1, ["download-file"])
if table_type == "youtube" and next_cmd not in (
"download-media",
"download_media",
"download-file",
".pipe",
):
debug("Auto-inserting download-media after YouTube selection")
stages.insert(stage_index + 1, ["download-media"])
if table_type == "bandcamp" and next_cmd not in (
"download-media",
"download_media",
"download-file",
".pipe",
):
print("Auto-inserting download-media after Bandcamp selection")
stages.insert(stage_index + 1, ["download-media"])
if table_type == "internetarchive" and next_cmd not in (
"download-file",
"download-media",
"download_media",
".pipe",
):
debug("Auto-inserting download-file after Internet Archive selection")
stages.insert(stage_index + 1, ["download-file"])
if table_type == "podcastindex.episodes" and next_cmd not in (
"download-file",
"download-media",
"download_media",
".pipe",
):
print("Auto-inserting download-file after PodcastIndex episode selection")
stages.insert(stage_index + 1, ["download-file"])
if table_type == "libgen" and next_cmd not in (
"download-file",
"download-media",
"download_media",
".pipe",
):
print("Auto-inserting download-file after Libgen selection")
stages.insert(stage_index + 1, ["download-file"])
continue
ensure_registry_loaded()