subcategories DONE
This commit is contained in:
parent
9568d19cfc
commit
81f4002eff
4 changed files with 99937 additions and 238 deletions
|
|
@ -24,6 +24,7 @@ DONE:
|
|||
- Search function
|
||||
- System mode toggle
|
||||
- Update button functions
|
||||
- Implement subcategories
|
||||
|
||||
TODO:
|
||||
- Refresh metadata button
|
||||
|
|
@ -31,7 +32,6 @@ TODO:
|
|||
- List Applications only checkbox
|
||||
- Sort runtimes from Desktop Apps
|
||||
- Package information page/section.
|
||||
- Implement subcategories
|
||||
- General GUI layout/theming improvements
|
||||
- add about section
|
||||
|
||||
|
|
@ -52,6 +52,7 @@ options:
|
|||
--repo REPO Filter results to specific repository
|
||||
--list-all List all available apps
|
||||
--categories Show apps grouped by category
|
||||
--subcategories Show apps grouped by subcategory
|
||||
--list-installed List all installed Flatpak applications
|
||||
--check-updates Check for available updates
|
||||
--list-repos List all configured Flatpak repositories
|
||||
|
|
@ -75,6 +76,8 @@ Common CLI combinations:
|
|||
./libflatpak_query.py --list-all --system
|
||||
./libflatpak_query.py --categories
|
||||
./libflatpak_query.py --categories --system
|
||||
./libflatpak_query.py --subcategories
|
||||
./libflatpak_query.py --subcategories --system
|
||||
./libflatpak_query.py --list-installed
|
||||
./libflatpak_query.py --list-installed --system
|
||||
./libflatpak_query.py --check-updates
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
# which have been modified and extended.
|
||||
|
||||
|
||||
from typing import cast
|
||||
import gi
|
||||
gi.require_version("AppStream", "1.0")
|
||||
gi.require_version("Flatpak", "1.0")
|
||||
|
|
@ -229,6 +230,110 @@ class AppstreamSearcher:
|
|||
}
|
||||
}
|
||||
|
||||
self.subcategory_groups = {
|
||||
'audiovideo': {
|
||||
'audiovideoediting': 'Audio & Video Editing',
|
||||
'discburning': 'Disc Burning',
|
||||
'midi': 'Midi',
|
||||
'mixer': 'Mixer',
|
||||
'player': 'Player',
|
||||
'recorder': 'Recorder',
|
||||
'sequencer': 'Sequencer',
|
||||
'tuner': 'Tuner',
|
||||
'tv': 'TV'
|
||||
},
|
||||
'development': {
|
||||
'building': 'Building',
|
||||
'database': 'Database',
|
||||
'debugger': 'Debugger',
|
||||
'guidesigner': 'GUI Designer',
|
||||
'ide': 'IDE',
|
||||
'profiling': 'Profiling',
|
||||
'revisioncontrol': 'Revision Control',
|
||||
'translation': 'Translation',
|
||||
'webdevelopment': 'Web Development'
|
||||
},
|
||||
'game': {
|
||||
'actiongame': 'Action Games',
|
||||
'adventuregame': 'Adventure Games',
|
||||
'arcadegame': 'Arcade Games',
|
||||
'blocksgame': 'Blocks Games',
|
||||
'boardgame': 'Board Games',
|
||||
'cardgame': 'Card Games',
|
||||
'emulator': 'Emulators',
|
||||
'kidsgame': 'Kids\' Games',
|
||||
'logicgame': 'Logic Games',
|
||||
'roleplaying': 'Role Playing',
|
||||
'shooter': 'Shooter',
|
||||
'simulation': 'Simulation',
|
||||
'sportsgame': 'Sports Games',
|
||||
'strategygame': 'Strategy Games'
|
||||
},
|
||||
'graphics': {
|
||||
'2dgraphics': '2D Graphics',
|
||||
'3dgraphics': '3D Graphics',
|
||||
'ocr': 'OCR',
|
||||
'photography': 'Photography',
|
||||
'publishing': 'Publishing',
|
||||
'rastergraphics': 'Raster Graphics',
|
||||
'scanning': 'Scanning',
|
||||
'vectorgraphics': 'Vector Graphics',
|
||||
'viewer': 'Viewer'
|
||||
},
|
||||
'network': {
|
||||
'chat': 'Chat',
|
||||
'email': 'Email',
|
||||
'feed': 'Feed',
|
||||
'filetransfer': 'File Transfer',
|
||||
'hamradio': 'Ham Radio',
|
||||
'instantmessaging': 'Instant Messaging',
|
||||
'ircclient': 'IRC Client',
|
||||
'monitor': 'Monitor',
|
||||
'news': 'News',
|
||||
'p2p': 'P2P',
|
||||
'remoteaccess': 'Remote Access',
|
||||
'telephony': 'Telephony',
|
||||
'videoconference': 'Video Conference',
|
||||
'webbrowser': 'Web Browser',
|
||||
'webdevelopment': 'Web Development'
|
||||
},
|
||||
'office': {
|
||||
'calendar': 'Calendar',
|
||||
'chart': 'Chart',
|
||||
'contactmanagement': 'Contact Management',
|
||||
'database': 'Database',
|
||||
'dictionary': 'Dictionary',
|
||||
'email': 'Email',
|
||||
'finance': 'Finance',
|
||||
'presentation': 'Presentation',
|
||||
'projectmanagement': 'Project Management',
|
||||
'publishing': 'Publishing',
|
||||
'spreadsheet': 'Spreadsheet',
|
||||
'viewer': 'Viewer',
|
||||
'wordprocessor': 'Word Processor'
|
||||
},
|
||||
'system': {
|
||||
'emulator': 'Emulators',
|
||||
'filemanager': 'File Manager',
|
||||
'filesystem': 'Filesystem',
|
||||
'filetools': 'File Tools',
|
||||
'monitor': 'Monitor',
|
||||
'security': 'Security',
|
||||
'terminalemulator': 'Terminal Emulator'
|
||||
},
|
||||
'utility': {
|
||||
'accessibility': 'Accessibility',
|
||||
'archiving': 'Archiving',
|
||||
'calculator': 'Calculator',
|
||||
'clock': 'Clock',
|
||||
'compression': 'Compression',
|
||||
'filetools': 'File Tools',
|
||||
'telephonytools': 'Telephony Tools',
|
||||
'texteditor': 'Text Editor',
|
||||
'texttools': 'Text Tools'
|
||||
}
|
||||
}
|
||||
|
||||
def add_installation(self, inst: Flatpak.Installation):
|
||||
"""Add enabled flatpak repositories from Flatpak.Installation"""
|
||||
remotes = inst.list_remotes()
|
||||
|
|
@ -331,12 +436,40 @@ class AppstreamSearcher:
|
|||
|
||||
for app in apps:
|
||||
for category in app.categories:
|
||||
if category not in categories:
|
||||
categories[category] = []
|
||||
categories[category].append(app)
|
||||
# Normalize category names to match our groups
|
||||
normalized_category = category.lower()
|
||||
|
||||
# Map category to its group title
|
||||
for group_name, categories_dict in self.category_groups.items():
|
||||
if normalized_category in categories_dict:
|
||||
display_category = categories_dict[normalized_category]
|
||||
break
|
||||
else:
|
||||
display_category = normalized_category.title()
|
||||
|
||||
if display_category not in categories:
|
||||
categories[display_category] = []
|
||||
categories[display_category].append(app)
|
||||
|
||||
return categories
|
||||
|
||||
def get_subcategories_summary(self, repo_name=None) -> list[tuple[str, str, list[AppStreamPackage]]]:
|
||||
"""Get a summary of all apps grouped by category and subcategory."""
|
||||
apps = self.get_all_apps(repo_name)
|
||||
subcategories = []
|
||||
|
||||
# Process each category and its subcategories
|
||||
for category, subcategories_dict in self.subcategory_groups.items():
|
||||
for subcategory, title in subcategories_dict.items():
|
||||
apps_in_subcategory = []
|
||||
for app in apps:
|
||||
if category in app.categories and subcategory in app.categories:
|
||||
apps_in_subcategory.append(app)
|
||||
if apps_in_subcategory:
|
||||
subcategories.append((category, subcategory, apps_in_subcategory))
|
||||
|
||||
return subcategories
|
||||
|
||||
def get_installed_apps(self, system=False) -> list[tuple[str, str, str]]:
|
||||
"""Get a list of all installed Flatpak applications with their repository source"""
|
||||
installed_refs = []
|
||||
|
|
@ -454,6 +587,58 @@ class AppstreamSearcher:
|
|||
|
||||
self.collection_results = updated_results
|
||||
|
||||
def fetch_flathub_subcategory_apps(self, category: str, subcategory: str) -> dict:
|
||||
"""Fetch applications from Flathub API for the specified category and subcategory."""
|
||||
try:
|
||||
# URL encode the category and subcategory to handle special characters
|
||||
encoded_category = quote_plus(category)
|
||||
encoded_subcategory = quote_plus(subcategory)
|
||||
|
||||
# Construct the API URL for subcategories
|
||||
url = f"https://flathub.org/api/v2/collection/category/{encoded_category}/subcategories?subcategory={encoded_subcategory}"
|
||||
|
||||
response = requests.get(url, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data
|
||||
else:
|
||||
print(f"Failed to fetch apps: Status code {response.status_code}")
|
||||
return None
|
||||
except requests.RequestException as e:
|
||||
print(f"Error fetching apps: {str(e)}")
|
||||
return None
|
||||
|
||||
def save_subcategories_data(self, filename='subcategories_data.json'):
|
||||
"""Save all collected subcategories data to a JSON file."""
|
||||
if not hasattr(self, 'subcategories_results') or not self.subcategories_results:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.subcategories_results, f, indent=2, ensure_ascii=False)
|
||||
except IOError as e:
|
||||
print(f"Error saving subcategories data: {str(e)}")
|
||||
|
||||
def update_subcategories_data(self):
|
||||
"""Fetch and store data for all subcategories."""
|
||||
if not hasattr(self, 'subcategories_results'):
|
||||
self.subcategories_results = []
|
||||
|
||||
# Process each category and its subcategories
|
||||
for category, subcategories in self.subcategory_groups.items():
|
||||
for subcategory, title in subcategories.items():
|
||||
api_data = self.fetch_flathub_subcategory_apps(category, subcategory)
|
||||
if api_data:
|
||||
self.subcategories_results.append({
|
||||
'category': category,
|
||||
'subcategory': subcategory,
|
||||
'data': api_data
|
||||
})
|
||||
|
||||
# Save the collected data
|
||||
self.save_subcategories_data()
|
||||
|
||||
def refresh_local(self, system=False):
|
||||
|
||||
# make sure to reset these to empty before refreshing.
|
||||
|
|
@ -502,6 +687,7 @@ class AppstreamSearcher:
|
|||
"""Initialize empty lists for metadata storage."""
|
||||
self.category_results = []
|
||||
self.collection_results = []
|
||||
self.subcategories_results = []
|
||||
self.installed_results = []
|
||||
self.updates_results = []
|
||||
self.all_apps = []
|
||||
|
|
@ -517,6 +703,16 @@ class AppstreamSearcher:
|
|||
logger.error(f"Error loading offline data: {str(e)}")
|
||||
return None, [], [], [], []
|
||||
|
||||
# Also load subcategories data
|
||||
subcategories_path = "subcategories_data.json"
|
||||
try:
|
||||
with open(subcategories_path, 'r', encoding='utf-8') as f:
|
||||
subcategories_data = json.load(f)
|
||||
self.subcategories_results = subcategories_data
|
||||
except (IOError, json.JSONDecodeError) as e:
|
||||
logger.error(f"Error loading subcategories data: {str(e)}")
|
||||
self.subcategories_results = []
|
||||
|
||||
def _process_offline_data(self, collections_data):
|
||||
"""Process cached collections data when offline."""
|
||||
for collection in collections_data:
|
||||
|
|
@ -540,13 +736,14 @@ class AppstreamSearcher:
|
|||
else:
|
||||
self._process_system_category(searcher, category, system)
|
||||
current_category += 1
|
||||
if self._should_refresh():
|
||||
self.update_subcategories_data()
|
||||
|
||||
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)
|
||||
|
|
@ -554,7 +751,7 @@ class AppstreamSearcher:
|
|||
except (IOError, json.JSONDecodeError) as e:
|
||||
logger.error(f"Error loading collections data: {str(e)}")
|
||||
|
||||
if self._should_refresh_category(category):
|
||||
if self._should_refresh():
|
||||
self._refresh_category_data(searcher, category)
|
||||
|
||||
self.refresh_progress = (current_category / total_categories) * 100
|
||||
|
|
@ -568,12 +765,12 @@ class AppstreamSearcher:
|
|||
search_result = self.search_flatpak(app_id, 'flathub')
|
||||
self.collection_results.extend(search_result)
|
||||
|
||||
def _should_refresh_category(self, category):
|
||||
def _should_refresh(self):
|
||||
"""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
|
||||
return (time.time() - mod_time) > 168 * 3600
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
|
@ -610,11 +807,14 @@ class AppstreamSearcher:
|
|||
|
||||
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)
|
||||
return (
|
||||
self.category_results,
|
||||
self.collection_results,
|
||||
self.subcategories_results,
|
||||
self.installed_results,
|
||||
self.updates_results,
|
||||
self.all_apps
|
||||
)
|
||||
|
||||
def install_flatpak(app: AppStreamPackage, repo_name=None, system=False) -> tuple[bool, str]:
|
||||
"""
|
||||
|
|
@ -867,6 +1067,8 @@ def main():
|
|||
parser.add_argument('--repo', help='Filter results to specific repository')
|
||||
parser.add_argument('--list-all', action='store_true', help='List all available apps')
|
||||
parser.add_argument('--categories', action='store_true', help='Show apps grouped by category')
|
||||
parser.add_argument('--subcategories', action='store_true',
|
||||
help='Show apps grouped by subcategory')
|
||||
parser.add_argument('--list-installed', action='store_true',
|
||||
help='List all installed Flatpak applications')
|
||||
parser.add_argument('--check-updates', action='store_true',
|
||||
|
|
@ -941,6 +1143,10 @@ def main():
|
|||
handle_categories(args, searcher)
|
||||
return
|
||||
|
||||
if args.subcategories:
|
||||
handle_subcategories(args, searcher)
|
||||
return
|
||||
|
||||
if args.id:
|
||||
handle_search(args, searcher)
|
||||
return
|
||||
|
|
@ -1043,6 +1249,14 @@ def handle_categories(args, searcher):
|
|||
for app in apps:
|
||||
print(f" - {app.name} ({app.id})")
|
||||
|
||||
def handle_subcategories(args, searcher):
|
||||
"""Handle showing apps grouped by subcategory."""
|
||||
subcategories = searcher.get_subcategories_summary(args.repo)
|
||||
for category, subcategory, apps in subcategories:
|
||||
print(f"\n{category.upper()} > {subcategory.upper()}:")
|
||||
for app in apps:
|
||||
print(f" - {app.name} ({app.id})")
|
||||
|
||||
def handle_search(args, searcher):
|
||||
if args.repo:
|
||||
search_results = searcher.search_flatpak(args.id, args.repo)
|
||||
|
|
|
|||
519
main.py
519
main.py
|
|
@ -19,6 +19,7 @@ class MainWindow(Gtk.Window):
|
|||
# Store search results as an instance variable
|
||||
self.all_apps = []
|
||||
self.category_results = [] # Initialize empty list
|
||||
self.subcategory_results = [] # Initialize empty list
|
||||
self.collection_results = [] # Initialize empty list
|
||||
self.installed_results = [] # Initialize empty list
|
||||
self.updates_results = [] # Initialize empty list
|
||||
|
|
@ -56,12 +57,11 @@ class MainWindow(Gtk.Window):
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
# Define subcategories
|
||||
self.subcategory_groups = {
|
||||
'audiovideo': {
|
||||
'audiovideoediting': 'AudioVideoEditing',
|
||||
'discburning': 'DiscBurning',
|
||||
'audiovideoediting': 'Audio & Video Editing',
|
||||
'discburning': 'Disc Burning',
|
||||
'midi': 'Midi',
|
||||
'mixer': 'Mixer',
|
||||
'player': 'Player',
|
||||
|
|
@ -74,80 +74,80 @@ class MainWindow(Gtk.Window):
|
|||
'building': 'Building',
|
||||
'database': 'Database',
|
||||
'debugger': 'Debugger',
|
||||
'guidesigner': 'GUIDesigner',
|
||||
'guidesigner': 'GUI Designer',
|
||||
'ide': 'IDE',
|
||||
'profiling': 'Profiling',
|
||||
'revisioncontrol': 'RevisionControl',
|
||||
'revisioncontrol': 'Revision Control',
|
||||
'translation': 'Translation',
|
||||
'webdevelopment': 'WebDevelopment'
|
||||
'webdevelopment': 'Web Development'
|
||||
},
|
||||
'game': {
|
||||
'actiongame': 'ActionGame',
|
||||
'adventuregame': 'AdventureGame',
|
||||
'arcadegame': 'ArcadeGame',
|
||||
'blocksgame': 'BlocksGame',
|
||||
'boardgame': 'BoardGame',
|
||||
'cardgame': 'CardGame',
|
||||
'emulator': 'Emulator',
|
||||
'kidsgame': 'KidsGame',
|
||||
'logicgame': 'LogicGame',
|
||||
'roleplaying': 'RolePlaying',
|
||||
'actiongame': 'Action Games',
|
||||
'adventuregame': 'Adventure Games',
|
||||
'arcadegame': 'Arcade Games',
|
||||
'blocksgame': 'Blocks Games',
|
||||
'boardgame': 'Board Games',
|
||||
'cardgame': 'Card Games',
|
||||
'emulator': 'Emulators',
|
||||
'kidsgame': 'Kids\' Games',
|
||||
'logicgame': 'Logic Games',
|
||||
'roleplaying': 'Role Playing',
|
||||
'shooter': 'Shooter',
|
||||
'simulation': 'Simulation',
|
||||
'sportsgame': 'SportsGame',
|
||||
'strategygame': 'StrategyGame'
|
||||
'sportsgame': 'Sports Games',
|
||||
'strategygame': 'Strategy Games'
|
||||
},
|
||||
'graphics': {
|
||||
'2dgraphics': '2DGraphics',
|
||||
'3dgraphics': '3DGraphics',
|
||||
'2dgraphics': '2D Graphics',
|
||||
'3dgraphics': '3D Graphics',
|
||||
'ocr': 'OCR',
|
||||
'photography': 'Photography',
|
||||
'publishing': 'Publishing',
|
||||
'rastergraphics': 'RasterGraphics',
|
||||
'rastergraphics': 'Raster Graphics',
|
||||
'scanning': 'Scanning',
|
||||
'vectorgraphics': 'VectorGraphics',
|
||||
'vectorgraphics': 'Vector Graphics',
|
||||
'viewer': 'Viewer'
|
||||
},
|
||||
'network': {
|
||||
'chat': 'Chat',
|
||||
'email': 'Email',
|
||||
'feed': 'Feed',
|
||||
'filetransfer': 'FileTransfer',
|
||||
'hamradio': 'HamRadio',
|
||||
'instantmessaging': 'InstantMessaging',
|
||||
'ircclient': 'IRCClient',
|
||||
'filetransfer': 'File Transfer',
|
||||
'hamradio': 'Ham Radio',
|
||||
'instantmessaging': 'Instant Messaging',
|
||||
'ircclient': 'IRC Client',
|
||||
'monitor': 'Monitor',
|
||||
'news': 'News',
|
||||
'p2p': 'P2P',
|
||||
'remoteaccess': 'RemoteAccess',
|
||||
'remoteaccess': 'Remote Access',
|
||||
'telephony': 'Telephony',
|
||||
'videoconference': 'VideoConference',
|
||||
'webbrowser': 'WebBrowser',
|
||||
'webdevelopment': 'WebDevelopment'
|
||||
'videoconference': 'Video Conference',
|
||||
'webbrowser': 'Web Browser',
|
||||
'webdevelopment': 'Web Development'
|
||||
},
|
||||
'office': {
|
||||
'calendar': 'Calendar',
|
||||
'chart': 'Chart',
|
||||
'contactmanagement': 'ContactManagement',
|
||||
'contactmanagement': 'Contact Management',
|
||||
'database': 'Database',
|
||||
'dictionary': 'Dictionary',
|
||||
'email': 'Email',
|
||||
'finance': 'Finance',
|
||||
'presentation': 'Presentation',
|
||||
'projectmanagement': 'ProjectManagement',
|
||||
'projectmanagement': 'Project Management',
|
||||
'publishing': 'Publishing',
|
||||
'spreadsheet': 'Spreadsheet',
|
||||
'viewer': 'Viewer',
|
||||
'wordprocessor': 'WordProcessor'
|
||||
'wordprocessor': 'Word Processor'
|
||||
},
|
||||
'system': {
|
||||
'emulator': 'Emulator',
|
||||
'filemanager': 'FileManager',
|
||||
'emulator': 'Emulators',
|
||||
'filemanager': 'File Manager',
|
||||
'filesystem': 'Filesystem',
|
||||
'filetools': 'FileTools',
|
||||
'filetools': 'File Tools',
|
||||
'monitor': 'Monitor',
|
||||
'security': 'Security',
|
||||
'terminalemulator': 'TerminalEmulator'
|
||||
'terminalemulator': 'Terminal Emulator'
|
||||
},
|
||||
'utility': {
|
||||
'accessibility': 'Accessibility',
|
||||
|
|
@ -155,14 +155,12 @@ class MainWindow(Gtk.Window):
|
|||
'calculator': 'Calculator',
|
||||
'clock': 'Clock',
|
||||
'compression': 'Compression',
|
||||
'filetools': 'FileTools',
|
||||
'telephonytools': 'TelephonyTools',
|
||||
'texteditor': 'TextEditor',
|
||||
'texttools': 'TextTools'
|
||||
'filetools': 'File Tools',
|
||||
'telephonytools': 'Telephony Tools',
|
||||
'texteditor': 'Text Editor',
|
||||
'texttools': 'Text Tools'
|
||||
}
|
||||
}
|
||||
# Where to get data for each subcategory:
|
||||
# https://flathub.org/api/v2/collection/category/audiovideo/subcategories?subcategory=audiovideoediting
|
||||
|
||||
# Add CSS provider for custom styling
|
||||
css_provider = Gtk.CssProvider()
|
||||
|
|
@ -253,6 +251,33 @@ class MainWindow(Gtk.Window):
|
|||
padding: 2px 4px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.dark-category-button {
|
||||
border: 0px;
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.dark-category-button-active {
|
||||
background-color: #18A3FF;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.subcategories-scroll {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.subcategories-scroll > GtkViewport {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
.no-scroll-bars scrollbar {
|
||||
min-width: 0px;
|
||||
opacity: 0;
|
||||
margin-top: -20px;
|
||||
}
|
||||
""")
|
||||
|
||||
# Add CSS provider to the default screen
|
||||
|
|
@ -406,8 +431,9 @@ class MainWindow(Gtk.Window):
|
|||
# Define thread target function
|
||||
def retrieve_metadata():
|
||||
try:
|
||||
category_results, collection_results, installed_results, updates_results, all_apps = searcher.retrieve_metadata(self.system_mode)
|
||||
category_results, collection_results, subcategory_results, installed_results, updates_results, all_apps = searcher.retrieve_metadata(self.system_mode)
|
||||
self.category_results = category_results
|
||||
self.category_results = subcategory_results
|
||||
self.collection_results = collection_results
|
||||
self.installed_results = installed_results
|
||||
self.updates_results = updates_results
|
||||
|
|
@ -474,7 +500,7 @@ class MainWindow(Gtk.Window):
|
|||
self.left_panel = self.create_grouped_category_panel("Categories", self.category_groups)
|
||||
|
||||
# Create right panel
|
||||
self.right_panel = self.create_applications_panel("Applications", self.subcategory_groups)
|
||||
self.right_panel = self.create_applications_panel("Applications")
|
||||
|
||||
# Create panels container
|
||||
self.panels_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
|
@ -639,7 +665,7 @@ class MainWindow(Gtk.Window):
|
|||
"""Display search results in the right panel"""
|
||||
self.display_apps(apps)
|
||||
|
||||
def on_category_clicked(self, category, group):
|
||||
def on_category_clicked(self, category, group, *args):
|
||||
# Remove active state from all widgets in all groups
|
||||
for group_name in self.category_widgets:
|
||||
for widget in self.category_widgets[group_name]:
|
||||
|
|
@ -651,85 +677,10 @@ class MainWindow(Gtk.Window):
|
|||
if widget.get_children()[0].get_label() == display_title:
|
||||
widget.get_style_context().add_class("dark-category-button-active")
|
||||
break
|
||||
|
||||
self.current_page = category
|
||||
self.current_group = group
|
||||
self.update_category_header(category)
|
||||
|
||||
# Clear the right container
|
||||
for child in self.right_container.get_children():
|
||||
child.destroy()
|
||||
|
||||
# Recreate the right container
|
||||
self.right_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
self.right_container.set_spacing(6)
|
||||
self.right_container.set_border_width(6)
|
||||
|
||||
# Create container for applications
|
||||
self.app_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.app_container.set_spacing(6)
|
||||
self.app_container.set_border_width(6)
|
||||
|
||||
# Create subcategory container
|
||||
self.subcat_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.subcat_container.set_spacing(6)
|
||||
self.subcat_container.set_border_width(6)
|
||||
self.subcat_container.set_size_request(200, -1)
|
||||
self.subcat_container.set_hexpand(False)
|
||||
self.subcat_container.set_vexpand(True)
|
||||
self.subcat_container.set_halign(Gtk.Align.START)
|
||||
self.subcat_container.set_valign(Gtk.Align.FILL)
|
||||
|
||||
# Create scrollable area for subcategories
|
||||
scrolled_window_subcats = Gtk.ScrolledWindow()
|
||||
scrolled_window_subcats.set_hexpand(False)
|
||||
scrolled_window_subcats.set_vexpand(True)
|
||||
scrolled_window_subcats.add(self.subcat_container)
|
||||
scrolled_window_subcats.set_min_content_width(200)
|
||||
|
||||
# Create scrollable area for apps
|
||||
scrolled_window_apps = Gtk.ScrolledWindow()
|
||||
scrolled_window_apps.set_hexpand(True)
|
||||
scrolled_window_apps.set_vexpand(True)
|
||||
scrolled_window_apps.add(self.app_container)
|
||||
|
||||
# Add widgets to right container
|
||||
if category in self.subcategory_groups:
|
||||
self.right_container.pack_start(scrolled_window_subcats, False, False, 0)
|
||||
self.right_container.pack_end(scrolled_window_apps, True, True, 0)
|
||||
|
||||
# Add right container to right panel
|
||||
self.right_panel.pack_start(self.right_container, False, True, 0)
|
||||
|
||||
# Update subcategories
|
||||
if category in self.subcategory_groups:
|
||||
|
||||
# Add subcategories
|
||||
for subcategory, display_title in self.subcategory_groups[category].items():
|
||||
# Create a clickable box for each category
|
||||
subcategory_box = Gtk.EventBox()
|
||||
subcategory_box.set_hexpand(True)
|
||||
subcategory_box.set_halign(Gtk.Align.FILL)
|
||||
subcategory_box.set_margin_top(2)
|
||||
subcategory_box.set_margin_bottom(2)
|
||||
|
||||
# Create label for the category
|
||||
subcategory_label = Gtk.Label(label=display_title)
|
||||
subcategory_label.set_halign(Gtk.Align.START)
|
||||
subcategory_label.set_hexpand(True)
|
||||
subcategory_label.get_style_context().add_class("dark-category-button")
|
||||
|
||||
# Add label to the box
|
||||
subcategory_box.add(subcategory_label)
|
||||
|
||||
# Connect click event
|
||||
subcategory_box.connect("button-release-event",
|
||||
lambda widget, event, cat=subcategory, grp=category:
|
||||
self.on_category_clicked(cat, grp))
|
||||
|
||||
self.subcat_container.pack_start(subcategory_box, False, False, 0)
|
||||
|
||||
self.right_container.show_all()
|
||||
self.update_subcategories_bar(category)
|
||||
self.show_category_apps(category)
|
||||
|
||||
def refresh_current_page(self):
|
||||
|
|
@ -739,18 +690,26 @@ class MainWindow(Gtk.Window):
|
|||
|
||||
def update_category_header(self, category):
|
||||
"""Update the category header text based on the selected category."""
|
||||
display_title = ""
|
||||
if category in self.category_groups['collections']:
|
||||
display_title = self.category_groups['collections'][category]
|
||||
elif category in self.category_groups['categories']:
|
||||
display_title = self.category_groups['categories'][category]
|
||||
else:
|
||||
display_title = category.capitalize()
|
||||
|
||||
else: # Find the parent category and get the title
|
||||
for parent_category, subcategories in self.subcategory_groups.items():
|
||||
if category in subcategories:
|
||||
display_title = subcategories[category]
|
||||
break
|
||||
if display_title == "":
|
||||
# Fallback if category isn't found
|
||||
display_title = category.capitalize()
|
||||
self.category_header.set_label(display_title)
|
||||
|
||||
def create_applications_panel(self, title, groups):
|
||||
def create_applications_panel(self, title):
|
||||
# Create right panel
|
||||
self.right_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.right_panel.set_hexpand(True) # Add this line
|
||||
self.right_panel.set_vexpand(True) # Add this line
|
||||
|
||||
# Add category header
|
||||
self.category_header = Gtk.Label(label="")
|
||||
|
|
@ -759,100 +718,212 @@ class MainWindow(Gtk.Window):
|
|||
self.category_header.set_halign(Gtk.Align.START)
|
||||
self.right_panel.pack_start(self.category_header, False, False, 0)
|
||||
|
||||
self.right_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
self.right_container.set_spacing(6)
|
||||
self.right_container.set_border_width(6)
|
||||
# Create subcategories bar (initially hidden)
|
||||
self.subcategories_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
self.subcategories_bar.set_hexpand(True)
|
||||
self.subcategories_bar.set_spacing(6)
|
||||
self.subcategories_bar.set_border_width(6)
|
||||
#self.subcategories_bar.get_style_context().add_class("dark-header")
|
||||
self.subcategories_bar.set_visible(False)
|
||||
self.subcategories_bar.set_halign(Gtk.Align.FILL) # Ensure full width
|
||||
self.right_panel.pack_start(self.subcategories_bar, False, False, 0)
|
||||
|
||||
# Create scrollable area
|
||||
scrolled_window = Gtk.ScrolledWindow()
|
||||
scrolled_window.set_hexpand(True)
|
||||
scrolled_window.set_vexpand(True)
|
||||
|
||||
# Create container for applications
|
||||
self.app_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.app_container.set_spacing(6)
|
||||
self.app_container.set_border_width(6)
|
||||
|
||||
# Create subcategory container
|
||||
self.subcat_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.subcat_container.set_spacing(6)
|
||||
self.subcat_container.set_border_width(6)
|
||||
self.subcat_container.set_size_request(200, -1)
|
||||
self.subcat_container.set_hexpand(False)
|
||||
self.subcat_container.set_vexpand(True)
|
||||
self.subcat_container.set_halign(Gtk.Align.START) # Fill horizontally
|
||||
self.subcat_container.set_valign(Gtk.Align.FILL) # Align to top
|
||||
|
||||
# Dictionary to store category widgets
|
||||
self.subcategory_widgets = {}
|
||||
|
||||
# Find the actual category name from the display title
|
||||
current_category = self.current_page
|
||||
print(current_category)
|
||||
|
||||
# Add group headers and categories
|
||||
for group_name, subcategories in groups.items():
|
||||
if group_name == current_category:
|
||||
# Create a box for the header
|
||||
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
header_box.get_style_context().add_class("dark-header")
|
||||
header_box.set_hexpand(True) # Make the box expand horizontally
|
||||
|
||||
# Create the label
|
||||
group_header = Gtk.Label(label=group_name.upper())
|
||||
group_header.get_style_context().add_class("title-2")
|
||||
group_header.set_halign(Gtk.Align.START)
|
||||
|
||||
# Add the label to the box
|
||||
header_box.pack_start(group_header, False, False, 0)
|
||||
|
||||
# Add the box to the container
|
||||
self.subcat_container.pack_start(header_box, False, False, 0)
|
||||
|
||||
# Store widgets for this group
|
||||
self.subcategory_widgets[group_name] = []
|
||||
# Add categories in the group
|
||||
for subcategory, display_title in subcategories.items():
|
||||
# Create a clickable box for each category
|
||||
subcategory_box = Gtk.EventBox()
|
||||
subcategory_box.set_hexpand(True)
|
||||
subcategory_box.set_halign(Gtk.Align.FILL)
|
||||
subcategory_box.set_margin_top(2)
|
||||
subcategory_box.set_margin_bottom(2)
|
||||
|
||||
# Create label for the category
|
||||
subcategory_label = Gtk.Label(label=display_title)
|
||||
subcategory_label.set_halign(Gtk.Align.START)
|
||||
subcategory_label.set_hexpand(True)
|
||||
subcategory_label.get_style_context().add_class("dark-category-button")
|
||||
|
||||
# Add label to the box
|
||||
subcategory_box.add(subcategory_label)
|
||||
|
||||
# Connect click event
|
||||
subcategory_box.connect("button-release-event",
|
||||
lambda widget, event, cat=subcategory, grp=group_name:
|
||||
self.on_category_clicked(cat, grp))
|
||||
|
||||
# Store widget in group
|
||||
self.subcategory_widgets[group_name].append(subcategory_box)
|
||||
self.subcat_container.pack_start(subcategory_box, False, False, 0)
|
||||
|
||||
# Create scrollable area for subcategories
|
||||
scrolled_window_subcats = Gtk.ScrolledWindow()
|
||||
scrolled_window_subcats.set_hexpand(False)
|
||||
scrolled_window_subcats.set_vexpand(True)
|
||||
scrolled_window_subcats.add(self.subcat_container)
|
||||
scrolled_window_subcats.set_min_content_width(200)
|
||||
|
||||
# Create scrollable area for apps
|
||||
scrolled_window_apps = Gtk.ScrolledWindow()
|
||||
scrolled_window_apps.set_hexpand(True)
|
||||
scrolled_window_apps.set_vexpand(True)
|
||||
scrolled_window_apps.add(self.app_container)
|
||||
# Only add subcategories if they exist for the current category
|
||||
if current_category:
|
||||
self.right_container.pack_start(scrolled_window_subcats, False, False, 0)
|
||||
self.right_container.pack_end(scrolled_window_apps, True, True, 0)
|
||||
self.right_panel.pack_start(self.right_container, False, True, 0)
|
||||
self.right_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.right_container.set_spacing(6)
|
||||
self.right_container.set_border_width(6)
|
||||
self.right_container.set_hexpand(True) # Add this line
|
||||
self.right_container.set_vexpand(True) # Add this line
|
||||
|
||||
scrolled_window.add(self.right_container)
|
||||
self.right_panel.pack_start(scrolled_window, True, True, 0)
|
||||
return self.right_panel
|
||||
|
||||
def update_subcategories_bar(self, category):
|
||||
"""Update the subcategories bar based on the current category."""
|
||||
# Clear existing subcategories
|
||||
for child in self.subcategories_bar.get_children():
|
||||
child.destroy()
|
||||
|
||||
# Create pan start button
|
||||
pan_start = Gtk.Button()
|
||||
pan_start_icon = Gio.Icon.new_for_string('pan-start-symbolic')
|
||||
pan_start.set_image(Gtk.Image.new_from_gicon(pan_start_icon, Gtk.IconSize.BUTTON))
|
||||
pan_start.get_style_context().add_class("dark-category-button")
|
||||
pan_start.connect("clicked", self.on_pan_start)
|
||||
|
||||
# Create scrolled window
|
||||
self.scrolled_window = Gtk.ScrolledWindow()
|
||||
self.scrolled_window.set_hexpand(True)
|
||||
self.scrolled_window.set_vexpand(False)
|
||||
self.scrolled_window.set_size_request(-1, 40)
|
||||
self.scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER)
|
||||
self.scrolled_window.set_min_content_width(0) # Allow shrinking below content size
|
||||
self.scrolled_window.set_max_content_width(-1) # No artificial width limit
|
||||
self.scrolled_window.set_overlay_scrolling(False)
|
||||
self.scrolled_window.get_style_context().add_class("no-scroll-bars")
|
||||
|
||||
# Create container for subcategories
|
||||
container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
container.set_spacing(6)
|
||||
container.set_border_width(6)
|
||||
container.set_hexpand(True)
|
||||
container.set_halign(Gtk.Align.CENTER)
|
||||
container.set_homogeneous(False)
|
||||
|
||||
# Check if the category has subcategories
|
||||
if category in self.subcategory_groups:
|
||||
# Add subcategories
|
||||
for subcategory, title in self.subcategory_groups[category].items():
|
||||
# Create clickable box for subcategory
|
||||
subcategory_box = Gtk.EventBox()
|
||||
subcategory_box.set_hexpand(False)
|
||||
subcategory_box.set_halign(Gtk.Align.START)
|
||||
subcategory_box.set_margin_top(2)
|
||||
subcategory_box.set_margin_bottom(2)
|
||||
|
||||
# Create label for subcategory
|
||||
subcategory_label = Gtk.Label(label=title)
|
||||
subcategory_label.set_halign(Gtk.Align.START)
|
||||
subcategory_label.set_hexpand(False)
|
||||
subcategory_label.get_style_context().add_class("dark-category-button")
|
||||
|
||||
# Add label to box
|
||||
subcategory_box.add(subcategory_label)
|
||||
|
||||
# Connect click event
|
||||
subcategory_box.connect("button-release-event",
|
||||
lambda widget, event, subcat=subcategory:
|
||||
self.on_subcategory_clicked(subcat))
|
||||
|
||||
# Store widget in group
|
||||
container.pack_start(subcategory_box, False, False, 0)
|
||||
|
||||
# Add container to scrolled window
|
||||
self.scrolled_window.add(container)
|
||||
|
||||
# Create pan end button
|
||||
pan_end = Gtk.Button()
|
||||
pan_end_icon = Gio.Icon.new_for_string('pan-end-symbolic')
|
||||
pan_end.set_image(Gtk.Image.new_from_gicon(pan_end_icon, Gtk.IconSize.BUTTON))
|
||||
pan_end.get_style_context().add_class("dark-category-button")
|
||||
pan_end.connect("clicked", self.on_pan_end)
|
||||
|
||||
# Show the bar and force a layout update
|
||||
self.subcategories_bar.get_style_context().add_class("dark-header")
|
||||
self.subcategories_bar.set_visible(True)
|
||||
self.subcategories_bar.pack_start(pan_start, False, False, 0)
|
||||
self.subcategories_bar.pack_start(self.scrolled_window, True, True, 0)
|
||||
self.subcategories_bar.pack_start(pan_end, False, False, 0)
|
||||
#self.subcategories_bar.pack_start(container, True, True, 0)
|
||||
self.subcategories_bar.queue_resize()
|
||||
self.subcategories_bar.show_all()
|
||||
else:
|
||||
# Check if current category is a subcategory
|
||||
is_subcategory = False
|
||||
parent_category = None
|
||||
for parent, subcategories in self.subcategory_groups.items():
|
||||
if category in subcategories:
|
||||
is_subcategory = True
|
||||
parent_category = parent
|
||||
break
|
||||
|
||||
if is_subcategory:
|
||||
# Add parent category and current subcategory
|
||||
container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
container.set_spacing(6)
|
||||
container.set_border_width(6)
|
||||
container.set_hexpand(True)
|
||||
container.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
# Create parent category box
|
||||
parent_box = Gtk.EventBox()
|
||||
parent_box.set_hexpand(False)
|
||||
parent_box.set_halign(Gtk.Align.START)
|
||||
parent_box.set_margin_top(2)
|
||||
parent_box.set_margin_bottom(2)
|
||||
|
||||
# Create parent label
|
||||
parent_label = Gtk.Label(label=self.category_groups['categories'][parent_category])
|
||||
parent_label.set_halign(Gtk.Align.START)
|
||||
parent_label.set_hexpand(False)
|
||||
parent_label.get_style_context().add_class("dark-category-button")
|
||||
|
||||
# Add label to box
|
||||
parent_box.add(parent_label)
|
||||
|
||||
# Connect click event
|
||||
parent_box.connect("button-release-event",
|
||||
lambda widget, event, cat=parent_category, grp='categories':
|
||||
self.on_category_clicked(cat, grp))
|
||||
|
||||
# Add parent box to container
|
||||
container.pack_start(parent_box, False, False, 0)
|
||||
|
||||
# Create current subcategory box
|
||||
subcategory_box = Gtk.EventBox()
|
||||
subcategory_box.set_hexpand(False)
|
||||
subcategory_box.set_halign(Gtk.Align.START)
|
||||
subcategory_box.set_margin_top(2)
|
||||
subcategory_box.set_margin_bottom(2)
|
||||
|
||||
# Create subcategory label
|
||||
subcategory_label = Gtk.Label(label=self.subcategory_groups[parent_category][category])
|
||||
subcategory_label.set_halign(Gtk.Align.START)
|
||||
subcategory_label.set_hexpand(False)
|
||||
subcategory_label.get_style_context().add_class("dark-category-button")
|
||||
|
||||
# Add label to box
|
||||
subcategory_box.add(subcategory_label)
|
||||
|
||||
# Connect click event
|
||||
subcategory_box.connect("button-release-event",
|
||||
lambda widget, event, subcat=category:
|
||||
self.on_subcategory_clicked(subcat))
|
||||
|
||||
# Add subcategory box to container
|
||||
container.pack_start(subcategory_box, False, False, 0)
|
||||
|
||||
# Add container to scrolled window
|
||||
self.scrolled_window.add(container)
|
||||
self.subcategories_bar.get_style_context().add_class("dark-header")
|
||||
# Show the bar and force a layout update
|
||||
self.subcategories_bar.set_visible(True)
|
||||
self.subcategories_bar.pack_start(self.scrolled_window, True, True, 0)
|
||||
#self.subcategories_bar.pack_start(container, True, True, 0)
|
||||
self.subcategories_bar.queue_resize()
|
||||
self.subcategories_bar.show_all()
|
||||
else:
|
||||
self.subcategories_bar.get_style_context().remove_class("dark-header")
|
||||
# Hide the bar and force a layout update
|
||||
self.subcategories_bar.set_visible(False)
|
||||
|
||||
def on_pan_start(self, button):
|
||||
# Get the scrolled window's adjustment
|
||||
adjustment = self.scrolled_window.get_hadjustment()
|
||||
# Scroll to the left by a page
|
||||
adjustment.set_value(adjustment.get_value() - adjustment.get_page_size())
|
||||
|
||||
def on_pan_end(self, button):
|
||||
# Get the scrolled window's adjustment
|
||||
adjustment = self.scrolled_window.get_hadjustment()
|
||||
# Scroll to the right by a page
|
||||
adjustment.set_value(adjustment.get_value() + adjustment.get_page_size())
|
||||
|
||||
def on_subcategory_clicked(self, subcategory):
|
||||
"""Handle subcategory button clicks."""
|
||||
# Update the current page to the subcategory
|
||||
self.current_page = subcategory
|
||||
self.current_group = 'subcategories'
|
||||
self.update_category_header(subcategory)
|
||||
self.update_subcategories_bar(subcategory)
|
||||
self.show_category_apps(subcategory)
|
||||
|
||||
# Create and connect buttons
|
||||
def create_button(self, callback, app, label=None, condition=None):
|
||||
"""Create a button with optional visibility condition"""
|
||||
|
|
@ -919,7 +990,7 @@ class MainWindow(Gtk.Window):
|
|||
|
||||
if 'repositories' in category:
|
||||
# Clear existing content
|
||||
for child in self.app_container.get_children():
|
||||
for child in self.right_container.get_children():
|
||||
child.destroy()
|
||||
|
||||
# Create header bar
|
||||
|
|
@ -953,7 +1024,7 @@ class MainWindow(Gtk.Window):
|
|||
header_bar.pack_end(right_label, False, False, 0)
|
||||
|
||||
# Add header bar to container
|
||||
self.app_container.pack_start(header_bar, False, False, 0)
|
||||
self.right_container.pack_start(header_bar, False, False, 0)
|
||||
|
||||
# Get list of repositories
|
||||
repos = libflatpak_query.repolist(self.system_mode)
|
||||
|
|
@ -1034,13 +1105,11 @@ class MainWindow(Gtk.Window):
|
|||
|
||||
# Add container to scrolled window
|
||||
scrolled_window.add(repo_container)
|
||||
self.app_container.pack_start(scrolled_window, True, True, 0)
|
||||
self.right_container.pack_start(scrolled_window, True, True, 0)
|
||||
|
||||
self.app_container.show_all()
|
||||
self.right_container.show_all()
|
||||
return
|
||||
self.current_page = category
|
||||
self.display_apps(apps)
|
||||
self.subcat_container.show_all()
|
||||
|
||||
def create_scaled_icon(self, icon, is_themed=False):
|
||||
if is_themed:
|
||||
|
|
@ -1061,7 +1130,7 @@ class MainWindow(Gtk.Window):
|
|||
return Gtk.Image.new_from_pixbuf(scaled_pb)
|
||||
|
||||
def display_apps(self, apps):
|
||||
for child in self.app_container.get_children():
|
||||
for child in self.right_container.get_children():
|
||||
child.destroy()
|
||||
# Create a dictionary to group apps by ID
|
||||
apps_by_id = {}
|
||||
|
|
@ -1231,10 +1300,10 @@ class MainWindow(Gtk.Window):
|
|||
# Add to container
|
||||
app_container.pack_start(icon_box, False, False, 0)
|
||||
app_container.pack_start(right_box, True, True, 0)
|
||||
self.app_container.pack_start(app_container, False, False, 0)
|
||||
self.app_container.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0)
|
||||
self.right_container.pack_start(app_container, False, False, 0)
|
||||
self.right_container.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0)
|
||||
|
||||
self.app_container.show_all() # Show all widgets after adding them
|
||||
self.right_container.show_all() # Show all widgets after adding them
|
||||
|
||||
def show_waiting_dialog(self, message="Please wait while task is running..."):
|
||||
"""Show a modal dialog with a spinner"""
|
||||
|
|
|
|||
99413
subcategories_data.json
Normal file
99413
subcategories_data.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue