Skip to content

Instantly share code, notes, and snippets.

@suntong
Last active April 8, 2026 22:03
Show Gist options
  • Select an option

  • Save suntong/8a3c4f9599b41ad04c906405d0bc9000 to your computer and use it in GitHub Desktop.

Select an option

Save suntong/8a3c4f9599b41ad04c906405d0bc9000 to your computer and use it in GitHub Desktop.
#!/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