AST
This commit is contained in:
1
TUI/__init__.py
Normal file
1
TUI/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Medeia-Macina TUI - Terminal User Interface."""
|
||||
105
TUI/menu_actions.py
Normal file
105
TUI/menu_actions.py
Normal 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))
|
||||
7
TUI/modalscreen/__init__.py
Normal file
7
TUI/modalscreen/__init__.py
Normal 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
139
TUI/modalscreen/access.py
Normal 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
1880
TUI/modalscreen/download.py
Normal file
File diff suppressed because it is too large
Load Diff
183
TUI/modalscreen/download.tcss
Normal file
183
TUI/modalscreen/download.tcss
Normal 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
512
TUI/modalscreen/export.py
Normal 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
|
||||
85
TUI/modalscreen/export.tcss
Normal file
85
TUI/modalscreen/export.tcss
Normal 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
505
TUI/modalscreen/search.py
Normal 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
121
TUI/modalscreen/search.tcss
Normal 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
585
TUI/modalscreen/workers.py
Normal 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)
|
||||
119
TUI/modalscreen/workers.tcss
Normal file
119
TUI/modalscreen/workers.tcss
Normal 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
356
TUI/pipeline_runner.py
Normal 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
332
TUI/tui.py
Normal 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
100
TUI/tui.tcss
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user