Instantly share code, notes, and snippets.
Last active
April 8, 2026 22:03
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save suntong/8a3c4f9599b41ad04c906405d0bc9000 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| XDG Menu Parser | |
| =============== | |
| Parses .menu files from /etc/xdg/menus (and other XDG paths), resolves | |
| .desktop and .directory entries, and exports to JSON, YAML, or fluxbox menu. | |
| The XDG Menu Specification defines how desktop environments organize applications | |
| into hierarchical menus. This script reads those XML definitions, applies the | |
| complex matching rules (<Include>, <Exclude>, <And>, <Or>, <Not>), and outputs | |
| the resolved tree in various formats. | |
| Usage: | |
| xdg-menu-parser -l # List available menus | |
| xdg-menu-parser -m applications.menu -f json # JSON output | |
| xdg-menu-parser -m applications.menu -f yaml # YAML output | |
| xdg-menu-parser -m applications.menu -f fluxbox # Fluxbox menu output | |
| xdg-menu-parser -m kf5-applications.menu -f fluxbox --ignore-desktop | |
| """ | |
| import os | |
| import sys | |
| import json | |
| import re | |
| import argparse | |
| import shutil | |
| from pathlib import Path | |
| from xml.etree import ElementTree as ET | |
| from dataclasses import dataclass, field | |
| from typing import List, Optional, Dict, Any, Set | |
| try: | |
| import yaml | |
| HAS_YAML = True | |
| except ImportError: | |
| HAS_YAML = False | |
| # --------------------------------------------------------------------------- | |
| # XDG Base Directory Specification Path Helpers | |
| # --------------------------------------------------------------------------- | |
| HOME = os.environ.get("HOME", "/root") | |
| XDG_DATA_HOME = os.environ.get("XDG_DATA_HOME", os.path.join(HOME, ".local", "share")) | |
| XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME", os.path.join(HOME, ".config")) | |
| XDG_DATA_DIRS = os.environ.get("XDG_DATA_DIRS", "/usr/share:/usr/local/share").split(":") | |
| XDG_CONFIG_DIRS = os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":") | |
| def _expand_paths(base_list: List[str], subdirs: List[str]) -> List[str]: | |
| """Generate a deduplicated list of existing directories by joining base paths with subdirs. | |
| User-specific paths (first in base_list) take precedence over system paths. | |
| """ | |
| seen: Set[str] = set() | |
| out: List[str] = [] | |
| for base in base_list: | |
| for sd in subdirs: | |
| p = os.path.join(base, sd) | |
| if os.path.isdir(p) and p not in seen: | |
| seen.add(p) | |
| out.append(p) | |
| return out | |
| # Standard search paths for .desktop and .directory files | |
| DEFAULT_APP_DIRS = _expand_paths( | |
| [XDG_DATA_HOME] + XDG_DATA_DIRS, | |
| ["applications", "applnk"], # applnk is legacy KDE | |
| ) | |
| DEFAULT_DIR_DIRS = _expand_paths( | |
| [XDG_DATA_HOME] + XDG_DATA_DIRS, | |
| ["desktop-directories"], | |
| ) | |
| # Include well-known third-party application directories (Flatpak, Snap) | |
| for _extra in [ | |
| os.path.join(HOME, ".local/share/flatpak/exports/share/applications"), | |
| "/var/lib/flatpak/exports/share/applications", | |
| "/var/lib/snapd/desktop/applications", | |
| ]: | |
| if os.path.isdir(_extra) and _extra not in DEFAULT_APP_DIRS: | |
| DEFAULT_APP_DIRS.append(_extra) | |
| # --------------------------------------------------------------------------- | |
| # Data Classes representing parsed XDG entities | |
| # --------------------------------------------------------------------------- | |
| @dataclass | |
| class DesktopEntry: | |
| """Represents a parsed .desktop application entry.""" | |
| name: str = "" | |
| generic_name: str = "" | |
| comment: str = "" | |
| exec: str = "" | |
| icon: str = "" | |
| terminal: bool = False | |
| categories: List[str] = field(default_factory=list) | |
| only_show_in: List[str] = field(default_factory=list) | |
| not_show_in: List[str] = field(default_factory=list) | |
| tryexec: str = "" | |
| path: str = "" | |
| filename: str = "" | |
| hidden: bool = False | |
| nodisplay: bool = False | |
| startup_notify: bool = True | |
| @dataclass | |
| class DirectoryEntry: | |
| """Represents a parsed .directory submenu definition entry.""" | |
| name: str = "" | |
| icon: str = "" | |
| comment: str = "" | |
| filename: str = "" | |
| @dataclass | |
| class MenuItem: | |
| """Represents a node in the final parsed menu tree. | |
| Can be either a submenu (is_submenu=True) containing other MenuItems, | |
| or an application leaf node (is_submenu=False) holding a DesktopEntry. | |
| """ | |
| name: str = "" | |
| icon: str = "" | |
| comment: str = "" | |
| directory: str = "" | |
| is_submenu: bool = True | |
| only_unallocated: bool = False # True if <OnlyUnallocated/> is present | |
| entries: List["MenuItem"] = field(default_factory=list) | |
| desktop_entry: Optional[DesktopEntry] = None | |
| # --------------------------------------------------------------------------- | |
| # Minimal Freedesktop INI File Parser | |
| # --------------------------------------------------------------------------- | |
| def _parse_ini(filepath: str) -> Dict[str, Dict[str, str]]: | |
| """Parse a freedesktop.org .desktop/.directory INI file. | |
| Handles basic continuation lines (lines starting with whitespace) | |
| and standard escape sequences (\\s, \\n, \\t, \\\\). | |
| """ | |
| result: Dict[str, Dict[str, str]] = {} | |
| section: Optional[str] = None | |
| try: | |
| with open(filepath, "r", encoding="utf-8", errors="replace") as fh: | |
| for raw_line in fh: | |
| line = raw_line.rstrip("\n\r") | |
| # Handle continuation lines: if a line starts with a space or tab, | |
| # append it to the previous key's value (minus the leading whitespace). | |
| if line.startswith((" ", "\t")) and section is not None: | |
| prev_key = result[section].get("__last_key__") | |
| if prev_key: | |
| result[section][prev_key] += line[1:] | |
| continue | |
| line = line.strip() | |
| if not line or line.startswith("#"): | |
| continue | |
| # Section headers [Section Name] | |
| m = re.match(r"^\[(.+)\]$", line) | |
| if m: | |
| section = m.group(1) | |
| result.setdefault(section, {}) | |
| continue | |
| if section is None or "=" not in line: | |
| continue | |
| # Key=Value pairs | |
| key, _, value = line.partition("=") | |
| key = key.strip() | |
| value = value.strip() | |
| # Process standard freedesktop escape sequences | |
| value = ( | |
| value.replace("\\s", " ") | |
| .replace("\\n", "\n") | |
| .replace("\\t", "\t") | |
| .replace("\\\\", "\\") | |
| ) | |
| result[section][key] = value | |
| result[section]["__last_key__"] = key # Track for continuations | |
| except OSError: | |
| pass | |
| # Clean up internal bookkeeping keys | |
| for sec in result.values(): | |
| sec.pop("__last_key__", None) | |
| return result | |
| def _current_locale_keys(base: str) -> List[str]: | |
| """Generate locale-variant keys for a given base key based on $LANG. | |
| Example for LANG=de_DE.UTF-8 and base="Name": | |
| Returns ["Name", "Name[de_DE]", "Name[de]"] | |
| """ | |
| lang = os.environ.get("LANG", "en_US.UTF-8") | |
| lang = lang.split(".")[0] | |
| parts = lang.split("_") | |
| keys = [base] | |
| if len(parts) == 2: | |
| keys.append(f"{base}[{parts[0]}_{parts[1]}]") | |
| if parts: | |
| keys.append(f"{base}[{parts[0]}]") | |
| return keys | |
| def _localized(d: Dict[str, str], base: str, fallback: str = "") -> str: | |
| """Fetch a localized string from an INI section dict, falling back gracefully.""" | |
| for k in _current_locale_keys(base): | |
| if k in d: | |
| return d[k] | |
| return d.get(base, fallback) | |
| # --------------------------------------------------------------------------- | |
| # Desktop File Finder & Parser | |
| # --------------------------------------------------------------------------- | |
| class DesktopFileParser: | |
| """Handles discovery, caching, and parsing of .desktop and .directory files. | |
| Implements the XDG search order: user-local directories are searched first, | |
| meaning user overrides naturally take precedence over system-wide files. | |
| """ | |
| def __init__(self) -> None: | |
| self._app_dirs: List[str] = list(DEFAULT_APP_DIRS) | |
| self._dir_dirs: List[str] = list(DEFAULT_DIR_DIRS) | |
| self._de_cache: Dict[str, Optional[DesktopEntry]] = {} | |
| self._dir_cache: Dict[str, Optional[DirectoryEntry]] = {} | |
| def add_app_dir(self, p: str) -> None: | |
| """Add an extra directory to search for .desktop files (e.g. from <AppDir> in XML).""" | |
| if os.path.isdir(p) and p not in self._app_dirs: | |
| self._app_dirs.append(p) | |
| def add_directory_dir(self, p: str) -> None: | |
| """Add an extra directory to search for .directory files (e.g. from <DirectoryDir> in XML).""" | |
| if os.path.isdir(p) and p not in self._dir_dirs: | |
| self._dir_dirs.append(p) | |
| def _find(self, directories: List[str], filename: str) -> Optional[str]: | |
| """Find the first existing path for a given filename across a list of directories.""" | |
| for d in directories: | |
| p = os.path.join(d, filename) | |
| if os.path.isfile(p): | |
| return p | |
| return None | |
| def find_desktop_file(self, fn: str) -> Optional[str]: | |
| return self._find(self._app_dirs, fn) | |
| def find_directory_file(self, fn: str) -> Optional[str]: | |
| return self._find(self._dir_dirs, fn) | |
| def parse_desktop(self, filepath: str) -> Optional[DesktopEntry]: | |
| """Parse a .desktop file into a DesktopEntry object. Returns None if invalid/hidden.""" | |
| if filepath in self._de_cache: | |
| return self._de_cache[filepath] | |
| data = _parse_ini(filepath) | |
| sec = data.get("Desktop Entry") | |
| if sec is None: | |
| self._de_cache[filepath] = None | |
| return None | |
| # We only care about Applications and Links, not directories or mime types | |
| if sec.get("Type", "Application") not in ("Application", "Link"): | |
| self._de_cache[filepath] = None | |
| return None | |
| entry = DesktopEntry( | |
| filename=os.path.basename(filepath), | |
| name=_localized(sec, "Name", os.path.basename(filepath)), | |
| generic_name=_localized(sec, "GenericName"), | |
| comment=_localized(sec, "Comment"), | |
| exec=sec.get("Exec", ""), | |
| icon=sec.get("Icon", ""), | |
| terminal=sec.get("Terminal", "false").lower() == "true", | |
| tryexec=sec.get("TryExec", ""), | |
| path=sec.get("Path", ""), | |
| hidden=sec.get("Hidden", "false").lower() == "true", | |
| nodisplay=sec.get("NoDisplay", "false").lower() == "true", | |
| startup_notify=sec.get("StartupNotify", "true").lower() != "false", | |
| categories=[c.strip() for c in sec.get("Categories", "").split(";") if c.strip()], | |
| only_show_in=[c.strip() for c in sec.get("OnlyShowIn", "").split(";") if c.strip()], | |
| not_show_in=[c.strip() for c in sec.get("NotShowIn", "").split(";") if c.strip()], | |
| ) | |
| self._de_cache[filepath] = entry | |
| return entry | |
| def parse_directory(self, filepath: str) -> Optional[DirectoryEntry]: | |
| """Parse a .directory file into a DirectoryEntry object.""" | |
| if filepath in self._dir_cache: | |
| return self._dir_cache[filepath] | |
| data = _parse_ini(filepath) | |
| sec = data.get("Desktop Entry") | |
| if sec is None: | |
| self._dir_cache[filepath] = None | |
| return None | |
| entry = DirectoryEntry( | |
| filename=os.path.basename(filepath), | |
| name=_localized(sec, "Name", os.path.basename(filepath)), | |
| icon=sec.get("Icon", ""), | |
| comment=_localized(sec, "Comment"), | |
| ) | |
| self._dir_cache[filepath] = entry | |
| return entry | |
| def get_desktop_entry(self, filename: str) -> Optional[DesktopEntry]: | |
| """Convenience method: find by filename and parse.""" | |
| p = self.find_desktop_file(filename) | |
| return self.parse_desktop(p) if p else None | |
| def get_directory_entry(self, filename: str) -> Optional[DirectoryEntry]: | |
| """Convenience method: find by filename and parse.""" | |
| p = self.find_directory_file(filename) | |
| return self.parse_directory(p) if p else None | |
| def scan_all(self) -> Dict[str, DesktopEntry]: | |
| """Walk all app directories and build a master map of all DesktopEntries. | |
| The first-seen filename wins. Since user directories are at the front | |
| of the search path, user overrides correctly shadow system defaults. | |
| """ | |
| entries: Dict[str, DesktopEntry] = {} | |
| seen: Set[str] = set() | |
| for d in self._app_dirs: | |
| if not os.path.isdir(d): | |
| continue | |
| for root, _dirs, files in os.walk(d): | |
| for fn in files: | |
| if fn.endswith(".desktop") and fn not in seen: | |
| seen.add(fn) | |
| fp = os.path.join(root, fn) | |
| de = self.parse_desktop(fp) | |
| if de is not None: | |
| entries[fn] = de | |
| return entries | |
| # --------------------------------------------------------------------------- | |
| # XDG Menu Specification Parser (XML -> Menu Tree) | |
| # --------------------------------------------------------------------------- | |
| class XDGMenuParser: | |
| """Parses an XDG .menu XML file and constructs the application menu tree. | |
| Handles the complex matching logic defined in the spec, including <Include>, | |
| <Exclude>, <And>, <Or>, <Not>, <OnlyUnallocated/>, and file merging. | |
| """ | |
| def __init__(self, dp: DesktopFileParser, ignore_desktop: bool = False) -> None: | |
| self.dp = dp | |
| self.all_entries: Dict[str, DesktopEntry] = {} | |
| self._desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").split(":")[0].lower() | |
| self._ignore_desktop = ignore_desktop | |
| # -- visibility --------------------------------------------------------- | |
| def _visible(self, de: DesktopEntry) -> bool: | |
| """Determine if a DesktopEntry should be shown in the current environment. | |
| Checks Hidden, NoDisplay, OnlyShowIn/NotShowIn, and TryExec. | |
| """ | |
| if de.hidden or de.nodisplay: | |
| return False | |
| # Desktop environment filtering (can be bypassed with --ignore-desktop) | |
| if not self._ignore_desktop and self._desktop: | |
| if de.only_show_in and self._desktop not in [x.lower() for x in de.only_show_in]: | |
| return False | |
| if de.not_show_in and self._desktop in [x.lower() for x in de.not_show_in]: | |
| return False | |
| # TryExec check: ensure the binary exists in $PATH | |
| if de.tryexec: | |
| for p in os.environ.get("PATH", "").split(":"): | |
| if os.path.isfile(os.path.join(p, de.tryexec)): | |
| break | |
| else: | |
| return False | |
| return True | |
| # -- rule evaluation ---------------------------------------------------- | |
| def _match(self, elem: ET.Element, de: DesktopEntry) -> bool: | |
| """Recursively evaluate an XDG Menu match rule element against a DesktopEntry. | |
| This is the core engine of the menu parser. It handles: | |
| - <Filename>: exact match on the .desktop filename | |
| - <Category>: match if the entry has this category | |
| - <All>: always matches | |
| - <And>, <Or>, <Not>: boolean logic combinators | |
| - <Include>, <Exclude>: treated as implicit <Or> containers | |
| """ | |
| tag = elem.tag | |
| if tag == "Filename": | |
| return de.filename == (elem.text or "") | |
| if tag == "Category": | |
| return (elem.text or "") in de.categories | |
| if tag == "All": | |
| return True | |
| if tag == "And": | |
| return all(self._match(c, de) for c in elem) | |
| if tag == "Or": | |
| return any(self._match(c, de) for c in elem) | |
| if tag == "Not": | |
| children = list(elem) | |
| return not self._match(children[0], de) if children else True | |
| # <Include> and <Exclude> act as implicit <Or> blocks. | |
| # If not handled here, _match falls through to `return False`, breaking | |
| # all category-based menus (like <Include><Category>Development</Category></Include>). | |
| if tag in ("Include", "Exclude"): | |
| return any(self._match(c, de) for c in elem) | |
| return False | |
| # -- XML preprocessing -------------------------------------------------- | |
| def _preprocess(self, root: ET.Element, base_path: str) -> None: | |
| """Handle XDG menu XML preprocessing directives before tree building. | |
| Expands dynamic directory paths and merges external .menu files into | |
| the current XML tree so the tree builder sees a unified document. | |
| """ | |
| # <DefaultAppDirs/> and <DefaultDirectoryDirs/> are already handled | |
| # by our DEFAULT_APP_DIRS and DEFAULT_DIR_DIRS constants. | |
| for el in root.findall("AppDir"): | |
| if el.text: | |
| self.dp.add_app_dir(el.text) | |
| for el in root.findall("DirectoryDir"): | |
| if el.text: | |
| self.dp.add_directory_dir(el.text) | |
| # <LegacyDir> contains app dirs, and its subdirectories are also app dirs | |
| for el in root.findall("LegacyDir"): | |
| if el.text and os.path.isdir(el.text): | |
| self.dp.add_app_dir(el.text) | |
| for sub in os.listdir(el.text): | |
| sp = os.path.join(el.text, sub) | |
| if os.path.isdir(sp): | |
| self.dp.add_app_dir(sp) | |
| # <DefaultMergeDirs/>: automatically merge <menu-basename>-*.menu files | |
| # found in all XDG config directories. | |
| for _el in root.findall("DefaultMergeDirs"): | |
| base_name = os.path.splitext(os.path.basename(base_path))[0] | |
| for cfg_dir in [XDG_CONFIG_HOME] + XDG_CONFIG_DIRS: | |
| menu_dir = os.path.join(cfg_dir, "menus") | |
| if not os.path.isdir(menu_dir): | |
| continue | |
| for fn in sorted(os.listdir(menu_dir)): | |
| if fn.startswith(base_name + "-") and fn.endswith(".menu"): | |
| self._merge_file(os.path.join(menu_dir, fn), root) | |
| # <MergeFile>: explicitly merge a specific .menu file | |
| for mf in list(root.findall("MergeFile")): | |
| mtype = mf.get("type", "path") | |
| merge_path: Optional[str] = None | |
| if mtype == "parent": | |
| # Look for the same filename in the parent directory | |
| pdir = os.path.dirname(os.path.dirname(base_path)) | |
| candidate = os.path.join(pdir, os.path.basename(base_path)) | |
| if os.path.isfile(candidate): | |
| merge_path = candidate | |
| elif mf.text: | |
| candidate = mf.text | |
| if not os.path.isabs(candidate): | |
| candidate = os.path.join(os.path.dirname(base_path), candidate) | |
| if os.path.isfile(candidate): | |
| merge_path = candidate | |
| if merge_path: | |
| self._merge_file(merge_path, root) | |
| root.remove(mf) # Remove the directive after processing | |
| def _merge_file(self, merge_path: str, target_root: ET.Element) -> None: | |
| """Inject contents of an external .menu file into the target XML tree. | |
| Submenus are appended to the end; rule elements (<Include>, etc.) are | |
| prepended so they apply before the target's own rules. | |
| """ | |
| try: | |
| mt = ET.parse(merge_path) | |
| mr = mt.getroot() | |
| for child in reversed(list(mr)): | |
| if child.tag == "Menu": | |
| target_root.append(child) | |
| elif child.tag not in ("Name", "Directory", "DefaultAppDirs", | |
| "DefaultDirectoryDirs", "DefaultLayout", | |
| "Layout", "DefaultMergeDirs"): | |
| # Prepend rules/includes so they are processed first | |
| target_root.insert(0, child) | |
| except ET.ParseError: | |
| pass | |
| # -- tree builder ------------------------------------------------------- | |
| def _build(self, elem: ET.Element, | |
| par_inc: List[ET.Element], par_exc: List[ET.Element]) -> MenuItem: | |
| """Recursively build the MenuItem tree from a <Menu> XML element. | |
| Handles XDG Spec Include Inheritance: | |
| If a menu has its OWN <Include>, its children do NOT inherit the parent's | |
| <Include> rules (only <Exclude> rules are inherited). If a menu has NO | |
| <Include>, children inherit the parent's <Include> rules. | |
| """ | |
| node = MenuItem(is_submenu=True) | |
| # Resolve menu name from XML, falling back to .directory file if available | |
| name_el = elem.find("Name") | |
| if name_el is not None and name_el.text: | |
| node.name = name_el.text | |
| dir_el = elem.find("Directory") | |
| if dir_el is not None and dir_el.text: | |
| node.directory = dir_el.text | |
| dirent = self.dp.get_directory_entry(dir_el.text) | |
| if dirent: | |
| # .directory file provides the canonical localized name and icon | |
| node.name = dirent.name | |
| node.icon = dirent.icon | |
| node.comment = dirent.comment | |
| # Detect <OnlyUnallocated/> (handled in a post-processing pass) | |
| if elem.find("OnlyUnallocated") is not None: | |
| node.only_unallocated = True | |
| # Collect local rules defined directly inside this <Menu> | |
| loc_inc: List[ET.Element] = [c for c in elem if c.tag == "Include"] | |
| loc_exc: List[ET.Element] = [c for c in elem if c.tag == "Exclude"] | |
| # Effective excludes are ALWAYS cumulative (this level + ancestors) | |
| eff_exc = par_exc + loc_exc | |
| # Include inheritance: pass locals to children if they exist, else pass parent's -- | |
| # If this menu has local <Include>, children inherit locals. | |
| # If this menu has NO local <Include>, children inherit parent's. | |
| child_inc = loc_inc if loc_inc else par_inc | |
| # Recursively build submenus first | |
| for child in elem: | |
| if child.tag == "Menu": | |
| sub = self._build(child, child_inc, eff_exc) | |
| # Only keep non-empty submenus in the final tree | |
| if sub.entries: | |
| node.entries.append(sub) | |
| # Add applications from THIS menu's local <Include> rules only. | |
| # (We don't add apps using parent rules here, that was already done upstream). | |
| if loc_inc: | |
| added: Set[str] = set() # Track added filenames to prevent duplicates | |
| self._add_apps(loc_inc, eff_exc, node, added) | |
| return node | |
| def _add_apps(self, inc_rules: List[ET.Element], | |
| exc_rules: List[ET.Element], | |
| node: MenuItem, added: Set[str]) -> None: | |
| """Evaluate <Include> and <Exclude> rules and add matching DesktopEntries. | |
| An entry is added if: | |
| 1. It matches at least ONE rule inside at least ONE <Include> block. | |
| 2. It does NOT match ANY rule inside ANY <Exclude> block. | |
| 3. It passes visibility checks (_visible). | |
| 4. It hasn't already been added to this specific node (dedup). | |
| """ | |
| for fn, de in self.all_entries.items(): | |
| if fn in added or not self._visible(de): | |
| continue | |
| # Must match at least one <Include> block | |
| if not any(self._match(r, de) for r in inc_rules): | |
| continue | |
| # Must not match any <Exclude> block | |
| if any(self._match(r, de) for r in exc_rules): | |
| continue | |
| app = MenuItem( | |
| is_submenu=False, | |
| name=de.name, | |
| icon=de.icon, | |
| comment=de.comment, | |
| desktop_entry=de, | |
| ) | |
| node.entries.append(app) | |
| added.add(fn) | |
| # -- post-processing: <OnlyUnallocated/> --------------------------------- | |
| def _prune_unallocated(self, item: MenuItem) -> None: | |
| """Post-processing pass to handle the <OnlyUnallocated/> directive. | |
| If a menu has <OnlyUnallocated/>, it should only contain applications | |
| that were NOT already allocated to a sibling submenu. | |
| """ | |
| # First, collect all filenames allocated by sibling (non-unallocated) submenus | |
| allocated: Set[str] = set() | |
| def collect(node: MenuItem) -> None: | |
| if not node.is_submenu and node.desktop_entry: | |
| allocated.add(node.desktop_entry.filename) | |
| for child in node.entries: | |
| collect(child) | |
| for child in item.entries: | |
| if child.is_submenu and not child.only_unallocated: | |
| collect(child) | |
| # Filter the unallocated submenus based on the collected set | |
| for child in item.entries: | |
| if child.is_submenu and child.only_unallocated: | |
| child.entries = [ | |
| e for e in child.entries | |
| if not e.desktop_entry or e.desktop_entry.filename not in allocated | |
| ] | |
| # Recurse down the tree | |
| if child.is_submenu: | |
| self._prune_unallocated(child) | |
| # -- public entry point ------------------------------------------------- | |
| def parse(self, menu_path: str) -> MenuItem: | |
| """Main entry point: parse a .menu file and return the resolved MenuItem tree.""" | |
| if not os.path.isfile(menu_path): | |
| raise FileNotFoundError(menu_path) | |
| tree = ET.parse(menu_path) | |
| root = tree.getroot() | |
| if root.tag != "Menu": | |
| raise ValueError(f"Root element is <{root.tag}>, expected <Menu>") | |
| self._preprocess(root, menu_path) | |
| self.all_entries = self.dp.scan_all() | |
| result = self._build(root, [], []) | |
| self._prune_unallocated(result) | |
| return result | |
| # --------------------------------------------------------------------------- | |
| # Exporters (Menu Tree -> Target Format) | |
| # --------------------------------------------------------------------------- | |
| class MenuExporter: | |
| """Exports a parsed MenuItem tree to various serialization formats.""" | |
| def __init__(self, root: MenuItem) -> None: | |
| self.root = root | |
| def _to_dict(self, item: MenuItem) -> Dict[str, Any]: | |
| """Convert the MenuItem tree into a standard Python dictionary structure.""" | |
| d: Dict[str, Any] = { | |
| "name": item.name, | |
| "icon": item.icon, | |
| "comment": item.comment, | |
| } | |
| if item.directory: | |
| d["directory"] = item.directory | |
| if item.is_submenu: | |
| d["type"] = "submenu" | |
| d["entries"] = [self._to_dict(e) for e in item.entries] | |
| else: | |
| d["type"] = "application" | |
| de = item.desktop_entry | |
| if de: | |
| d["exec"] = de.exec | |
| d["terminal"] = de.terminal | |
| d["startup_notify"] = de.startup_notify | |
| d["generic_name"] = de.generic_name | |
| d["filename"] = de.filename | |
| if de.path: | |
| d["path"] = de.path | |
| return d | |
| def to_json(self, indent: int = 2) -> str: | |
| """Serialize the menu tree to a JSON string.""" | |
| return json.dumps(self._to_dict(self.root), indent=indent, ensure_ascii=False) | |
| def to_yaml(self) -> str: | |
| """Serialize the menu tree to a YAML string. Requires PyYAML.""" | |
| if not HAS_YAML: | |
| raise ImportError("PyYAML required for YAML output. pip install pyyaml") | |
| return yaml.dump( | |
| self._to_dict(self.root), | |
| default_flow_style=False, | |
| allow_unicode=True, | |
| sort_keys=False, | |
| ) | |
| @staticmethod | |
| def _fb_escape(text: str) -> str: | |
| """Escape special characters for fluxbox menu labels (parentheses and backslashes).""" | |
| return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") | |
| def to_fluxbox(self, terminal_cmd: str = "xterm -e", | |
| include_icons: bool = True) -> str: | |
| """Generate a fluxbox-compatible menu string. | |
| Args: | |
| terminal_cmd: Command to wrap terminal applications (e.g., 'xterm -e'). | |
| Set to empty string to skip wrapping. | |
| include_icons: If True, append fluxbox icon hints (<iconname>). | |
| """ | |
| lines: List[str] = ["# Generated by xdg-menu-parser"] | |
| lines.append(f"[begin] ({self._fb_escape(self.root.name)})") | |
| self._fb_walk(lines, self.root, terminal_cmd, include_icons, depth=0) | |
| lines.append("[end]") | |
| return "\n".join(lines) | |
| @staticmethod | |
| def _resolve_exec_to_absolute(exec_str: str) -> str: | |
| """Resolve the base command in an Exec string to its absolute path. | |
| Fluxbox (and other WMs) often start without a full login shell, meaning | |
| their $PATH is minimal and might miss directories like ~/.local/bin. | |
| Since this script is run from a terminal (which HAS a full $PATH), | |
| we resolve binaries now so the generated menu works regardless. | |
| """ | |
| if not exec_str: | |
| return exec_str | |
| # Split into base command and its arguments | |
| parts = exec_str.split(None, 1) | |
| cmd_base = parts[0] | |
| # If it's already an absolute path, or a shell built-in/variable, leave it alone | |
| if os.path.isabs(cmd_base) or cmd_base.startswith("$"): | |
| return exec_str | |
| # Look up the command in the script's current $PATH | |
| abs_path = shutil.which(cmd_base) | |
| if abs_path: | |
| parts[0] = abs_path | |
| return " ".join(parts) | |
| # Fallback: if not found, return the original string as-is | |
| return exec_str | |
| def _fb_walk(self, lines: List[str], item: MenuItem, | |
| term_cmd: str, icons: bool, depth: int) -> None: | |
| """Recursively walk the menu tree, appending fluxbox syntax lines.""" | |
| indent = " " * (depth + 1) | |
| # Separate submenus and applications for ordered output | |
| submenus = [e for e in item.entries if e.is_submenu] | |
| apps = sorted( | |
| [e for e in item.entries if not e.is_submenu], | |
| key=lambda a: a.name.lower(), # Sort apps alphabetically | |
| ) | |
| # Output submenus first | |
| for sub in submenus: | |
| if not sub.entries: | |
| continue # Skip empty submenus entirely | |
| ico = f" <{sub.icon}>" if icons and sub.icon else "" | |
| # Standard fluxbox format: [submenu] (label) <icon> | |
| lines.append(f"{indent}[submenu] ({self._fb_escape(sub.name)}){ico}") | |
| self._fb_walk(lines, sub, term_cmd, icons, depth + 1) | |
| lines.append(f"{indent}[end]") | |
| # Output applications | |
| for app in apps: | |
| de = app.desktop_entry | |
| if de is None: | |
| continue | |
| # Strip Exec field codes (%f, %u, etc.) as fluxbox doesn't understand them | |
| cmd = de.exec | |
| cmd = re.sub(r"%[fFuUdDnNickvm]", "", cmd).strip() | |
| # Resolve relative paths to absolute paths | |
| # which prevents commands work in terminal but fail in Fluxbox | |
| cmd = self._resolve_exec_to_absolute(cmd) | |
| if not cmd: | |
| continue | |
| # Respect the Path= key if set. Because Fluxbox doesn't use a login shell, | |
| # we must explicitly wrap it in a shell `cd` command. | |
| if de.path: | |
| cmd = f"/bin/sh -c 'cd {de.path} && {cmd}'" | |
| # Wrap terminal applications if a wrapper command is provided. | |
| # Note: If Path= was used above, we are already inside an `sh -c`, so | |
| # xterm -e will correctly execute that whole shell string. | |
| if de.terminal and term_cmd: | |
| cmd = f"{term_cmd} {cmd}" | |
| ico = f" <{app.icon}>" if icons and app.icon else "" | |
| # STRICT FLUXBOX FORMAT: | |
| # 1. NO spaces inside { } (prevents parsing errors on strict parsers) | |
| # 2. Icon placed AFTER {command} (official standard order) | |
| lines.append( | |
| f"{indent}[exec] ({self._fb_escape(app.name)}) {{{cmd}}}{ico}" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # XDG Menu File Discovery Utilities | |
| # --------------------------------------------------------------------------- | |
| def find_menu_files(extra_dir: Optional[str] = None) -> List[str]: | |
| """Locate all available .menu files in standard XDG configuration directories.""" | |
| dirs: List[str] = [] | |
| if extra_dir: | |
| dirs.append(extra_dir) | |
| for cfg in [XDG_CONFIG_HOME] + XDG_CONFIG_DIRS: | |
| dirs.append(os.path.join(cfg, "menus")) | |
| out: List[str] = [] | |
| seen: Set[str] = set() | |
| for d in dirs: | |
| if not os.path.isdir(d): | |
| continue | |
| for fn in sorted(os.listdir(d)): | |
| if fn.endswith(".menu"): | |
| rp = os.path.realpath(d) | |
| if rp not in seen: | |
| seen.add(rp) | |
| out.append(os.path.join(d, fn)) | |
| return out | |
| def resolve_menu(name: str, extra_dir: Optional[str] = None) -> Optional[str]: | |
| """Resolve a menu filename (or absolute path) to an actual file path.""" | |
| if os.path.isabs(name) and os.path.isfile(name): | |
| return name | |
| dirs: List[str] = [] | |
| if extra_dir: | |
| dirs.append(extra_dir) | |
| for cfg in [XDG_CONFIG_HOME] + XDG_CONFIG_DIRS: | |
| dirs.append(os.path.join(cfg, "menus")) | |
| for d in dirs: | |
| p = os.path.join(d, name) | |
| if os.path.isfile(p): | |
| return p | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # Command Line Interface | |
| # --------------------------------------------------------------------------- | |
| def main() -> int: | |
| ap = argparse.ArgumentParser( | |
| description="Parse XDG .menu files and export to JSON, YAML, or fluxbox format.", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog="""\ | |
| examples: | |
| %(prog)s -l List available menu files | |
| %(prog)s -m applications.menu -f json Export as JSON | |
| %(prog)s -m applications.menu -f yaml Export as YAML | |
| %(prog)s -m kf5-applications.menu -f fluxbox --ignore-desktop | |
| %(prog)s -m applications.menu -f fluxbox -o ~/.fluxbox/menu | |
| %(prog)s -m applications.menu -f fluxbox --no-icons --terminal-cmd "urxvt -e" | |
| """, | |
| ) | |
| ap.add_argument("-l", "--list", action="store_true", | |
| help="List available .menu files and exit") | |
| ap.add_argument("-m", "--menu", metavar="FILE", | |
| help="Menu file path or filename to search in XDG dirs") | |
| ap.add_argument("-d", "--menu-dir", metavar="DIR", | |
| help="Additional directory to search for menu files") | |
| ap.add_argument("-f", "--format", choices=["json", "yaml", "fluxbox"], | |
| default="json", help="Output format (default: json)") | |
| ap.add_argument("-o", "--output", metavar="FILE", | |
| help="Write output to FILE instead of stdout") | |
| ap.add_argument("--terminal-cmd", default="xterm -e", | |
| help="Terminal wrapper for fluxbox (default: 'xterm -e')") | |
| ap.add_argument("--no-terminal-cmd", action="store_true", | |
| help="Do not wrap terminal apps in fluxbox output") | |
| ap.add_argument("--no-icons", action="store_true", | |
| help="Omit icons in fluxbox output") | |
| ap.add_argument("--ignore-desktop", action="store_true", | |
| help="Ignore OnlyShowIn/NotShowIn (recommended for cross-DE menus)") | |
| ap.add_argument("--indent", type=int, default=2, | |
| help="JSON indent (default: 2)") | |
| ap.add_argument("-v", "--verbose", action="store_true", | |
| help="Print progress to stderr") | |
| args = ap.parse_args() | |
| # Handle --list mode | |
| if args.list: | |
| menus = find_menu_files(args.menu_dir) | |
| if not menus: | |
| print("No .menu files found.", file=sys.stderr) | |
| return 1 | |
| print("Available .menu files:") | |
| for m in menus: | |
| print(f" {m}") | |
| return 0 | |
| # Validate --menu requirement | |
| if not args.menu: | |
| ap.error("Specify a menu with -m/--menu, or use -l to list available menus") | |
| menu_path = resolve_menu(args.menu, args.menu_dir) | |
| if menu_path is None: | |
| print(f"Error: menu file not found: {args.menu}", file=sys.stderr) | |
| return 1 | |
| if args.verbose: | |
| print(f"[info] Parsing {menu_path} …", file=sys.stderr) | |
| # Initialize parsers | |
| dp = DesktopFileParser() | |
| mp = XDGMenuParser(dp, ignore_desktop=args.ignore_desktop) | |
| # Parse the menu tree | |
| try: | |
| tree = mp.parse(menu_path) | |
| except (FileNotFoundError, ValueError, ET.ParseError) as exc: | |
| print(f"Error: {exc}", file=sys.stderr) | |
| return 1 | |
| if args.verbose: | |
| print(f"[info] Scanned {len(mp.all_entries)} .desktop entries", file=sys.stderr) | |
| # Export to requested format | |
| exporter = MenuExporter(tree) | |
| try: | |
| if args.format == "json": | |
| text = exporter.to_json(indent=args.indent) | |
| elif args.format == "yaml": | |
| text = exporter.to_yaml() | |
| elif args.format == "fluxbox": | |
| tcmd = "" if args.no_terminal_cmd else args.terminal_cmd | |
| text = exporter.to_fluxbox(terminal_cmd=tcmd, | |
| include_icons=not args.no_icons) | |
| else: | |
| text = "" | |
| except ImportError as exc: | |
| print(f"Error: {exc}", file=sys.stderr) | |
| return 1 | |
| # Output result (to file or stdout) | |
| if args.output: | |
| os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True) | |
| with open(args.output, "w", encoding="utf-8") as fh: | |
| fh.write(text) | |
| fh.write("\n") | |
| if args.verbose: | |
| print(f"[info] Written to {args.output}", file=sys.stderr) | |
| else: | |
| sys.stdout.buffer.write(text.encode("utf-8")) | |
| sys.stdout.buffer.write(b"\n") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment