df
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled

This commit is contained in:
2025-12-29 17:05:03 -08:00
parent 226de9316a
commit c019c00aed
104 changed files with 19669 additions and 12954 deletions

View File

@@ -11,7 +11,7 @@ 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;
@@ -65,10 +65,10 @@ class AccessModal(ModalScreen):
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
@@ -78,20 +78,20 @@ class AccessModal(ModalScreen):
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 url containing special chars
# Instead, use the HTML link markup that Textual-serve renders as <a> tag
@@ -99,16 +99,19 @@ class AccessModal(ModalScreen):
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")
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":
@@ -118,19 +121,23 @@ class AccessModal(ModalScreen):
# 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'))
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}")

File diff suppressed because it is too large Load Diff

View File

@@ -23,16 +23,21 @@ 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):
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
@@ -57,22 +62,22 @@ class ExportModal(ModalScreen):
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 ''
ext_lower = ext.lower() if ext else ""
from SYS.utils_constant import mime_maps
found_type = "unknown"
# Find type based on extension
for category, formats in mime_maps.items():
for fmt_key, fmt_info in formats.items():
@@ -81,13 +86,13 @@ class ExportModal(ModalScreen):
break
if found_type != "unknown":
break
# Build format options for the found type
format_options = []
# If unknown, fallback to audio (matching legacy behavior)
target_type = found_type if found_type in mime_maps else "audio"
if target_type in mime_maps:
# Sort formats alphabetically
sorted_formats = sorted(mime_maps[target_type].items())
@@ -95,15 +100,21 @@ class ExportModal(ModalScreen):
label = fmt_key.upper()
value = fmt_key
format_options.append((label, value))
return (target_type, format_options)
def _get_library_options(self) -> list:
"""Get available library options from config.conf."""
options = [("Local", "local")]
try:
from config import load_config, get_hydrus_access_key, get_hydrus_url, get_debrid_api_key
from config import (
load_config,
get_hydrus_access_key,
get_hydrus_url,
get_debrid_api_key,
)
config = load_config()
hydrus_url = (get_hydrus_url(config, "home") or "").strip()
@@ -114,29 +125,39 @@ class ExportModal(ModalScreen):
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'}")
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'")
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'
"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:
@@ -147,9 +168,9 @@ class ExportModal(ModalScreen):
# Use central formatting rule
formatted_value = format_metadata_value(field, value)
# Format: "Field Name: value"
field_label = field.replace('_', ' ').title()
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")
@@ -157,12 +178,12 @@ class ExportModal(ModalScreen):
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 (Tag, Metadata, Export-To Options)
self.tags_textarea = TextArea(
text=self._format_tags(),
@@ -171,7 +192,7 @@ class ExportModal(ModalScreen):
)
yield self.tags_textarea
self.tags_textarea.border_title = "Tag"
# Metadata display instead of files tree
self.metadata_display = Static(
self._get_metadata_text(),
@@ -179,120 +200,128 @@ class ExportModal(ModalScreen):
)
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"
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"
)
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"
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', '')
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_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
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
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}")):
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}")
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)
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', '')
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)
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 ''
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")
self.custom_path_input.display = event.value == "path"
if self.libraries_select:
self.libraries_select.display = (event.value == "libraries")
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")
@@ -300,24 +329,27 @@ class ExportModal(ModalScreen):
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'))}")
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":
@@ -331,55 +363,87 @@ class ExportModal(ModalScreen):
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'):
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:, url:, relationship:)
if export_to == "libraries" and library == "hydrus":
metadata_prefixes = {'hash:', '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")
metadata_prefixes = {"hash:", "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()
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):
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'}
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())
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")
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":
@@ -391,61 +455,62 @@ class ExportModal(ModalScreen):
export_path = library # "local", "hydrus", "debrid"
else:
export_path = export_to # "0x0"
# Get metadata from result_data
metadata = self.result_data.get('metadata', {})
metadata = self.result_data.get("metadata", {})
# Extract file source info from result_data (passed by hub-ui)
file_hash = self.result_data.get('hash')
file_url = self.result_data.get('url')
file_path = self.result_data.get('path')
source = self.result_data.get('source', 'unknown')
file_hash = self.result_data.get("hash")
file_url = self.result_data.get("url")
file_path = self.result_data.get("path")
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,
'hash': file_hash,
'url': file_url,
'path': file_path,
'source': source,
"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,
"hash": file_hash,
"url": file_url,
"path": file_path,
"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}")
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')
notes_path = file_path.with_suffix(file_path.suffix + ".notes")
try:
with open(notes_path, 'w', encoding='utf-8') as f:
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:
@@ -454,50 +519,56 @@ def create_notes_sidecar(file_path: Path, notes: str) -> None:
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':
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('.')
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]]:
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'):
if size_str.endswith("mb"):
size_str = size_str[:-2]
elif size_str.endswith('m'):
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

View File

@@ -23,24 +23,25 @@ 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
"""
@@ -55,108 +56,98 @@ class SearchModal(ModalScreen):
self.current_result_table: Optional[ResultTable] = None
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"
id="source-select",
)
yield self.source_select
# Search input
self.search_input = Input(
placeholder="Enter search query...",
id="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
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"
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"
)
self.results_table.add_columns("Title", "Author/Artist", "Year/Album", "Details")
# Focus on search input
self.search_input.focus()
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(columns=True)
self.current_results = []
self.current_result_table = None
self.is_searching = True
# Create worker for tracking
if self.app_instance and hasattr(self.app_instance, 'create_worker'):
if self.app_instance and hasattr(self.app_instance, "create_worker"):
self.current_worker = self.app_instance.create_worker(
source,
title=f"{source.capitalize()} Search: {query[:40]}",
description=f"Searching {source} for: {query}"
description=f"Searching {source} for: {query}",
)
self.current_worker.log_step(f"Connecting to {source}...")
try:
provider = get_search_provider(source)
if not provider:
@@ -168,10 +159,10 @@ class SearchModal(ModalScreen):
logger.info(f"[search-modal] Searching {source} for: {query}")
results = provider.search(query, limit=20)
self.current_results = results
if self.current_worker:
self.current_worker.log_step(f"Found {len(results)} results")
# Create ResultTable
table = ResultTable(f"Search Results: {query}")
for res in results:
@@ -183,10 +174,16 @@ class SearchModal(ModalScreen):
else:
# Fallback if no columns defined
row.add_column("Title", res.title)
row.add_column("Target", getattr(res, 'path', None) or getattr(res, 'url', None) or getattr(res, 'target', None) or '')
row.add_column(
"Target",
getattr(res, "path", None)
or getattr(res, "url", None)
or getattr(res, "target", None)
or "",
)
self.current_result_table = table
# Populate UI
if table.rows:
# Add headers
@@ -198,27 +195,27 @@ class SearchModal(ModalScreen):
else:
self.results_table.add_columns("Message")
self.results_table.add_row("No results found")
# Finish worker
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}", exc_info=True)
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:
@@ -229,7 +226,7 @@ class SearchModal(ModalScreen):
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:
@@ -239,67 +236,75 @@ class SearchModal(ModalScreen):
if getattr(result, "table", "") == "openlibrary":
asyncio.create_task(self._download_book(result))
else:
logger.warning("[search-modal] Download only supported for OpenLibrary results")
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]
# Convert to dict if needed for submission
if hasattr(result, 'to_dict'):
if hasattr(result, "to_dict"):
result_dict = result.to_dict()
else:
result_dict = result
# 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"
library_source = (
self.library_source_select.value if self.library_source_select else "local"
)
# Add tags and source to result
result_dict["tags_text"] = tags_text
result_dict["library_source"] = library_source
# Post message and dismiss
self.post_message(self.SearchSelected(result_dict))
self.dismiss(result_dict)
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: Any) -> None:
"""Populate the tags textarea from a selected result."""
if not self.tags_textarea:
return
# Handle both SearchResult objects and dicts
if hasattr(result, 'full_metadata'):
if hasattr(result, "full_metadata"):
metadata = result.full_metadata or {}
source = result.table
title = result.title
else:
# Handle dict (legacy or from to_dict)
if 'full_metadata' in result:
metadata = result['full_metadata'] or {}
elif 'raw_data' in result:
metadata = result['raw_data'] or {}
if "full_metadata" in result:
metadata = result["full_metadata"] or {}
elif "raw_data" in result:
metadata = result["raw_data"] or {}
else:
metadata = result
source = result.get('table', '')
title = result.get('title', '')
source = result.get("table", "")
title = result.get("title", "")
# Format tags based on result source
if source == "openlibrary":
# For OpenLibrary: title, author, year
author = ", ".join(metadata.get("authors", [])) if isinstance(metadata.get("authors"), list) else metadata.get("authors", "")
author = (
", ".join(metadata.get("authors", []))
if isinstance(metadata.get("authors"), list)
else metadata.get("authors", "")
)
year = str(metadata.get("year", ""))
tags = []
if title:
@@ -325,10 +330,10 @@ class SearchModal(ModalScreen):
# Generic fallback
tags = [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: Any) -> None:
"""Download a book from OpenLibrary using the provider."""
if getattr(result, "table", "") != "openlibrary":
@@ -355,12 +360,12 @@ class SearchModal(ModalScreen):
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:
@@ -369,18 +374,20 @@ class SearchModal(ModalScreen):
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}")
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":

View File

@@ -19,28 +19,30 @@ 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
"""
@@ -53,7 +55,7 @@ class WorkersModal(ModalScreen):
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"):
@@ -62,110 +64,107 @@ class WorkersModal(ModalScreen):
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"
"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"
"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'):
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'):
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")
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]}")
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])}")
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")
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:
@@ -173,7 +172,7 @@ class WorkersModal(ModalScreen):
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
@@ -188,48 +187,52 @@ class WorkersModal(ModalScreen):
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")
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")
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', '')
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 '---'
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],
@@ -237,57 +240,68 @@ class WorkersModal(ModalScreen):
pipe_display,
progress,
started,
details
details,
)
if idx == 0: # Log first entry
logger.debug(f"[workers-modal] Added running row {idx}: {worker_id[:8]} {worker_type[:15]} {status}")
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")
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")
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")
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', '')
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 '---'
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]
@@ -295,7 +309,7 @@ class WorkersModal(ModalScreen):
duration = duration[:10]
details = details[:30]
pipe_display = pipe_display[:40]
self.finished_table.add_row(
worker_id[:8],
worker_type[:15],
@@ -304,24 +318,31 @@ class WorkersModal(ModalScreen):
started,
completed,
duration,
details
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}")
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:
@@ -329,33 +350,39 @@ class WorkersModal(ModalScreen):
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)}")
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', '')
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}")
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}")
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:
@@ -363,15 +390,17 @@ class WorkersModal(ModalScreen):
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)}")
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', '')
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
@@ -379,8 +408,10 @@ class WorkersModal(ModalScreen):
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:
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:
@@ -400,7 +431,9 @@ class WorkersModal(ModalScreen):
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 '')})")
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:
@@ -410,37 +443,37 @@ class WorkersModal(ModalScreen):
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:
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:
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 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 ''
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 ''
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'))
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 ''
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)
manager = getattr(self.app_instance, "worker_manager", None)
if not manager:
return None
try:
@@ -448,9 +481,9 @@ class WorkersModal(ModalScreen):
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)
manager = getattr(self.app_instance, "worker_manager", None)
if not manager:
return []
try:
@@ -458,28 +491,28 @@ class WorkersModal(ModalScreen):
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()
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:
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 "--:--:--"
@@ -491,9 +524,9 @@ class WorkersModal(ModalScreen):
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()
clean = (message or "").strip()
if not clean:
return "(empty)"
lines = clean.splitlines()
@@ -502,31 +535,31 @@ class WorkersModal(ModalScreen):
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()
text = str(pipe_value or "").strip()
if not text:
return "(none)"
return text if len(text) <= limit else text[: limit - 3] + '...'
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:
@@ -534,24 +567,24 @@ class WorkersModal(ModalScreen):
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'):
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'):
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:
@@ -559,27 +592,29 @@ class WorkersModal(ModalScreen):
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'):
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'):
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

@@ -3,6 +3,7 @@
The TUI is a frontend to the CLI, so it must use the same pipeline executor
implementation as the CLI (`CLI.PipelineExecutor`).
"""
from __future__ import annotations
import contextlib
@@ -149,7 +150,10 @@ class PipelineRunner:
try:
with capture_rich_output(stdout=stdout_buffer, stderr=stderr_buffer):
with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer):
with (
contextlib.redirect_stdout(stdout_buffer),
contextlib.redirect_stderr(stderr_buffer),
):
if on_log:
on_log("Executing pipeline via CLI executor...")
self._executor.execute_tokens(list(tokens))
@@ -166,7 +170,11 @@ class PipelineRunner:
# Pull the canonical state out of pipeline context.
table = None
try:
table = ctx.get_display_table() or ctx.get_current_stage_table() or ctx.get_last_result_table()
table = (
ctx.get_display_table()
or ctx.get_current_stage_table()
or ctx.get_last_result_table()
)
except Exception:
table = None
@@ -259,7 +267,11 @@ class PipelineRunner:
try:
hist = list(getattr(ctx, "_RESULT_TABLE_HISTORY", []) or [])
snap["_RESULT_TABLE_HISTORY"] = [
(t, (items.copy() if isinstance(items, list) else list(items) if items else []), subj)
(
t,
(items.copy() if isinstance(items, list) else list(items) if items else []),
subj,
)
for (t, items, subj) in hist
if isinstance((t, items, subj), tuple)
]
@@ -269,7 +281,11 @@ class PipelineRunner:
try:
fwd = list(getattr(ctx, "_RESULT_TABLE_FORWARD", []) or [])
snap["_RESULT_TABLE_FORWARD"] = [
(t, (items.copy() if isinstance(items, list) else list(items) if items else []), subj)
(
t,
(items.copy() if isinstance(items, list) else list(items) if items else []),
subj,
)
for (t, items, subj) in fwd
if isinstance((t, items, subj), tuple)
]
@@ -278,7 +294,9 @@ class PipelineRunner:
try:
tail = list(getattr(ctx, "_PENDING_PIPELINE_TAIL", []) or [])
snap["_PENDING_PIPELINE_TAIL"] = [list(stage) for stage in tail if isinstance(stage, list)]
snap["_PENDING_PIPELINE_TAIL"] = [
list(stage) for stage in tail if isinstance(stage, list)
]
except Exception:
pass

View File

@@ -1,4 +1,5 @@
"""Modern Textual UI for driving Medeia-Macina pipelines."""
from __future__ import annotations
import json
@@ -13,7 +14,18 @@ from textual.binding import Binding
from textual.events import Key
from textual.containers import Container, Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, DataTable, Footer, Header, Input, Label, OptionList, Select, Static, TextArea
from textual.widgets import (
Button,
DataTable,
Footer,
Header,
Input,
Label,
OptionList,
Select,
Static,
TextArea,
)
from textual.widgets.option_list import Option
BASE_DIR = Path(__file__).resolve().parent
@@ -174,7 +186,9 @@ class TagEditorPopup(ModalScreen[None]):
self._save_tags_background(to_add, to_del, desired)
@work(thread=True)
def _save_tags_background(self, to_add: List[str], to_del: List[str], desired: List[str]) -> None:
def _save_tags_background(
self, to_add: List[str], to_del: List[str], desired: List[str]
) -> None:
app = self.app # PipelineHubApp
try:
runner: PipelineRunner = getattr(app, "executor")
@@ -188,14 +202,26 @@ class TagEditorPopup(ModalScreen[None]):
del_cmd = f"@1 | delete-tag -store {store_tok}{query_chunk} {del_args}"
del_res = runner.run_pipeline(del_cmd, seeds=self._seeds, isolate=True)
if not getattr(del_res, "success", False):
failures.append(str(getattr(del_res, "error", "") or getattr(del_res, "stderr", "") or "delete-tag failed").strip())
failures.append(
str(
getattr(del_res, "error", "")
or getattr(del_res, "stderr", "")
or "delete-tag failed"
).strip()
)
if to_add:
add_args = " ".join(json.dumps(t) for t in to_add)
add_cmd = f"@1 | add-tag -store {store_tok}{query_chunk} {add_args}"
add_res = runner.run_pipeline(add_cmd, seeds=self._seeds, isolate=True)
if not getattr(add_res, "success", False):
failures.append(str(getattr(add_res, "error", "") or getattr(add_res, "stderr", "") or "add-tag failed").strip())
failures.append(
str(
getattr(add_res, "error", "")
or getattr(add_res, "stderr", "")
or "add-tag failed"
).strip()
)
if failures:
msg = failures[0]
@@ -401,7 +427,9 @@ class PipelineHubApp(App):
if not suggestion:
return
self.command_input.value = self._apply_suggestion_to_text(str(self.command_input.value or ""), suggestion)
self.command_input.value = self._apply_suggestion_to_text(
str(self.command_input.value or ""), suggestion
)
if self.suggestion_list:
self.suggestion_list.display = False
event.prevent_default()
@@ -496,7 +524,11 @@ class PipelineHubApp(App):
# Identify first stage command name for conservative auto-augmentation.
first_stage_cmd = ""
try:
first_stage_cmd = str(stages[0].split()[0]).replace("_", "-").strip().lower() if stages[0].split() else ""
first_stage_cmd = (
str(stages[0].split()[0]).replace("_", "-").strip().lower()
if stages[0].split()
else ""
)
except Exception:
first_stage_cmd = ""
@@ -720,7 +752,9 @@ class PipelineHubApp(App):
item: Any = None
# Prefer mapping displayed table row -> source item.
if self.current_result_table and 0 <= index < len(getattr(self.current_result_table, "rows", []) or []):
if self.current_result_table and 0 <= index < len(
getattr(self.current_result_table, "rows", []) or []
):
row = self.current_result_table.rows[index]
src_idx = getattr(row, "source_index", None)
if isinstance(src_idx, int) and 0 <= src_idx < len(self.result_items):
@@ -782,7 +816,9 @@ class PipelineHubApp(App):
return
text = ""
idx = int(getattr(self, "_selected_row_index", 0) or 0)
if self.current_result_table and 0 <= idx < len(getattr(self.current_result_table, "rows", []) or []):
if self.current_result_table and 0 <= idx < len(
getattr(self.current_result_table, "rows", []) or []
):
row = self.current_result_table.rows[idx]
lines = [f"{col.name}: {col.value}" for col in getattr(row, "columns", []) or []]
text = "\n".join(lines)
@@ -874,7 +910,9 @@ class PipelineHubApp(App):
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 ""
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])