Files
Medios-Macina/pipeline.py

1045 lines
35 KiB
Python
Raw Normal View History

2025-12-12 21:55:38 -08:00
"""Pipeline execution context and state management for cmdlet.
2025-11-25 20:09:33 -08:00
2025-12-12 21:55:38 -08:00
This module provides functions for managing pipeline state, allowing cmdlet to
2025-11-25 20:09:33 -08:00
emit results and control printing behavior within a piped execution context.
Key Concepts:
- Pipeline stages are chained command invocations
- Each stage receives input items and emits output items
- Printing behavior is controlled based on pipeline position
- Stage context tracks whether this is the last stage (affects output verbosity)
PowerShell-like piping model:
- Each stage processes items individually
- Stage calls emit() for each output item
- Output items become input for next stage
- Batch commands receive all items at once (special case)
"""
from __future__ import annotations
import sys
2025-12-20 23:57:44 -08:00
import shlex
2025-12-21 05:10:09 -08:00
from contextlib import contextmanager
2025-11-25 20:09:33 -08:00
from typing import Any, Dict, List, Optional, Sequence
from models import PipelineStageContext
2025-12-11 19:04:02 -08:00
from SYS.logger import log
2025-11-25 20:09:33 -08:00
2025-12-21 05:10:09 -08:00
# Live progress UI instance (optional). Set by the pipeline runner.
_LIVE_PROGRESS: Any = None
def set_live_progress(progress_ui: Any) -> None:
2025-12-29 17:05:03 -08:00
"""Register the current Live progress UI so cmdlets can suspend it during prompts."""
global _LIVE_PROGRESS
_LIVE_PROGRESS = progress_ui
2025-12-21 05:10:09 -08:00
def get_live_progress() -> Any:
2025-12-29 17:05:03 -08:00
return _LIVE_PROGRESS
2025-12-21 05:10:09 -08:00
@contextmanager
def suspend_live_progress():
2025-12-29 17:05:03 -08:00
"""Temporarily pause Live progress rendering.
This avoids Rich Live cursor control interfering with interactive tables/prompts
emitted by cmdlets during preflight (e.g. URL-duplicate confirmation).
"""
ui = _LIVE_PROGRESS
paused = False
try:
if ui is not None and hasattr(ui, "pause"):
try:
ui.pause()
paused = True
except Exception:
paused = False
yield
finally:
# If a stage requested the pipeline stop (e.g. user declined a preflight prompt),
# do not resume Live rendering.
if get_pipeline_stop() is not None:
return
if paused and ui is not None and hasattr(ui, "resume"):
try:
ui.resume()
except Exception:
pass
2025-12-21 05:10:09 -08:00
2025-11-25 20:09:33 -08:00
2025-12-11 12:47:30 -08:00
def _is_selectable_table(table: Any) -> bool:
2025-12-29 17:05:03 -08:00
"""Return True when a table can be used for @ selection."""
return bool(table) and not getattr(table, "no_choice", False)
2025-12-11 12:47:30 -08:00
2025-11-25 20:09:33 -08:00
# ============================================================================
2025-12-11 12:47:30 -08:00
# PIPELINE STATE
2025-11-25 20:09:33 -08:00
# ============================================================================
2025-12-11 12:47:30 -08:00
# Current pipeline context
2025-11-25 20:09:33 -08:00
_CURRENT_CONTEXT: Optional[PipelineStageContext] = None
# Remember last search query to support refreshing results after pipeline actions
_LAST_SEARCH_QUERY: Optional[str] = None
# Track whether the last pipeline execution already refreshed and displayed results
_PIPELINE_REFRESHED: bool = False
# Cache the last pipeline outputs so non-interactive callers can inspect results
_PIPELINE_LAST_ITEMS: List[Any] = []
# Store the last result table for @ selection syntax (e.g., @2, @2-5, @{1,3,5})
_LAST_RESULT_TABLE: Optional[Any] = None
_LAST_RESULT_ITEMS: List[Any] = []
2025-12-11 12:47:30 -08:00
# Subject for the current result table (e.g., the file whose tags/url are displayed)
2025-12-06 00:10:19 -08:00
_LAST_RESULT_SUBJECT: Optional[Any] = None
2025-11-25 20:09:33 -08:00
# History of result tables for @.. navigation (LIFO stack, max 20 tables)
2025-12-06 00:10:19 -08:00
_RESULT_TABLE_HISTORY: List[tuple[Optional[Any], List[Any], Optional[Any]]] = []
2025-11-25 20:09:33 -08:00
_MAX_RESULT_TABLE_HISTORY = 20
2025-12-11 12:47:30 -08:00
# Forward history for @,, navigation (LIFO stack for popped tables)
_RESULT_TABLE_FORWARD: List[tuple[Optional[Any], List[Any], Optional[Any]]] = []
2025-11-25 20:09:33 -08:00
# Current stage table for @N expansion (separate from history)
_CURRENT_STAGE_TABLE: Optional[Any] = None
# Items displayed by non-selectable commands (get-tag, delete-tag, etc.)
_DISPLAY_ITEMS: List[Any] = []
# Table for display-only commands (overlay)
_DISPLAY_TABLE: Optional[Any] = None
2025-12-06 00:10:19 -08:00
# Subject for overlay/display-only tables (takes precedence over _LAST_RESULT_SUBJECT)
_DISPLAY_SUBJECT: Optional[Any] = None
2025-11-25 20:09:33 -08:00
# Track the indices the user selected via @ syntax for the current invocation
_PIPELINE_LAST_SELECTION: List[int] = []
# Track the currently executing command/pipeline string for worker attribution
_PIPELINE_COMMAND_TEXT: str = ""
2025-12-20 23:57:44 -08:00
# Track the currently executing cmdlet name so debug helpers can label objects
# with the active stage (e.g., "1 - add-file").
_CURRENT_CMDLET_NAME: str = ""
# Track the currently executing stage text (best-effort, quotes preserved).
_CURRENT_STAGE_TEXT: str = ""
2025-12-12 21:55:38 -08:00
# Shared scratchpad for cmdlet/funacts to stash structured data between stages
_PIPELINE_VALUES: Dict[str,
Any] = {}
2025-11-25 20:09:33 -08:00
_PIPELINE_MISSING = object()
2025-12-07 00:21:30 -08:00
# Preserve downstream pipeline stages when a command pauses for @N selection
_PENDING_PIPELINE_TAIL: List[List[str]] = []
_PENDING_PIPELINE_SOURCE: Optional[str] = None
2025-11-25 20:09:33 -08:00
# Global callback to notify UI when library content changes
_UI_LIBRARY_REFRESH_CALLBACK: Optional[Any] = None
2025-12-21 05:10:09 -08:00
# ============================================================================
# PIPELINE STOP SIGNAL
# ============================================================================
_PIPELINE_STOP: Optional[Dict[str, Any]] = None
def request_pipeline_stop(*, reason: str = "", exit_code: int = 0) -> None:
2025-12-29 17:05:03 -08:00
"""Request that the pipeline runner stop gracefully after the current stage."""
global _PIPELINE_STOP
_PIPELINE_STOP = {
"reason": str(reason or "").strip(),
"exit_code": int(exit_code)
}
2025-12-21 05:10:09 -08:00
def get_pipeline_stop() -> Optional[Dict[str, Any]]:
2025-12-29 17:05:03 -08:00
return _PIPELINE_STOP
2025-12-21 05:10:09 -08:00
def clear_pipeline_stop() -> None:
2025-12-29 17:05:03 -08:00
global _PIPELINE_STOP
_PIPELINE_STOP = None
2025-12-21 05:10:09 -08:00
2025-11-25 20:09:33 -08:00
# ============================================================================
# PUBLIC API
# ============================================================================
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
def set_stage_context(context: Optional[PipelineStageContext]) -> None:
2025-12-29 17:05:03 -08:00
"""Set the current pipeline stage context."""
global _CURRENT_CONTEXT
_CURRENT_CONTEXT = context
2025-11-25 20:09:33 -08:00
def get_stage_context() -> Optional[PipelineStageContext]:
2025-12-29 17:05:03 -08:00
"""Get the current pipeline stage context."""
return _CURRENT_CONTEXT
2025-11-25 20:09:33 -08:00
def emit(obj: Any) -> None:
2025-12-29 17:05:03 -08:00
"""Emit an object to the current pipeline stage output.
Call this from a cmdlet to pass data to the next pipeline stage.
If not in a pipeline context, this is a no-op.
Args:
obj: Any object to emit downstream
Example:
```python
def _run(item, args, config):
result = process(item)
if result:
emit(result) # Pass to next stage
return 0
```
"""
if _CURRENT_CONTEXT is not None:
_CURRENT_CONTEXT.emit(obj)
2025-12-11 12:47:30 -08:00
def emit_list(objects: List[Any]) -> None:
2025-12-29 17:05:03 -08:00
"""Emit a list of objects to the next pipeline stage.
This allows cmdlet to emit multiple results that are tracked as a list,
enabling downstream cmdlet to process all of them or filter by metadata.
Args:
objects: List of objects to emit
"""
if _CURRENT_CONTEXT is not None:
_CURRENT_CONTEXT.emit(objects)
2025-11-25 20:09:33 -08:00
def print_if_visible(*args: Any, file=None, **kwargs: Any) -> None:
2025-12-29 17:05:03 -08:00
"""Print only if this is not a quiet mid-pipeline stage.
- Always allow errors printed to stderr by callers (they pass file=sys.stderr).
- For normal info messages, this suppresses printing for intermediate pipeline stages.
- Use this instead of log() in cmdlet when you want stage-aware output.
Args:
*args: Arguments to print (same as built-in print)
file: Output stream (default: stdout)
**kwargs: Keyword arguments for print
Example:
```python
# Always shows errors
print_if_visible("[error] Something failed", file=sys.stderr)
# Only shows in non-piped context or as final stage
print_if_visible(f"Processed {count} items")
```
"""
try:
# Print if: not in a pipeline OR this is the last stage
should_print = (_CURRENT_CONTEXT is None
) or (_CURRENT_CONTEXT and _CURRENT_CONTEXT.is_last_stage)
2025-12-29 17:05:03 -08:00
# Always print to stderr regardless
if file is not None:
should_print = True
if should_print:
log(*args, **kwargs) if file is None else log(*args, file=file, **kwargs)
except Exception:
pass
2025-11-25 20:09:33 -08:00
def store_value(key: str, value: Any) -> None:
2025-12-29 17:05:03 -08:00
"""Store a value to pass to later pipeline stages.
Values are stored in a shared dictionary keyed by normalized lowercase strings.
This allows one stage to prepare data for the next stage without intermediate output.
Args:
key: Variable name (normalized to lowercase, non-empty)
value: Any Python object to store
"""
if not isinstance(key, str):
return
text = key.strip().lower()
if not text:
return
try:
_PIPELINE_VALUES[text] = value
except Exception:
pass
2025-11-25 20:09:33 -08:00
def load_value(key: str, default: Any = None) -> Any:
2025-12-29 17:05:03 -08:00
"""Retrieve a value stored by an earlier pipeline stage.
Supports dotted path notation for nested access (e.g., "metadata.tag" or "items.0").
Args:
key: Variable name or dotted path (e.g., "my_var", "metadata.title", "list.0")
default: Value to return if key not found or access fails
Returns:
The stored value, or default if not found
"""
if not isinstance(key, str):
return default
text = key.strip()
if not text:
return default
parts = [segment.strip() for segment in text.split(".") if segment.strip()]
if not parts:
return default
root_key = parts[0].lower()
container = _PIPELINE_VALUES.get(root_key, _PIPELINE_MISSING)
if container is _PIPELINE_MISSING:
return default
if len(parts) == 1:
return container
current: Any = container
for fragment in parts[1:]:
if isinstance(current, dict):
fragment_lower = fragment.lower()
if fragment in current:
current = current[fragment]
continue
match = _PIPELINE_MISSING
for key_name, value in current.items():
if isinstance(key_name, str) and key_name.lower() == fragment_lower:
match = value
break
if match is _PIPELINE_MISSING:
return default
current = match
continue
if isinstance(current, (list, tuple)):
if fragment.isdigit():
try:
idx = int(fragment)
except ValueError:
return default
if 0 <= idx < len(current):
current = current[idx]
continue
return default
if hasattr(current, fragment):
try:
current = getattr(current, fragment)
continue
except Exception:
return default
return default
return current
def set_pending_pipeline_tail(
stages: Optional[Sequence[Sequence[str]]],
source_command: Optional[str] = None
2025-12-29 17:05:03 -08:00
) -> None:
"""Store the remaining pipeline stages when execution pauses for @N selection.
Args:
stages: Iterable of pipeline stage token lists
source_command: Command that produced the selection table (for validation)
"""
global _PENDING_PIPELINE_TAIL, _PENDING_PIPELINE_SOURCE
try:
pending: List[List[str]] = []
for stage in stages or []:
if isinstance(stage, (list, tuple)):
pending.append([str(token) for token in stage])
_PENDING_PIPELINE_TAIL = pending
clean_source = (source_command or "").strip()
_PENDING_PIPELINE_SOURCE = clean_source if clean_source else None
except Exception:
# Keep existing pending tail on failure
pass
2025-12-07 00:21:30 -08:00
def get_pending_pipeline_tail() -> List[List[str]]:
2025-12-29 17:05:03 -08:00
"""Get a copy of the pending pipeline tail (stages queued after selection)."""
return [list(stage) for stage in _PENDING_PIPELINE_TAIL]
2025-12-07 00:21:30 -08:00
def get_pending_pipeline_source() -> Optional[str]:
2025-12-29 17:05:03 -08:00
"""Get the source command associated with the pending pipeline tail."""
return _PENDING_PIPELINE_SOURCE
2025-12-07 00:21:30 -08:00
def clear_pending_pipeline_tail() -> None:
2025-12-29 17:05:03 -08:00
"""Clear any stored pending pipeline tail."""
global _PENDING_PIPELINE_TAIL, _PENDING_PIPELINE_SOURCE
_PENDING_PIPELINE_TAIL = []
_PENDING_PIPELINE_SOURCE = None
2025-12-11 12:47:30 -08:00
2025-11-25 20:09:33 -08:00
def reset() -> None:
2025-12-29 17:05:03 -08:00
"""Reset all pipeline state. Called between pipeline executions."""
global _PIPELINE_VALUES, _LAST_SEARCH_QUERY, _PIPELINE_REFRESHED
global _PIPELINE_LAST_ITEMS, _PIPELINE_COMMAND_TEXT, _LAST_RESULT_SUBJECT
global _DISPLAY_SUBJECT, _PENDING_PIPELINE_TAIL, _PENDING_PIPELINE_SOURCE
global _CURRENT_CONTEXT
_CURRENT_CONTEXT = None
_LAST_SEARCH_QUERY = None
_PIPELINE_REFRESHED = False
_PIPELINE_LAST_ITEMS = []
_PIPELINE_VALUES = {}
_PIPELINE_COMMAND_TEXT = ""
_LAST_RESULT_SUBJECT = None
_DISPLAY_SUBJECT = None
_PENDING_PIPELINE_TAIL = []
_PENDING_PIPELINE_SOURCE = None
2025-11-25 20:09:33 -08:00
def get_emitted_items() -> List[Any]:
2025-12-29 17:05:03 -08:00
"""Get a copy of all items emitted by the current pipeline stage."""
if _CURRENT_CONTEXT is not None:
return list(_CURRENT_CONTEXT.emits)
return []
2025-11-25 20:09:33 -08:00
def clear_emits() -> None:
2025-12-29 17:05:03 -08:00
"""Clear the emitted items list (called between stages)."""
if _CURRENT_CONTEXT is not None:
_CURRENT_CONTEXT.emits.clear()
2025-11-25 20:09:33 -08:00
def set_last_selection(indices: Sequence[int]) -> None:
2025-12-29 17:05:03 -08:00
"""Record the indices selected via @ syntax for the next cmdlet.
2025-11-25 20:09:33 -08:00
2025-12-29 17:05:03 -08:00
Args:
indices: Iterable of 0-based indices captured from the REPL parser
"""
global _PIPELINE_LAST_SELECTION
_PIPELINE_LAST_SELECTION = list(indices or [])
2025-11-25 20:09:33 -08:00
def get_last_selection() -> List[int]:
2025-12-29 17:05:03 -08:00
"""Return the indices selected via @ syntax for the current invocation."""
return list(_PIPELINE_LAST_SELECTION)
2025-11-25 20:09:33 -08:00
def clear_last_selection() -> None:
2025-12-29 17:05:03 -08:00
"""Clear the cached selection indices after a cmdlet finishes."""
global _PIPELINE_LAST_SELECTION
_PIPELINE_LAST_SELECTION = []
2025-11-25 20:09:33 -08:00
def set_current_command_text(command_text: Optional[str]) -> None:
2025-12-29 17:05:03 -08:00
"""Record the raw pipeline/command text for downstream consumers."""
global _PIPELINE_COMMAND_TEXT
_PIPELINE_COMMAND_TEXT = (command_text or "").strip()
2025-11-25 20:09:33 -08:00
def get_current_command_text(default: str = "") -> str:
2025-12-29 17:05:03 -08:00
"""Return the last recorded command/pipeline text."""
text = _PIPELINE_COMMAND_TEXT.strip()
return text if text else default
2025-11-25 20:09:33 -08:00
def clear_current_command_text() -> None:
2025-12-29 17:05:03 -08:00
"""Clear the cached command text after execution completes."""
global _PIPELINE_COMMAND_TEXT
_PIPELINE_COMMAND_TEXT = ""
2025-11-25 20:09:33 -08:00
2025-12-20 23:57:44 -08:00
def split_pipeline_text(pipeline_text: str) -> List[str]:
2025-12-29 17:05:03 -08:00
"""Split a pipeline string on unquoted '|' characters.
Preserves original quoting/spacing within each returned stage segment.
"""
text = str(pipeline_text or "")
if not text:
return []
stages: List[str] = []
buf: List[str] = []
quote: Optional[str] = None
escape = False
for ch in text:
if escape:
buf.append(ch)
escape = False
continue
if ch == "\\" and quote is not None:
buf.append(ch)
escape = True
continue
if ch in ('"', "'"):
if quote is None:
quote = ch
elif quote == ch:
quote = None
buf.append(ch)
continue
if ch == "|" and quote is None:
stages.append("".join(buf).strip())
buf = []
continue
buf.append(ch)
tail = "".join(buf).strip()
if tail:
stages.append(tail)
return [s for s in stages if s]
2025-12-20 23:57:44 -08:00
def get_current_command_stages() -> List[str]:
2025-12-29 17:05:03 -08:00
"""Return the raw stage segments for the current command text."""
return split_pipeline_text(get_current_command_text(""))
2025-12-20 23:57:44 -08:00
def set_current_stage_text(stage_text: Optional[str]) -> None:
2025-12-29 17:05:03 -08:00
"""Record the raw stage text currently being executed."""
global _CURRENT_STAGE_TEXT
_CURRENT_STAGE_TEXT = str(stage_text or "").strip()
2025-12-20 23:57:44 -08:00
def get_current_stage_text(default: str = "") -> str:
2025-12-29 17:05:03 -08:00
"""Return the raw stage text currently being executed."""
text = _CURRENT_STAGE_TEXT.strip()
return text if text else default
2025-12-20 23:57:44 -08:00
def clear_current_stage_text() -> None:
2025-12-29 17:05:03 -08:00
"""Clear the cached stage text after a stage completes."""
global _CURRENT_STAGE_TEXT
_CURRENT_STAGE_TEXT = ""
2025-12-20 23:57:44 -08:00
def set_current_cmdlet_name(cmdlet_name: Optional[str]) -> None:
2025-12-29 17:05:03 -08:00
"""Record the currently executing cmdlet name (stage-local)."""
global _CURRENT_CMDLET_NAME
_CURRENT_CMDLET_NAME = str(cmdlet_name or "").strip()
2025-12-20 23:57:44 -08:00
def get_current_cmdlet_name(default: str = "") -> str:
2025-12-29 17:05:03 -08:00
"""Return the currently executing cmdlet name (stage-local)."""
text = _CURRENT_CMDLET_NAME.strip()
return text if text else default
2025-12-20 23:57:44 -08:00
def clear_current_cmdlet_name() -> None:
2025-12-29 17:05:03 -08:00
"""Clear the cached cmdlet name after a stage completes."""
global _CURRENT_CMDLET_NAME
_CURRENT_CMDLET_NAME = ""
2025-12-20 23:57:44 -08:00
2025-11-25 20:09:33 -08:00
def set_search_query(query: Optional[str]) -> None:
2025-12-29 17:05:03 -08:00
"""Set the last search query for refresh purposes."""
global _LAST_SEARCH_QUERY
_LAST_SEARCH_QUERY = query
2025-11-25 20:09:33 -08:00
def get_search_query() -> Optional[str]:
2025-12-29 17:05:03 -08:00
"""Get the last search query."""
return _LAST_SEARCH_QUERY
2025-11-25 20:09:33 -08:00
def set_pipeline_refreshed(refreshed: bool) -> None:
2025-12-29 17:05:03 -08:00
"""Track whether the pipeline already refreshed results."""
global _PIPELINE_REFRESHED
_PIPELINE_REFRESHED = refreshed
2025-11-25 20:09:33 -08:00
def was_pipeline_refreshed() -> bool:
2025-12-29 17:05:03 -08:00
"""Check if the pipeline already refreshed results."""
return _PIPELINE_REFRESHED
2025-11-25 20:09:33 -08:00
def set_last_items(items: list) -> None:
2025-12-29 17:05:03 -08:00
"""Cache the last pipeline outputs."""
global _PIPELINE_LAST_ITEMS
_PIPELINE_LAST_ITEMS = list(items) if items else []
2025-11-25 20:09:33 -08:00
def get_last_items() -> List[Any]:
2025-12-29 17:05:03 -08:00
"""Get the last pipeline outputs."""
return list(_PIPELINE_LAST_ITEMS)
2025-11-25 20:09:33 -08:00
def set_ui_library_refresh_callback(callback: Any) -> None:
2025-12-29 17:05:03 -08:00
"""Set a callback to be called when library content is updated.
The callback will be called with:
callback(library_filter: str = 'local')
Args:
callback: A callable that accepts optional library_filter parameter
Example:
def my_refresh_callback(library_filter='local'):
print(f"Refresh library: {library_filter}")
set_ui_library_refresh_callback(my_refresh_callback)
"""
global _UI_LIBRARY_REFRESH_CALLBACK
_UI_LIBRARY_REFRESH_CALLBACK = callback
2025-11-25 20:09:33 -08:00
def get_ui_library_refresh_callback() -> Optional[Any]:
2025-12-29 17:05:03 -08:00
"""Get the current library refresh callback."""
return _UI_LIBRARY_REFRESH_CALLBACK
def trigger_ui_library_refresh(library_filter: str = "local") -> None:
"""Trigger a library refresh in the UI if callback is registered.
This should be called from cmdlet/funacts after content is added to library.
Args:
library_filter: Which library to refresh ('local', 'hydrus', etc)
"""
callback = get_ui_library_refresh_callback()
if callback:
try:
callback(library_filter)
except Exception as e:
print(
f"[trigger_ui_library_refresh] Error calling refresh callback: {e}",
file=sys.stderr
2025-12-29 17:05:03 -08:00
)
def set_last_result_table(
result_table: Optional[Any],
items: Optional[List[Any]] = None,
subject: Optional[Any] = None
2025-12-29 17:05:03 -08:00
) -> None:
"""Store the last result table and items for @ selection syntax.
This should be called after displaying a result table, so users can reference
rows with @2, @2-5, @{1,3,5} syntax in subsequent commands.
Also maintains a history stack for @.. navigation (restore previous result table).
Only selectable commands (search-file, download-file when listing options) should call this to create history.
For action commands (delete-tag, add-tags, etc), use set_last_result_table_preserve_history() instead.
Args:
result_table: The ResultTable object that was displayed (or None)
items: List of items that populated the table (optional)
"""
global _LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT
global _RESULT_TABLE_HISTORY, _DISPLAY_ITEMS, _DISPLAY_TABLE, _DISPLAY_SUBJECT
# Push current table to history before replacing
if _LAST_RESULT_TABLE is not None:
_RESULT_TABLE_HISTORY.append(
(_LAST_RESULT_TABLE,
_LAST_RESULT_ITEMS.copy(),
_LAST_RESULT_SUBJECT)
2025-12-29 17:05:03 -08:00
)
# Keep history size limited
if len(_RESULT_TABLE_HISTORY) > _MAX_RESULT_TABLE_HISTORY:
_RESULT_TABLE_HISTORY.pop(0)
# Set new current table and clear any display items/table
_DISPLAY_ITEMS = []
_DISPLAY_TABLE = None
_DISPLAY_SUBJECT = None
_LAST_RESULT_TABLE = result_table
_LAST_RESULT_ITEMS = items or []
_LAST_RESULT_SUBJECT = subject
# Sort table by Title/Name column alphabetically if available
if (result_table is not None and hasattr(result_table,
"sort_by_title")
and not getattr(result_table,
"preserve_order",
False)):
2025-12-29 17:05:03 -08:00
try:
result_table.sort_by_title()
# Re-order items list to match the sorted table
if _LAST_RESULT_ITEMS and hasattr(result_table, "rows"):
sorted_items = []
for row in result_table.rows:
src_idx = getattr(row, "source_index", None)
if isinstance(src_idx,
int) and 0 <= src_idx < len(_LAST_RESULT_ITEMS):
2025-12-29 17:05:03 -08:00
sorted_items.append(_LAST_RESULT_ITEMS[src_idx])
if len(sorted_items) == len(result_table.rows):
_LAST_RESULT_ITEMS = sorted_items
except Exception:
pass
def set_last_result_table_overlay(
result_table: Optional[Any],
items: Optional[List[Any]] = None,
subject: Optional[Any] = None
2025-12-29 17:05:03 -08:00
) -> None:
"""Set a result table as an overlay (display only, no history).
Used for commands like get-tag that want to show a formatted table but
should be treated as a transient view (closing it returns to previous table).
Args:
result_table: The ResultTable object to display
items: List of items for @N selection
"""
global _DISPLAY_ITEMS, _DISPLAY_TABLE, _DISPLAY_SUBJECT
_DISPLAY_TABLE = result_table
_DISPLAY_ITEMS = items or []
_DISPLAY_SUBJECT = subject
# Sort table by Title/Name column alphabetically if available
if (result_table is not None and hasattr(result_table,
"sort_by_title")
and not getattr(result_table,
"preserve_order",
False)):
2025-12-29 17:05:03 -08:00
try:
result_table.sort_by_title()
# Re-order items list to match the sorted table
if _DISPLAY_ITEMS and hasattr(result_table, "rows"):
sorted_items = []
for row in result_table.rows:
src_idx = getattr(row, "source_index", None)
if isinstance(src_idx, int) and 0 <= src_idx < len(_DISPLAY_ITEMS):
sorted_items.append(_DISPLAY_ITEMS[src_idx])
if len(sorted_items) == len(result_table.rows):
_DISPLAY_ITEMS = sorted_items
except Exception:
pass
def set_last_result_table_preserve_history(
result_table: Optional[Any],
items: Optional[List[Any]] = None,
subject: Optional[Any] = None
2025-12-29 17:05:03 -08:00
) -> None:
"""Update the last result table WITHOUT adding to history.
Used for action commands (delete-tag, add-tags, etc.) that modify data but shouldn't
create history entries. This allows @.. to navigate search results, not undo stacks.
Args:
result_table: The ResultTable object that was displayed (or None)
items: List of items that populated the table (optional)
"""
global _LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT
# Update current table WITHOUT pushing to history
_LAST_RESULT_TABLE = result_table
_LAST_RESULT_ITEMS = items or []
_LAST_RESULT_SUBJECT = subject
2025-11-25 20:09:33 -08:00
def set_last_result_items_only(items: Optional[List[Any]]) -> None:
2025-12-29 17:05:03 -08:00
"""Store items for @N selection WITHOUT affecting history or saved search data.
Used for display-only commands (get-tag, get-url, etc.) and action commands
(delete-tag, add-tags, etc.) that emit results but shouldn't affect history.
These items are available for @1, @2, etc. selection in the next command,
but are NOT saved to history. This preserves search context for @.. navigation.
Args:
items: List of items to select from
"""
global _DISPLAY_ITEMS, _DISPLAY_TABLE, _DISPLAY_SUBJECT
# Store items for immediate @N selection, but DON'T modify _LAST_RESULT_ITEMS
# This ensures history contains original search data, not display transformations
_DISPLAY_ITEMS = items or []
# Clear display table since we're setting items only (CLI will generate table if needed)
_DISPLAY_TABLE = None
_DISPLAY_SUBJECT = None
2025-11-25 20:09:33 -08:00
def restore_previous_result_table() -> bool:
2025-12-29 17:05:03 -08:00
"""Restore the previous result table from history (for @.. navigation).
Returns:
True if a previous table was restored, False if history is empty
"""
global _LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT
global _RESULT_TABLE_HISTORY, _RESULT_TABLE_FORWARD, _DISPLAY_ITEMS, _DISPLAY_TABLE, _DISPLAY_SUBJECT
# If we have an active overlay (display items/table), clear it to "go back" to the underlying table
if _DISPLAY_ITEMS or _DISPLAY_TABLE or _DISPLAY_SUBJECT is not None:
_DISPLAY_ITEMS = []
_DISPLAY_TABLE = None
_DISPLAY_SUBJECT = None
# If an underlying table exists, we're done.
# Otherwise, fall through to history restore so @.. actually returns to the last table.
if _LAST_RESULT_TABLE is not None:
return True
if not _RESULT_TABLE_HISTORY:
return True
if not _RESULT_TABLE_HISTORY:
return False
# Save current state to forward stack before popping
_RESULT_TABLE_FORWARD.append(
(_LAST_RESULT_TABLE,
_LAST_RESULT_ITEMS,
_LAST_RESULT_SUBJECT)
)
2025-12-29 17:05:03 -08:00
# Pop from history and restore
prev = _RESULT_TABLE_HISTORY.pop()
if isinstance(prev, tuple) and len(prev) >= 3:
_LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT = prev[0], prev[1], prev[2]
elif isinstance(prev, tuple) and len(prev) == 2:
_LAST_RESULT_TABLE, _LAST_RESULT_ITEMS = prev
_LAST_RESULT_SUBJECT = None
else:
_LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT = None, [], None
# Clear display items so get_last_result_items() falls back to restored items
_DISPLAY_ITEMS = []
_DISPLAY_TABLE = None
_DISPLAY_SUBJECT = None
return True
2025-11-25 20:09:33 -08:00
2025-12-11 12:47:30 -08:00
def restore_next_result_table() -> bool:
2025-12-29 17:05:03 -08:00
"""Restore the next result table from forward history (for @,, navigation).
Returns:
True if a next table was restored, False if forward history is empty
"""
global _LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT
global _RESULT_TABLE_HISTORY, _RESULT_TABLE_FORWARD, _DISPLAY_ITEMS, _DISPLAY_TABLE, _DISPLAY_SUBJECT
# If we have an active overlay (display items/table), clear it to "go forward" to the underlying table
if _DISPLAY_ITEMS or _DISPLAY_TABLE or _DISPLAY_SUBJECT is not None:
_DISPLAY_ITEMS = []
_DISPLAY_TABLE = None
_DISPLAY_SUBJECT = None
# If an underlying table exists, we're done.
# Otherwise, fall through to forward restore when available.
if _LAST_RESULT_TABLE is not None:
return True
if not _RESULT_TABLE_FORWARD:
return True
if not _RESULT_TABLE_FORWARD:
return False
# Save current state to history stack before popping forward
_RESULT_TABLE_HISTORY.append(
(_LAST_RESULT_TABLE,
_LAST_RESULT_ITEMS,
_LAST_RESULT_SUBJECT)
)
2025-12-29 17:05:03 -08:00
# Pop from forward stack and restore
next_state = _RESULT_TABLE_FORWARD.pop()
if isinstance(next_state, tuple) and len(next_state) >= 3:
_LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT = (
next_state[0],
next_state[1],
next_state[2],
)
elif isinstance(next_state, tuple) and len(next_state) == 2:
_LAST_RESULT_TABLE, _LAST_RESULT_ITEMS = next_state
_LAST_RESULT_SUBJECT = None
else:
_LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT = None, [], None
# Clear display items so get_last_result_items() falls back to restored items
_DISPLAY_ITEMS = []
_DISPLAY_TABLE = None
_DISPLAY_SUBJECT = None
return True
2025-12-11 12:47:30 -08:00
2025-11-25 20:09:33 -08:00
def get_display_table() -> Optional[Any]:
2025-12-29 17:05:03 -08:00
"""Get the current display overlay table.
Returns:
The ResultTable object, or None if no overlay table is set
"""
return _DISPLAY_TABLE
2025-11-25 20:09:33 -08:00
2025-12-06 00:10:19 -08:00
def get_last_result_subject() -> Optional[Any]:
2025-12-29 17:05:03 -08:00
"""Get the subject associated with the current result table or overlay.
2025-12-06 00:10:19 -08:00
2025-12-29 17:05:03 -08:00
Overlay subject (from display-only tables) takes precedence; otherwise returns
the subject stored with the last result table.
"""
if _DISPLAY_SUBJECT is not None:
return _DISPLAY_SUBJECT
return _LAST_RESULT_SUBJECT
2025-12-06 00:10:19 -08:00
2025-11-25 20:09:33 -08:00
def get_last_result_table() -> Optional[Any]:
2025-12-29 17:05:03 -08:00
"""Get the current last result table.
Returns:
The ResultTable object, or None if no table is set
"""
return _LAST_RESULT_TABLE
2025-11-25 20:09:33 -08:00
def get_last_result_items() -> List[Any]:
2025-12-29 17:05:03 -08:00
"""Get the items available for @N selection.
Returns items from display/action commands (get-tag, delete-tag, etc.) if available,
otherwise returns items from the last search command. This ensures @N selection
works for both display operations and search results.
Returns:
List of items, or empty list if no prior results
"""
# Prioritize items from display commands (get-tag, delete-tag, etc.)
# These are available for immediate @N selection
if _DISPLAY_ITEMS:
if _DISPLAY_TABLE is not None and not _is_selectable_table(_DISPLAY_TABLE):
return []
return _DISPLAY_ITEMS
# Fall back to items from last search/selectable command
if _LAST_RESULT_TABLE is None:
return _LAST_RESULT_ITEMS
if _is_selectable_table(_LAST_RESULT_TABLE):
return _LAST_RESULT_ITEMS
return []
2025-11-25 20:09:33 -08:00
2025-12-25 04:49:22 -08:00
def get_last_selectable_result_items() -> List[Any]:
2025-12-29 17:05:03 -08:00
"""Get items from the last *selectable* result table, ignoring display-only items.
2025-12-25 04:49:22 -08:00
2025-12-29 17:05:03 -08:00
This is useful when a selection stage should target the last visible selectable table
(e.g., a playlist/search table), even if a prior action command emitted items and
populated _DISPLAY_ITEMS.
"""
if _LAST_RESULT_TABLE is None:
return list(_LAST_RESULT_ITEMS)
if _is_selectable_table(_LAST_RESULT_TABLE):
return list(_LAST_RESULT_ITEMS)
return []
2025-12-25 04:49:22 -08:00
2025-11-25 20:09:33 -08:00
def get_last_result_table_source_command() -> Optional[str]:
2025-12-29 17:05:03 -08:00
"""Get the source command from the last displayed result table.
Returns:
Command name (e.g., 'download-file') or None if not set
"""
if _is_selectable_table(_LAST_RESULT_TABLE) and hasattr(_LAST_RESULT_TABLE,
"source_command"):
2025-12-29 17:05:03 -08:00
return _LAST_RESULT_TABLE.source_command
return None
2025-11-25 20:09:33 -08:00
def get_last_result_table_source_args() -> List[str]:
2025-12-29 17:05:03 -08:00
"""Get the base source arguments from the last displayed result table.
Returns:
List of arguments (e.g., ['https://example.com']) or empty list
"""
if _is_selectable_table(_LAST_RESULT_TABLE) and hasattr(_LAST_RESULT_TABLE,
"source_args"):
2025-12-29 17:05:03 -08:00
return _LAST_RESULT_TABLE.source_args or []
return []
2025-11-25 20:09:33 -08:00
def get_last_result_table_row_selection_args(row_index: int) -> Optional[List[str]]:
2025-12-29 17:05:03 -08:00
"""Get the selection arguments for a specific row in the last result table.
Args:
row_index: Index of the row (0-based)
Returns:
Selection arguments (e.g., ['-item', '3']) or None
"""
if _is_selectable_table(_LAST_RESULT_TABLE) and hasattr(_LAST_RESULT_TABLE, "rows"):
if 0 <= row_index < len(_LAST_RESULT_TABLE.rows):
row = _LAST_RESULT_TABLE.rows[row_index]
if hasattr(row, "selection_args"):
return row.selection_args
return None
2025-11-25 20:09:33 -08:00
def set_current_stage_table(result_table: Optional[Any]) -> None:
2025-12-29 17:05:03 -08:00
"""Store the current pipeline stage table for @N expansion.
Used by cmdlet that display tabular results (e.g., download-file listing formats)
to make their result table available for @N expansion logic.
Does NOT push to history - purely for command expansion in the current pipeline.
Args:
result_table: The ResultTable object (or None to clear)
"""
global _CURRENT_STAGE_TABLE
_CURRENT_STAGE_TABLE = result_table
2025-11-25 20:09:33 -08:00
2025-12-11 12:47:30 -08:00
def get_current_stage_table() -> Optional[Any]:
2025-12-29 17:05:03 -08:00
"""Get the current pipeline stage table (if any)."""
return _CURRENT_STAGE_TABLE
2025-12-11 12:47:30 -08:00
2025-11-25 20:09:33 -08:00
def get_current_stage_table_source_command() -> Optional[str]:
2025-12-29 17:05:03 -08:00
"""Get the source command from the current pipeline stage table.
Returns:
Command name (e.g., 'download-file') or None
"""
if _is_selectable_table(_CURRENT_STAGE_TABLE) and hasattr(_CURRENT_STAGE_TABLE,
"source_command"):
2025-12-29 17:05:03 -08:00
return _CURRENT_STAGE_TABLE.source_command
return None
2025-11-25 20:09:33 -08:00
def get_current_stage_table_source_args() -> List[str]:
2025-12-29 17:05:03 -08:00
"""Get the source arguments from the current pipeline stage table.
Returns:
List of arguments or empty list
"""
if _is_selectable_table(_CURRENT_STAGE_TABLE) and hasattr(_CURRENT_STAGE_TABLE,
"source_args"):
2025-12-29 17:05:03 -08:00
return _CURRENT_STAGE_TABLE.source_args or []
return []
2025-11-25 20:09:33 -08:00
def get_current_stage_table_row_selection_args(row_index: int) -> Optional[List[str]]:
2025-12-29 17:05:03 -08:00
"""Get the selection arguments for a row in the current pipeline stage table.
Args:
row_index: Index of the row (0-based)
Returns:
Selection arguments or None
"""
if _is_selectable_table(_CURRENT_STAGE_TABLE) and hasattr(_CURRENT_STAGE_TABLE,
"rows"):
2025-12-29 17:05:03 -08:00
if 0 <= row_index < len(_CURRENT_STAGE_TABLE.rows):
row = _CURRENT_STAGE_TABLE.rows[row_index]
if hasattr(row, "selection_args"):
return row.selection_args
return None
2025-11-25 20:09:33 -08:00
2025-12-11 12:47:30 -08:00
def get_current_stage_table_row_source_index(row_index: int) -> Optional[int]:
2025-12-29 17:05:03 -08:00
"""Get the original source index for a row in the current stage table.
2025-12-11 12:47:30 -08:00
2025-12-29 17:05:03 -08:00
Useful when the table has been sorted for display but selections should map
back to the original item order (e.g., playlist or provider order).
"""
if _is_selectable_table(_CURRENT_STAGE_TABLE) and hasattr(_CURRENT_STAGE_TABLE,
"rows"):
2025-12-29 17:05:03 -08:00
if 0 <= row_index < len(_CURRENT_STAGE_TABLE.rows):
row = _CURRENT_STAGE_TABLE.rows[row_index]
return getattr(row, "source_index", None)
return None
2025-12-11 12:47:30 -08:00
2025-11-25 20:09:33 -08:00
def clear_last_result() -> None:
2025-12-29 17:05:03 -08:00
"""Clear the stored last result table and items."""
global _LAST_RESULT_TABLE, _LAST_RESULT_ITEMS
_LAST_RESULT_TABLE = None
_LAST_RESULT_ITEMS = []