This commit is contained in:
nose
2025-11-25 20:09:33 -08:00
parent d75c644a82
commit bd69119996
80 changed files with 39615 additions and 0 deletions

1
TUI/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Medeia-Macina TUI - Terminal User Interface."""

105
TUI/menu_actions.py Normal file
View File

@@ -0,0 +1,105 @@
"""Utilities that drive the modern Textual UI menus and presets."""
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence
BASE_DIR = Path(__file__).resolve().parent
ROOT_DIR = BASE_DIR.parent
for path in (ROOT_DIR, BASE_DIR):
str_path = str(path)
if str_path not in sys.path:
sys.path.insert(0, str_path)
import metadata
@dataclass(slots=True)
class PipelinePreset:
"""Simple descriptor for a reusable pipeline."""
label: str
description: str
pipeline: str
PIPELINE_PRESETS: List[PipelinePreset] = [
PipelinePreset(
label="Download → Merge → Local",
description="Use download-data with playlist auto-selection, merge the pieces, tag, then import into local storage.",
pipeline='download-data "<url>" | merge-file | add-tag | add-file -storage local',
),
PipelinePreset(
label="Download → Hydrus",
description="Fetch media, auto-tag, and push directly into Hydrus.",
pipeline='download-data "<url>" | merge-file | add-tag | add-file -storage hydrus',
),
PipelinePreset(
label="Search Local Library",
description="Run search-file against the local library and emit a result table for further piping.",
pipeline='search-file -library local -query "<keywords>"',
),
]
def load_tags(file_path: Path) -> List[str]:
"""Read tags for a file using metadata.py as the single source of truth."""
try:
return metadata.read_tags_from_file(file_path)
except Exception:
return []
def group_tags_by_namespace(tags: Sequence[str]) -> Dict[str, List[str]]:
"""Return tags grouped by namespace for quick UI summaries."""
grouped: Dict[str, List[str]] = {}
for tag in metadata.normalize_tags(list(tags)):
namespace, value = metadata.split_tag(tag)
key = namespace or "_untagged"
grouped.setdefault(key, []).append(value)
for items in grouped.values():
items.sort()
return grouped
def build_metadata_snapshot(file_path: Path) -> Dict[str, Any]:
"""Load any available sidecar metadata for the selected file."""
snapshot: Dict[str, Any] = {
"file": str(file_path),
"tags": group_tags_by_namespace(load_tags(file_path)),
}
try:
sidecar = metadata._derive_sidecar_path(file_path)
if sidecar.is_file():
title, tags, notes = metadata._read_sidecar_metadata(sidecar)
snapshot["sidecar"] = {
"title": title,
"tags": group_tags_by_namespace(tags),
"notes": notes,
}
except Exception:
snapshot["sidecar"] = None
return snapshot
def summarize_result(result: Dict[str, Any]) -> str:
"""Build a one-line summary for a pipeline result row."""
title = result.get("title") or result.get("identifier") or result.get("file_path")
source = result.get("source") or result.get("cmdlet") or "result"
return f"{source}: {title}" if title else source
def normalize_tags(tags: Iterable[str]) -> List[str]:
"""Expose metadata.normalize_tags for callers that imported the old helper."""
return metadata.normalize_tags(list(tags))

View File

@@ -0,0 +1,7 @@
"""Modal screens for the Downlow Hub UI application."""
from .export import ExportModal
from .search import SearchModal
from .workers import WorkersModal
__all__ = ["ExportModal", "SearchModal", "WorkersModal"]

139
TUI/modalscreen/access.py Normal file
View File

@@ -0,0 +1,139 @@
"""Modal for displaying files/URLs to access in web mode."""
from textual.screen import ModalScreen
from textual.containers import Container, Vertical, Horizontal
from textual.widgets import Static, Button, Label
from textual.app import ComposeResult
import logging
logger = logging.getLogger(__name__)
class AccessModal(ModalScreen):
"""Modal to display a file/URL that can be accessed from phone browser."""
CSS = """
Screen {
align: center middle;
}
#access-container {
width: 80;
height: auto;
border: thick $primary;
background: $surface;
}
#access-header {
dock: top;
height: 3;
background: $boost;
border-bottom: solid $accent;
content-align: center middle;
}
#access-content {
height: auto;
width: 1fr;
padding: 1 2;
border-bottom: solid $accent;
}
#access-footer {
dock: bottom;
height: 3;
background: $boost;
border-top: solid $accent;
align: center middle;
}
.access-url {
width: 1fr;
height: auto;
margin-bottom: 1;
border: solid $accent;
padding: 1;
}
.access-label {
width: 1fr;
height: auto;
margin-bottom: 1;
}
Button {
margin-right: 1;
}
"""
def __init__(self, title: str, content: str, is_url: bool = False):
"""Initialize access modal.
Args:
title: Title of the item being accessed
content: The URL or file path
is_url: Whether this is a URL (True) or file path (False)
"""
super().__init__()
self.item_title = title
self.item_content = content
self.is_url = is_url
def compose(self) -> ComposeResult:
"""Create the modal layout."""
with Container(id="access-container"):
with Vertical(id="access-header"):
yield Label(f"[bold]{self.item_title}[/bold]")
yield Label("[dim]Click link below to open in your browser[/dim]")
with Vertical(id="access-content"):
if self.is_url:
yield Label("[bold cyan]Link:[/bold cyan]", classes="access-label")
else:
yield Label("[bold cyan]File:[/bold cyan]", classes="access-label")
# Display as clickable link using HTML link element for web mode
# Rich link markup `[link=URL]` has parsing issues with URLs containing special chars
# Instead, use the HTML link markup that Textual-serve renders as <a> tag
# Format: [link=URL "tooltip"]text[/link] - the quotes help with parsing
link_text = f'[link="{self.item_content}"]Open in Browser[/link]'
content_box = Static(link_text, classes="access-url")
yield content_box
# Also show the URL for reference/copying
yield Label(self.item_content, classes="access-label")
yield Label("\n[yellow]↑ Click the link above to open on your device[/yellow]", classes="access-label")
with Horizontal(id="access-footer"):
yield Button("Copy URL", id="copy-btn", variant="primary")
yield Button("Close", id="close-btn", variant="default")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "copy-btn":
# Copy to clipboard (optional - not critical if fails)
logger.info(f"Attempting to copy: {self.item_content}")
try:
# Try to use pyperclip if available
try:
import pyperclip
pyperclip.copy(self.item_content)
logger.info("URL copied to clipboard via pyperclip")
except ImportError:
# Fallback: try xclip on Linux or pbcopy on Mac
import subprocess
import sys
if sys.platform == "win32":
# Windows: use clipboard via pyperclip (already tried)
logger.debug("Windows clipboard not available without pyperclip")
else:
# Linux/Mac
process = subprocess.Popen(['xclip', '-selection', 'clipboard'], stdin=subprocess.PIPE)
process.communicate(self.item_content.encode('utf-8'))
logger.info("URL copied to clipboard via xclip")
except Exception as e:
logger.debug(f"Clipboard copy not available: {e}")
# Not critical - just informational
elif event.button.id == "close-btn":
self.dismiss()

1880
TUI/modalscreen/download.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,183 @@
/* Download Modal Screen Stylesheet */
Screen {
background: $surface;
overlay: screen;
}
#download_modal {
width: 100%;
height: 100%;
border: heavy $primary;
background: $boost;
}
#download_title {
dock: top;
height: 1;
content-align: center middle;
background: $primary;
color: $text;
text-style: bold;
padding: 0 1;
}
/* Main horizontal layout: 2 columns left/right split */
#main_layout {
width: 1fr;
height: 1fr;
layout: horizontal;
padding: 1;
border: none;
}
/* Left column */
#left_column {
width: 2fr;
height: 1fr;
layout: vertical;
}
/* Right column */
#right_column {
width: 1fr;
height: 1fr;
layout: vertical;
}
/* All containers styling */
.grid_container {
width: 1fr;
height: 1fr;
padding: 1;
layout: vertical;
margin: 0 0 1 0;
}
#tags_container {
border: mediumpurple;
}
#url_container {
border: solid $accent;
}
#files_container {
border: solid $accent;
}
#playlist_container {
border: solid $accent;
layout: vertical;
height: 0;
}
#playlist_tree {
width: 1fr;
height: auto;
border: none;
padding: 0;
}
#playlist_input {
width: 1fr;
height: 1;
border: none;
padding: 0 1;
margin: 1 0 0 0;
}
#playlist_input_row {
width: 1fr;
height: auto;
layout: horizontal;
margin: 1 0 0 0;
}
.section_title {
width: 1fr;
height: 1;
text-align: left;
color: $text-muted;
text-style: bold;
margin: 0 0 0 0;
padding: 0;
}
/* TextArea widgets in containers */
#tags_textarea {
width: 1fr;
height: 1fr;
border: none;
padding: 0;
}
#paragraph_textarea {
width: 1fr;
height: 1fr;
border: none;
padding: 0;
}
/* Select widgets in containers */
#files_select {
width: 1fr;
height: 1fr;
border: none;
}
/* Footer layout - horizontal: checkboxes left, source middle, buttons right */
#footer_layout {
width: 1fr;
height: auto;
layout: horizontal;
padding: 1;
margin: 0;
background: $boost;
}
#checkbox_row {
width: auto;
height: auto;
layout: horizontal;
align: left middle;
}
#source_select {
width: 30;
height: 1;
border: none;
padding: 0 1;
margin: 0;
}
#button_row {
width: auto;
height: auto;
layout: horizontal;
align: right middle;
}
/* Progress bar - shown during download */
#progress_bar {
width: 1fr;
height: 0;
}
/* Checkbox and Button styling */
Checkbox {
margin: 0 2 0 0;
}
Button {
margin: 0 1 0 0;
width: 12;
}
#cancel_btn {
width: 12;
}
#submit_btn {
width: 12;
}

512
TUI/modalscreen/export.py Normal file
View File

@@ -0,0 +1,512 @@
"""Export modal screen for exporting files with metadata."""
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Static, Button, Input, TextArea, Tree, Select
from textual.binding import Binding
import logging
from typing import Optional, Any
from pathlib import Path
import json
import sys
import subprocess
from datetime import datetime
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from helper.utils import format_metadata_value
from config import load_config
logger = logging.getLogger(__name__)
class ExportModal(ModalScreen):
"""Modal screen for exporting files with metadata and tags."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
]
CSS_PATH = "export.tcss"
def __init__(self, result_data: Optional[dict] = None, hydrus_available: bool = False, debrid_available: bool = False):
"""Initialize the export modal with result data.
Args:
result_data: Dictionary containing:
- title: str - Item title
- tags: str - Comma-separated tags
- metadata: dict - File metadata (source-specific from item.metadata or local DB)
- source: str - Source identifier ('local', 'hydrus', 'debrid', etc)
- current_result: object - The full search result object
hydrus_available: bool - Whether Hydrus API is available
debrid_available: bool - Whether Debrid API is available
"""
super().__init__()
self.result_data = result_data or {}
self.hydrus_available = hydrus_available
self.debrid_available = debrid_available
self.metadata_display: Optional[Static] = None
self.tags_textarea: Optional[TextArea] = None
self.export_to_select: Optional[Select] = None
self.custom_path_input: Optional[Input] = None
self.libraries_select: Optional[Select] = None
self.size_input: Optional[Input] = None
self.format_select: Optional[Select] = None
self.file_ext: Optional[str] = None # Store the file extension for format filtering
self.file_type: Optional[str] = None # Store the file type (audio, video, image, document)
self.default_format: Optional[str] = None # Store the default format to set after mount
def _determine_file_type(self, ext: str) -> tuple[str, list]:
"""Determine file type from extension and return type and format options.
Args:
ext: File extension (e.g., '.mp3', '.mp4', '.jpg')
Returns:
Tuple of (file_type, format_options) where format_options is a list of (label, value) tuples
"""
ext_lower = ext.lower() if ext else ''
# Audio formats
audio_exts = {'.mp3', '.flac', '.wav', '.aac', '.ogg', '.m4a', '.wma', '.opus', '.mka'}
audio_formats = [("MKA", "mka"), ("MP3", "mp3"), ("M4A", "m4a"), ("FLAC", "flac"), ("WAV", "wav"), ("AAC", "aac"), ("OGG", "ogg"), ("Opus", "opus")]
# Video formats (can have audio too)
video_exts = {'.mp4', '.mkv', '.webm', '.avi', '.mov', '.flv', '.wmv', '.m4v', '.ts', '.mpg', '.mpeg'}
video_formats = [("MP4", "mp4"), ("MKV", "mkv"), ("WebM", "webm"), ("AVI", "avi"), ("MOV", "mov")]
# Image formats
image_exts = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.ico'}
image_formats = [("JPG", "jpg"), ("PNG", "png"), ("WebP", "webp"), ("GIF", "gif"), ("BMP", "bmp")]
# Document formats - no conversion for now
document_exts = {'.pdf', '.epub', '.txt', '.docx', '.doc', '.rtf', '.md', '.html', '.mobi', '.cbz', '.cbr'}
document_formats = []
if ext_lower in audio_exts:
return ('audio', audio_formats)
elif ext_lower in video_exts:
return ('video', video_formats)
elif ext_lower in image_exts:
return ('image', image_formats)
elif ext_lower in document_exts:
return ('document', document_formats)
else:
# Default to audio if unknown
return ('unknown', audio_formats)
def _get_library_options(self) -> list:
"""Get available library options from config.json."""
options = [("Local", "local")]
try:
# Try to load config
config_path = Path(__file__).parent.parent / "config.json"
if not config_path.exists():
return options
with open(config_path, 'r') as f:
config = json.load(f)
# Check if Hydrus is configured AND available (supports both new and old format)
from config import get_hydrus_instance
hydrus_instance = get_hydrus_instance(config, "home")
if self.hydrus_available and hydrus_instance and hydrus_instance.get("key") and hydrus_instance.get("url"):
options.append(("Hydrus Network", "hydrus"))
# Check if Debrid is configured AND available (supports both new and old format)
from config import get_debrid_api_key
debrid_api_key = get_debrid_api_key(config)
if self.debrid_available and debrid_api_key:
options.append(("Debrid", "debrid"))
except Exception as e:
logger.error(f"Error loading config for libraries: {e}")
return options
def _get_metadata_text(self) -> str:
"""Format metadata from result data in a consistent display format."""
metadata = self.result_data.get('metadata', {})
source = self.result_data.get('source', 'unknown')
logger.info(f"_get_metadata_text called - source: {source}, metadata type: {type(metadata)}, keys: {list(metadata.keys()) if metadata else 'empty'}")
if not metadata:
logger.info(f"_get_metadata_text - No metadata found, returning 'No metadata available'")
return "No metadata available"
lines = []
# Only display these specific fields in this order
display_fields = [
'duration', 'size', 'ext', 'media_type', 'time_imported', 'time_modified', 'hash'
]
# Display fields in a consistent order
for field in display_fields:
if field in metadata:
value = metadata[field]
# Skip complex types and None values
if isinstance(value, (dict, list)) or value is None:
continue
# Use central formatting rule
formatted_value = format_metadata_value(field, value)
# Format: "Field Name: value"
field_label = field.replace('_', ' ').title()
lines.append(f"{field_label}: {formatted_value}")
# If we found any fields, display them
if lines:
logger.info(f"_get_metadata_text - Returning {len(lines)} formatted metadata lines")
return "\n".join(lines)
else:
logger.info(f"_get_metadata_text - No matching fields found in metadata")
return "No metadata available"
def compose(self) -> ComposeResult:
"""Compose the export modal screen."""
with Container(id="export-container"):
yield Static("Export File with Metadata", id="export-title")
# Row 1: Three columns (Tags, Metadata, Export-To Options)
self.tags_textarea = TextArea(
text=self._format_tags(),
id="tags-area",
read_only=False,
)
yield self.tags_textarea
self.tags_textarea.border_title = "Tags"
# Metadata display instead of files tree
self.metadata_display = Static(
self._get_metadata_text(),
id="metadata-display",
)
yield self.metadata_display
self.metadata_display.border = ("solid", "dodgerblue")
# Right column: Export options
with Vertical(id="export-options"):
# Export To selector
self.export_to_select = Select(
[("0x0", "0x0"), ("Libraries", "libraries"), ("Custom Path", "path")],
id="export-to-select"
)
yield self.export_to_select
# Libraries selector (initially hidden)
library_options = self._get_library_options()
self.libraries_select = Select(
library_options,
id="libraries-select"
)
yield self.libraries_select
# Custom path input (initially hidden)
self.custom_path_input = Input(
placeholder="Enter custom export path",
id="custom-path-input"
)
yield self.custom_path_input
# Get metadata for size and format options
metadata = self.result_data.get('metadata', {})
original_size = metadata.get('size', '')
ext = metadata.get('ext', '')
# Store the extension and determine file type
self.file_ext = ext
self.file_type, format_options = self._determine_file_type(ext)
# Format size in MB for display
if original_size:
size_mb = int(original_size / (1024 * 1024)) if isinstance(original_size, (int, float)) else original_size
size_display = f"{size_mb}Mb"
else:
size_display = ""
# Size input
self.size_input = Input(
value=size_display,
placeholder="Size (can reduce)",
id="size-input",
disabled=(self.file_type == 'document') # Disable for documents - no resizing needed
)
yield self.size_input
# Determine the default format value (match current extension to format options)
default_format = None
if ext and format_options:
# Map extension to format value (e.g., .flac -> "flac", .mp3 -> "mp3", .m4a -> "m4a")
ext_lower = ext.lower().lstrip('.') # Remove leading dot if present
# Try to find matching format option
for _, value in format_options:
if value and (ext_lower == value or f".{ext_lower}" == ext or ext.endswith(f".{value}")):
default_format = value
logger.debug(f"Matched extension {ext} to format {value}")
break
# If no exact match, use first option
if not default_format and format_options:
default_format = format_options[0][1]
logger.debug(f"No format match for {ext}, using first option: {default_format}")
# Store the default format to apply after mount
self.default_format = default_format
# Format selector based on file type
self.format_select = Select(
format_options if format_options else [("No conversion", "")],
id="format-select",
disabled=not format_options # Disable if no format options (e.g., documents)
)
yield self.format_select
# Row 2: Buttons
with Horizontal(id="export-buttons"):
yield Button("Cancel", id="cancel-btn", variant="default")
yield Button("Export", id="export-btn", variant="primary")
def _format_tags(self) -> str:
"""Format tags from result data."""
tags = self.result_data.get('tags', '')
if isinstance(tags, str):
# Split by comma and rejoin with newlines
tags_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
return '\n'.join(tags_list)
elif isinstance(tags, list):
return '\n'.join(tags)
return ''
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button press events."""
button_id = event.button.id
if button_id == "export-btn":
self._handle_export()
elif button_id == "cancel-btn":
self.action_cancel()
def on_select_changed(self, event: Select.Changed) -> None:
"""Handle select widget changes."""
if event.control.id == "export-to-select":
# Show/hide custom path and libraries based on selection
if self.custom_path_input:
self.custom_path_input.display = (event.value == "path")
if self.libraries_select:
self.libraries_select.display = (event.value == "libraries")
elif event.control.id == "libraries-select":
# Handle library selection (no special action needed currently)
logger.debug(f"Library selected: {event.value}")
def on_mount(self) -> None:
"""Handle mount event."""
# Initially hide custom path and libraries inputs (default is "0x0")
if self.custom_path_input:
self.custom_path_input.display = False
if self.libraries_select:
self.libraries_select.display = False
# Set the default format value to show it selected instead of "Select"
if self.default_format and self.format_select:
self.format_select.value = self.default_format
logger.debug(f"Set format selector to default value: {self.default_format}")
# Refresh metadata display after mount to ensure data is loaded
if self.metadata_display:
metadata_text = self._get_metadata_text()
self.metadata_display.update(metadata_text)
logger.debug(f"Updated metadata display on mount: {bool(self.result_data.get('metadata'))}")
def _handle_export(self) -> None:
"""Handle the export action."""
try:
tags_text = self.tags_textarea.text.strip()
export_to = self.export_to_select.value if self.export_to_select else "0x0"
custom_path = self.custom_path_input.value.strip() if self.custom_path_input else ""
# Get library value - handle Select.BLANK case
library = "local" # default
if self.libraries_select and str(self.libraries_select.value) != "Select.BLANK":
library = str(self.libraries_select.value)
elif self.libraries_select and self.libraries_select:
# If value is Select.BLANK, try to get from the options
try:
# Get first available library option as fallback
options = self._get_library_options()
if options:
library = options[0][1] # Get the value part of first option tuple
except Exception:
library = "local"
size = self.size_input.value.strip() if self.size_input else ""
file_format = self.format_select.value if self.format_select else "mp4"
# Parse tags from textarea (one per line)
export_tags = set()
for line in tags_text.split('\n'):
tag = line.strip()
if tag:
export_tags.add(tag)
# For Hydrus export, filter out metadata-only tags (hash:, known_url:, relationship:)
if export_to == "libraries" and library == "hydrus":
metadata_prefixes = {'hash:', 'known_url:', 'relationship:'}
export_tags = {tag for tag in export_tags if not any(tag.lower().startswith(prefix) for prefix in metadata_prefixes)}
logger.info(f"Filtered tags for Hydrus - removed metadata tags, {len(export_tags)} tags remaining")
# Extract title and add as searchable tags if not already present
title = self.result_data.get('title', '').strip()
if title:
# Add the full title as a tag if not already present
title_tag = f"title:{title}"
if title_tag not in export_tags and not any(t.startswith('title:') for t in export_tags):
export_tags.add(title_tag)
# Extract individual words from title as searchable tags (if reasonable length)
# Skip very short words and common stop words
if len(title) < 100: # Only for reasonably short titles
stop_words = {'the', 'a', 'an', 'and', 'or', 'of', 'in', 'to', 'for', 'is', 'it', 'at', 'by', 'from', 'with', 'as', 'be', 'on', 'that', 'this', 'this'}
words = title.lower().split()
for word in words:
# Clean up word (remove punctuation)
clean_word = ''.join(c for c in word if c.isalnum())
# Only add if not a stop word and has some length
if clean_word and len(clean_word) > 2 and clean_word not in stop_words:
if clean_word not in export_tags:
export_tags.add(clean_word)
logger.info(f"Extracted {len(words)} words from title, added searchable title tags")
# Validate required fields - allow export to continue for Hydrus even with 0 actual tags
# (metadata tags will still be in the sidecar, and tags can be added later)
if not export_tags and export_to != "libraries":
logger.warning("No tags provided for export")
return
if export_to == "libraries" and not export_tags:
logger.warning("No actual tags for Hydrus export (only metadata was present)")
# Don't return - allow export to continue, file will be added to Hydrus even without tags
# Determine export path
export_path = None
if export_to == "path":
if not custom_path:
logger.warning("Custom path required but not provided")
return
export_path = custom_path
elif export_to == "libraries":
export_path = library # "local", "hydrus", "debrid"
else:
export_path = export_to # "0x0"
# Get metadata from result_data
metadata = self.result_data.get('metadata', {})
# Extract file source info from result_data (passed by hub-ui)
file_hash = self.result_data.get('file_hash')
file_url = self.result_data.get('file_url')
file_path = self.result_data.get('file_path') # For local files
source = self.result_data.get('source', 'unknown')
# Prepare export data
export_data = {
'export_to': export_to,
'export_path': export_path,
'library': library if export_to == "libraries" else None,
'tags': export_tags,
'size': size if size else None,
'format': file_format,
'metadata': metadata,
'original_data': self.result_data,
'file_hash': file_hash,
'file_url': file_url,
'file_path': file_path, # Pass file path for local files
'source': source,
}
logger.info(f"Export initiated: destination={export_path}, format={file_format}, size={size}, tags={export_tags}, source={source}, hash={file_hash}, path={file_path}")
# Dismiss the modal and return the export data
self.dismiss(export_data)
except Exception as e:
logger.error(f"Error during export: {e}", exc_info=True)
def action_cancel(self) -> None:
"""Handle cancel action."""
self.dismiss(None)
def create_notes_sidecar(file_path: Path, notes: str) -> None:
"""Create a .notes sidecar file with notes text.
Only creates file if notes are not empty.
Args:
file_path: Path to the exported file
notes: Notes text
"""
if not notes or not notes.strip():
return
notes_path = file_path.with_suffix(file_path.suffix + '.notes')
try:
with open(notes_path, 'w', encoding='utf-8') as f:
f.write(notes.strip())
logger.info(f"Created notes sidecar: {notes_path}")
except Exception as e:
logger.error(f"Failed to create notes sidecar: {e}", exc_info=True)
def determine_needs_conversion(current_ext: str, target_format: str) -> bool:
"""Determine if conversion is needed between two formats.
Args:
current_ext: Current file extension (e.g., '.flac')
target_format: Target format name (e.g., 'mp3') or NoSelection object
Returns:
True if conversion is needed, False if it's already the target format
"""
# Handle NoSelection or None
if not target_format or target_format == "" or str(target_format.__class__.__name__) == 'NoSelection':
return False # No conversion requested
# Normalize the current extension
current_ext_lower = current_ext.lower().lstrip('.')
target_format_lower = str(target_format).lower()
# Check if they match
return current_ext_lower != target_format_lower
def calculate_size_tolerance(metadata: dict, user_size_mb: Optional[str]) -> tuple[Optional[int], Optional[int]]:
"""Calculate target size with 1MB grace period.
Args:
metadata: File metadata containing 'size' in bytes
user_size_mb: User-entered size like "756Mb" or empty string
Returns:
Tuple of (target_bytes, grace_bytes) where grace_bytes is 1MB (1048576),
or (None, None) if no size specified
"""
grace_bytes = 1 * 1024 * 1024 # 1MB grace period
if not user_size_mb or not user_size_mb.strip():
return None, grace_bytes
try:
# Parse the size string (format like "756Mb")
size_str = user_size_mb.strip().lower()
if size_str.endswith('mb'):
size_str = size_str[:-2]
elif size_str.endswith('m'):
size_str = size_str[:-1]
size_mb = float(size_str)
target_bytes = int(size_mb * 1024 * 1024)
return target_bytes, grace_bytes
except (ValueError, AttributeError):
return None, grace_bytes

View File

@@ -0,0 +1,85 @@
/* Export Modal Screen Styling */
ExportModal {
align: center middle;
}
#export-container {
width: 140;
height: 55;
background: $panel;
border: solid $primary;
layout: grid;
grid-columns: 1fr 1fr 1fr;
grid-rows: auto 1fr auto;
}
#export-title {
height: 1;
text-align: center;
text-style: bold;
color: $accent;
background: $boost;
padding: 1 2;
column-span: 3;
}
/* Row 1: Three columns */
#tags-area {
height: 1fr;
column-span: 1;
border: solid mediumvioletred;
}
#metadata-display {
height: 1fr;
column-span: 1;
border: solid dodgerblue;
overflow: auto;
padding: 1;
}
#export-options {
height: 1fr;
column-span: 1;
border: solid mediumpurple;
layout: vertical;
padding: 1;
}
#export-options Select,
#export-options Input {
height: 3;
margin: 0 0 1 0;
}
#custom-path-input {
height: 3;
margin: 0 0 1 0;
}
#libraries-select {
height: 3;
margin: 0 0 1 0;
}
#size-input {
height: 3;
margin: 0 0 1 0;
}
#format-select {
height: 3;
}
/* Row 2: Buttons */
#export-buttons {
height: auto;
column-span: 3;
layout: horizontal;
}
#export-buttons Button {
width: 1fr;
margin: 0 1;
}

505
TUI/modalscreen/search.py Normal file
View File

@@ -0,0 +1,505 @@
"""Search modal screen for OpenLibrary and Soulseek."""
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Static, Button, Input, Select, DataTable, TextArea
from textual.binding import Binding
from textual.message import Message
import logging
from typing import Optional, Any, List
from pathlib import Path
import sys
import asyncio
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import load_config
logger = logging.getLogger(__name__)
class SearchModal(ModalScreen):
"""Modal screen for searching OpenLibrary and Soulseek."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("enter", "search_focused", "Search"),
Binding("ctrl+t", "scrape_tags", "Scrape Tags"),
]
CSS_PATH = "search.tcss"
class SearchSelected(Message):
"""Posted when user selects a search result."""
def __init__(self, result: dict) -> None:
self.result = result
super().__init__()
def __init__(self, app_instance=None):
"""Initialize the search modal.
Args:
app_instance: Reference to the main App instance for worker creation
"""
super().__init__()
self.app_instance = app_instance
self.source_select: Optional[Select] = None
self.search_input: Optional[Input] = None
self.results_table: Optional[DataTable] = None
self.tags_textarea: Optional[TextArea] = None
self.library_source_select: Optional[Select] = None
self.current_results: List[dict] = []
self.is_searching = False
self.current_worker = None # Track worker for search operations
def compose(self) -> ComposeResult:
"""Create child widgets for the search modal."""
with Vertical(id="search-container"):
yield Static("Search Books & Music", id="search-title")
with Horizontal(id="search-controls"):
# Source selector
self.source_select = Select(
[("OpenLibrary", "openlibrary"), ("Soulseek", "soulseek")],
value="openlibrary",
id="source-select"
)
yield self.source_select
# Search input
self.search_input = Input(
placeholder="Enter search query...",
id="search-input"
)
yield self.search_input
# Search button
yield Button("Search", id="search-button", variant="primary")
# Results table
self.results_table = DataTable(id="results-table")
yield self.results_table
# Two-column layout: tags on left, source/submit on right
with Horizontal(id="bottom-controls"):
# Left column: Tags textarea
with Vertical(id="tags-column"):
self.tags_textarea = TextArea(
text="",
id="result-tags-textarea",
read_only=False
)
self.tags_textarea.border_title = "Tags [Ctrl+T: Scrape]"
yield self.tags_textarea
# Right column: Library source and submit button
with Vertical(id="source-submit-column"):
# Library source selector (for OpenLibrary results)
self.library_source_select = Select(
[("Local", "local"), ("Download", "download")],
value="local",
id="library-source-select"
)
yield self.library_source_select
# Submit button
yield Button("Submit", id="submit-button", variant="primary")
# Buttons at bottom
with Horizontal(id="search-buttons"):
yield Button("Select", id="select-button", variant="primary")
yield Button("Download", id="download-button", variant="primary")
yield Button("Cancel", id="cancel-button", variant="default")
def on_mount(self) -> None:
"""Set up the table columns and focus."""
# Set up results table columns
self.results_table.add_columns(
"Title",
"Author/Artist",
"Year/Album",
"Details"
)
# Focus on search input
self.search_input.focus()
async def _search_openlibrary(self, query: str) -> List[dict]:
"""Search OpenLibrary for books."""
try:
from helper.search_provider import get_provider
logger.info(f"[search-modal] Searching OpenLibrary for: {query}")
# Get the OpenLibrary provider (now has smart search built-in)
provider = get_provider("openlibrary")
if not provider:
logger.error("[search-modal] OpenLibrary provider not available")
return []
# Search using the provider (smart search is now default)
search_results = provider.search(query, limit=20)
formatted_results = []
for result in search_results:
# Extract metadata from SearchResult.full_metadata
metadata = result.full_metadata or {}
formatted_results.append({
"title": result.title,
"author": ", ".join(metadata.get("authors", [])) if metadata.get("authors") else "Unknown",
"year": metadata.get("year", ""),
"publisher": metadata.get("publisher", ""),
"isbn": metadata.get("isbn", ""),
"oclc": metadata.get("oclc", ""),
"lccn": metadata.get("lccn", ""),
"openlibrary_id": metadata.get("olid", ""),
"pages": metadata.get("pages", ""),
"language": metadata.get("language", ""),
"source": "openlibrary",
"columns": result.columns,
"raw_data": metadata
})
logger.info(f"[search-modal] Found {len(formatted_results)} OpenLibrary results")
return formatted_results
except Exception as e:
logger.error(f"[search-modal] OpenLibrary search error: {e}", exc_info=True)
import traceback
traceback.print_exc()
return []
async def _search_soulseek(self, query: str) -> List[dict]:
"""Search Soulseek for music with automatic worker tracking."""
try:
from helper.search_provider import get_provider
# Create worker for tracking
worker = None
if self.app_instance and hasattr(self.app_instance, 'create_worker'):
worker = self.app_instance.create_worker(
'soulseek',
title=f"Soulseek Search: {query[:40]}",
description=f"Searching P2P network for music"
)
self.current_worker = worker
if worker:
worker.log_step("Connecting to Soulseek peer network...")
logger.info(f"[search-modal] Searching Soulseek for: {query}")
provider = get_provider("soulseek")
search_results = provider.search(query, limit=20)
if worker:
worker.log_step(f"Search returned {len(search_results)} results")
logger.info(f"[search-modal] Found {len(search_results)} Soulseek results")
# Format results for display
formatted_results = []
for idx, result in enumerate(search_results):
metadata = result.full_metadata or {}
artist = metadata.get('artist', '')
album = metadata.get('album', '')
title = result.title
track_num = metadata.get('track_num', '')
size_bytes = result.size_bytes or 0
# Format size as human-readable
if size_bytes > 1024 * 1024:
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
elif size_bytes > 1024:
size_str = f"{size_bytes / 1024:.1f} KB"
else:
size_str = f"{size_bytes} B"
# Build columns for display
columns = [
("#", str(idx + 1)),
("Title", title[:50] if title else "Unknown"),
("Artist", artist[:30] if artist else "(no artist)"),
("Album", album[:30] if album else ""),
]
formatted_results.append({
"title": title if title else "Unknown",
"artist": artist if artist else "(no artist)",
"album": album,
"track": track_num,
"filesize": size_str,
"bitrate": "", # Not available in Soulseek results
"source": "soulseek",
"columns": columns,
"raw_data": result.to_dict()
})
return formatted_results
except Exception as e:
logger.error(f"[search-modal] Soulseek search error: {e}")
import traceback
traceback.print_exc()
return []
async def _perform_search(self) -> None:
"""Perform the actual search based on selected source."""
if not self.search_input or not self.source_select or not self.results_table:
logger.error("[search-modal] Widgets not initialized")
return
query = self.search_input.value.strip()
if not query:
logger.warning("[search-modal] Empty search query")
return
source = self.source_select.value
# Clear existing results
self.results_table.clear()
self.current_results = []
self.is_searching = True
try:
if source == "openlibrary":
results = await self._search_openlibrary(query)
elif source == "soulseek":
results = await self._search_soulseek(query)
else:
logger.warning(f"[search-modal] Unknown source: {source}")
if self.current_worker:
self.current_worker.finish("error", "Unknown search source")
return
self.current_results = results
# Populate table with results
if results:
# Check if first result has columns field
first_result = results[0]
if "columns" in first_result and first_result["columns"]:
# Use dynamic columns from result
# Clear existing columns and rebuild based on result columns
self.results_table.clear()
# Extract column headers from first result's columns field
column_headers = [col[0] for col in first_result["columns"]]
# Remove existing columns (we'll readd them with the right headers)
# Note: This is a workaround since Textual's DataTable doesn't support dynamic column management well
# For now, we just use the dynamic column headers from the result
logger.info(f"[search-modal] Using dynamic columns: {column_headers}")
# Populate rows using the column order from results
for result in results:
if "columns" in result and result["columns"]:
# Extract values in column order
row_data = [col[1] for col in result["columns"]]
self.results_table.add_row(*row_data)
else:
# Fallback for results without columns
logger.warning(f"[search-modal] Result missing columns field: {result.get('title', 'Unknown')}")
else:
# Fallback to original hardcoded behavior if columns not available
logger.info("[search-modal] No dynamic columns found, using default formatting")
for result in results:
if source == "openlibrary":
# Format OpenLibrary results (original hardcoded)
year = str(result.get("year", ""))[:4] if result.get("year") else ""
details = f"ISBN: {result.get('isbn', '')}" if result.get('isbn') else ""
if result.get('openlibrary_id'):
details += f" | OL: {result.get('openlibrary_id')}"
row_data = [
result["title"][:60],
result["author"][:35],
year,
details[:40]
]
else: # soulseek
row_data = [
result["title"][:50],
result["artist"][:30],
result["album"][:30],
result['filesize']
]
self.results_table.add_row(*row_data)
else:
# Add a "no results" message
self.results_table.add_row("No results found", "", "", "")
# Finish worker if tracking
if self.current_worker:
self.current_worker.finish("completed", f"Found {len(results)} results")
except Exception as e:
logger.error(f"[search-modal] Search error: {e}")
if self.current_worker:
self.current_worker.finish("error", f"Search failed: {str(e)}")
finally:
self.is_searching = False
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
button_id = event.button.id
if button_id == "search-button":
# Run search asynchronously
asyncio.create_task(self._perform_search())
elif button_id == "select-button":
# Get selected row and populate tags textarea
if self.results_table and self.results_table.row_count > 0:
selected_row = self.results_table.cursor_row
if 0 <= selected_row < len(self.current_results):
result = self.current_results[selected_row]
# Populate tags textarea with result metadata
self._populate_tags_from_result(result)
else:
logger.warning("[search-modal] No results to select")
elif button_id == "download-button":
# Download the selected result
if self.current_results and self.results_table.row_count > 0:
selected_row = self.results_table.cursor_row
if 0 <= selected_row < len(self.current_results):
result = self.current_results[selected_row]
if result.get("source") == "openlibrary":
asyncio.create_task(self._download_book(result))
else:
logger.warning("[search-modal] Download only supported for OpenLibrary results")
else:
logger.warning("[search-modal] No result selected for download")
elif button_id == "submit-button":
# Submit the current result with tags and source
if self.current_results and self.results_table.row_count > 0:
selected_row = self.results_table.cursor_row
if 0 <= selected_row < len(self.current_results):
result = self.current_results[selected_row]
# Get tags from textarea
tags_text = self.tags_textarea.text if self.tags_textarea else ""
# Get library source (if OpenLibrary)
library_source = self.library_source_select.value if self.library_source_select else "local"
# Add tags and source to result
result["tags_text"] = tags_text
result["library_source"] = library_source
# Post message and dismiss
self.post_message(self.SearchSelected(result))
self.dismiss(result)
else:
logger.warning("[search-modal] No result selected for submission")
elif button_id == "cancel-button":
self.dismiss(None)
def _populate_tags_from_result(self, result: dict) -> None:
"""Populate the tags textarea from a selected result."""
if not self.tags_textarea:
return
# Format tags based on result source
if result.get("source") == "openlibrary":
# For OpenLibrary: title, author, year
title = result.get("title", "")
author = result.get("author", "")
year = result.get("year", "")
tags = []
if title:
tags.append(title)
if author:
tags.append(author)
if year:
tags.append(year)
tags_text = "\n".join(tags)
else: # soulseek
# For Soulseek: artist, album, title, track
tags = []
if result.get("artist"):
tags.append(result["artist"])
if result.get("album"):
tags.append(result["album"])
if result.get("track"):
tags.append(f"Track {result['track']}")
if result.get("title"):
tags.append(result["title"])
tags_text = "\n".join(tags)
self.tags_textarea.text = tags_text
logger.info(f"[search-modal] Populated tags textarea from result")
async def _download_book(self, result: dict) -> None:
"""Download a book from OpenLibrary using unified downloader."""
try:
from helper.unified_book_downloader import UnifiedBookDownloader
from config import load_config
logger.info(f"[search-modal] Starting download for: {result.get('title')}")
config = load_config()
downloader = UnifiedBookDownloader(config=config)
# Get download options for this book
options = downloader.get_download_options(result)
if not options['methods']:
logger.warning(f"[search-modal] No download methods available for: {result.get('title')}")
# Could show a modal dialog here
return
# For now, use the first available method (we could show a dialog to choose)
method = options['methods'][0]
logger.info(f"[search-modal] Using download method: {method.get('label')}")
# Perform the download
success, message = await downloader.download_book(method)
if success:
logger.info(f"[search-modal] Download successful: {message}")
# Could show success dialog
else:
logger.warning(f"[search-modal] Download failed: {message}")
# Could show error dialog
downloader.close()
except Exception as e:
logger.error(f"[search-modal] Download error: {e}", exc_info=True)
def action_search_focused(self) -> None:
"""Action for Enter key - only search if search input is focused."""
if self.search_input and self.search_input.has_focus and not self.is_searching:
asyncio.create_task(self._perform_search())
def action_scrape_tags(self) -> None:
"""Action for Ctrl+T - populate tags from selected result."""
if self.current_results and self.results_table and self.results_table.row_count > 0:
try:
selected_row = self.results_table.cursor_row
if 0 <= selected_row < len(self.current_results):
result = self.current_results[selected_row]
self._populate_tags_from_result(result)
logger.info(f"[search-modal] Ctrl+T: Populated tags from result at row {selected_row}")
else:
logger.warning(f"[search-modal] Ctrl+T: Invalid row index {selected_row}")
except Exception as e:
logger.error(f"[search-modal] Ctrl+T error: {e}")
else:
logger.warning("[search-modal] Ctrl+T: No results selected")
def action_cancel(self) -> None:
"""Action for Escape key - close modal."""
self.dismiss(None)
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle Enter key in search input - only trigger search here."""
if event.input.id == "search-input":
if not self.is_searching:
asyncio.create_task(self._perform_search())

121
TUI/modalscreen/search.tcss Normal file
View File

@@ -0,0 +1,121 @@
/* Search Modal Screen Styling */
SearchModal {
align: center middle;
}
Screen {
layout: vertical;
}
#search-container {
width: 140;
height: 40;
background: $panel;
border: solid $primary;
layout: vertical;
}
Static#search-title {
height: 3;
dock: top;
text-align: center;
text-style: bold;
color: $accent;
background: $boost;
padding: 1 2;
}
#search-controls {
height: auto;
layout: horizontal;
padding: 1;
border: solid $primary;
}
#source-select {
width: 20;
margin-right: 1;
}
#search-input {
width: 1fr;
margin-right: 1;
}
#search-button {
width: 12;
}
#results-table {
height: 1fr;
border: solid $primary;
}
DataTable {
border: solid $accent;
}
DataTable > .datatable--header {
background: $boost;
color: $accent;
text-style: bold;
}
DataTable > .datatable--cursor-row {
background: $accent;
}
#bottom-controls {
height: auto;
layout: horizontal;
padding: 1;
border: solid $primary;
}
#tags-column {
width: 1fr;
layout: vertical;
padding-right: 1;
height: auto;
}
#result-tags-textarea {
height: 10;
width: 1fr;
border: solid $accent;
}
#source-submit-column {
width: 20;
layout: vertical;
padding-left: 1;
height: auto;
}
#library-source-select {
width: 1fr;
margin-bottom: 1;
}
#submit-button {
width: 1fr;
}
#search-buttons {
height: 3;
dock: bottom;
layout: horizontal;
padding: 1;
border: solid $primary;
align: center middle;
}
#select-button {
width: 12;
margin-right: 2;
}
#cancel-button {
width: 12;
}

585
TUI/modalscreen/workers.py Normal file
View File

@@ -0,0 +1,585 @@
"""Workers modal screen for monitoring and managing background tasks."""
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.containers import Horizontal, Vertical
from textual.widgets import Static, Button, DataTable, TextArea
from textual.binding import Binding
from textual.message import Message
import logging
from typing import Optional, Dict, List, Any
from pathlib import Path
import sys
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
logger = logging.getLogger(__name__)
class WorkersModal(ModalScreen):
"""Modal screen for monitoring running and finished workers."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
]
CSS_PATH = "workers.tcss"
class WorkerUpdated(Message):
"""Posted when worker list is updated."""
def __init__(self, workers: List[Dict[str, Any]]) -> None:
self.workers = workers
super().__init__()
class WorkerCancelled(Message):
"""Posted when user cancels a worker."""
def __init__(self, worker_id: str) -> None:
self.worker_id = worker_id
super().__init__()
def __init__(self, app_instance=None):
"""Initialize the workers modal.
Args:
app_instance: Reference to the hub app for accessing worker info
"""
super().__init__()
self.app_instance = app_instance
self.running_table: Optional[DataTable] = None
self.finished_table: Optional[DataTable] = None
self.stdout_display: Optional[TextArea] = None
self.running_workers: List[Dict[str, Any]] = []
self.finished_workers: List[Dict[str, Any]] = []
self.selected_worker_id: Optional[str] = None
self.show_running = False # Start with finished tab
def compose(self) -> ComposeResult:
"""Create child widgets for the workers modal."""
with Vertical(id="workers-container"):
# Title with toggle buttons
with Horizontal(id="workers-title-bar"):
yield Static("Workers Monitor", id="workers-title")
yield Button("Running", id="toggle-running-btn", variant="primary")
yield Button("Finished", id="toggle-finished-btn", variant="default")
# Running tab content (initially hidden)
with Vertical(id="running-section"):
self.running_table = DataTable(id="running-table")
yield self.running_table
with Horizontal(id="running-controls"):
yield Button("Refresh", id="running-refresh-btn", variant="primary")
yield Button("Stop Selected", id="running-stop-btn", variant="warning")
yield Button("Stop All", id="running-stop-all-btn", variant="error")
# Finished tab content (initially visible)
with Vertical(id="finished-section"):
self.finished_table = DataTable(id="finished-table")
yield self.finished_table
with Horizontal(id="finished-controls"):
yield Button("Refresh", id="finished-refresh-btn", variant="primary")
yield Button("Clear Selected", id="finished-clear-btn", variant="warning")
yield Button("Clear All", id="finished-clear-all-btn", variant="error")
# Shared textarea for displaying worker logs
with Vertical(id="logs-section"):
yield Static("Worker Logs:", id="logs-label")
self.stdout_display = TextArea(id="stdout-display", read_only=True)
yield self.stdout_display
with Horizontal(id="workers-buttons"):
yield Button("Close", id="close-btn", variant="primary")
def on_mount(self) -> None:
"""Set up the tables and load worker data."""
# Set up running workers table
if self.running_table:
self.running_table.add_columns(
"ID",
"Type",
"Status",
"Pipe",
"Progress",
"Started",
"Details"
)
self.running_table.zebra_stripes = True
# Set up finished workers table
if self.finished_table:
self.finished_table.add_columns(
"ID",
"Type",
"Result",
"Pipe",
"Started",
"Completed",
"Duration",
"Details"
)
self.finished_table.zebra_stripes = True
# Set initial view (show finished by default)
self._update_view_visibility()
# Load initial data
self.refresh_workers()
# Don't set up periodic refresh - it was causing issues with stdout display
# Users can click the Refresh button to update manually
def refresh_workers(self) -> None:
"""Refresh the workers data from app instance."""
try:
if not self.app_instance:
logger.warning("[workers-modal] No app instance provided")
return
# Get running workers from app instance
# This assumes the app has a get_running_workers() method
if hasattr(self.app_instance, 'get_running_workers'):
self.running_workers = self.app_instance.get_running_workers()
else:
self.running_workers = []
# Get finished workers from app instance
if hasattr(self.app_instance, 'get_finished_workers'):
self.finished_workers = self.app_instance.get_finished_workers()
if self.finished_workers:
logger.info(f"[workers-modal-refresh] Got {len(self.finished_workers)} finished workers from app")
# Log the keys in the first worker to verify structure
if isinstance(self.finished_workers[0], dict):
logger.info(f"[workers-modal-refresh] First worker keys: {list(self.finished_workers[0].keys())}")
logger.info(f"[workers-modal-refresh] First worker: {self.finished_workers[0]}")
else:
logger.warning(f"[workers-modal-refresh] First worker is not a dict: {type(self.finished_workers[0])}")
else:
self.finished_workers = []
# Update tables
self._update_running_table()
self._update_finished_table()
logger.info(f"[workers-modal] Refreshed: {len(self.running_workers)} running, {len(self.finished_workers)} finished")
except Exception as e:
logger.error(f"[workers-modal] Error refreshing workers: {e}")
def _update_view_visibility(self) -> None:
"""Toggle visibility between running and finished views."""
try:
running_section = self.query_one("#running-section", Vertical)
finished_section = self.query_one("#finished-section", Vertical)
toggle_running_btn = self.query_one("#toggle-running-btn", Button)
toggle_finished_btn = self.query_one("#toggle-finished-btn", Button)
if self.show_running:
running_section.display = True
finished_section.display = False
toggle_running_btn.variant = "primary"
toggle_finished_btn.variant = "default"
logger.debug("[workers-modal] Switched to Running view")
else:
running_section.display = False
finished_section.display = True
toggle_running_btn.variant = "default"
toggle_finished_btn.variant = "primary"
logger.debug("[workers-modal] Switched to Finished view")
except Exception as e:
logger.error(f"[workers-modal] Error updating view visibility: {e}")
def _update_running_table(self) -> None:
"""Update the running workers table."""
try:
if not self.running_table:
logger.error("[workers-modal] Running table not initialized")
return
self.running_table.clear()
if not self.running_workers:
self.running_table.add_row("---", "---", "---", "---", "---", "---", "No workers running")
logger.debug(f"[workers-modal] No running workers to display")
return
logger.debug(f"[workers-modal] Updating running table with {len(self.running_workers)} workers")
for idx, worker_info in enumerate(self.running_workers):
try:
worker_id = worker_info.get('id', 'unknown')
worker_type = worker_info.get('type', 'unknown')
status = worker_info.get('status', 'running')
progress = worker_info.get('progress', '')
started = worker_info.get('started', '')
details = worker_info.get('details', '')
pipe = worker_info.get('pipe', '')
# Ensure values are strings
worker_id = str(worker_id) if worker_id else 'unknown'
worker_type = str(worker_type) if worker_type else 'unknown'
status = str(status) if status else 'running'
progress = str(progress) if progress else '---'
started = str(started) if started else '---'
details = str(details) if details else '---'
pipe_display = self._summarize_pipe(pipe)
# Truncate long strings
progress = progress[:20]
started = started[:19]
details = details[:30]
pipe_display = pipe_display[:40]
self.running_table.add_row(
worker_id[:8],
worker_type[:15],
status[:10],
pipe_display,
progress,
started,
details
)
if idx == 0: # Log first entry
logger.debug(f"[workers-modal] Added running row {idx}: {worker_id[:8]} {worker_type[:15]} {status}")
except Exception as row_error:
logger.error(f"[workers-modal] Error adding running row {idx}: {row_error}", exc_info=True)
logger.debug(f"[workers-modal] Updated running table with {len(self.running_workers)} workers")
except Exception as e:
logger.error(f"[workers-modal] Error updating running table: {e}", exc_info=True)
def _update_finished_table(self) -> None:
"""Update the finished workers table."""
try:
if not self.finished_table:
logger.error("[workers-modal] Finished table not initialized")
return
self.finished_table.clear()
if not self.finished_workers:
self.finished_table.add_row("---", "---", "---", "---", "---", "---", "---", "No finished workers")
logger.debug(f"[workers-modal] No finished workers to display")
return
logger.info(f"[workers-modal-update] STARTING to update finished table with {len(self.finished_workers)} workers")
added_count = 0
error_count = 0
for idx, worker_info in enumerate(self.finished_workers):
try:
worker_id = worker_info.get('id', 'unknown')
worker_type = worker_info.get('type', 'unknown')
result = worker_info.get('result', 'unknown')
completed = worker_info.get('completed', '')
duration = worker_info.get('duration', '')
details = worker_info.get('details', '')
pipe = worker_info.get('pipe', '')
started = worker_info.get('started', '')
# Ensure values are strings
worker_id = str(worker_id) if worker_id else 'unknown'
worker_type = str(worker_type) if worker_type else 'unknown'
result = str(result) if result else 'unknown'
completed = str(completed) if completed else '---'
duration = str(duration) if duration else '---'
details = str(details) if details else '---'
started = str(started) if started else '---'
pipe_display = self._summarize_pipe(pipe)
# Truncate long strings
result = result[:15]
completed = completed[:19]
started = started[:19]
duration = duration[:10]
details = details[:30]
pipe_display = pipe_display[:40]
self.finished_table.add_row(
worker_id[:8],
worker_type[:15],
result,
pipe_display,
started,
completed,
duration,
details
)
added_count += 1
except Exception as row_error:
error_count += 1
logger.error(f"[workers-modal-update] Error adding finished row {idx}: {row_error}", exc_info=True)
logger.info(f"[workers-modal-update] COMPLETED: Added {added_count}/{len(self.finished_workers)} finished workers (errors: {error_count})")
logger.debug(f"[workers-modal-update] Finished table row_count after update: {self.finished_table.row_count}")
except Exception as e:
logger.error(f"[workers-modal] Error updating finished table: {e}", exc_info=True)
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
"""Handle row highlight in tables - display stdout."""
try:
logger.info(f"[workers-modal] Row highlighted, cursor_row: {event.cursor_row}")
# Get the selected worker from the correct table
workers_list = None
if event.control == self.running_table:
workers_list = self.running_workers
logger.debug(f"[workers-modal] Highlighted in running table")
elif event.control == self.finished_table:
workers_list = self.finished_workers
logger.debug(f"[workers-modal] Highlighted in finished table, list size: {len(workers_list)}")
else:
logger.warning(f"[workers-modal] Unknown table: {event.control}")
return
# Get the worker at this row
if workers_list and 0 <= event.cursor_row < len(workers_list):
worker = workers_list[event.cursor_row]
worker_id = worker.get('id', '')
logger.info(f"[workers-modal] Highlighted worker: {worker_id}")
if worker_id:
self.selected_worker_id = worker_id
# Display the stdout
self._update_stdout_display(worker_id, worker)
else:
logger.warning(f"[workers-modal] Row {event.cursor_row} out of bounds for list of size {len(workers_list) if workers_list else 0}")
except Exception as e:
logger.error(f"[workers-modal] Error handling row highlight: {e}", exc_info=True)
def on_data_table_cell_highlighted(self, event: DataTable.CellHighlighted) -> None:
"""Handle cell highlight in tables - display stdout (backup for row selection)."""
try:
# CellHighlighted has coordinate (row, column) not cursor_row
cursor_row = event.coordinate.row
logger.debug(f"[workers-modal] Cell highlighted, row: {cursor_row}, column: {event.coordinate.column}")
# Get the selected worker from the correct table
workers_list = None
if event.data_table == self.running_table:
workers_list = self.running_workers
logger.debug(f"[workers-modal] Cell highlighted in running table")
elif event.data_table == self.finished_table:
workers_list = self.finished_workers
logger.debug(f"[workers-modal] Cell highlighted in finished table, list size: {len(workers_list)}")
else:
return
# Get the worker at this row
if workers_list and 0 <= cursor_row < len(workers_list):
worker = workers_list[cursor_row]
worker_id = worker.get('id', '')
if worker_id and worker_id != self.selected_worker_id:
logger.info(f"[workers-modal] Cell-highlighted worker: {worker_id}")
self.selected_worker_id = worker_id
# Display the stdout
self._update_stdout_display(worker_id, worker)
except Exception as e:
logger.debug(f"[workers-modal] Error handling cell highlight: {e}")
def _update_stdout_display(self, worker_id: str, worker: Optional[Dict[str, Any]] = None) -> None:
"""Update the stdout textarea with logs from the selected worker."""
try:
if not self.stdout_display:
logger.error("[workers-modal] stdout_display not initialized")
return
logger.debug(f"[workers-modal] Updating stdout display for worker: {worker_id}")
worker_data = worker or self._locate_worker(worker_id)
stdout_text = self._resolve_worker_stdout(worker_id, worker_data)
pipe_text = self._resolve_worker_pipe(worker_id, worker_data)
events = self._get_worker_events(worker_id)
timeline_text = self._format_worker_timeline(events)
sections = []
if pipe_text:
sections.append(f"Pipe:\n{pipe_text}")
if timeline_text:
sections.append("Timeline:\n" + timeline_text)
logs_body = (stdout_text or "").strip()
sections.append("Logs:\n" + (logs_body if logs_body else "(no logs recorded)"))
combined_text = "\n\n".join(sections)
logger.debug(f"[workers-modal] Setting textarea to {len(combined_text)} chars (stdout_len={len(stdout_text or '')})")
self.stdout_display.text = combined_text
if len(combined_text) > 10:
try:
self.stdout_display.cursor_location = (len(combined_text) - 1, 0)
except Exception:
pass
logger.info(f"[workers-modal] Updated stdout display successfully")
except Exception as e:
logger.error(f"[workers-modal] Error updating stdout display: {e}", exc_info=True)
def _locate_worker(self, worker_id: str) -> Optional[Dict[str, Any]]:
for worker in self.running_workers or []:
if isinstance(worker, dict) and worker.get('id') == worker_id:
return worker
for worker in self.finished_workers or []:
if isinstance(worker, dict) and worker.get('id') == worker_id:
return worker
return None
def _resolve_worker_stdout(self, worker_id: str, worker: Optional[Dict[str, Any]]) -> str:
if worker and worker.get('stdout'):
return worker.get('stdout', '') or ''
manager = getattr(self.app_instance, 'worker_manager', None)
if manager:
try:
return manager.get_stdout(worker_id) or ''
except Exception as exc:
logger.debug(f"[workers-modal] Could not fetch stdout for {worker_id}: {exc}")
return ''
def _resolve_worker_pipe(self, worker_id: str, worker: Optional[Dict[str, Any]]) -> str:
if worker and worker.get('pipe'):
return str(worker.get('pipe'))
record = self._fetch_worker_record(worker_id)
if record and record.get('pipe'):
return str(record.get('pipe'))
return ''
def _fetch_worker_record(self, worker_id: str) -> Optional[Dict[str, Any]]:
manager = getattr(self.app_instance, 'worker_manager', None)
if not manager:
return None
try:
return manager.get_worker(worker_id)
except Exception as exc:
logger.debug(f"[workers-modal] Could not fetch worker record {worker_id}: {exc}")
return None
def _get_worker_events(self, worker_id: str, limit: int = 250) -> List[Dict[str, Any]]:
manager = getattr(self.app_instance, 'worker_manager', None)
if not manager:
return []
try:
return manager.get_worker_events(worker_id, limit=limit)
except Exception as exc:
logger.debug(f"[workers-modal] Could not fetch worker events {worker_id}: {exc}")
return []
def _format_worker_timeline(self, events: List[Dict[str, Any]]) -> str:
if not events:
return ""
lines: List[str] = []
for event in events:
timestamp = self._format_event_timestamp(event.get('created_at'))
label = (event.get('event_type') or '').upper() or 'EVENT'
channel = (event.get('channel') or '').upper()
if channel and channel not in label:
label = f"{label}/{channel}"
step = event.get('step') or ''
message = event.get('message') or ''
prefix = ''
if event.get('event_type') == 'step' and step:
prefix = f"{step} :: "
elif step and step not in message:
prefix = f"{step} :: "
formatted_message = self._format_message_block(message)
lines.append(f"[{timestamp}] {label}: {prefix}{formatted_message}")
return "\n".join(lines)
def _format_event_timestamp(self, raw_timestamp: Any) -> str:
if not raw_timestamp:
return "--:--:--"
text = str(raw_timestamp)
if "T" in text:
time_part = text.split("T", 1)[1]
elif " " in text:
time_part = text.split(" ", 1)[1]
else:
time_part = text
return time_part[:8] if len(time_part) >= 8 else time_part
def _format_message_block(self, message: str) -> str:
clean = (message or '').strip()
if not clean:
return "(empty)"
lines = clean.splitlines()
if len(lines) == 1:
return lines[0]
head, *rest = lines
indented = "\n".join(f" {line}" for line in rest)
return f"{head}\n{indented}"
def _summarize_pipe(self, pipe_value: Any, limit: int = 40) -> str:
text = str(pipe_value or '').strip()
if not text:
return "(none)"
return text if len(text) <= limit else text[: limit - 3] + '...'
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
button_id = event.button.id
try:
if button_id == "toggle-running-btn":
self.show_running = True
self._update_view_visibility()
return
elif button_id == "toggle-finished-btn":
self.show_running = False
self._update_view_visibility()
return
if button_id == "running-refresh-btn":
self.refresh_workers()
elif button_id == "running-stop-btn":
# Stop selected running worker
if self.running_table and self.running_table.row_count > 0:
try:
selected_row = self.running_table.cursor_row
if 0 <= selected_row < len(self.running_workers):
worker = self.running_workers[selected_row]
worker_id = worker.get('id')
if self.app_instance and hasattr(self.app_instance, 'stop_worker'):
self.app_instance.stop_worker(worker_id)
logger.info(f"[workers-modal] Stopped worker: {worker_id}")
self.refresh_workers()
except Exception as e:
logger.error(f"[workers-modal] Error stopping worker: {e}")
elif button_id == "running-stop-all-btn":
# Stop all running workers
if self.app_instance and hasattr(self.app_instance, 'stop_all_workers'):
self.app_instance.stop_all_workers()
logger.info("[workers-modal] Stopped all workers")
self.refresh_workers()
elif button_id == "finished-refresh-btn":
self.refresh_workers()
elif button_id == "finished-clear-btn":
# Clear selected finished worker
if self.finished_table and self.finished_table.row_count > 0:
try:
selected_row = self.finished_table.cursor_row
if 0 <= selected_row < len(self.finished_workers):
worker = self.finished_workers[selected_row]
worker_id = worker.get('id')
if self.app_instance and hasattr(self.app_instance, 'clear_finished_worker'):
self.app_instance.clear_finished_worker(worker_id)
logger.info(f"[workers-modal] Cleared worker: {worker_id}")
self.refresh_workers()
except Exception as e:
logger.error(f"[workers-modal] Error clearing worker: {e}")
elif button_id == "finished-clear-all-btn":
# Clear all finished workers
if self.app_instance and hasattr(self.app_instance, 'clear_all_finished_workers'):
self.app_instance.clear_all_finished_workers()
logger.info("[workers-modal] Cleared all finished workers")
self.refresh_workers()
elif button_id == "close-btn":
self.dismiss(None)
except Exception as e:
logger.error(f"[workers-modal] Error in on_button_pressed: {e}")
def action_cancel(self) -> None:
"""Action for Escape key - close modal."""
self.dismiss(None)

View File

@@ -0,0 +1,119 @@
/* Workers Modal Stylesheet */
Screen {
background: $surface;
color: $text;
}
#workers-container {
width: 100%;
height: 100%;
layout: vertical;
background: $panel;
}
#workers-title-bar {
dock: top;
height: 3;
layout: horizontal;
background: $boost;
border: solid $accent;
padding: 0 1;
}
#workers-title {
width: 1fr;
height: 100%;
content-align-vertical: middle;
color: $text;
text-style: bold;
}
#toggle-running-btn,
#toggle-finished-btn {
width: auto;
height: 100%;
margin: 0;
}
#running-section,
#finished-section {
width: 100%;
height: 40%;
layout: vertical;
border: solid $accent;
}
#running-table,
#finished-table {
width: 100%;
height: 1fr;
border: solid $accent;
}
#running-controls,
#finished-controls {
width: 100%;
height: auto;
min-height: 3;
layout: horizontal;
background: $boost;
padding: 1;
border-top: solid $accent;
}
#running-controls Button,
#finished-controls Button {
margin-right: 1;
min-width: 15;
}
#logs-label {
height: 1;
margin: 0 1;
text-style: bold;
}
#logs-section {
width: 100%;
height: 1fr;
layout: vertical;
border: solid $accent;
background: $panel;
}
#stdout-display {
width: 100%;
height: 1fr;
border: solid $accent;
margin: 1;
}
#workers-buttons {
dock: bottom;
height: auto;
min-height: 3;
layout: horizontal;
border: solid $accent;
padding: 1;
}
#workers-buttons Button {
margin-right: 1;
min-width: 15;
}
DataTable {
border: solid $accent;
}
DataTable > .datatable--header {
background: $boost;
color: $text;
text-style: bold;
}
DataTable > .datatable--cursor {
background: $accent;
color: $panel;
}

356
TUI/pipeline_runner.py Normal file
View File

@@ -0,0 +1,356 @@
"""Pipeline execution utilities for the Textual UI.
This module mirrors the CLI pipeline behaviour while exposing a class-based
interface that the TUI can call. It keeps all pipeline/cmdlet integration in
one place so the interface layer stays focused on presentation.
"""
from __future__ import annotations
import contextlib
import io
import shlex
import uuid
from dataclasses import dataclass, field
import sys
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence
BASE_DIR = Path(__file__).resolve().parent
ROOT_DIR = BASE_DIR.parent
for path in (ROOT_DIR, BASE_DIR):
str_path = str(path)
if str_path not in sys.path:
sys.path.insert(0, str_path)
import pipeline as ctx
from cmdlets import REGISTRY
from config import get_local_storage_path, load_config
from helper.worker_manager import WorkerManager
try: # Reuse the CLI selection parser instead of reimplementing it.
from CLI import _parse_selection_syntax
except ImportError: # pragma: no cover - fallback for atypical environments
_parse_selection_syntax = None # type: ignore
@dataclass(slots=True)
class PipelineStageResult:
"""Summary for a single pipeline stage."""
name: str
args: Sequence[str]
emitted: List[Any] = field(default_factory=list)
status: str = "pending"
error: Optional[str] = None
@dataclass(slots=True)
class PipelineRunResult:
"""Aggregate result for a pipeline run."""
pipeline: str
success: bool
stages: List[PipelineStageResult] = field(default_factory=list)
emitted: List[Any] = field(default_factory=list)
stdout: str = ""
stderr: str = ""
error: Optional[str] = None
def to_summary(self) -> Dict[str, Any]:
"""Provide a JSON-friendly representation for logging or UI."""
return {
"pipeline": self.pipeline,
"success": self.success,
"error": self.error,
"stages": [
{
"name": stage.name,
"status": stage.status,
"error": stage.error,
"emitted": len(stage.emitted),
}
for stage in self.stages
],
}
class PipelineExecutor:
"""Thin wrapper over the cmdlet registry + pipeline context."""
def __init__(
self,
*,
config: Optional[Dict[str, Any]] = None,
worker_manager: Optional[WorkerManager] = None,
) -> None:
self._config = config or load_config()
self._worker_manager = worker_manager
if self._worker_manager is None:
self._worker_manager = self._ensure_worker_manager()
if self._worker_manager:
self._config["_worker_manager"] = self._worker_manager
@property
def worker_manager(self) -> Optional[WorkerManager]:
return self._worker_manager
def run_pipeline(
self,
pipeline_text: str,
*,
on_log: Optional[Callable[[str], None]] = None,
) -> PipelineRunResult:
"""Execute a pipeline string and return structured results.
Args:
pipeline_text: Raw pipeline text entered by the user.
on_log: Optional callback that receives human-readable log lines.
"""
normalized = pipeline_text.strip()
result = PipelineRunResult(pipeline=normalized, success=False)
if not normalized:
result.error = "Pipeline is empty"
return result
tokens = self._tokenize(normalized)
stages = self._split_stages(tokens)
if not stages:
result.error = "Pipeline contains no stages"
return result
ctx.reset()
ctx.set_current_command_text(normalized)
stdout_buffer = io.StringIO()
stderr_buffer = io.StringIO()
piped_result: Any = None
worker_session = self._start_worker_session(normalized)
try:
with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(
stderr_buffer
):
for index, stage_tokens in enumerate(stages):
stage = self._execute_stage(
index=index,
total=len(stages),
stage_tokens=stage_tokens,
piped_input=piped_result,
on_log=on_log,
)
result.stages.append(stage)
if stage.status != "completed":
result.error = stage.error or f"Stage {stage.name} failed"
return result
if index == len(stages) - 1:
result.emitted = stage.emitted
else:
piped_result = stage.emitted
result.success = True
return result
finally:
result.stdout = stdout_buffer.getvalue()
result.stderr = stderr_buffer.getvalue()
ctx.clear_current_command_text()
if worker_session is not None:
status = "completed" if result.success else "error"
worker_session.finish(status=status, message=result.error or "")
# ------------------------------------------------------------------
# Stage execution helpers
# ------------------------------------------------------------------
def _execute_stage(
self,
*,
index: int,
total: int,
stage_tokens: Sequence[str],
piped_input: Any,
on_log: Optional[Callable[[str], None]],
) -> PipelineStageResult:
if not stage_tokens:
return PipelineStageResult(name="(empty)", args=[], status="skipped")
cmd_name = stage_tokens[0].replace("_", "-").lower()
stage_args = stage_tokens[1:]
stage = PipelineStageResult(name=cmd_name, args=stage_args)
if cmd_name.startswith("@"):
return self._apply_selection_stage(
token=cmd_name,
stage=stage,
piped_input=piped_input,
on_log=on_log,
)
cmd_fn = REGISTRY.get(cmd_name)
if not cmd_fn:
stage.status = "failed"
stage.error = f"Unknown command: {cmd_name}"
return stage
pipeline_ctx = ctx.PipelineStageContext(stage_index=index, total_stages=total)
ctx.set_stage_context(pipeline_ctx)
ctx.set_active(True)
ctx.set_last_stage(index == total - 1)
try:
return_code = cmd_fn(piped_input, list(stage_args), self._config)
except Exception as exc: # pragma: no cover - surfaced in UI
stage.status = "failed"
stage.error = f"{type(exc).__name__}: {exc}"
if on_log:
on_log(stage.error)
return stage
finally:
ctx.set_stage_context(None)
ctx.set_active(False)
emitted = list(getattr(pipeline_ctx, "emits", []) or [])
stage.emitted = emitted
if return_code != 0:
stage.status = "failed"
stage.error = f"Exit code {return_code}"
else:
stage.status = "completed"
stage.error = None
worker_id = self._current_worker_id()
if self._worker_manager and worker_id:
label = f"[Stage {index + 1}/{total}] {cmd_name} {stage.status}"
self._worker_manager.log_step(worker_id, label)
ctx.set_last_result_table(None, emitted)
ctx.set_last_items(emitted)
return stage
def _apply_selection_stage(
self,
*,
token: str,
stage: PipelineStageResult,
piped_input: Any,
on_log: Optional[Callable[[str], None]],
) -> PipelineStageResult:
selection = self._parse_selection(token)
items = piped_input or []
if not isinstance(items, list):
items = list(items if isinstance(items, Sequence) else [items])
if not items:
stage.status = "failed"
stage.error = "Selection requested but there is no upstream data"
return stage
if selection is None:
stage.emitted = list(items)
else:
zero_based = sorted(i - 1 for i in selection if i > 0)
stage.emitted = [items[i] for i in zero_based if 0 <= i < len(items)]
if not stage.emitted:
stage.status = "failed"
stage.error = "Selection matched no rows"
return stage
ctx.set_last_items(stage.emitted)
ctx.set_last_result_table(None, stage.emitted)
stage.status = "completed"
if on_log:
on_log(f"Selected {len(stage.emitted)} item(s) via {token}")
return stage
# ------------------------------------------------------------------
# Worker/session helpers
# ------------------------------------------------------------------
def _start_worker_session(self, pipeline_text: str) -> Optional[_WorkerSession]:
manager = self._ensure_worker_manager()
if manager is None:
return None
worker_id = f"tui_pipeline_{uuid.uuid4().hex[:8]}"
tracked = manager.track_worker(
worker_id,
worker_type="pipeline",
title="Pipeline run",
description=pipeline_text,
pipe=pipeline_text,
)
if not tracked:
return None
manager.log_step(worker_id, "Pipeline started")
self._config["_current_worker_id"] = worker_id
return _WorkerSession(manager=manager, worker_id=worker_id, config=self._config)
def _ensure_worker_manager(self) -> Optional[WorkerManager]:
if self._worker_manager:
return self._worker_manager
library_root = get_local_storage_path(self._config)
if not library_root:
return None
try:
self._worker_manager = WorkerManager(Path(library_root), auto_refresh_interval=0)
self._config["_worker_manager"] = self._worker_manager
except Exception:
self._worker_manager = None
return self._worker_manager
def _current_worker_id(self) -> Optional[str]:
worker_id = self._config.get("_current_worker_id")
return str(worker_id) if worker_id else None
# ------------------------------------------------------------------
# Parsing helpers
# ------------------------------------------------------------------
@staticmethod
def _tokenize(pipeline_text: str) -> List[str]:
try:
return shlex.split(pipeline_text)
except ValueError:
return pipeline_text.split()
@staticmethod
def _split_stages(tokens: Sequence[str]) -> List[List[str]]:
stages: List[List[str]] = []
current: List[str] = []
for token in tokens:
if token == "|":
if current:
stages.append(current)
current = []
else:
current.append(token)
if current:
stages.append(current)
return stages
@staticmethod
def _parse_selection(token: str) -> Optional[Sequence[int]]:
if _parse_selection_syntax:
parsed = _parse_selection_syntax(token)
if parsed:
return sorted(parsed)
return None
class _WorkerSession:
"""Minimal worker session wrapper for the TUI executor."""
def __init__(self, *, manager: WorkerManager, worker_id: str, config: Optional[Dict[str, Any]] = None) -> None:
self._manager = manager
self.worker_id = worker_id
self._config = config
def finish(self, *, status: str, message: str) -> None:
try:
self._manager.finish_worker(self.worker_id, result=status, error_msg=message)
self._manager.log_step(self.worker_id, f"Pipeline {status}")
except Exception:
pass
if self._config and self._config.get("_current_worker_id") == self.worker_id:
self._config.pop("_current_worker_id", None)

332
TUI/tui.py Normal file
View File

@@ -0,0 +1,332 @@
"""Modern Textual UI for driving Medeia-Macina pipelines."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.widgets import (
Button,
DataTable,
Footer,
Header,
Input,
ListItem,
ListView,
Static,
TextArea,
Tree,
)
BASE_DIR = Path(__file__).resolve().parent
ROOT_DIR = BASE_DIR.parent
for path in (BASE_DIR, ROOT_DIR):
str_path = str(path)
if str_path not in sys.path:
sys.path.insert(0, str_path)
from menu_actions import ( # type: ignore # noqa: E402
PIPELINE_PRESETS,
PipelinePreset,
build_metadata_snapshot,
summarize_result,
)
from pipeline_runner import PipelineExecutor, PipelineRunResult # type: ignore # noqa: E402
class PresetListItem(ListItem):
"""List entry that stores its pipeline preset."""
def __init__(self, preset: PipelinePreset) -> None:
super().__init__(
Static(
f"[b]{preset.label}[/b]\n[pale_green4]{preset.description}[/pale_green4]",
classes="preset-entry",
)
)
self.preset = preset
class PipelineHubApp(App):
"""Textual front-end that executes cmdlet pipelines inline."""
CSS_PATH = "tui.tcss"
BINDINGS = [
Binding("ctrl+enter", "run_pipeline", "Run Pipeline"),
Binding("f5", "refresh_workers", "Refresh Workers"),
Binding("ctrl+l", "focus_command", "Focus Input", show=False),
]
def __init__(self) -> None:
super().__init__()
self.executor = PipelineExecutor()
self.result_items: List[Any] = []
self.log_lines: List[str] = []
self.command_input: Optional[Input] = None
self.log_output: Optional[TextArea] = None
self.results_table: Optional[DataTable] = None
self.metadata_tree: Optional[Tree] = None
self.worker_table: Optional[DataTable] = None
self.preset_list: Optional[ListView] = None
self.status_panel: Optional[Static] = None
self._pipeline_running = False
# ------------------------------------------------------------------
# Layout
# ------------------------------------------------------------------
def compose(self) -> ComposeResult: # noqa: D401 - Textual compose hook
yield Header(show_clock=True)
with Container(id="app-shell"):
with Horizontal(id="command-row"):
self.command_input = Input(
placeholder='download-data "<url>" | merge-file | add-tag | add-file -storage local',
id="pipeline-input",
)
yield self.command_input
yield Button("Run", id="run-button", variant="primary")
self.status_panel = Static("Idle", id="status-panel")
yield self.status_panel
with Horizontal(id="content-row"):
with VerticalScroll(id="left-pane"):
yield Static("Pipeline Presets", classes="section-title")
self.preset_list = ListView(
*(PresetListItem(preset) for preset in PIPELINE_PRESETS),
id="preset-list",
)
yield self.preset_list
yield Static("Logs", classes="section-title")
self.log_output = TextArea(id="log-output", read_only=True)
yield self.log_output
yield Static("Workers", classes="section-title")
self.worker_table = DataTable(id="workers-table")
yield self.worker_table
with Vertical(id="right-pane"):
yield Static("Results", classes="section-title")
self.results_table = DataTable(id="results-table")
yield self.results_table
yield Static("Metadata", classes="section-title")
self.metadata_tree = Tree("Run a pipeline", id="metadata-tree")
yield self.metadata_tree
yield Footer()
def on_mount(self) -> None:
if self.results_table:
self.results_table.add_columns("Row", "Title", "Source", "File")
if self.worker_table:
self.worker_table.add_columns("ID", "Type", "Status", "Details")
if self.executor.worker_manager:
self.set_interval(2.0, self.refresh_workers)
self.refresh_workers()
if self.command_input:
self.command_input.focus()
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_focus_command(self) -> None:
if self.command_input:
self.command_input.focus()
def action_run_pipeline(self) -> None:
if self._pipeline_running:
self.notify("Pipeline already running", severity="warning", timeout=3)
return
if not self.command_input:
return
pipeline_text = self.command_input.value.strip()
if not pipeline_text:
self.notify("Enter a pipeline to run", severity="warning", timeout=3)
return
self._pipeline_running = True
self._set_status("Running…", level="info")
self._clear_log()
self._append_log_line(f"$ {pipeline_text}")
self._clear_results()
self._run_pipeline_background(pipeline_text)
def action_refresh_workers(self) -> None:
self.refresh_workers()
# ------------------------------------------------------------------
# Event handlers
# ------------------------------------------------------------------
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "run-button":
self.action_run_pipeline()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "pipeline-input":
self.action_run_pipeline()
def on_list_view_selected(self, event: ListView.Selected) -> None:
if isinstance(event.item, PresetListItem) and self.command_input:
self.command_input.value = event.item.preset.pipeline
self.notify(f"Loaded preset: {event.item.preset.label}", timeout=2)
event.stop()
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
if not self.results_table or event.control is not self.results_table:
return
index = event.cursor_row
if 0 <= index < len(self.result_items):
self._display_metadata(self.result_items[index])
# ------------------------------------------------------------------
# Pipeline execution helpers
# ------------------------------------------------------------------
@work(exclusive=True, thread=True)
def _run_pipeline_background(self, pipeline_text: str) -> None:
run_result = self.executor.run_pipeline(pipeline_text, on_log=self._log_from_worker)
self.call_from_thread(self._on_pipeline_finished, run_result)
def _on_pipeline_finished(self, run_result: PipelineRunResult) -> None:
self._pipeline_running = False
status_level = "success" if run_result.success else "error"
status_text = "Completed" if run_result.success else "Failed"
self._set_status(status_text, level=status_level)
if not run_result.success:
self.notify(run_result.error or "Pipeline failed", severity="error", timeout=6)
else:
self.notify("Pipeline completed", timeout=3)
if run_result.stdout.strip():
self._append_log_line("stdout:")
self._append_block(run_result.stdout)
if run_result.stderr.strip():
self._append_log_line("stderr:")
self._append_block(run_result.stderr)
for stage in run_result.stages:
summary = f"[{stage.status}] {stage.name} -> {len(stage.emitted)} item(s)"
if stage.error:
summary += f" ({stage.error})"
self._append_log_line(summary)
emitted = run_result.emitted
if isinstance(emitted, list):
self.result_items = emitted
elif emitted:
self.result_items = [emitted]
else:
self.result_items = []
self._populate_results_table()
self.refresh_workers()
def _log_from_worker(self, message: str) -> None:
self.call_from_thread(self._append_log_line, message)
# ------------------------------------------------------------------
# UI helpers
# ------------------------------------------------------------------
def _populate_results_table(self) -> None:
if not self.results_table:
return
self.results_table.clear()
if not self.result_items:
self.results_table.add_row("", "No results", "", "")
return
for idx, item in enumerate(self.result_items, start=1):
if isinstance(item, dict):
title = summarize_result(item)
source = item.get("source") or item.get("cmdlet_name") or item.get("cmdlet") or ""
file_path = item.get("file_path") or item.get("path") or ""
else:
title = str(item)
source = ""
file_path = ""
self.results_table.add_row(str(idx), title, source, file_path, key=str(idx - 1))
def _display_metadata(self, item: Any) -> None:
if not self.metadata_tree:
return
root = self.metadata_tree.root
root.label = "Metadata"
root.remove_children()
payload: Dict[str, Any]
if isinstance(item, dict):
file_path = item.get("file_path") or item.get("path")
if file_path:
payload = build_metadata_snapshot(Path(file_path))
else:
payload = item
else:
payload = {"value": str(item)}
self._populate_tree_node(root, payload)
root.expand_all()
def _populate_tree_node(self, node, data: Any) -> None:
if isinstance(data, dict):
for key, value in data.items():
child = node.add(f"[b]{key}[/b]")
self._populate_tree_node(child, value)
elif isinstance(data, Sequence) and not isinstance(data, (str, bytes)):
for idx, value in enumerate(data):
child = node.add(f"[{idx}]")
self._populate_tree_node(child, value)
else:
node.add(str(data))
def _clear_log(self) -> None:
self.log_lines = []
if self.log_output:
self.log_output.value = ""
def _append_log_line(self, line: str) -> None:
self.log_lines.append(line)
if len(self.log_lines) > 500:
self.log_lines = self.log_lines[-500:]
if self.log_output:
self.log_output.value = "\n".join(self.log_lines)
def _append_block(self, text: str) -> None:
for line in text.strip().splitlines():
self._append_log_line(f" {line}")
def _clear_results(self) -> None:
self.result_items = []
if self.results_table:
self.results_table.clear()
if self.metadata_tree:
self.metadata_tree.root.label = "Awaiting results"
self.metadata_tree.root.remove_children()
def _set_status(self, message: str, *, level: str = "info") -> None:
if not self.status_panel:
return
for css in ("status-info", "status-success", "status-error"):
self.status_panel.remove_class(css)
css_class = f"status-{level if level in {'success', 'error'} else 'info'}"
self.status_panel.add_class(css_class)
self.status_panel.update(message)
def refresh_workers(self) -> None:
if not self.worker_table:
return
manager = self.executor.worker_manager
self.worker_table.clear()
if manager is None:
self.worker_table.add_row("", "", "", "Worker manager unavailable")
return
workers = manager.get_active_workers()
if not workers:
self.worker_table.add_row("", "", "", "No active workers")
return
for worker in workers:
worker_id = str(worker.get("worker_id") or worker.get("id") or "?")[:8]
worker_type = str(worker.get("worker_type") or worker.get("type") or "?")
status = str(worker.get("status") or worker.get("result") or "running")
details = worker.get("current_step") or worker.get("description") or worker.get("pipe") or ""
self.worker_table.add_row(worker_id, worker_type, status, str(details)[:80])
if __name__ == "__main__":
PipelineHubApp().run()

100
TUI/tui.tcss Normal file
View File

@@ -0,0 +1,100 @@
#app-shell {
width: 100%;
height: 100%;
padding: 1 2;
background: $surface;
layout: vertical;
}
#command-row {
width: 100%;
height: auto;
background: $boost;
padding: 1;
border: round $primary;
}
#pipeline-input {
width: 1fr;
min-height: 3;
padding: 0 1;
margin-right: 1;
background: $surface;
color: $text;
border: round $primary;
}
#pipeline-input:focus {
border: double $primary;
background: $surface;
}
#status-panel {
min-width: 20;
text-style: bold;
content-align: center middle;
padding: 0 1;
border: solid $panel-darken-1;
}
#content-row {
width: 100%;
height: 1fr;
}
#left-pane,
#right-pane {
width: 1fr;
height: 100%;
padding: 1;
background: $panel;
border: round $panel-darken-2;
}
#left-pane {
max-width: 48;
}
.section-title {
text-style: bold;
color: $text-muted;
margin-top: 1;
}
.preset-entry {
padding: 1;
border: tall $panel-darken-1;
margin-bottom: 1;
}
#log-output {
height: 16;
}
#workers-table {
height: auto;
}
#results-table {
height: 1fr;
}
#metadata-tree {
height: 1fr;
border: round $panel-darken-1;
}
.status-info {
background: $boost;
color: $text;
}
.status-success {
background: $success 20%;
color: $success;
}
.status-error {
background: $error 20%;
color: $error;
}