AkurAI Build
Menu

BifrOSt-Apps

public

Latest change f35619cfe0419c596645328bf0e1b682ad3a4473 - Harden RÚV for the 0.1.1 candidate by Ólafur Búi Ólafsson

#!/usr/bin/env python3
"""Generate a complete BifrOSt Flatpak application from the repository template."""

from __future__ import annotations

import argparse
from datetime import datetime, timezone
import json
from pathlib import Path
import re
import shutil
import sys

APP_ID = re.compile(r"^org\.bifrost\.[A-Z][A-Za-z0-9]*$")
TOKEN = re.compile(r"@@[A-Z_]+@@")
ROOT = Path(__file__).resolve().parents[1]
TEMPLATES = {
    "gtk": ROOT / "template",
    "dioxus": ROOT / "template-dioxus",
}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("app_id", help="reverse-DNS identifier, for example org.bifrost.Notes")
    parser.add_argument("name", help="English display name")
    parser.add_argument("--name-is", required=True, help="Icelandic display name")
    parser.add_argument("--summary", required=True, help="short English AppStream summary")
    parser.add_argument("--summary-is", required=True, help="short Icelandic AppStream summary")
    parser.add_argument("--description", required=True, help="English AppStream description")
    parser.add_argument("--description-is", required=True, help="Icelandic AppStream description")
    parser.add_argument("--version", default="0.1.0", help="initial application version")
    parser.add_argument(
        "--output",
        type=Path,
        help="destination directory; defaults to apps/<derived-name>",
    )
    parser.add_argument(
        "--stack",
        choices=sorted(TEMPLATES),
        default="gtk",
        help="application template to generate from (default: gtk)",
    )
    return parser.parse_args()


def slug_for(app_id: str) -> str:
    component = app_id.rsplit(".", 1)[1]
    words = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", component)
    return words.lower()


def xml_escape(value: str) -> str:
    return (
        value.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
        .replace("'", "&apos;")
    )


def po_escape(value: str) -> str:
    return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")


def rust_escape(value: str) -> str:
    return value.replace("\\", "\\\\").replace('"', '\\"')


def desktop_escape(value: str, *, list_value: bool = False) -> str:
    """Escape a Desktop Entry string value per the freedesktop.org spec.

    Backslash, tab, newline, and carriage return become their escape
    sequences; other control characters are rejected because the spec
    forbids them in values. Leading and trailing ASCII spaces are escaped
    as ``\\s`` so parsers cannot strip them. In list contexts (``list_value``)
    the ``;`` separator is escaped as well.
    """
    sequences = {"\\": "\\\\", "\n": "\\n", "\t": "\\t", "\r": "\\r"}
    escaped = []
    for character in value:
        if character in sequences:
            escaped.append(sequences[character])
        elif list_value and character == ";":
            escaped.append("\\;")
        elif ord(character) < 0x20 or character == "\x7f":
            raise ValueError("desktop entry values must not contain control characters")
        else:
            escaped.append(character)
    if escaped and escaped[0] == " ":
        escaped[0] = "\\s"
    if escaped and escaped[-1] == " ":
        escaped[-1] = "\\s"
    return "".join(escaped)


def validate(arguments: argparse.Namespace) -> None:
    if not APP_ID.fullmatch(arguments.app_id):
        raise ValueError("application ID must match org.bifrost.<PascalCaseName>")
    for field in ("name", "name_is", "summary", "summary_is", "description", "description_is", "version"):
        value = getattr(arguments, field)
        if not value.strip() or "\n" in value or "\r" in value:
            raise ValueError(f"{field.replace('_', '-')} must be non-empty and single-line")
    for field in ("summary", "summary_is"):
        if len(getattr(arguments, field)) > 100:
            raise ValueError(f"{field.replace('_', '-')} must not exceed 100 characters")
    for field in ("description", "description_is"):
        if len(getattr(arguments, field)) < 80:
            raise ValueError(f"{field.replace('_', '-')} must contain at least 80 characters")
    if not re.fullmatch(r"[0-9]+(?:\.[0-9]+){1,2}(?:[-+][A-Za-z0-9.-]+)?", arguments.version):
        raise ValueError("version must be a release version such as 1.0.0 or 1.0.0-beta.1")


def substitutions(arguments: argparse.Namespace) -> dict[str, str]:
    return {
        "@@APP_ID@@": arguments.app_id,
        "@@APP_NAME@@": arguments.name,
        "@@APP_NAME_IS@@": arguments.name_is,
        "@@SUMMARY@@": arguments.summary,
        "@@SUMMARY_IS@@": arguments.summary_is,
        "@@DESCRIPTION@@": arguments.description,
        "@@DESCRIPTION_IS@@": arguments.description_is,
        "@@PY_APP_ID@@": json.dumps(arguments.app_id),
        "@@PY_APP_NAME@@": json.dumps(arguments.name),
        "@@XML_APP_NAME@@": xml_escape(arguments.name),
        "@@XML_APP_NAME_IS@@": xml_escape(arguments.name_is),
        "@@XML_SUMMARY@@": xml_escape(arguments.summary),
        "@@XML_SUMMARY_IS@@": xml_escape(arguments.summary_is),
        "@@XML_DESCRIPTION@@": xml_escape(arguments.description),
        "@@XML_DESCRIPTION_IS@@": xml_escape(arguments.description_is),
        "@@DESKTOP_APP_NAME@@": desktop_escape(arguments.name),
        "@@DESKTOP_APP_NAME_IS@@": desktop_escape(arguments.name_is),
        "@@DESKTOP_SUMMARY@@": desktop_escape(arguments.summary),
        "@@DESKTOP_SUMMARY_IS@@": desktop_escape(arguments.summary_is),
        "@@VERSION@@": arguments.version,
        "@@CRATE_NAME@@": f"bifrost-{slug_for(arguments.app_id)}",
        "@@RS_APP_NAME@@": rust_escape(arguments.name),
        "@@RS_SUMMARY@@": rust_escape(arguments.summary),
        "@@RS_DESCRIPTION@@": rust_escape(arguments.description),
        "@@RS_DESCRIPTION_IS@@": rust_escape(arguments.description_is),
        "@@RELEASE_DATE@@": datetime.now(timezone.utc).date().isoformat(),
        "@@PO_READY@@": po_escape("Tilbúið til notkunar"),
        "@@PO_DESCRIPTION@@": po_escape("Öruggur grunnur fyrir BifrOSt-forrit."),
    }


def destination_name(relative: Path, app_id: str) -> Path:
    name = relative.name
    if name.endswith(".in"):
        name = name[:-3]
    name = name.replace("app-id", app_id)
    return relative.with_name(name)


def render_file(source: Path, destination: Path, values: dict[str, str]) -> None:
    text = source.read_text(encoding="utf-8")
    for token, value in values.items():
        text = text.replace(token, value)
    remaining = sorted(set(TOKEN.findall(text)))
    if remaining:
        raise ValueError(f"unresolved template tokens in {source}: {', '.join(remaining)}")
    destination.parent.mkdir(parents=True, exist_ok=True)
    destination.write_text(text, encoding="utf-8")
    if source.name == "main.py.in":
        destination.chmod(0o755)


def generate(arguments: argparse.Namespace) -> Path:
    validate(arguments)
    destination = (arguments.output or ROOT / "apps" / slug_for(arguments.app_id)).resolve()
    if destination.exists():
        raise FileExistsError(f"destination already exists: {destination}")
    destination.mkdir(parents=True)
    try:
        values = substitutions(arguments)
        template = TEMPLATES[arguments.stack]
        for source in sorted(template.rglob("*")):
            if not source.is_file():
                continue
            relative = destination_name(source.relative_to(template), arguments.app_id)
            render_file(source, destination / relative, values)
        return destination
    except Exception:
        shutil.rmtree(destination, ignore_errors=True)
        raise


def main() -> int:
    try:
        destination = generate(parse_args())
    except (OSError, ValueError) as error:
        print(f"new-app.py: {error}", file=sys.stderr)
        return 1
    print(destination)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())