diff --git a/README.md b/README.md index d3efa04..7c4a9e3 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,64 @@ TODO: - Implement subcategories - General GUI layout/theming improvements - Sort runtimes from Desktop Apps + + +Usage (Temporary until proper packaging is added): + +Shop: `./main.py` +CLI: +``` +./libflatpak_query.py -h +usage: libflatpak_query.py [-h] [--id ID] [--repo REPO] [--list-all] [--categories] [--list-installed] [--check-updates] [--list-repos] [--add-repo REPO_FILE] [--remove-repo REPO_NAME] [--toggle-repo REPO_NAME ENABLE/DISABLE] [--install APP_ID] [--remove APP_ID] [--system] + +Search Flatpak packages + +options: + -h, --help show this help message and exit + --id ID Application ID to search for + --repo REPO Filter results to specific repository + --list-all List all available apps + --categories Show apps grouped by category + --list-installed List all installed Flatpak applications + --check-updates Check for available updates + --list-repos List all configured Flatpak repositories + --add-repo REPO_FILE Add a new repository from a .flatpakrepo file + --remove-repo REPO_NAME + Remove a Flatpak repository + --toggle-repo REPO_NAME ENABLE/DISABLE + Enable or disable a repository + --install APP_ID Install a Flatpak package + --remove APP_ID Remove a Flatpak package + --system Install as system instead of user + +``` + +Common CLI combinations: +``` +./libflatpak_query.py --id net.lutris.Lutris +./libflatpak_query.py --id net.lutris.Lutris --repo flatpak beta +./libflatpak_query.py --id net.lutris.Lutris --repo flatpak-beta --system +./libflatpak_query.py --list-all +./libflatpak_query.py --list-all --system +./libflatpak_query.py --categories +./libflatpak_query.py --categories --system +./libflatpak_query.py --list-installed +./libflatpak_query.py --list-installed --system +./libflatpak_query.py --check-updates +./libflatpak_query.py --check-updates --system +./libflatpak_query.py --list-repos +./libflatpak_query.py --list-repos --system +./libflatpak_query.py --add-repo <.flatpakrepo or url to .flatpakrepo file> +./libflatpak_query.py --add-repo <.flatpakrepo or url to .flatpakrepo file> --system +./libflatpak_query.py --remove-repo +./libflatpak_query.py --remove-repo --system +./libflatpak_query.py --toggle-repo --repo +./libflatpak_query.py --toggle-repo --repo --system +./libflatpak_query.py --install +./libflatpak_query.py --install --repo +./libflatpak_query.py --install --repo --system +./libflatpak_query.py --remove +./libflatpak_query.py --remove --system + + +``` diff --git a/libflatpak_query.py b/libflatpak_query.py index 0a3faa9..fc7edde 100755 --- a/libflatpak_query.py +++ b/libflatpak_query.py @@ -463,105 +463,136 @@ class AppstreamSearcher: def retrieve_metadata(self, system=False, refresh=True): + """Retrieve and refresh metadata for Flatpak repositories.""" + self._initialize_metadata() - # make sure to reset these to empty before refreshing. - self.category_results = [] # Initialize empty list - self.collection_results = [] # Initialize empty list - self.installed_results = [] # Initialize empty list - self.updates_results = [] # Initialize empty list - self.all_apps = [] # Initialize empty list + if not check_internet(): + return self._handle_offline_mode() - total_categories = sum(len(categories) for categories in self.category_groups.values()) - current_category = 0 - # Search for each app in local repositories searcher = get_reposearcher(system, refresh) self.all_apps = searcher.get_all_apps() + return self._process_categories(searcher) + + def _initialize_metadata(self): + """Initialize empty lists for metadata storage.""" + self.category_results = [] + self.collection_results = [] + self.installed_results = [] + self.updates_results = [] + self.all_apps = [] + + def _handle_offline_mode(self): + """Handle metadata retrieval when offline.""" json_path = "collections_data.json" - search_result = [] + try: + with open(json_path, 'r', encoding='utf-8') as f: + collections_data = json.load(f) + return self._process_offline_data(collections_data) + except (IOError, json.JSONDecodeError) as e: + logger.error(f"Error loading offline data: {str(e)}") + return None, [], [], [], [] + + def _process_offline_data(self, collections_data): + """Process cached collections data when offline.""" + for collection in collections_data: + category = collection['category'] + if category in self.category_groups['collections']: + apps = [app['app_id'] for app in collection['data'].get('hits', [])] + for app_id in apps: + search_result = self.search_flatpak(app_id, 'flathub') + self.collection_results.extend(search_result) + return self._get_current_results() + + def _process_categories(self, searcher): + """Process categories and retrieve metadata.""" + total_categories = sum(len(categories) for categories in self.category_groups.values()) + current_category = 0 + for group_name, categories in self.category_groups.items(): - # Process categories one at a time to keep GUI responsive for category, title in categories.items(): if category not in self.category_groups['system']: - # Preload the currently saved collections data first - try: - with open(json_path, 'r', encoding='utf-8') as f: - collections_data = json.load(f) - for collection in collections_data: - if collection['category'] == category: - apps = [app['app_id'] for app in collection['data'].get('hits', [])] - for app_id in apps: - search_result = searcher.search_flatpak(app_id, 'flathub') - self.collection_results.extend(search_result) - except (IOError, json.JSONDecodeError) as e: - print(f"Error loading collections data: {str(e)}") - # Try to get apps from Flathub API if internet is available - if check_internet(): - # Get modification time in seconds since epoch - mod_time = os.path.getmtime(json_path) - # Calculate 24 hours in seconds - hours_24 = 24 * 3600 - # Check if file is older than 24 hours - if (time.time() - mod_time) > hours_24: - api_data = self.fetch_flathub_category_apps(category) - if api_data: - apps = api_data['hits'] - - for app in apps: - app_id = app['app_id'] - # Search for the app in local repositories - search_result = searcher.search_flatpak(app_id, 'flathub') - self.category_results.extend(search_result) - else: - apps = searcher.get_all_apps('flathub') - for app in apps: - details = app.get_details() - if category in details['categories']: - search_result = searcher.search_flatpak(details['name'], 'flathub') - self.category_results.extend(search_result) - - current_category += 1 - - # Update progress bar - self.refresh_progress = (current_category / total_categories) * 100 + self._process_category(searcher, category, current_category, total_categories) else: - if "installed" in category: - installed_apps = searcher.get_installed_apps() - for app_id, repo_name, repo_type in installed_apps: - parts = app_id.split('/') - app_id = parts[parts.index('app') + 1] - if repo_name: - search_result = searcher.search_flatpak(app_id, repo_name) - self.installed_results.extend(search_result) - elif "updates" in category: - updates = searcher.check_updates() - for repo_name, app_id, repo_type in updates: - if repo_name: - search_result = searcher.search_flatpak(app_id, repo_name) - self.updates_results.extend(search_result) - self.save_collections_data() + self._process_system_category(searcher, category) + current_category += 1 - # load collections from json file again - # we do this in one go after all of the data from each category has been saved to the json file. - # this time we update entries that already exist and add new entries that don't exist. - for group_name, categories in self.category_groups.items(): - for category, title in categories.items(): - if category in self.category_groups['collections']: - try: - with open(json_path, 'r', encoding='utf-8') as f: - collections_data = json.load(f) - for collection in collections_data: - if collection['category'] == category: - apps = [app['app_id'] for app in collection['data'].get('hits', [])] - new_results = [] - for app_id in apps: - search_result = searcher.search_flatpak(app_id, 'flathub') - new_results.extend(search_result) - self.update_collection_results(new_results) - except (IOError, json.JSONDecodeError) as e: - print(f"Error loading collections data: {str(e)}") - # make sure to reset these to empty before refreshing. - return self.category_results, self.collection_results, self.installed_results, self.updates_results, self.all_apps + return self._get_current_results() + + def _process_category(self, searcher, category, current_category, total_categories): + """Process a single category and retrieve its metadata.""" + json_path = "collections_data.json" + + try: + with open(json_path, 'r', encoding='utf-8') as f: + collections_data = json.load(f) + self._update_from_collections(collections_data, category) + except (IOError, json.JSONDecodeError) as e: + logger.error(f"Error loading collections data: {str(e)}") + + if self._should_refresh_category(category): + self._refresh_category_data(searcher, category) + + self.refresh_progress = (current_category / total_categories) * 100 + + def _update_from_collections(self, collections_data, category): + """Update results from cached collections data.""" + for collection in collections_data: + if collection['category'] == category: + apps = [app['app_id'] for app in collection['data'].get('hits', [])] + for app_id in apps: + search_result = self.search_flatpak(app_id, 'flathub') + self.collection_results.extend(search_result) + + def _should_refresh_category(self, category): + """Check if category data needs refresh.""" + json_path = "collections_data.json" + try: + mod_time = os.path.getmtime(json_path) + return (time.time() - mod_time) > 24 * 3600 + except OSError: + return True + + def _refresh_category_data(self, searcher, category): + """Refresh category data from Flathub API.""" + try: + api_data = self.fetch_flathub_category_apps(category) + if api_data: + apps = api_data['hits'] + for app in apps: + app_id = app['app_id'] + search_result = searcher.search_flatpak(app_id, 'flathub') + if category in self.category_groups['collections']: + self.update_collection_results(search_result) + else: + self.category_results.extend(search_result) + except requests.RequestException as e: + logger.error(f"Error refreshing category {category}: {str(e)}") + + def _process_system_category(self, searcher, category): + """Process system-related categories.""" + if "installed" in category: + installed_apps = searcher.get_installed_apps() + for app_id, repo_name, repo_type in installed_apps: + parts = app_id.split('/') + app_id = parts[parts.index('app') + 1] + if repo_name: + search_result = searcher.search_flatpak(app_id, repo_name) + self.installed_results.extend(search_result) + elif "updates" in category: + updates = searcher.check_updates() + for app_id, repo_name, repo_type in updates: + if repo_name: + search_result = searcher.search_flatpak(app_id, repo_name) + self.updates_results.extend(search_result) + + def _get_current_results(self): + """Return current metadata results.""" + return (self.category_results, + self.collection_results, + self.installed_results, + self.updates_results, + self.all_apps) def install_flatpak(app: AppStreamPackage, repo_name=None, system=False) -> tuple[bool, str]: """ @@ -677,6 +708,10 @@ def repotoggle(repo, toggle=True, system=False): Returns: tuple: (success, error_message) """ + + if not repo: + return False, "Repository name cannot be empty" + if system is False: installation = get_installation() else: @@ -689,7 +724,7 @@ def repotoggle(repo, toggle=True, system=False): remote.set_disabled(not toggle) - # Modify the remote's disabled status + # Modify the remote's disabled status success = installation.modify_remote( remote, None @@ -702,7 +737,9 @@ def repotoggle(repo, toggle=True, system=False): return True, message except GLib.GError as e: - return False, f"Failed to toggle repository: {str(e)}." + return False, f"Failed to toggle repository: {str(e)}" + + return False, "Operation failed" def repolist(system=False): if system is False: @@ -810,8 +847,6 @@ def download_repo(url): def main(): - """Main function demonstrating Flatpak information retrieval""" - parser = argparse.ArgumentParser(description='Search Flatpak packages') parser.add_argument('--id', help='Application ID to search for') parser.add_argument('--repo', help='Filter results to specific repository') @@ -827,8 +862,8 @@ def main(): help='Add a new repository from a .flatpakrepo file') parser.add_argument('--remove-repo', type=str, metavar='REPO_NAME', help='Remove a Flatpak repository') - parser.add_argument('--toggle-repo', type=str, nargs=2, - metavar=('REPO_NAME', 'ENABLE/DISABLE'), + parser.add_argument('--toggle-repo', type=str, + metavar=('ENABLE/DISABLE'), help='Enable or disable a repository') parser.add_argument('--install', type=str, metavar='APP_ID', help='Install a Flatpak package') @@ -837,108 +872,137 @@ def main(): parser.add_argument('--system', action='store_true', help='Install as system instead of user') args = parser.parse_args() - app_id = args.id - repo_filter = args.repo - list_all = args.list_all - show_categories = args.categories - # Repository management operations + # Handle repository operations if args.toggle_repo: - repo_name, enable_str = args.toggle_repo - if enable_str.lower() not in ['true', 'false', 'enable', 'disable']: - print("Invalid enable/disable value. Use 'true/false' or 'enable/disable'") - sys.exit(1) - - enable = enable_str.lower() in ['true', 'enable'] - success, message = repotoggle(repo_name, enable, args.system) - print(message) - sys.exit(0 if success else 1) + handle_repo_toggle(args) + return if args.list_repos: - repos = repolist(args.system) - print("\nConfigured Repositories:") - for repo in repos: - print(f"- {repo.get_name()} ({repo.get_url()})") + handle_list_repos(args) return if args.add_repo: - success, error_message = repoadd(args.add_repo, args.system) - if error_message: - print(error_message) - sys.exit(1) - else: - print(f"\nRepository added successfully: {args.add_repo}") + handle_add_repo(args) return if args.remove_repo: - repodelete(args.remove_repo, args.system) - print(f"\nRepository removed successfully: {args.remove_repo}") + handle_remove_repo(args) return + # Handle package operations searcher = get_reposearcher(args.system) - # Install a flatpak if args.install: - packagelist = searcher.search_flatpak(args.install, args.repo) - for package in packagelist: - if package.id == args.install: - success, message = install_flatpak(package, args.repo, args.system) - print(message) - sys.exit(0 if success else 1) + handle_install(args, searcher) + return - # remove a flatpak if args.remove: - packagelist = searcher.search_flatpak(args.remove, args.repo) - for package in packagelist: - if package.id == args.remove: - success, message = remove_flatpak(package, args.repo, args.system) - print(message) - sys.exit(0 if success else 1) + handle_remove(args, searcher) + return + # Handle information operations if args.list_installed: - installed_apps = searcher.get_installed_apps(args.system) - print(f"\nInstalled Flatpak Applications ({len(installed_apps)}):") - for app_id, repo_name, repo_type in installed_apps: - parts = app_id.split('/') - app_id = parts[parts.index('app') + 1] - print(f"{app_id} (Repository: {repo_name}, Installation: {repo_type})") + handle_list_installed(args, searcher) return if args.check_updates: - updates = searcher.check_updates(args.system) - print(f"\nAvailable Updates ({len(updates)}):") - for repo_name, app_id, repo_type in updates: - print(f"{app_id} (Repository: {repo_name}, Installation: {repo_type})") + handle_check_updates(args, searcher) return - if list_all: - apps = searcher.get_all_apps(repo_filter,args.system) + if args.list_all: + handle_list_all(args, searcher) + return + + if args.categories: + handle_categories(args, searcher) + return + + if args.id: + handle_search(args, searcher) + return + + print("Missing options. Use -h for help.") + +def handle_repo_toggle(args): + repo_name = args.repo + if not repo_name: + print("Error: must specify a repo.") + sys.exit(1) + + get_status = args.toggle_repo.lower() in ['true', 'enable'] + success, message = repotoggle(repo_name, get_status, args.system) + print(message) + sys.exit(0 if success else 1) + +def handle_list_repos(args): + repos = repolist(args.system) + print("\nConfigured Repositories:") + for repo in repos: + print(f"- {repo.get_name()} ({repo.get_url()})") + +def handle_add_repo(args): + success, error_message = repoadd(args.add_repo, args.system) + if error_message: + print(error_message) + sys.exit(1) + print(f"\nRepository added successfully: {args.add_repo}") + +def handle_remove_repo(args): + repodelete(args.remove_repo, args.system) + print(f"\nRepository removed successfully: {args.remove_repo}") + +def handle_install(args, searcher): + packagelist = searcher.search_flatpak(args.install, args.repo) + for package in packagelist: + if package.id == args.install: + success, message = install_flatpak(package, args.repo, args.system) + print(message) + sys.exit(0 if success else 1) + +def handle_remove(args, searcher): + packagelist = searcher.search_flatpak(args.remove, args.repo) + for package in packagelist: + if package.id == args.remove: + success, message = remove_flatpak(package, args.repo, args.system) + print(message) + sys.exit(0 if success else 1) + +def handle_list_installed(args, searcher): + installed_apps = searcher.get_installed_apps(args.system) + print(f"\nInstalled Flatpak Applications ({len(installed_apps)}):") + for app_id, repo_name, repo_type in installed_apps: + parts = app_id.split('/') + app_id = parts[parts.index('app') + 1] + print(f"{app_id} (Repository: {repo_name}, Installation: {repo_type})") + +def handle_check_updates(args, searcher): + updates = searcher.check_updates(args.system) + print(f"\nAvailable Updates ({len(updates)}):") + for repo_name, app_id, repo_type in updates: + print(f"{app_id} (Repository: {repo_name}, Installation: {repo_type})") + +def handle_list_all(args, searcher): + apps = searcher.get_all_apps(args.repo) + for app in apps: + details = app.get_details() + print(f"Name: {details['name']}") + print(f"Categories: {', '.join(details['categories'])}") + print("-" * 50) + +def handle_categories(args, searcher): + categories = searcher.get_categories_summary(args.repo) + for category, apps in categories.items(): + print(f"\n{category.upper()}:") for app in apps: - details = app.get_details() - print(f"Name: {details['name']}") - print(f"Categories: {', '.join(details['categories'])}") - print("-" * 50) - return + print(f" - {app.name} ({app.id})") - if show_categories: - categories = searcher.get_categories_summary(repo_filter, args.system) - for category, apps in categories.items(): - print(f"\n{category.upper()}:") - for app in apps: - print(f" - {app.name} ({app.id})") - return - - if app_id == "" or len(app_id) < 3: - print("Invalid App ID.") - return - - logger.debug(f"(flatpak_search) key: {app_id}") - - # Now you can call search method on the searcher instance - if repo_filter: - search_results = searcher.search_flatpak(app_id, repo_filter) +def handle_search(args, searcher): + if args.repo: + search_results = searcher.search_flatpak(args.id, args.repo) else: - search_results = searcher.search_flatpak(app_id) + search_results = searcher.search_flatpak(args.id) + if search_results: for package in search_results: details = package.get_details() @@ -953,17 +1017,14 @@ def main(): print(f"Icon FILE: {details['icon_filename']}") print(f"Developer: {details['developer']}") print(f"Categories: {details['categories']}") - urls = details['urls'] print(f"Donation URL: {urls['donation']}") print(f"Homepage URL: {urls['homepage']}") print(f"Bug Tracker URL: {urls['bugtracker']}") - print(f"Bundle ID: {details['bundle_id']}") print(f"Match Type: {details['match_type']}") print(f"Repo: {details['repo']}") print("-" * 50) - return if __name__ == "__main__": main() diff --git a/main.py b/main.py index 22ca9ba..03cfd5d 100755 --- a/main.py +++ b/main.py @@ -596,9 +596,10 @@ class MainWindow(Gtk.Window): None, condition=lambda x: True ) - remove_icon = Gio.Icon.new_for_string('list-remove') - button.set_image(Gtk.Image.new_from_gicon(remove_icon, Gtk.IconSize.BUTTON)) - button.get_style_context().add_class("dark-remove-button") + if button: + remove_icon = Gio.Icon.new_for_string('list-remove') + button.set_image(Gtk.Image.new_from_gicon(remove_icon, Gtk.IconSize.BUTTON)) + button.get_style_context().add_class("dark-remove-button") else: button = self.create_button( self.on_install_clicked, @@ -606,9 +607,10 @@ class MainWindow(Gtk.Window): None, condition=lambda x: True ) - install_icon = Gio.Icon.new_for_string('list-add') - button.set_image(Gtk.Image.new_from_gicon(install_icon, Gtk.IconSize.BUTTON)) - button.get_style_context().add_class("dark-install-button") + if button: + install_icon = Gio.Icon.new_for_string('list-add') + button.set_image(Gtk.Image.new_from_gicon(install_icon, Gtk.IconSize.BUTTON)) + button.get_style_context().add_class("dark-install-button") buttons_box.pack_end(button, False, False, 0) # Add Update button if available @@ -619,10 +621,11 @@ class MainWindow(Gtk.Window): None, condition=lambda x: True ) - update_icon = Gio.Icon.new_for_string('synchronize') - update_button.set_image(Gtk.Image.new_from_gicon(update_icon, Gtk.IconSize.BUTTON)) - update_button.get_style_context().add_class("dark-install-button") - buttons_box.pack_end(update_button, False, False, 0) + if update_button: + update_icon = Gio.Icon.new_for_string('synchronize') + update_button.set_image(Gtk.Image.new_from_gicon(update_icon, Gtk.IconSize.BUTTON)) + update_button.get_style_context().add_class("dark-install-button") + buttons_box.pack_end(update_button, False, False, 0) # Details button details_btn = self.create_button( @@ -630,10 +633,11 @@ class MainWindow(Gtk.Window): app, None ) - details_icon = Gio.Icon.new_for_string('question') - details_btn.set_image(Gtk.Image.new_from_gicon(details_icon, Gtk.IconSize.BUTTON)) - details_btn.get_style_context().add_class("dark-install-button") - buttons_box.pack_end(details_btn, False, False, 0) + if details_btn: + details_icon = Gio.Icon.new_for_string('question') + details_btn.set_image(Gtk.Image.new_from_gicon(details_icon, Gtk.IconSize.BUTTON)) + details_btn.get_style_context().add_class("dark-install-button") + buttons_box.pack_end(details_btn, False, False, 0) # Donate button with condition donate_btn = self.create_button( @@ -949,7 +953,7 @@ class MainWindow(Gtk.Window): icon_box.set_size_request(148, -1) # Create and add the icon - icon = Gtk.Image.new_from_file(f"{details['icon_path_128']}/{details['icon_filename']}") + icon = Gtk.Image.new_from_file(f"{details['icon_path_64']}/{details['icon_filename']}") icon.set_size_request(48, 48) icon_box.pack_start(icon, True, True, 0) @@ -1003,9 +1007,10 @@ class MainWindow(Gtk.Window): None, condition=lambda x: True ) - remove_icon = Gio.Icon.new_for_string('list-remove') - button.set_image(Gtk.Image.new_from_gicon(remove_icon, Gtk.IconSize.BUTTON)) - button.get_style_context().add_class("dark-remove-button") + if button: + remove_icon = Gio.Icon.new_for_string('list-remove') + button.set_image(Gtk.Image.new_from_gicon(remove_icon, Gtk.IconSize.BUTTON)) + button.get_style_context().add_class("dark-remove-button") else: button = self.create_button( self.on_install_clicked, @@ -1013,9 +1018,10 @@ class MainWindow(Gtk.Window): None, condition=lambda x: True ) - install_icon = Gio.Icon.new_for_string('list-add') - button.set_image(Gtk.Image.new_from_gicon(install_icon, Gtk.IconSize.BUTTON)) - button.get_style_context().add_class("dark-install-button") + if button: + install_icon = Gio.Icon.new_for_string('list-add') + button.set_image(Gtk.Image.new_from_gicon(install_icon, Gtk.IconSize.BUTTON)) + button.get_style_context().add_class("dark-install-button") buttons_box.pack_end(button, False, False, 0) # Add Update button if available @@ -1026,10 +1032,11 @@ class MainWindow(Gtk.Window): None, condition=lambda x: True ) - update_icon = Gio.Icon.new_for_string('synchronize') - update_button.set_image(Gtk.Image.new_from_gicon(update_icon, Gtk.IconSize.BUTTON)) - update_button.get_style_context().add_class("dark-install-button") - buttons_box.pack_end(update_button, False, False, 0) + if update_button: + update_icon = Gio.Icon.new_for_string('synchronize') + update_button.set_image(Gtk.Image.new_from_gicon(update_icon, Gtk.IconSize.BUTTON)) + update_button.get_style_context().add_class("dark-install-button") + buttons_box.pack_end(update_button, False, False, 0) # Details button details_btn = self.create_button( @@ -1037,10 +1044,11 @@ class MainWindow(Gtk.Window): app, None ) - details_icon = Gio.Icon.new_for_string('question') - details_btn.set_image(Gtk.Image.new_from_gicon(details_icon, Gtk.IconSize.BUTTON)) - details_btn.get_style_context().add_class("dark-install-button") - buttons_box.pack_end(details_btn, False, False, 0) + if details_btn: + details_icon = Gio.Icon.new_for_string('question') + details_btn.set_image(Gtk.Image.new_from_gicon(details_icon, Gtk.IconSize.BUTTON)) + details_btn.get_style_context().add_class("dark-install-button") + buttons_box.pack_end(details_btn, False, False, 0) # Donate button with condition donate_btn = self.create_button(