#!/usr/bin/env python3 from __future__ import annotations import re import subprocess import sys import tomllib from pathlib import Path ROOT_DIR = Path(__file__).resolve().parents[3] CATALOG_PATH = ROOT_DIR / "catalog.toml" REQUIRED_FIELDS = ("id", "name", "version", "author", "plugin_api", "tags") OPTIONAL_STRING_FIELDS = ("license", "icon", "description") OPTIONAL_BOOL_FIELDS = ("deprecated",) # Oldest plugin API any supported Noctalia accepts (kOldestSupportedPluginApiVersion in the # shell's src/scripting/plugin_api.h). Release rows below it can never be installed, so the # history walk stops there. OLDEST_SUPPORTED_PLUGIN_API = 3 def git_commit_time(path: Path, *extra_args: str) -> int | None: """Commit time in Unix seconds, or None when `path` has no matching commit. An uncommitted plugin has no history, so `git log` prints nothing. """ stdout = subprocess.run( ["git", "log", "-1", *extra_args, "--format=%ct", "--", path], capture_output=True, text=True, check=True, ).stdout.strip() return int(stdout) if stdout else None def git_output(*args: str) -> str: return subprocess.run( ["git", *args], capture_output=True, text=True, check=True ).stdout def plugin_history(subdir: str) -> list[tuple[str, int, dict]]: """Every readable revision of `/plugin.toml`, newest first. One `git show` per revision, and both the release ladder and the per-version release dates come out of this single walk. (A directory rename starts the history over, the same way it resets `added_at`.) """ history = [] revisions = git_output( "log", "--format=%H %ct", "--", f"{subdir}/plugin.toml" ).splitlines() for line in revisions: revision, _, commit_time = line.partition(" ") try: manifest = tomllib.loads( git_output("show", f"{revision}:{subdir}/plugin.toml") ) except (subprocess.CalledProcessError, tomllib.TOMLDecodeError): continue # unreadable or pre-`plugin_api` history history.append((revision, int(commit_time), manifest)) return history def release_times(history: list[tuple[str, int, dict]]) -> dict[str, int]: """When each version string was first committed, keyed by version. The earliest commit carrying a version is the bump that released it. A later commit editing plugin.toml without bumping (tags, description, translations) must not move the date, and a version that reappears after a revert keeps its original one. """ times: dict[str, int] = {} for _, commit_time, manifest in reversed(history): # oldest first version = manifest.get("version") if isinstance(version, str) and version: times.setdefault(version, commit_time) return times def release_history( history: list[tuple[str, int, dict]], tip_api: int, released: dict[str, int] ) -> list[dict]: """Older revisions of a plugin, one per API level below the tip's, newest first. A Noctalia below the tip's `plugin_api` has nothing to install unless the catalog names a revision it can run. Walking `/plugin.toml` newest-first and keeping only the revisions that lower the API level yields a strictly decreasing sequence, so each row is the newest revision at or below its own level -- exactly what the host resolves against. """ releases = [] lowest_api = tip_api for revision, _, manifest in history: if lowest_api <= OLDEST_SUPPORTED_PLUGIN_API: break plugin_api = manifest.get("plugin_api") version = manifest.get("version") if not isinstance(plugin_api, int) or isinstance(plugin_api, bool): continue if not isinstance(version, str) or not version: continue if plugin_api >= lowest_api or plugin_api < OLDEST_SUPPORTED_PLUGIN_API: continue # `rev` is the newest revision still on this API level, not necessarily the bump # commit, so the date comes from the version rather than from `rev` itself. releases.append( { "plugin_api": plugin_api, "version": version, "rev": revision, "updated_at": released[version], } ) lowest_api = plugin_api return releases def load_plugin_manifest(path: Path) -> dict: with path.open("rb") as handle: manifest = tomllib.load(handle) missing = [field for field in REQUIRED_FIELDS if field not in manifest] if missing: missing_fields = ", ".join(missing) raise ValueError(f"{path.relative_to(ROOT_DIR)} is missing: {missing_fields}") plugin_api = manifest["plugin_api"] if not isinstance(plugin_api, int) or isinstance(plugin_api, bool) or plugin_api <= 0: raise ValueError( f"{path.relative_to(ROOT_DIR)} has invalid plugin_api; expected a positive integer" ) if not isinstance(manifest["tags"], list) or not all( isinstance(tag, str) for tag in manifest["tags"] ): raise ValueError(f"{path.relative_to(ROOT_DIR)} has invalid tags; expected strings") out = {field: manifest[field] for field in REQUIRED_FIELDS} for field in OPTIONAL_STRING_FIELDS: if field in manifest: if not isinstance(manifest[field], str): raise ValueError(f"{path.relative_to(ROOT_DIR)} has invalid {field}; expected string") out[field] = manifest[field] for field in OPTIONAL_BOOL_FIELDS: if field in manifest: if not isinstance(manifest[field], bool): raise ValueError(f"{path.relative_to(ROOT_DIR)} has invalid {field}; expected bool") out[field] = manifest[field] # Git dates a committed plugin, and is stable across checkouts. Anything git cannot date # falls back to the file's mtime: an uncommitted plugin still gets a sensible entry so the # catalog can be generated mid-development. (A rename also breaks the link to the commit # that first added the file, which is why added_at falls back too.) # `updated_at` is only the last plugin.toml touch here; discover_plugins replaces it with # the date `version` was actually bumped once the file's history has been walked. mtime = int(path.stat().st_mtime) out["updated_at"] = git_commit_time(path) or mtime out["added_at"] = git_commit_time(path, "--diff-filter=A") or out["updated_at"] return out def existing_catalog_order() -> dict[str, int]: if not CATALOG_PATH.exists(): return {} content = CATALOG_PATH.read_text(encoding="utf-8") ids = re.findall(r'(?m)^id\s*=\s*"([^"]+)"', content) return {plugin_id: index for index, plugin_id in enumerate(ids)} def discover_plugins() -> list[dict]: order = existing_catalog_order() plugins = [] for manifest_path in sorted(ROOT_DIR.glob("*/plugin.toml")): manifest = load_plugin_manifest(manifest_path) directory = manifest_path.parent.name history = plugin_history(directory) released = release_times(history) manifest["_directory"] = directory manifest["_order"] = order.get(manifest["id"], len(order)) # A bump that is not committed yet has no commit to date it, so the last touch stands. manifest["updated_at"] = released.get(manifest["version"], manifest["updated_at"]) manifest["releases"] = release_history(history, manifest["plugin_api"], released) plugins.append(manifest) plugins.sort(key=lambda plugin: (plugin["_order"], plugin["_directory"])) return plugins def toml_string(value: str) -> str: return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' def toml_bool(value: bool) -> str: return "true" if value else "false" def render_catalog(plugins: list[dict]) -> str: lines = [ "# This file is auto-generated. Do not edit manually.", "# Do not include it in your commit.", "# Noctalia plugins catalog.", "# Index of every plugin this source ships: the minimum needed to render, search,", "# and compat-check the list. The per-plugin plugin.toml stays authoritative; the", "# host re-reads it on enable. Keep one [[plugin]] row per plugin subdirectory.", "# A [[plugin.release]] row names an older revision for a Noctalia below the tip's", "# plugin_api, so an older release stays installable instead of the plugin vanishing.", "# On every row, updated_at dates the commit that first shipped that row's version.", "", ] for index, plugin in enumerate(plugins): if index: lines.append("") lines.extend( [ "[[plugin]]", f"id = {toml_string(plugin['id'])}", f"name = {toml_string(plugin['name'])}", f"version = {toml_string(plugin['version'])}", f"updated_at = {plugin['updated_at']}", f"added_at = {plugin['added_at']}", f"author = {toml_string(plugin['author'])}", ] ) if "license" in plugin: lines.append(f"license = {toml_string(plugin['license'])}") if "icon" in plugin: lines.append(f"icon = {toml_string(plugin['icon'])}") if "description" in plugin: lines.append(f"description = {toml_string(plugin['description'])}") if "deprecated" in plugin: lines.append(f"deprecated = {toml_bool(plugin['deprecated'])}") lines.extend( [ f"plugin_api = {plugin['plugin_api']}", "tags = [" + ", ".join(toml_string(tag) for tag in plugin["tags"]) + "]", ] ) for release in plugin["releases"]: lines.extend( [ "", "[[plugin.release]]", f"plugin_api = {release['plugin_api']}", f"version = {toml_string(release['version'])}", f"updated_at = {release['updated_at']}", f"rev = {toml_string(release['rev'])}", ] ) return "\n".join(lines) + "\n" def main() -> int: plugins = discover_plugins() CATALOG_PATH.write_text(render_catalog(plugins), encoding="utf-8") print(f"Updated {CATALOG_PATH.relative_to(ROOT_DIR)} with {len(plugins)} plugin(s).") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as error: print(f"error: {error}", file=sys.stderr) raise SystemExit(1)