🔒 Repository is read-only – file editing is disabled.

PaganLinux/pagan-web-v2/migrate-recipes.py main

1017 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017
#!/usr/bin/env python3
"""
================================================================================
 migrate-recipes.py — konwertuje receptury cards/NuTyX (Pkgfile) na PAGBUILD.yaml
================================================================================

Przenosi system receptur z formatu `cards` (bash Pkgfile) do natywnego formatu
PaganOS (PAGBUILD.yaml) używanego przez `pagbuild`, zachowując lokalne pliki
(patche, service, config). Footprinty (pliki `*.footprint`) są CELOWO pomijane:
PaganOS działa jak Arch/Debian — pakiet powstaje z katalogu DESTDIR, a `pag` sam
śledzi listę plików i sumy SHA256 (/var/lib/pag/files.db). Zadeklarowane
w footprintach podpakiety (devel/doc/man/językowe) są scalane z powrotem do
pakietu głównego, więc ich lista plików jest zbędna.

Mapowanie pól cards -> PAGBUILD.yaml:
  description                -> pkgdesc
  url                        -> url
  license                    -> license (lista)
  name                       -> pkgname
  version                    -> pkgver
  release                    -> pkgrel
  makedepends + PKGMK_DEPENDS-> makedepends
  depends + run              -> depends
  source                     -> source (URL-e rozwiązane, lokalne pliki kopiowane)
  prepare()/prepre()/unpack_source() -> prefiks fazy build
  build() | build=<typ>      -> build (szablony 1:1 z pkgmk NuTyX)
  package() | packager()     -> package
  *.footprint                -> (pomijane — patrz nota o footprintach wyżej)
  *.post-install itd. + echo-generowane -> hooks: (post-install/pre-install/...)

Domyślny build (bez build()/build=) jak w cards: autotools, a dla źródeł
.deb – rozpakowanie payloadu. Subpakiety PKGMK_GROUPS są scalane do jednego
pakietu (format pagbuild) – funkcje grup przenoszące pliki są pomijane,
a te wykonujące realną pracę (unpack_source/prepre/packager) zostają
wkomponowane w fazy build/package.

Pomijane (na życzenie): packager, contributors, maintainer.

Użycie:
  python3 migrate-recipes.py [--source DIR] [--dest DIR] [--dry-run]
                             [--delete-source] [--brand] [--only NAME] [--limit N]
                             [--verbose]
================================================================================
"""

import argparse
import os
import re
import shlex
import shutil
import sys

try:
    import yaml
except ImportError:
    print("❌ Brak PyYAML. Zainstaluj: pip install pyyaml (lub python3-yaml)", file=sys.stderr)
    sys.exit(2)


# ── Mapowanie kategorii źródłowych (x86_64-testingd) na docelowe (recipes/) ──
CATEGORY_MAP = {
    "base": "core",
    "cli": "utils",
    "cli-extra": "utils",
    "gui": "gui",
    "gui-extra": "gui",
}

# ── Szablony faz dla predefiniowanych typów build= (1:1 z pkgmk NuTyX) ──
# {src_dir}  – katalog źródłowy (np. attr-2.6.0 / libICE-1.1.2) podstawiany
#              w trakcie migracji; {mopts} – PKGMK_MESON_OPTIONS; {pkg} – nazwa
#              pakietu pythona (np. pysmbc).
BUILD_TEMPLATES = {
    "meson": {
        "build": "meson setup --prefix /usr --buildtype plain --libexecdir lib --sbindir bin -D b_pie=true {src_dir} builddir{mopts}\nmeson compile -C builddir",
        "package": 'DESTDIR="${{PKGDIR}}" meson install -C builddir',
        "needs_review": False,
    },
    "autotools": {
        "build": "cd {src_dir}\n./configure --prefix=/usr --disable-static\nmake",
        "package": 'make DESTDIR="${{PKGDIR}}" install',
        "needs_review": False,
    },
    "cmake": {
        "build": "cmake -B build -S {src_dir} -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_LIBDIR=lib -DBUILD_TESTING=OFF -Wno-dev\ncmake --build build",
        "package": 'DESTDIR="${{PKGDIR}}" cmake --install build',
        "needs_review": False,
    },
    "kde5": {
        "build": "cmake -B build -S {src_dir} -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_LIBDIR=lib -DBUILD_TESTING=OFF -Wno-dev\ncmake --build build",
        "package": 'DESTDIR="${{PKGDIR}}" cmake --install build',
        "needs_review": False,
    },
    "kde6": {
        "build": "cmake -B build -S {src_dir} -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_LIBDIR=lib -DBUILD_TESTING=OFF -Wno-dev\ncmake --build build",
        "package": 'DESTDIR="${{PKGDIR}}" cmake --install build',
        "needs_review": False,
    },
    "xorg": {
        "build": "cd {src_dir}\n./configure --prefix=/usr --sysconfdir=/etc --localstatedir=/var --disable-static\nmake",
        "package": 'make DESTDIR="${{PKGDIR}}" install',
        "needs_review": False,
    },
    "python3": {
        "build": 'cd {src_dir}\npip3 wheel -w dist --no-build-isolation --no-deps .\npip3 install --no-index --find-links dist --no-cache-dir --no-user --root="${{PKGDIR}}" {pkg}',
        "package": "true",
        "needs_review": False,
    },
}

# Domyślny build cards (brak build()/build=): autotools; dla źródeł .deb –
# rozpakowanie payloadu (cards robi to samo po rozpakowaniu archiwum .deb).
DEFAULT_PACKAGE_BODY = 'make DESTDIR="${PKGDIR}" install'
DEFAULT_BUILD_AUTOTOOLS = "./configure --prefix=/usr\nmake"
DEB_PACKAGE_BODY = 'bsdtar -xf data.tar.* -C "${PKGDIR}"'

# Funkcje grup (subpakiety) bezpieczne do pominięcia przy scalaniu w 1 pakiet:
# tylko przenoszą pliki z pakietu głównego do subpakietu, więc po scaleniu
# pliki po prostu zostają w pakiecie głównym. (packager() jest dołączany do
# fazy package – wykonuje realną pracę, np. instalację licencji.)
SAFE_DROP_GROUP_FNS = {
    "devel", "man", "doc", "lib", "service",
    "pack_locale", "pack_remove_locale",
}

# Podmiany zmiennych w generowanych skryptach hooków (post-install).
HOOK_VAR_FALLBACK = {"BRANCH": "systemd"}


# =============================================================================
# Parsowanie Pkgfile
# =============================================================================

def _unquote(s: str) -> str:
    s = s.strip()
    if len(s) >= 2 and s[0] in ('"', "'") and s[-1] == s[0]:
        return s[1:-1]
    return s


def _split_items(s: str) -> list:
    s = s.strip()
    if not s:
        return []
    s = re.sub(r"\\[ \t]*\n[ \t]*", " ", s)
    s = re.sub(r"\\$", "", s)
    try:
        parts = shlex.split(s, posix=True)
    except ValueError:
        parts = s.split()
    return [p for p in parts if p != ""]


def parse_pkgfile(text: str):
    """Zwraca (scalars, arrays, functions)."""
    lines = text.splitlines()
    scalars = {}
    arrays = {}
    functions = {}

    i, n = 0, len(lines)
    while i < n:
        stripped = lines[i].strip()
        if not stripped or stripped.startswith("#"):
            i += 1
            continue

        mfunc = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)\s*\{?\s*$", stripped)
        if mfunc:
            fname = mfunc.group(1)
            has_brace = stripped.endswith("{")
            i += 1
            if not has_brace:
                if i < n and lines[i].strip() == "{":
                    i += 1
            body = []
            while i < n and lines[i].strip() != "}":
                body.append(lines[i])
                i += 1
            if i < n:
                i += 1
            functions[fname] = "\n".join(body)
            continue

        marr = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)=\(\s*(.*)$", stripped)
        if marr:
            vname = marr.group(1)
            rest = marr.group(2)
            if rest.rstrip().endswith(")"):
                content = rest.rstrip()[:-1]
                i += 1
            else:
                buf = [rest]
                i += 1
                while i < n:
                    line = lines[i]
                    buf.append(line)
                    i += 1
                    if line.rstrip().endswith(")"):
                        break
                joined = "\n".join(buf)
                content = joined.rstrip()[:-1]
            arrays[vname] = _split_items(content)
            continue

        mscalar = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$", stripped)
        if mscalar:
            vname = mscalar.group(1)
            value = mscalar.group(2).strip()
            while value.endswith("\\") and i + 1 < n:
                i += 1
                value = value[:-1].rstrip() + " " + lines[i].strip()
            scalars[vname] = _unquote(value)
            i += 1
            continue

        i += 1

    return scalars, arrays, functions


# =============================================================================
# Translacja ciała build/package (cards -> pagbuild)
# =============================================================================

def _dedent(s: str) -> str:
    if not s:
        return s
    lines = s.split("\n")
    ref = None
    for l in lines:
        if l.strip() != "":
            ref = len(l) - len(l.lstrip(" \t"))
            break
    if ref is None or ref == 0:
        return s.strip("\n")
    out = []
    for l in lines:
        lead = len(l) - len(l.lstrip(" \t"))
        out.append(l[min(ref, lead):])
    return "\n".join(out).strip("\n")


def translate_body(body: str, scalars: dict = None, pkgmk_root: str = None,
                  arch: str = "x86_64") -> str:
    """Tłumaczy ciało fazy z dialektu cards na pagbuild."""
    if not body:
        return body
    # Katalog roboczy pagbuild = katalog źródeł (build.sh robi tam cd).
    body = re.sub(r"\$\{SRC\}", ".", body)
    body = re.sub(r"\$SRC\b", ".", body)
    body = re.sub(r"\$\{PKGMK_SOURCE_DIR\}", ".", body)
    body = re.sub(r"\$PKGMK_SOURCE_DIR\b", ".", body)
    if pkgmk_root:
        body = re.sub(r"\$\{PKGMK_ROOT\}", pkgmk_root, body)
        body = re.sub(r"\$PKGMK_ROOT\b", pkgmk_root, body)
    body = re.sub(r"\$\{PKGMK_ARCH\}", arch, body)
    body = re.sub(r"\$PKGMK_ARCH\b", arch, body)
    body = re.sub(r"\$\{PKG\}", "${PKGDIR}", body)
    body = re.sub(r"\$PKG\b", "${PKGDIR}", body)
    body = re.sub(r"\$\{name\}", "${pkgname}", body)
    body = re.sub(r"\$name\b", "${pkgname}", body)
    body = re.sub(r"\$\{version\}", "${pkgver}", body)
    body = re.sub(r"\$version\b", "${pkgver}", body)
    body = re.sub(r"\$\{release\}", "${pkgrel}", body)
    body = re.sub(r"\$release\b", "${pkgrel}", body)
    # Skalarne zmienne _* z Pkgfile (np. _name=libICE) nie istnieją w środowisku
    # pagbuild – wstawiamy ich wartości wprost. Zmienne lokalne funkcji build()
    # (nie-skalarne) zostają nietknięte.
    if scalars:
        pkgname_v = str(scalars.get("name", ""))
        pkgver_v = str(scalars.get("version", ""))
        # Klucze metadanych receptury – nie są zmiennymi środowiska budowy.
        meta = {"name", "version", "release", "description", "url", "license",
                "maintainer", "packager", "contributors", "build", "arch",
                "ptodate", "uptodate", "renames", "alias", "setname"}
        for var, val in scalars.items():
            var_s = str(var)
            if var_s in meta or var_s.startswith("PKGMK_") or not str(val).strip():
                continue
            val_bare = str(val).strip().strip('"').strip("'")
            if val_bare in ("$name", "${name}"):
                val = pkgname_v
            elif val_bare in ("$version", "${version}"):
                val = pkgver_v
            expanded = _expand_scalar(val, scalars, pkgname_v, pkgver_v)
            # Zmienne _* wstawiaj zawsze (historyczne zachowanie). Pozostałe
            # (np. buildr=174877, KERNELNAME=kernel-612) tylko gdy wartość jest
            # prostym literałem – nie wstrzykujemy złożonych konstrukcji bash.
            if not var_s.startswith("_") and not re.fullmatch(
                    r"[A-Za-z0-9_./+\-:]+", expanded.strip()):
                continue
            body = re.sub(r"\$\{" + re.escape(var_s) + r"\}", expanded, body)
            body = re.sub(r"\$" + re.escape(var_s) + r"\b", expanded, body)
    # Rozwiń bashowe formy parametrów (${version:0:4}, ${name#...}, ${_name}...)
    # w ciele fazy – pagbuild eksportuje tylko pkgname/pkgver/pkgrel, więc
    # ${version:...} i ${name...} muszą zostać wyliczone już na etapie migracji.
    if scalars:
        body = _expand_scalar(body, scalars, str(scalars.get("name", "")),
                              str(scalars.get("version", "")))
    return body


SOURCE_VAR_MAP = {
    "name": "${pkgname}",
    "version": "${pkgver}",
    "release": "${pkgrel}",
}


def resolve_source_items(items: list, scalars: dict) -> list:
    version = str(scalars.get("version", ""))

    def repl(m):
        var = m.group(1)
        if var in SOURCE_VAR_MAP:
            return SOURCE_VAR_MAP[var]
        if var in scalars and str(scalars[var]).strip() != "":
            # Rozwiń wartość skalara (np. _version=${version%.*}).
            return _expand_scalar(str(scalars[var]), scalars,
                                 str(scalars.get("name", "")), version)
        return m.group(0)

    out = []
    for it in items:
        # Najpierw rozwinięcia parametrów bash w URL (${version%.*}, ${version:0:4}, ...).
        s = _expand_scalar(it, scalars, str(scalars.get("name", "")), version)
        # Podmiana zmiennych do punktu stałego (obsługuje zagnieżdżenia).
        for _ in range(4):
            new = re.sub(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?", repl, s)
            if new == s:
                break
            s = new
        if is_url(s):
            s = _normalize_url(s)
            if version:
                s = re.sub(
                    r"(?<![A-Za-z0-9])" + re.escape(version) + r"(?![A-Za-z0-9])",
                    "${pkgver}", s,
                )
        out.append(s)
    return out


def is_url(s: str) -> bool:
    return bool(re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", s))


def _normalize_url(s: str) -> str:
    """Zwija wielokrotne / w ścieżce URL (np. individual//driver ->
    individual/driver), nie naruszając schematu http:// ani query/fragmentu."""
    m = re.match(r"^([a-zA-Z][a-zA-Z0-9+.-]*://)([^?#]*)(.*)$", s)
    if not m:
        return s
    return m.group(1) + re.sub(r"/{2,}", "/", m.group(2)) + m.group(3)


# =============================================================================
# Budowanie dokumentu YAML
# =============================================================================

def _glob_to_regex(pat: str) -> str:
    out = ""
    i = 0
    while i < len(pat):
        c = pat[i]
        if c == "*":
            out += ".*"
        elif c == "?":
            out += "."
        elif c == "[":
            j = pat.find("]", i + 1)
            if j > i:
                out += pat[i:j + 1]
                i = j
            else:
                out += r"\["
        else:
            out += re.escape(c)
        i += 1
    return out


def _expand_scalar(value, scalars, pkgname, pkgver):
    """Rozwija ${var%sufiks} / ${var#prefiks} (parametry bash) w wartościach
    skalarnych, np. _version=${version%.*} -> 7.1."""
    value = str(value)

    def repl(m):
        expr = m.group(1)
        # ${var:off:len} – podciąg (np. ${version:0:4})
        msub = re.match(r"^([A-Za-z_]\w*):(-?\d*)(?::(-?\d*))?$", expr)
        if msub:
            var, off, ln = msub.group(1), msub.group(2) or "", msub.group(3) or ""
            if var == "version":
                val = pkgver
            elif var == "name":
                val = pkgname
            elif var in scalars:
                val = str(scalars[var])
            else:
                return m.group(0)
            o = int(off or 0)
            return val[o:] if ln == "" else val[o:o + int(ln)]
        mm = re.match(r"^([A-Za-z_]\w*)(//|%%|%|##|#|/)(.*)$", expr)
        if not mm:
            return m.group(0)
        var, op, rest = mm.group(1), mm.group(2), mm.group(3)
        if var == "version":
            val = pkgver
        elif var == "name":
            val = pkgname
        elif var in scalars:
            val = str(scalars[var])
        else:
            return m.group(0)
        if op in ("/", "//"):
            # ${var/wzorzec/zamiana} – podstawienie zamiast przycięcia
            if "/" in rest:
                pat, repl_text = rest.split("/", 1)
            else:
                pat, repl_text = rest, ""
            rx = _glob_to_regex(pat)
            count = 0 if op == "//" else 1
            return re.sub(rx, repl_text, val, count=count)
        rx = _glob_to_regex(rest)
        if op.startswith("%"):
            order = range(len(val) - 1, -1, -1) if op == "%" else range(0, len(val))
            for k in order:
                if re.fullmatch(rx, val[k:]):
                    return val[:k]
            return val
        order = range(1, len(val) + 1) if op == "#" else range(len(val), 0, -1)
        for k in order:
            if re.fullmatch(rx, val[:k]):
                return val[k:]
        return val

    for _ in range(3):
        new = re.sub(r"\$\{([^{}]+)\}", repl, value)
        if new == value:
            break
        value = new
    return value


def _subst_hook_vars(text: str, scalars: dict, pkgname: str, pkgver: str) -> str:
    """Podstawia znane wartości w treści skryptu hooka (post-install)."""
    def repl(m):
        var = m.group(1)
        if var == "name":
            return pkgname
        if var == "version":
            return pkgver
        if var == "release":
            return str(scalars.get("release", "1"))
        if var == "KERNELRELEASE":
            if pkgname.startswith("kernel"):
                # Wartość znana dopiero po instalacji – moduły są w
                # /usr/lib/modules/<release> (obok katalogu $name).
                return f"$(ls /usr/lib/modules | grep -vx '{pkgname}' | head -1)"
            raw = scalars.get("_version") or scalars.get("version") or m.group(0)
            return _expand_scalar(raw, scalars, pkgname, pkgver)
        if var in scalars and str(scalars[var]).strip():
            return _expand_scalar(scalars[var], scalars, pkgname, pkgver)
        if var in HOOK_VAR_FALLBACK:
            return HOOK_VAR_FALLBACK[var]
        return m.group(0)

    return re.sub(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?", repl, text)


_HOOK_ECHO_RE = re.compile(
    r'echo\s+"(.*?)"\s*>\s*\$\{?PKGMK_ROOT\}?/\$\{?name\}?\s*\.'
    r"(post-install|pre-install|post-remove|pre-remove)",
    re.DOTALL,
)


def extract_generated_hooks(body: str, scalars: dict, pkgname: str, pkgver: str):
    """Wyciąga `echo "..." > $PKGMK_ROOT/$name.post-install` z fazy build
    i zwraca (nowe_ciało, {nazwa_hooka: treść})."""
    hooks = {}

    def repl(m):
        content = m.group(1)
        hook = m.group(2)
        content = content.replace('\\"', '"').replace("\\\\", "\\")
        content = _subst_hook_vars(content, scalars, pkgname, pkgver)
        hooks.setdefault(hook, []).append(content.strip())
        return ""

    new_body = _HOOK_ECHO_RE.sub(repl, body)
    return new_body, {k: "\n".join(v) for k, v in hooks.items()}


def _normalize_deps(items, known_names, own_name):
    """Po scaleniu subpakietów (pkg.lib/pkg.devel) normalizuje zależności:
    - własny subpakiet (vlc -> vlc.lib) – pomiń (pliki są w tym samym pakiecie),
    - subpakiet innego pakietu (epiphany -> libxslt.lib) – mapuj na pakiet bazowy,
    - nieistniejące pakiety (np. cards.devel) – pomiń."""
    out = []
    for it in items:
        it = str(it).strip()
        if not it:
            continue
        if "." in it:
            base = it.split(".", 1)[0]
            if base == own_name:
                continue
            if it in (known_names or set()):
                out.append(it)
                continue
            if base in (known_names or set()):
                out.append(base)
            continue
        out.append(it)
    return _unique(out)


def build_yaml(pkgdir: str, scalars: dict, arrays: dict, functions: dict,
               known_names: set = None):
    review = []
    hook_files = []

    pkgname = scalars.get("name", "") or os.path.basename(pkgdir)
    pkgver = scalars.get("version", "")
    if not pkgname or not pkgver:
        raise ValueError("brak name/version w Pkgfile")

    pkgrel = scalars.get("release", "1")
    pkgdesc = scalars.get("description", "")
    url = scalars.get("url", "")
    arch = scalars.get("arch", scalars.get("PKGMK_ARCH", "x86_64"))

    license_raw = scalars.get("license", "")
    if isinstance(license_raw, str) and license_raw.strip():
        licenses = [l.strip() for l in re.split(r"[,;]", license_raw) if l.strip()]
    else:
        licenses = []

    makedepends = list(arrays.get("makedepends", []))
    pk_depends = list(arrays.get("PKGMK_DEPENDS", []))
    makedepends = _unique(makedepends + pk_depends)

    depends = list(arrays.get("depends", []))
    run = list(arrays.get("run", []))
    depends = _unique(depends + run)

    # Po scaleniu subpakietów: vlc.lib -> (pomiń), libxslt.lib -> libxslt.
    depends = _normalize_deps(depends, known_names, pkgname)
    makedepends = _normalize_deps(makedepends, known_names, pkgname)

    source = resolve_source_items(arrays.get("source", []), scalars)
    resolved = list(source)

    # Nazwa katalogu źródłowego po rozpakowaniu oraz odpowiednik $PKGMK_ROOT.
    _name = scalars.get("_name", "") or pkgname
    if str(_name).strip().strip('"').strip("'") in ("$name", "${name}"):
        _name = pkgname  # idiom cards: _name=$name
    if pkgname == "tcl":
        src_dir = pkgname + pkgver  # tcl rozpakowuje się do tcl<ver> (bez myślnika)
    else:
        src_dir = f"{_name}-{pkgver}"
    pkgmk_root = f"/tmp/src-{pkgname}-{pkgver}/{src_dir}"

    def tr(body):
        return translate_body(body, scalars=scalars, pkgmk_root=pkgmk_root, arch=arch)

    build_type = scalars.get("build", None)
    if build_type and build_type not in functions:
        if build_type not in BUILD_TEMPLATES:
            review.append(f"nieznany build={build_type!r}")
            build_type = None

    # ── Hooki z lokalnych plików: {name}[-.service].{pre|post}-{install|remove} ──
    local_hooks = {}
    for fn in sorted(os.listdir(pkgdir)):
        key = None
        for suffix in ("post-install", "pre-install", "pre-remove", "post-remove"):
            if fn in (f"{pkgname}.{suffix}", f"{pkgname}.service.{suffix}"):
                key = suffix
                break
        if key is None:
            continue
        with open(os.path.join(pkgdir, fn), encoding="utf-8", errors="replace") as f:
            content = f.read().strip()
        local_hooks[key] = (local_hooks[key] + "\n" + content) if key in local_hooks else content
        hook_files.append(fn)

    # ── Funkcje grup ──
    unpack_src = functions.get("unpack_source")
    prepre = functions.get("prepre")
    packager_fn = functions.get("packager")
    reset_cd = f"cd /tmp/src-{pkgname}-{pkgver}"

    pre_build = []
    if unpack_src is not None:
        # Jeżeli funkcja używa helperów pkgmk (get_filename/$source), domyślne
        # rozpakowywanie pagbuild robi dokładnie to samo – można pominąć.
        if "get_filename" in unpack_src or "${source" in unpack_src or " $source" in unpack_src:
            pass
        else:
            pre_build.append(_dedent(tr(unpack_src)))
            pre_build.append(reset_cd)
    if prepre is not None:
        pre_build.append(_dedent(tr(prepre)))
        pre_build.append(reset_cd)

    prepare = functions.get("prepare")
    if prepare is not None:
        pre_build.append(_dedent(tr(prepare)))
        pre_build.append(reset_cd)

    deb_source = next((s for s in resolved if re.search(r"\.deb$", s)), None)

    # Pakiety ze źródłem .deb i własnym build() zakładają, że cards rozpakował
    # już archiwum .deb do data.tar.* w katalogu roboczym – odtwórz to.
    if deb_source and functions.get("build") is not None:
        pre_build.insert(0, reset_cd)
        pre_build.insert(0, "bsdtar -xf *.deb")

    build_fn = functions.get("build")
    hooks = {}
    if build_fn is not None:
        # Hooki generowane w build() wyciągamy z SUROWEGO ciała (przed
        # tłumaczeniem $PKGMK_ROOT/$name na ścieżki pagbuild). W cards build()
        # NADPISUJE plik $PKGMK_ROOT/$name.post-install – generowane hooki
        # mają więc pierwszeństwo przed lokalnymi.
        raw_body = _dedent(build_fn)
        raw_body, gen_hooks = extract_generated_hooks(raw_body, scalars, pkgname, pkgver)
        hooks.update(gen_hooks)
        build_body = _dedent(tr(raw_body))
    elif build_type:
        tpl = BUILD_TEMPLATES[build_type]
        mopts = scalars.get("PKGMK_MESON_OPTIONS", "")
        mopts_str = (" " + " ".join(mopts.split())) if (build_type == "meson" and mopts) else ""
        build_body = tpl["build"].format(src_dir=src_dir, mopts=mopts_str, pkg=_name)
    elif deb_source:
        # cards: dla źródła .deb domyślny build = payload do $PKG
        build_body = "bsdtar -xf *.deb"
    else:
        # cards: domyślny build = autotools
        build_body = f"cd {src_dir}\n{DEFAULT_BUILD_AUTOTOOLS}"

    if pre_build:
        build_body = "\n".join(pre_build) + "\n" + build_body

    package_fn = functions.get("package")
    if package_fn is not None:
        package_body = _dedent(tr(package_fn))
    elif build_fn is not None:
        # build() sam instaluje do ${PKGDIR}? – faza package pusta, inaczej
        # domyślne make install (jak w cards).
        if "${PKGDIR}" in build_body or "DESTDIR" in build_body:
            package_body = "true"
        else:
            package_body = f"cd {src_dir}\n{DEFAULT_PACKAGE_BODY}"
    elif deb_source:
        package_body = DEB_PACKAGE_BODY
    elif build_type:
        package_body = BUILD_TEMPLATES[build_type]["package"].format(
            src_dir=src_dir, pkg=_name)
    else:
        package_body = f"cd {src_dir}\n{DEFAULT_PACKAGE_BODY}"
    if packager_fn is not None:
        # packager() w cards jest wykonywany po build() – dodajemy na początek
        # fazy package (np. instalacja licencji w gimp).
        package_body = _dedent(tr(packager_fn)) + "\n" + package_body

    for b in (build_body, package_body):
        # Pomijamy komentarze (# post-install autogen itp. – same echo jest
        # już przeniesione do hooks:).
        code = "\n".join(l for l in b.split("\n") if not l.strip().startswith("#"))
        if "$PKGMK_ROOT" in code or "post-install" in code:
            review.append("generuje/odwołuje się do post-install (wymaga hooków pag)")
            break
    # Lokalne hooki (bez wygenerowanego odpowiednika) – prolog z wartościami
    # z receptury, bo cards udostępniał je skryptom hooków.
    for k, v in local_hooks.items():
        if k in hooks:
            continue
        hooks[k] = f"name={pkgname}\nversion={pkgver}\nrelease={pkgrel}\n" + v
    # Scalanie subpakietów PKGMK_GROUPS jest zamierzone (pagbuild buduje 1 pakiet).
    merged_subpkgs = bool(arrays.get("PKGMK_GROUPS"))

    known = {"prepare", "build", "package", "uptodate", "ptodate",
             "unpack_source", "prepre", "packager"} | SAFE_DROP_GROUP_FNS
    group_fns = [k for k in functions if k not in known and not k.startswith("locale_")]
    if group_fns:
        review.append(f"nieznane funkcje grup porzucone (scalenie): {', '.join(group_fns)}")

    update_script = None
    for fname in ("uptodate", "ptodate"):
        if functions.get(fname, "").strip():
            update_script = _dedent(tr(functions[fname]))
            break

    doc = {
        "pkgname": pkgname,
        "pkgver": pkgver,
        "pkgrel": pkgrel,
        "pkgdesc": pkgdesc,
        "url": url,
        "arch": arch,
    }
    if licenses:
        doc["license"] = licenses
    doc["depends"] = depends
    doc["makedepends"] = makedepends
    doc["source"] = resolved
    doc["sha256sums"] = ["SKIP"] * len(resolved)

    if hooks:
        doc["hooks"] = hooks
    doc["build"] = build_body
    doc["package"] = package_body
    if update_script:
        doc["update"] = update_script

    return doc, review, hook_files, merged_subpkgs


def _unique(items):
    seen = set()
    out = []
    for it in items:
        it = it.strip()
        if it and it not in seen:
            seen.add(it)
            out.append(it)
    return out


# =============================================================================
# Emisja YAML
# =============================================================================

def _q(s) -> str:
    s = str(s)
    if s == "":
        return "''"
    if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", s):
        return s
    return "'" + s.replace("'", "''") + "'"


def _block(key: str, value: str) -> str:
    if value is None:
        value = ""
    value = str(value)
    if value == "":
        return f"{key}: ''"
    lines = value.split("\n")
    nonempty = [l for l in lines if l.strip() != ""]
    if not nonempty:
        return f"{key}: ''"
    min_indent = min(len(l) - len(l.lstrip(" \t")) for l in nonempty)
    out = [f"{key}: |"]
    for l in lines:
        if l.strip() == "":
            out.append("")
        else:
            out.append("  " + l[min_indent:])
    return "\n".join(out) + "\n"


def _list_block(key: str, items) -> str:
    if not items:
        return f"{key}: []"
    out = [f"{key}:"]
    for it in items:
        out.append("- " + _q(it))
    return "\n".join(out) + "\n"


def _hooks_block(items) -> str:
    if not items:
        return ""
    out = ["hooks:"]
    for k, v in items.items():
        out.append("  " + _q(k) + ": |")
        for line in str(v).split("\n"):
            out.append("    " + line)
    return "\n".join(out) + "\n"


def dump_yaml(doc: dict) -> str:
    parts = []
    for key, val in doc.items():
        if key == "hooks":
            parts.append(_hooks_block(val))
        elif key in ("build", "package", "update"):
            parts.append(_block(key, val))
        elif isinstance(val, list):
            parts.append(_list_block(key, val))
        else:
            parts.append(f"{key}: {_q(val)}")
    return "\n".join(parts)


# =============================================================================
# Branding (opcjonalnie)
# =============================================================================

BRAND_RE = re.compile(r"NutyX|Nutyx|nutyx", re.IGNORECASE)


def apply_brand(doc: dict) -> None:
    if isinstance(doc.get("pkgdesc"), str):
        doc["pkgdesc"] = BRAND_RE.sub("PaganLinux", doc["pkgdesc"])


# =============================================================================
# Optymalizacja odczytu plików (zastępuje find_existing w pętli)
# =============================================================================

def build_existing_index(recipes_root: str) -> dict:
    """Buduje indeks {pkgname: dirpath} jednorazowo w pamięci dla O(1) wyszukiwania."""
    index = {}
    if not os.path.exists(recipes_root):
        return index
    for dirpath, dirnames, filenames in os.walk(recipes_root):
        if "PAGBUILD.yaml" not in filenames:
            continue
        try:
            with open(os.path.join(dirpath, "PAGBUILD.yaml"), encoding="utf-8") as f:
                data = yaml.safe_load(f) or {}
            pkg = data.get("pkgname")
            if pkg:
                index[pkg] = dirpath
        except Exception:
            pass
    return index


# =============================================================================
# Składowanie + raport
# =============================================================================

def collect_pkgfiles(root: str):
    found = []
    for cat in sorted(os.listdir(root)):
        catdir = os.path.join(root, cat)
        if not os.path.isdir(catdir):
            continue
        for name in sorted(os.listdir(catdir)):
            pkgdir = os.path.join(catdir, name)
            pkgfile = os.path.join(pkgdir, "Pkgfile")
            if os.path.isfile(pkgfile):
                found.append((cat, name, pkgdir, pkgfile))
    return found


def main():
    ap = argparse.ArgumentParser(description="Migruj Pkgfile (cards) -> PAGBUILD.yaml")
    ap.add_argument("--source", default="x86_64-testingd")
    ap.add_argument("--dest", default="recipes")
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--delete-source", action="store_true",
                    help="Usuń źródłowe drzewo x86_64-testingd po migracji")
    ap.add_argument("--no-overwrite", action="store_true",
                    help="Nie nadpisuj istniejących PAGBUILD.yaml w recipes/ (tylko dodawaj nowe)")
    ap.add_argument("--brand", action="store_true",
                    help="Zamień NutyX/nutyx -> PaganLinux w pkgdesc")
    ap.add_argument("--only", help="Migruj tylko podany pakiet (nazwa)")
    ap.add_argument("--limit", type=int, help="Migruj tylko N pierwszych pakietów")
    ap.add_argument("--verbose", action="store_true")
    args = ap.parse_args()

    source_root = os.path.abspath(args.source)
    dest_root = os.path.abspath(args.dest)

    if not os.path.isdir(source_root):
        print(f"❌ Źródło {source_root} nie istnieje.", file=sys.stderr)
        sys.exit(2)

    entries = collect_pkgfiles(source_root)
    if args.only:
        entries = [e for e in entries if e[1] == args.only]
    if args.limit:
        entries = entries[: args.limit]

    # Zbiór wszystkich nazw pakietów (do normalizacji zależności po scaleniu).
    known_names = {name for (_cat, name, _dir, _pf) in entries}

    total_entries = len(entries)
    print(f"📦 Znaleziono {total_entries} receptur do migracji.", file=sys.stderr)
    
    # Inicjalizacja cache przed wejściem w pętlę
    if not args.dry_run and not args.only:
        print("🔍 Zbieranie informacji o już istniejących pakietach (cache)...", file=sys.stderr)
    existing_index = build_existing_index(dest_root)

    migrated = 0
    conflicts = 0
    review_pkgs = []
    errors = []
    deleted = 0
    merged_subpkgs = 0
    hooks_count = 0

    for idx, (cat, name, pkgdir, pkgfile) in enumerate(entries, 1):
        target_cat = CATEGORY_MAP.get(cat, cat)
        target_dir = os.path.join(dest_root, target_cat, name)
        left_to_do = total_entries - idx

        # Optymalizacja odświeżania terminala - odświeżaj co 20 plików, chyba że to ostatni
        if not args.verbose and not args.dry_run:
            if idx % 20 == 0 or idx == total_entries:
                print(f"\r⏳ [{idx}/{total_entries}] Buduje: {cat}/{name} | Zostało: {left_to_do}", end="\033[K", flush=True)

        try:
            with open(pkgfile, encoding="utf-8", errors="replace") as f:
                text = f.read()
            scalars, arrays, functions = parse_pkgfile(text)
            doc, review, hook_files, merged = build_yaml(
                pkgdir, scalars, arrays, functions, known_names=known_names)
        except Exception as e:
            errors.append((cat, name, str(e)))
            if args.verbose:
                print(f"  ⚠️  BŁĄD {cat}/{name}: {e}", file=sys.stderr)
            elif not args.dry_run:
                print(f"\n  ⚠️  BŁĄD {cat}/{name}: {e}", file=sys.stderr)
            continue

        if args.brand:
            apply_brand(doc)

        yaml_text = dump_yaml(doc)

        if review:
            review_pkgs.append((cat, name, review))
        if merged:
            merged_subpkgs += 1
        if doc.get("hooks"):
            hooks_count += 1

        if args.dry_run:
            print(f"[dry-run] [{idx}/{total_entries}] {cat}/{name} -> {target_cat}/{name}/PAGBUILD.yaml"
                  + (f"  ⚠️ {'; '.join(review)}" if review else ""))
            continue

        # Sprawdzanie konfliktów ze zbuforowanego słownika
        existing = existing_index.get(name)
        if existing and os.path.abspath(existing) != os.path.abspath(target_dir):
            conflicts += 1
            if args.verbose or args.dry_run:
                print(f"⚠️  Konflikt: {name} istnieje też w {os.path.relpath(existing, dest_root)}", file=sys.stderr)
            else:
                print(f"\n⚠️  Konflikt: {name} istnieje też w {os.path.relpath(existing, dest_root)}", file=sys.stderr)

        dst_file = os.path.join(target_dir, "PAGBUILD.yaml")
        if args.no_overwrite and os.path.isfile(dst_file):
            if args.verbose:
                print(f"⏭  Pomijam (istnieje): {cat}/{name} -> {target_cat}/{name}")
            continue

        os.makedirs(target_dir, exist_ok=True)
        with open(dst_file, "w", encoding="utf-8") as f:
            f.write(yaml_text)

        # Rejestrujemy nowy pakiet w indeksie, na wypadek duplikatów w dalszej części przetwarzania
        existing_index[name] = target_dir

        for item in os.listdir(pkgdir):
            if item == "Pkgfile" or item.endswith(".footprint"):
                # Pkgfile -> PAGBUILD.yaml; *.footprint celowo pomijane (model
                # jak Arch/Debian — bez deklarowanych manifestów plików).
                continue
            if item in hook_files:
                # hooki są osadzone w polu hooks: PAGBUILD.yaml
                continue
            src = os.path.join(pkgdir, item)
            dst = os.path.join(target_dir, item)
            if os.path.isfile(src):
                shutil.copy2(src, dst)
            elif os.path.isdir(src):
                if os.path.exists(dst):
                    shutil.rmtree(dst)
                shutil.copytree(src, dst)

        migrated += 1
        if args.verbose:
            flag = " ⚠️ " + "; ".join(review) if review else ""
            print(f"  ✅ [{idx}/{total_entries}] {cat}/{name} -> {target_cat}/{name}{flag}")

        if args.delete_source:
            pkg_removed = True
            shutil.rmtree(pkgdir)
            deleted += 1

    if not args.verbose and not args.dry_run:
        print()

    print("\n" + "=" * 70)
    print(f"Migrowano:       {migrated}")
    print(f"Konflikty nazw:  {conflicts}")
    print(f"Błędy:           {len(errors)}")
    print(f"Usunięto źródeł: {deleted}")
    print(f"Subpakiety scalone (PKGMK_GROUPS): {merged_subpkgs} (zgodnie z formatem pagbuild)")
    print(f"Hooki pre/post-install/remove:     {hooks_count}")
    if review_pkgs:
        print(f"\n⚠️  Wymaga przeglądu ({len(review_pkgs)}):")
        for cat, name, rs in review_pkgs:
            print(f"   - {cat}/{name}: {'; '.join(rs)}")
    if errors:
        print("\n❌ Błędy parsowania (pominięte):")
        for cat, name, msg in errors:
            print(f"   - {cat}/{name}: {msg}")

    if args.dry_run:
        print("\n(dry-run: nic nie zapisano ani nie usunięto)")


if __name__ == "__main__":
    main()