jjlj
This commit is contained in:
@@ -68,37 +68,6 @@ def group_tags_by_namespace(tags: Sequence[str]) -> Dict[str, List[str]]:
|
||||
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."""
|
||||
|
||||
|
||||
@@ -69,33 +69,34 @@ class ExportModal(ModalScreen):
|
||||
"""
|
||||
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")]
|
||||
from helper.utils_constant import mime_maps
|
||||
|
||||
# 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")]
|
||||
found_type = "unknown"
|
||||
|
||||
# Image formats
|
||||
image_exts = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.ico'}
|
||||
image_formats = [("JPG", "jpg"), ("PNG", "png"), ("WebP", "webp"), ("GIF", "gif"), ("BMP", "bmp")]
|
||||
# Find type based on extension
|
||||
for category, formats in mime_maps.items():
|
||||
for fmt_key, fmt_info in formats.items():
|
||||
if fmt_info.get("ext") == ext_lower:
|
||||
found_type = category
|
||||
break
|
||||
if found_type != "unknown":
|
||||
break
|
||||
|
||||
# Document formats - no conversion for now
|
||||
document_exts = {'.pdf', '.epub', '.txt', '.docx', '.doc', '.rtf', '.md', '.html', '.mobi', '.cbz', '.cbr'}
|
||||
document_formats = []
|
||||
# Build format options for the found type
|
||||
format_options = []
|
||||
|
||||
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)
|
||||
# If unknown, fallback to audio (matching legacy behavior)
|
||||
target_type = found_type if found_type in mime_maps else "audio"
|
||||
|
||||
if target_type in mime_maps:
|
||||
# Sort formats alphabetically
|
||||
sorted_formats = sorted(mime_maps[target_type].items())
|
||||
for fmt_key, fmt_info in sorted_formats:
|
||||
label = fmt_key.upper()
|
||||
value = fmt_key
|
||||
format_options.append((label, value))
|
||||
|
||||
return (target_type, format_options)
|
||||
|
||||
def _get_library_options(self) -> list:
|
||||
"""Get available library options from config.json."""
|
||||
|
||||
@@ -15,6 +15,8 @@ import asyncio
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from config import load_config
|
||||
from result_table import ResultTable
|
||||
from helper.search_provider import get_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,7 +51,8 @@ class SearchModal(ModalScreen):
|
||||
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.current_results: List[Any] = [] # List of SearchResult objects
|
||||
self.current_result_table: Optional[ResultTable] = None
|
||||
self.is_searching = False
|
||||
self.current_worker = None # Track worker for search operations
|
||||
|
||||
@@ -125,124 +128,6 @@ class SearchModal(ModalScreen):
|
||||
# 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:
|
||||
@@ -257,87 +142,69 @@ class SearchModal(ModalScreen):
|
||||
source = self.source_select.value
|
||||
|
||||
# Clear existing results
|
||||
self.results_table.clear()
|
||||
self.results_table.clear(columns=True)
|
||||
self.current_results = []
|
||||
self.current_result_table = None
|
||||
|
||||
self.is_searching = True
|
||||
|
||||
# Create worker for tracking
|
||||
if self.app_instance and hasattr(self.app_instance, 'create_worker'):
|
||||
self.current_worker = self.app_instance.create_worker(
|
||||
source,
|
||||
title=f"{source.capitalize()} Search: {query[:40]}",
|
||||
description=f"Searching {source} for: {query}"
|
||||
)
|
||||
self.current_worker.log_step(f"Connecting to {source}...")
|
||||
|
||||
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}")
|
||||
provider = get_provider(source)
|
||||
if not provider:
|
||||
logger.error(f"[search-modal] Provider not available: {source}")
|
||||
if self.current_worker:
|
||||
self.current_worker.finish("error", "Unknown search source")
|
||||
self.current_worker.finish("error", f"Provider not available: {source}")
|
||||
return
|
||||
|
||||
|
||||
logger.info(f"[search-modal] Searching {source} for: {query}")
|
||||
results = provider.search(query, limit=20)
|
||||
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", "", "", "")
|
||||
if self.current_worker:
|
||||
self.current_worker.log_step(f"Found {len(results)} results")
|
||||
|
||||
# Finish worker if tracking
|
||||
# Create ResultTable
|
||||
table = ResultTable(f"Search Results: {query}")
|
||||
for res in results:
|
||||
row = table.add_row()
|
||||
# Add columns from result.columns
|
||||
if res.columns:
|
||||
for name, value in res.columns:
|
||||
row.add_column(name, value)
|
||||
else:
|
||||
# Fallback if no columns defined
|
||||
row.add_column("Title", res.title)
|
||||
row.add_column("Target", res.target)
|
||||
|
||||
self.current_result_table = table
|
||||
|
||||
# Populate UI
|
||||
if table.rows:
|
||||
# Add headers
|
||||
headers = [col.name for col in table.rows[0].columns]
|
||||
self.results_table.add_columns(*headers)
|
||||
# Add rows
|
||||
for row_vals in table.to_datatable_rows():
|
||||
self.results_table.add_row(*row_vals)
|
||||
else:
|
||||
self.results_table.add_columns("Message")
|
||||
self.results_table.add_row("No results found")
|
||||
|
||||
# Finish worker
|
||||
if self.current_worker:
|
||||
self.current_worker.finish("completed", f"Found {len(results)} results")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[search-modal] Search error: {e}")
|
||||
logger.error(f"[search-modal] Search error: {e}", exc_info=True)
|
||||
if self.current_worker:
|
||||
self.current_worker.finish("error", f"Search failed: {str(e)}")
|
||||
|
||||
@@ -382,35 +249,58 @@ class SearchModal(ModalScreen):
|
||||
selected_row = self.results_table.cursor_row
|
||||
if 0 <= selected_row < len(self.current_results):
|
||||
result = self.current_results[selected_row]
|
||||
|
||||
# Convert to dict if needed for submission
|
||||
if hasattr(result, 'to_dict'):
|
||||
result_dict = result.to_dict()
|
||||
else:
|
||||
result_dict = result
|
||||
|
||||
# Get tags from textarea
|
||||
tags_text = self.tags_textarea.text if self.tags_textarea else ""
|
||||
# Get library source (if OpenLibrary)
|
||||
library_source = self.library_source_select.value if self.library_source_select else "local"
|
||||
|
||||
# Add tags and source to result
|
||||
result["tags_text"] = tags_text
|
||||
result["library_source"] = library_source
|
||||
result_dict["tags_text"] = tags_text
|
||||
result_dict["library_source"] = library_source
|
||||
|
||||
# Post message and dismiss
|
||||
self.post_message(self.SearchSelected(result))
|
||||
self.dismiss(result)
|
||||
self.post_message(self.SearchSelected(result_dict))
|
||||
self.dismiss(result_dict)
|
||||
else:
|
||||
logger.warning("[search-modal] No result selected for submission")
|
||||
|
||||
elif button_id == "cancel-button":
|
||||
self.dismiss(None)
|
||||
|
||||
def _populate_tags_from_result(self, result: dict) -> None:
|
||||
def _populate_tags_from_result(self, result: Any) -> None:
|
||||
"""Populate the tags textarea from a selected result."""
|
||||
if not self.tags_textarea:
|
||||
return
|
||||
|
||||
# Handle both SearchResult objects and dicts
|
||||
if hasattr(result, 'full_metadata'):
|
||||
metadata = result.full_metadata or {}
|
||||
source = result.origin
|
||||
title = result.title
|
||||
else:
|
||||
# Handle dict (legacy or from to_dict)
|
||||
if 'full_metadata' in result:
|
||||
metadata = result['full_metadata'] or {}
|
||||
elif 'raw_data' in result:
|
||||
metadata = result['raw_data'] or {}
|
||||
else:
|
||||
metadata = result
|
||||
|
||||
source = result.get('origin', result.get('source', ''))
|
||||
title = result.get('title', '')
|
||||
|
||||
# Format tags based on result source
|
||||
if result.get("source") == "openlibrary":
|
||||
if source == "openlibrary":
|
||||
# For OpenLibrary: title, author, year
|
||||
title = result.get("title", "")
|
||||
author = result.get("author", "")
|
||||
year = result.get("year", "")
|
||||
author = ", ".join(metadata.get("authors", [])) if isinstance(metadata.get("authors"), list) else metadata.get("authors", "")
|
||||
year = str(metadata.get("year", ""))
|
||||
tags = []
|
||||
if title:
|
||||
tags.append(title)
|
||||
@@ -419,38 +309,51 @@ class SearchModal(ModalScreen):
|
||||
if year:
|
||||
tags.append(year)
|
||||
tags_text = "\n".join(tags)
|
||||
else: # soulseek
|
||||
elif source == "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"])
|
||||
if metadata.get("artist"):
|
||||
tags.append(metadata["artist"])
|
||||
if metadata.get("album"):
|
||||
tags.append(metadata["album"])
|
||||
if metadata.get("track_num"):
|
||||
tags.append(f"Track {metadata['track_num']}")
|
||||
if title:
|
||||
tags.append(title)
|
||||
tags_text = "\n".join(tags)
|
||||
else:
|
||||
# Generic fallback
|
||||
tags = [title]
|
||||
tags_text = "\n".join(tags)
|
||||
|
||||
self.tags_textarea.text = tags_text
|
||||
logger.info(f"[search-modal] Populated tags textarea from result")
|
||||
|
||||
async def _download_book(self, result: dict) -> None:
|
||||
async def _download_book(self, result: Any) -> 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')}")
|
||||
# Convert SearchResult to dict if needed
|
||||
if hasattr(result, 'to_dict'):
|
||||
result_dict = result.to_dict()
|
||||
# Ensure raw_data is populated for downloader
|
||||
if 'raw_data' not in result_dict and result.full_metadata:
|
||||
result_dict['raw_data'] = result.full_metadata
|
||||
else:
|
||||
result_dict = result
|
||||
|
||||
logger.info(f"[search-modal] Starting download for: {result_dict.get('title')}")
|
||||
|
||||
config = load_config()
|
||||
downloader = UnifiedBookDownloader(config=config)
|
||||
|
||||
# Get download options for this book
|
||||
options = downloader.get_download_options(result)
|
||||
options = downloader.get_download_options(result_dict)
|
||||
|
||||
if not options['methods']:
|
||||
logger.warning(f"[search-modal] No download methods available for: {result.get('title')}")
|
||||
logger.warning(f"[search-modal] No download methods available for: {result_dict.get('title')}")
|
||||
# Could show a modal dialog here
|
||||
return
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ class PipelineStageResult:
|
||||
name: str
|
||||
args: Sequence[str]
|
||||
emitted: List[Any] = field(default_factory=list)
|
||||
result_table: Optional[Any] = None # ResultTable object if available
|
||||
status: str = "pending"
|
||||
error: Optional[str] = None
|
||||
|
||||
@@ -52,6 +53,7 @@ class PipelineRunResult:
|
||||
success: bool
|
||||
stages: List[PipelineStageResult] = field(default_factory=list)
|
||||
emitted: List[Any] = field(default_factory=list)
|
||||
result_table: Optional[Any] = None # Final ResultTable object if available
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
error: Optional[str] = None
|
||||
@@ -146,6 +148,7 @@ class PipelineExecutor:
|
||||
|
||||
if index == len(stages) - 1:
|
||||
result.emitted = stage.emitted
|
||||
result.result_table = stage.result_table
|
||||
else:
|
||||
piped_result = stage.emitted
|
||||
|
||||
@@ -211,6 +214,10 @@ class PipelineExecutor:
|
||||
|
||||
emitted = list(getattr(pipeline_ctx, "emits", []) or [])
|
||||
stage.emitted = emitted
|
||||
|
||||
# Capture the ResultTable if the cmdlet set one
|
||||
# Check display table first (overlay), then last result table
|
||||
stage.result_table = ctx.get_display_table() or ctx.get_last_result_table()
|
||||
|
||||
if return_code != 0:
|
||||
stage.status = "failed"
|
||||
@@ -224,7 +231,12 @@ class PipelineExecutor:
|
||||
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)
|
||||
# Don't clear the table if we just captured it, but ensure items are set for next stage
|
||||
# If we have a table, we should probably keep it in ctx for history if needed
|
||||
# But for pipeline execution, we mainly care about passing items to next stage
|
||||
# ctx.set_last_result_table(None, emitted) <-- This was clearing it
|
||||
|
||||
# Ensure items are available for next stage
|
||||
ctx.set_last_items(emitted)
|
||||
return stage
|
||||
|
||||
|
||||
72
TUI/tui.py
72
TUI/tui.py
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
from typing import Any, List, Optional, Sequence
|
||||
|
||||
from textual import work
|
||||
from textual.app import App, ComposeResult
|
||||
@@ -32,10 +32,9 @@ for path in (BASE_DIR, ROOT_DIR):
|
||||
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
|
||||
from result_table import ResultTable # type: ignore # noqa: E402
|
||||
|
||||
|
||||
class PresetListItem(ListItem):
|
||||
@@ -73,6 +72,7 @@ class PipelineHubApp(App):
|
||||
self.worker_table: Optional[DataTable] = None
|
||||
self.preset_list: Optional[ListView] = None
|
||||
self.status_panel: Optional[Static] = None
|
||||
self.current_result_table: Optional[ResultTable] = None
|
||||
self._pipeline_running = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -81,7 +81,7 @@ class PipelineHubApp(App):
|
||||
def compose(self) -> ComposeResult: # noqa: D401 - Textual compose hook
|
||||
yield Header(show_clock=True)
|
||||
with Container(id="app-shell"):
|
||||
with Horizontal(id="command-row"):
|
||||
with Horizontal(id="command-pane"):
|
||||
self.command_input = Input(
|
||||
placeholder='download-data "<url>" | merge-file | add-tag | add-file -storage local',
|
||||
id="pipeline-input",
|
||||
@@ -174,7 +174,7 @@ class PipelineHubApp(App):
|
||||
return
|
||||
index = event.cursor_row
|
||||
if 0 <= index < len(self.result_items):
|
||||
self._display_metadata(self.result_items[index])
|
||||
self._display_metadata(index)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pipeline execution helpers
|
||||
@@ -216,6 +216,7 @@ class PipelineHubApp(App):
|
||||
else:
|
||||
self.result_items = []
|
||||
|
||||
self.current_result_table = run_result.result_table
|
||||
self._populate_results_table()
|
||||
self.refresh_workers()
|
||||
|
||||
@@ -228,40 +229,45 @@ class PipelineHubApp(App):
|
||||
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))
|
||||
self.results_table.clear(columns=True)
|
||||
|
||||
def _display_metadata(self, item: Any) -> None:
|
||||
if self.current_result_table and self.current_result_table.rows:
|
||||
# Use ResultTable headers from the first row
|
||||
first_row = self.current_result_table.rows[0]
|
||||
headers = ["#"] + [col.name for col in first_row.columns]
|
||||
self.results_table.add_columns(*headers)
|
||||
|
||||
rows = self.current_result_table.to_datatable_rows()
|
||||
for idx, row_values in enumerate(rows, 1):
|
||||
self.results_table.add_row(str(idx), *row_values, key=str(idx - 1))
|
||||
else:
|
||||
# Fallback or empty state
|
||||
self.results_table.add_columns("Row", "Title", "Source", "File")
|
||||
if not self.result_items:
|
||||
self.results_table.add_row("—", "No results", "", "")
|
||||
return
|
||||
|
||||
# Fallback for items without a table
|
||||
for idx, item in enumerate(self.result_items, start=1):
|
||||
self.results_table.add_row(str(idx), str(item), "—", "—", key=str(idx - 1))
|
||||
|
||||
def _display_metadata(self, index: int) -> 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))
|
||||
if self.current_result_table and 0 <= index < len(self.current_result_table.rows):
|
||||
row = self.current_result_table.rows[index]
|
||||
for col in row.columns:
|
||||
root.add(f"[b]{col.name}[/b]: {col.value}")
|
||||
elif 0 <= index < len(self.result_items):
|
||||
item = self.result_items[index]
|
||||
if isinstance(item, dict):
|
||||
self._populate_tree_node(root, item)
|
||||
else:
|
||||
payload = item
|
||||
else:
|
||||
payload = {"value": str(item)}
|
||||
|
||||
self._populate_tree_node(root, payload)
|
||||
root.expand_all()
|
||||
root.add(str(item))
|
||||
|
||||
def _populate_tree_node(self, node, data: Any) -> None:
|
||||
if isinstance(data, dict):
|
||||
@@ -278,14 +284,14 @@ class PipelineHubApp(App):
|
||||
def _clear_log(self) -> None:
|
||||
self.log_lines = []
|
||||
if self.log_output:
|
||||
self.log_output.value = ""
|
||||
self.log_output.text = ""
|
||||
|
||||
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)
|
||||
self.log_output.text = "\n".join(self.log_lines)
|
||||
|
||||
def _append_block(self, text: str) -> None:
|
||||
for line in text.strip().splitlines():
|
||||
|
||||
20
TUI/tui.tcss
20
TUI/tui.tcss
@@ -6,7 +6,7 @@
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
#command-row {
|
||||
#command-pane {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: $boost;
|
||||
@@ -18,7 +18,6 @@
|
||||
width: 1fr;
|
||||
min-height: 3;
|
||||
padding: 0 1;
|
||||
margin-right: 1;
|
||||
background: $surface;
|
||||
color: $text;
|
||||
border: round $primary;
|
||||
@@ -30,7 +29,9 @@
|
||||
}
|
||||
|
||||
#status-panel {
|
||||
min-width: 20;
|
||||
width: auto;
|
||||
max-width: 25;
|
||||
height: 3;
|
||||
text-style: bold;
|
||||
content-align: center middle;
|
||||
padding: 0 1;
|
||||
@@ -52,7 +53,7 @@
|
||||
}
|
||||
|
||||
#left-pane {
|
||||
max-width: 48;
|
||||
max-width: 60;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@@ -67,6 +68,11 @@
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#preset-list {
|
||||
height: 25;
|
||||
border: solid $secondary;
|
||||
}
|
||||
|
||||
#log-output {
|
||||
height: 16;
|
||||
}
|
||||
@@ -97,4 +103,10 @@
|
||||
.status-error {
|
||||
background: $error 20%;
|
||||
color: $error;
|
||||
}
|
||||
|
||||
#run-button {
|
||||
width: auto;
|
||||
min-width: 10;
|
||||
margin: 0 1;
|
||||
}
|
||||
Reference in New Issue
Block a user