diff --git a/CLI.py b/CLI.py index 4e680ad..cef8c44 100644 --- a/CLI.py +++ b/CLI.py @@ -33,7 +33,13 @@ from rich.panel import Panel from rich.markdown import Markdown from rich.bar import Bar from rich.table import Table -from SYS.rich_display import stderr_console, stdout_console +from SYS.rich_display import ( + IMAGE_EXTENSIONS, + render_image_to_console, + render_item_details_panel, + stderr_console, + stdout_console, +) def _install_rich_traceback(*, show_locals: bool = False) -> None: @@ -4138,6 +4144,93 @@ class PipelineExecutor: ctx.set_last_result_items_only(items) return + # Special-case: selecting a single image should show it directly. + if len(items) == 1: + item = items[0] + + # Try to get hash and store to resolve through the backend + file_hash = None + store_name = None + if isinstance(item, dict): + file_hash = item.get("hash") + store_name = item.get("store") + else: + if hasattr(item, "hash"): + file_hash = getattr(item, "hash", None) + if hasattr(item, "store"): + store_name = getattr(item, "store", None) + + # Try to resolve the file through the Store backend if we have hash + store + resolved_file_path = None + if file_hash and store_name: + try: + from Store import Store + storage = Store(config=config or {}) + backend = storage[str(store_name)] + + # Call get_file to resolve the hash to an actual file path + maybe_path = backend.get_file(str(file_hash)) + + if isinstance(maybe_path, Path): + resolved_file_path = maybe_path + elif isinstance(maybe_path, str) and maybe_path: + # Only treat as a Path if it doesn't look like a URL + if not maybe_path.startswith(("http://", "https://")): + resolved_file_path = Path(maybe_path) + except Exception: + # Fallback: try using the path field from the item + pass + + # If backend resolution failed, try the path field + if not resolved_file_path: + path_str = None + if isinstance(item, dict): + path_str = ( + item.get("path") + or item.get("PATH") + or item.get("target") + or item.get("filename") + ) + else: + # Try attributes for PipeObject/SearchResult/etc. + for attr in ("path", "PATH", "target", "filename"): + if hasattr(item, attr): + val = getattr(item, attr) + if val and isinstance(val, (str, Path)): + path_str = val + break + + if path_str: + from SYS.utils import expand_path + resolved_file_path = expand_path(path_str).resolve() + + # Now check if it's an image and render it + is_image = False + if resolved_file_path: + try: + if resolved_file_path.suffix.lower() in IMAGE_EXTENSIONS and resolved_file_path.exists(): + # Use our image renderer + stdout_console().print() + render_image_to_console(resolved_file_path) + is_image = True + elif resolved_file_path.suffix.lower() in IMAGE_EXTENSIONS and not resolved_file_path.exists(): + stdout_console().print(f"[yellow]Warning: Image file not found at {resolved_file_path}[/yellow]") + except Exception: + pass + + # Render the comprehensive details panel for the item in either case + item_to_details = item if isinstance(item, dict) else ( + item.to_dict() if hasattr(item, "to_dict") else vars(item) + ) + # Ensure we include the resolved path if we found one + if resolved_file_path and "path" not in item_to_details: + item_to_details["path"] = str(resolved_file_path) + + render_item_details_panel(item_to_details) + + ctx.set_last_result_items_only(items) + return + table = ResultTable("Selection Result") for item in items: table.add_result(item) diff --git a/SYS/rich_display.py b/SYS/rich_display.py index d80a288..55e6f4a 100644 --- a/SYS/rich_display.py +++ b/SYS/rich_display.py @@ -16,6 +16,8 @@ from typing import Any, Iterator, Sequence, TextIO from rich.console import Console from rich.panel import Panel from rich.text import Text +from pathlib import Path +from SYS.utils import expand_path # Configure Rich pretty-printing to avoid truncating long strings (hashes/paths). # This is version-safe: older Rich versions may not support the max_* arguments. @@ -93,3 +95,170 @@ def show_store_config_panel( """Show a Rich panel explaining how to configure a storage backend.""" pass + +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"} + + +def render_image_to_console(image_path: str | Path, max_width: int | None = None) -> None: + """Render an image to the console using Rich and half-block characters. + + Requires Pillow (PIL). If not available or loading fails, does nothing. + """ + try: + from PIL import Image + from rich.color import Color + from rich.style import Style + from rich.text import Text + + # Ensure we expand environment variables and resolve to an absolute path. + path = expand_path(image_path).resolve() + if not path.exists() or not path.is_file(): + # If absolute path fails, try relative to current directory as a last resort. + path = Path(image_path).resolve() + if not path.exists() or not path.is_file(): + return + + with Image.open(path) as img: + img = img.convert("RGB") + orig_w, orig_h = img.size + + # Determine target dimensions + console = stdout_console() + if max_width is None: + max_width = console.width - 4 + + # Ensure max_width is at least something reasonable + if max_width < 10: + max_width = 80 + + # Terminal cells are approx 2x taller than wide. + # Half-block characters (upper half block) let us treat one cell as two vertical pixels. + aspect = orig_h / orig_w + target_w = min(orig_w, max_width) + + # Ensure target_h is even so we always have pairs of vertical pixels + target_h = int(target_w * aspect) + if target_h < 2: + target_h = 2 + target_h = (target_h // 2) * 2 + + # Ensure we don't exceed a reasonable height either (e.g. 40 characters) + max_height_chars = 40 + if (target_h // 2) > max_height_chars: + target_h = max_height_chars * 2 + target_w = int(target_h / aspect) + target_h = (target_h // 2) * 2 # Re-ensure even + + if target_w < 1: target_w = 1 + + img = img.resize((target_w, target_h), Image.Resampling.BILINEAR) + pixels = img.load() + + # Render using upper half block (U+2580) + # Each character row in terminal represents 2 pixel rows in image. + for y in range(0, target_h - 1, 2): + line = Text() + for x in range(target_w): + r1, g1, b1 = pixels[x, y] + r2, g2, b2 = pixels[x, y + 1] + # Foreground is top pixel, background is bottom pixel + line.append( + "▀", + style=Style( + color=Color.from_rgb(r1, g1, b1), + bgcolor=Color.from_rgb(r2, g2, b2), + ), + ) + console.print(line) + + except Exception: + # Silently fail if image cannot be rendered (e.g. missing PIL or corrupted file) + pass + + +def render_item_details_panel(item: Dict[str, Any]) -> None: + """Render a comprehensive details panel for a result item.""" + from rich.table import Table + from rich.columns import Columns + + title = ( + item.get("title") + or item.get("name") + or item.get("TITLE") + or "Unnamed Item" + ) + + # Main layout table for the panel + details_table = Table.grid(expand=True) + details_table.add_column(style="cyan", no_wrap=True, width=15) + details_table.add_column(style="white") + + # Basic Info + details_table.add_row("Title", f"[bold]{title}[/bold]") + + if "store" in item: + details_table.add_row("Store", str(item["store"])) + + if "hash" in item: + details_table.add_row("Hash", str(item["hash"])) + + # Metadata / Path + if "path" in item or "target" in item: + path = item.get("path") or item.get("target") + details_table.add_row("Path", str(path)) + + if "ext" in item or "extension" in item: + ext = item.get("ext") or item.get("extension") + details_table.add_row("Extension", str(ext)) + + if "size_bytes" in item or "size" in item: + size = item.get("size_bytes") or item.get("size") + if isinstance(size, (int, float)): + if size > 1024 * 1024 * 1024: + size_str = f"{size / (1024*1024*1024):.1f} GB" + elif size > 1024 * 1024: + size_str = f"{size / (1024*1024):.1f} MB" + elif size > 1024: + size_str = f"{size / 1024:.1f} KB" + else: + size_str = f"{size} bytes" + details_table.add_row("Size", size_str) + + # URL(s) + urls = item.get("url") or item.get("URL") or [] + if isinstance(urls, str): + urls = [urls] + if isinstance(urls, list) and urls: + url_text = "\n".join(map(str, urls)) + details_table.add_row("URL(s)", url_text) + + # Tags + tags = item.get("tag") or item.get("tags") or [] + if isinstance(tags, str): + tags = [tags] + if isinstance(tags, list) and tags: + # Sort and filter tags to look nice + tags_sorted = sorted(map(str, tags)) + # Group tags by namespace if they have them + tag_cols = Columns([f"[dim]#[/dim]{t}" for t in tags_sorted], equal=True, expand=True) + details_table.add_row("", "") # Spacer + details_table.add_row("Tags", tag_cols) + + # Relationships (if any) + rels = item.get("relationships") or item.get("rel") or [] + if isinstance(rels, list) and rels: + rel_text = "\n".join([f"[dim]→[/dim] {r}" for r in rels]) + details_table.add_row("Relations", rel_text) + + panel = Panel( + details_table, + title=f"[bold green]Item Details[/bold green]", + border_style="green", + padding=(1, 2), + expand=True + ) + + stdout_console().print() + stdout_console().print(panel) + + diff --git a/Store/HydrusNetwork.py b/Store/HydrusNetwork.py index 2aa1b40..439e01b 100644 --- a/Store/HydrusNetwork.py +++ b/Store/HydrusNetwork.py @@ -316,7 +316,7 @@ class HydrusNetwork(Store): metadata = client.fetch_file_metadata( hashes=[file_hash], include_service_keys_to_tags=False, - include_file_url=False, + include_file_url=True, include_duration=False, include_size=False, include_mime=False, @@ -808,7 +808,7 @@ class HydrusNetwork(Store): payload = client.fetch_file_metadata( file_ids=chunk, include_service_keys_to_tags=True, - include_file_url=False, + include_file_url=True, include_duration=True, include_size=True, include_mime=True, @@ -882,11 +882,18 @@ class HydrusNetwork(Store): ) _collect(display_tags) - file_url = f"{self.URL.rstrip('/')}/get_files/file?hash={hash_hex}" + # Unique tags + all_tags = sorted(list(set(all_tags))) + + # Use known URLs (source URLs) from Hydrus if available (matches get-url cmdlet) + item_url = meta.get("known_urls") or meta.get("urls") or meta.get("url") or [] + if not item_url: + item_url = meta.get("file_url") or f"{self.URL.rstrip('/')}/view_file?hash={hash_hex}" + results.append( { "hash": hash_hex, - "url": file_url, + "url": item_url, "name": title, "title": title, "size": size, @@ -912,7 +919,7 @@ class HydrusNetwork(Store): metadata = client.fetch_file_metadata( file_ids=file_ids, include_service_keys_to_tags=True, - include_file_url=False, + include_file_url=True, include_duration=True, include_size=True, include_mime=True, @@ -922,7 +929,7 @@ class HydrusNetwork(Store): metadata = client.fetch_file_metadata( hashes=hashes, include_service_keys_to_tags=True, - include_file_url=False, + include_file_url=True, include_duration=True, include_size=True, include_mime=True, @@ -946,7 +953,7 @@ class HydrusNetwork(Store): metadata = client.fetch_file_metadata( file_ids=file_ids, include_service_keys_to_tags=True, - include_file_url=False, + include_file_url=True, include_duration=True, include_size=True, include_mime=True, @@ -956,7 +963,7 @@ class HydrusNetwork(Store): metadata = client.fetch_file_metadata( hashes=hashes, include_service_keys_to_tags=True, - include_file_url=False, + include_file_url=True, include_duration=True, include_size=True, include_mime=True, @@ -1020,6 +1027,9 @@ class HydrusNetwork(Store): # Also consider top-level flattened tags payload if provided (Hydrus API sometimes includes it) top_level_tags = meta.get("tags_flat", []) or meta.get("tags", []) _collect(top_level_tags) + + # Unique tags + all_tags = sorted(list(set(all_tags))) # Prefer Hydrus-provided extension (e.g. ".webm"); fall back to MIME map. mime_type = meta.get("mime") @@ -1038,14 +1048,18 @@ class HydrusNetwork(Store): # Just include what the tag search returned has_namespace = ":" in query_lower + # Use known URLs (source URLs) from Hydrus if available (matches get-url cmdlet) + item_url = meta.get("known_urls") or meta.get("urls") or meta.get("url") or [] + if not item_url: + item_url = meta.get("file_url") or f"{self.URL.rstrip('/')}/view_file?hash={hash_hex}" + if has_namespace: # Explicit namespace search - already filtered by Hydrus tag search # Include this result as-is - file_url = f"{self.URL.rstrip('/')}/get_files/file?hash={hash_hex}" results.append( { "hash": hash_hex, - "url": file_url, + "url": item_url, "name": title, "title": title, "size": size, @@ -1074,11 +1088,10 @@ class HydrusNetwork(Store): break if match: - file_url = f"{self.URL.rstrip('/')}/get_files/file?hash={hash_hex}" results.append( { "hash": hash_hex, - "url": file_url, + "url": item_url, "name": title, "title": title, "size": size, @@ -1113,7 +1126,7 @@ class HydrusNetwork(Store): raise def get_file(self, file_hash: str, **kwargs: Any) -> Path | str | None: - """Return a browser URL for the file. + """Return the local file system path if available, else a browser URL. IMPORTANT: this method must be side-effect free (do not auto-open a browser). Only explicit user actions (e.g. the get-file cmdlet) should open files. @@ -1121,13 +1134,33 @@ class HydrusNetwork(Store): debug(f"{self._log_prefix()} get_file: start hash={file_hash[:12]}...") - # Build browser URL with access key + # Try to get the local disk path if possible (works if Hydrus is on same machine) + server_path = None + try: + path_res = self._client.get_file_path(file_hash) + if isinstance(path_res, dict) and "path" in path_res: + server_path = path_res["path"] + if server_path: + local_path = Path(server_path) + if local_path.exists(): + debug(f"{self._log_prefix()} get_file: found local path: {local_path}") + return local_path + except Exception as e: + debug(f"{self._log_prefix()} get_file: could not resolve path from API: {e}") + + # If we found a path on the server but it's not locally accessible, + # return it as a string so it can be displayed in metadata panels. + if server_path: + debug(f"{self._log_prefix()} get_file: returning server path (not local): {server_path}") + return server_path + + # Fallback to browser URL with access key base_url = str(self.URL).rstrip("/") access_key = str(self.API) browser_url = ( f"{base_url}/get_files/file?hash={file_hash}&Hydrus-Client-API-Access-Key={access_key}" ) - debug(f"{self._log_prefix()} get_file: url={browser_url}") + debug(f"{self._log_prefix()} get_file: falling back to url={browser_url}") return browser_url def download_to_temp( @@ -1449,7 +1482,7 @@ class HydrusNetwork(Store): payload = client.fetch_file_metadata( hashes=[file_hash], include_service_keys_to_tags=True, - include_file_url=False + include_file_url=True ) items = payload.get("metadata") if isinstance(payload, dict) else None @@ -1603,7 +1636,7 @@ class HydrusNetwork(Store): payload = client.fetch_file_metadata( hashes=[file_hash], - include_file_url=False + include_file_url=True ) items = payload.get("metadata") if isinstance(payload, dict) else None if not isinstance(items, list) or not items: diff --git a/config.conf.remove b/config.conf.remove deleted file mode 100644 index b77da45..0000000 --- a/config.conf.remove +++ /dev/null @@ -1,3 +0,0 @@ -####REMOVE THIS#### -####place config information below, rename this file to config.conf#### -####REMOVE THIS#### \ No newline at end of file