|
5 | 5 | import sys |
6 | 6 | import os |
7 | 7 | import webbrowser |
| 8 | +import threading |
| 9 | +import time |
8 | 10 | from typing import List, Tuple, Optional |
9 | 11 | from rich.console import Console |
10 | 12 | from rich.table import Table |
|
28 | 30 | from archpkg.search_apt import search_apt |
29 | 31 | from archpkg.search_dnf import search_dnf |
30 | 32 | from archpkg.search_zypper import search_zypper |
| 33 | +from archpkg.search_rpm import search_rpm |
31 | 34 | from archpkg.command_gen import generate_command |
32 | 35 | from archpkg.logging_config import get_logger, PackageHelperLogger |
33 | 36 | from archpkg.github_install import install_from_github, validate_github_url |
|
38 | 41 | from archpkg.installed_apps import add_installed_package, get_all_installed_packages, get_packages_with_updates |
39 | 42 | from archpkg.suggest import suggest_apps, list_purposes |
40 | 43 | from archpkg.cache import get_cache_manager, CacheConfig |
| 44 | +from archpkg.pkgs_org import PkgsOrgClient |
41 | 45 |
|
42 | 46 | console = Console() |
43 | 47 | logger = get_logger(__name__) |
@@ -329,6 +333,62 @@ def show_opensuse_brave_guidance() -> None: |
329 | 333 | border_style="blue" |
330 | 334 | )) |
331 | 335 |
|
| 336 | +def search_pkgs_org(query: str, detected_distro: str, limit: int = 10) -> List[dict]: |
| 337 | + """Search pkgs.org for packages across distributions. |
| 338 | + |
| 339 | + Args: |
| 340 | + query: Package search query |
| 341 | + detected_distro: Current detected distribution |
| 342 | + limit: Maximum number of results |
| 343 | + |
| 344 | + Returns: |
| 345 | + List of package dicts with install commands and metadata |
| 346 | + """ |
| 347 | + try: |
| 348 | + logger.debug("Attempting pkgs.org search as supplementary source") |
| 349 | + client = PkgsOrgClient() |
| 350 | + |
| 351 | + # Search with distro hint |
| 352 | + results = client.search(query, distro=detected_distro, limit=limit) |
| 353 | + |
| 354 | + if not results: |
| 355 | + logger.debug("pkgs.org returned no results") |
| 356 | + return [] |
| 357 | + |
| 358 | + logger.info(f"pkgs.org found {len(results)} cross-distro results") |
| 359 | + return results |
| 360 | + |
| 361 | + except Exception as e: |
| 362 | + logger.debug(f"pkgs.org search failed: {e}") |
| 363 | + return [] |
| 364 | + |
| 365 | +def show_pkgs_org_availability(results: List[dict]) -> None: |
| 366 | + """Show where a package is available across distributions. |
| 367 | + |
| 368 | + Args: |
| 369 | + results: List of package dict results from pkgs.org |
| 370 | + """ |
| 371 | + if not results: |
| 372 | + return |
| 373 | + |
| 374 | + try: |
| 375 | + # Group by distro |
| 376 | + distro_packages = {} |
| 377 | + for r in results: |
| 378 | + distro = r.get("distro", "Unknown") |
| 379 | + if distro not in distro_packages: |
| 380 | + distro_packages[distro] = [] |
| 381 | + distro_packages[distro].append(r.get("name", "")) |
| 382 | + |
| 383 | + console.print("\n[bold cyan]📊 Cross-Distribution Availability:[/bold cyan]") |
| 384 | + for distro, packages in distro_packages.items(): |
| 385 | + pkg_list = ", ".join(packages[:3]) |
| 386 | + if len(packages) > 3: |
| 387 | + pkg_list += f" (+{len(packages)-3} more)" |
| 388 | + console.print(f" • {distro}: {pkg_list}") |
| 389 | + except Exception as e: |
| 390 | + logger.debug(f"Failed to show availability: {e}") |
| 391 | + |
332 | 392 | def github_fallback(query: str, unavailable_sources: Optional[List[str]] = None) -> None: |
333 | 393 | """Provide GitHub search fallback with clear messaging and alternative installation options. |
334 | 394 | |
@@ -848,6 +908,23 @@ def search( |
848 | 908 | results = [] |
849 | 909 | search_errors = [] |
850 | 910 | use_cache = not no_cache |
| 911 | + |
| 912 | + # Start async pkgs.org search in background |
| 913 | + pkgs_org_results = [] |
| 914 | + pkgs_org_thread = None |
| 915 | + |
| 916 | + def async_pkgs_org_search(): |
| 917 | + nonlocal pkgs_org_results |
| 918 | + try: |
| 919 | + logger.debug("Background pkgs.org search started") |
| 920 | + pkgs_org_results = search_pkgs_org(query_str, detected, limit=10) |
| 921 | + logger.debug(f"Background pkgs.org search completed: {len(pkgs_org_results)} results") |
| 922 | + except Exception as e: |
| 923 | + logger.debug(f"Background pkgs.org search failed: {e}") |
| 924 | + |
| 925 | + # Launch background search |
| 926 | + pkgs_org_thread = threading.Thread(target=async_pkgs_org_search, daemon=True) |
| 927 | + pkgs_org_thread.start() |
851 | 928 |
|
852 | 929 | # Search with all query variations |
853 | 930 | for query_variant in query_variations: |
@@ -892,6 +969,14 @@ def search( |
892 | 969 | logger.debug(f"DNF search failed: {e}") |
893 | 970 | if query_variant == query_str: |
894 | 971 | search_errors.append("DNF") |
| 972 | + |
| 973 | + # Fallback to RPM if DNF fails |
| 974 | + try: |
| 975 | + logger.debug("Starting RPM search as fallback") |
| 976 | + rpm_results = search_rpm(query_variant, limit=limit) |
| 977 | + results.extend(rpm_results) |
| 978 | + except Exception as e: |
| 979 | + logger.debug(f"RPM search failed: {e}") |
895 | 980 |
|
896 | 981 | elif detected == "suse": |
897 | 982 | logger.info("Searching openSUSE-based repositories (Zypper)") |
@@ -932,16 +1017,84 @@ def search( |
932 | 1017 | if search_errors: |
933 | 1018 | logger.debug(f"Note: Some sources unavailable: {', '.join(search_errors)}") |
934 | 1019 |
|
| 1020 | + # Wait for background pkgs.org search to complete (max 5 seconds) |
| 1021 | + if pkgs_org_thread and pkgs_org_thread.is_alive(): |
| 1022 | + logger.debug("Waiting for background pkgs.org search...") |
| 1023 | + pkgs_org_thread.join(timeout=5.0) |
| 1024 | + |
935 | 1025 | if not results: |
936 | | - logger.info("No results found, providing GitHub fallback") |
| 1026 | + logger.info("No results found in local package managers") |
| 1027 | + |
| 1028 | + if pkgs_org_results: |
| 1029 | + # Show pkgs.org results |
| 1030 | + console.print(Panel( |
| 1031 | + f"[yellow]No packages found in your local repositories.[/yellow]\n\n" |
| 1032 | + f"[bold cyan]However, '{query_str}' is available on other distributions:[/bold cyan]", |
| 1033 | + title="📦 Cross-Distribution Search", |
| 1034 | + border_style="cyan" |
| 1035 | + )) |
| 1036 | + |
| 1037 | + # Display pkgs.org results in a table with install commands |
| 1038 | + table = Table(title="Available on Other Distributions (via pkgs.org)", width=120, expand=False) |
| 1039 | + table.add_column("Package Name", style="green") |
| 1040 | + table.add_column("Distribution/Repo", style="blue") |
| 1041 | + table.add_column("Description", style="magenta") |
| 1042 | + table.add_column("Install Command", style="yellow") |
| 1043 | + |
| 1044 | + for pkg_dict in pkgs_org_results[:limit]: |
| 1045 | + name = pkg_dict.get("name", "") |
| 1046 | + desc = pkg_dict.get("summary", "") or pkg_dict.get("description", "No description") |
| 1047 | + distro = pkg_dict.get("distro", "Unknown") |
| 1048 | + repo = pkg_dict.get("repo", "") |
| 1049 | + source_label = f"{distro}" + (f" ({repo})" if repo else "") |
| 1050 | + |
| 1051 | + # Extract install command if available |
| 1052 | + install_cmd = pkg_dict.get("install_command", "") |
| 1053 | + if not install_cmd and pkg_dict.get("url"): |
| 1054 | + install_cmd = f"Visit: {pkg_dict.get('url')}" |
| 1055 | + |
| 1056 | + table.add_row(name, source_label, desc or "No description", install_cmd or "Manual install") |
| 1057 | + |
| 1058 | + console.print(table) |
| 1059 | + |
| 1060 | + # Show cross-distro availability summary |
| 1061 | + show_pkgs_org_availability(pkgs_org_results) |
| 1062 | + |
| 1063 | + console.print("\n[bold yellow]💡 Suggestions:[/bold yellow]") |
| 1064 | + console.print(" • This package may be in a third-party repository") |
| 1065 | + console.print(" • Check if you need to enable additional repos") |
| 1066 | + console.print(" • Try installing via Snap or Flatpak (see below)") |
| 1067 | + |
| 1068 | + # Offer automatic installation if we have direct commands |
| 1069 | + installable = [p for p in pkgs_org_results if p.get("install_command")] |
| 1070 | + if installable: |
| 1071 | + console.print(f"\n[bold green]✨ {len(installable)} package(s) have direct install commands available[/bold green]") |
| 1072 | + console.print() |
| 1073 | + |
937 | 1074 | # Show special guidance for Brave browser on openSUSE |
938 | 1075 | if detected == "suse" and "brave" in query_str.lower(): |
939 | 1076 | show_opensuse_brave_guidance() |
| 1077 | + |
| 1078 | + # Always show the GitHub fallback and installation suggestions |
940 | 1079 | github_fallback(query_str, search_errors) |
941 | 1080 | return |
942 | 1081 |
|
943 | 1082 | deduplicated_results = deduplicate_packages(results, prefer_aur=aur) |
944 | 1083 | logger.info(f"After deduplication: {len(deduplicated_results)} unique packages") |
| 1084 | + |
| 1085 | + # Show pkgs.org supplementary results if available (even when local results exist) |
| 1086 | + if pkgs_org_results and len(pkgs_org_results) > 0: |
| 1087 | + console.print("\n[dim]📦 Additional packages available on other distributions:[/dim]") |
| 1088 | + pkgs_summary = [] |
| 1089 | + for pkg_dict in pkgs_org_results[:3]: |
| 1090 | + name = pkg_dict.get("name", "") |
| 1091 | + distro = pkg_dict.get("distro", "Unknown") |
| 1092 | + pkgs_summary.append(f"{name} ({distro})") |
| 1093 | + console.print(f"[dim] {', '.join(pkgs_summary)}[/dim]") |
| 1094 | + if len(pkgs_org_results) > 3: |
| 1095 | + console.print(f"[dim] +{len(pkgs_org_results)-3} more available via pkgs.org[/dim]\n") |
| 1096 | + else: |
| 1097 | + console.print() |
945 | 1098 |
|
946 | 1099 | top_matches = get_top_matches(query_str, deduplicated_results, limit=limit) |
947 | 1100 | if not top_matches: |
|
0 commit comments