"""Site -> site migration: rehydrate a legacy Joomla into a fresh target. The target installation is the contract. Client-owned entities/configuration move; target-installed factory/catalog rows do not. Where a source entity points at an install-assigned id, the id is translated by semantic identity. This is intentionally not a dump importer. The new Joomla/extension schema and factory data win, while the client's meaning, relationships and visual state are reconstructed on top of them. """ import argparse import fnmatch import json import re import extension_compat import mesh import migration_policy # Dependency order: a thing is moved only after what it points at. PHASES = ("users", "styles", "categories", "articles", "menus", "modules", "fields", "tags", "extensions") # Foreign keys we understand. @ lookups are install-time registries resolved by # name; normal names point at an id map built by an earlier migration/factory pass. FKS = { "usergroups": {"parent_id": "usergroups"}, "categories": {"parent_id": "categories", "created_user_id": "users", "modified_user_id": "users", "access": "viewlevels"}, "content": {"catid": "categories", "created_by": "users", "modified_by": "users", "access": "viewlevels"}, "menu": {"parent_id": "menu", "template_style_id": "template_styles", "component_id": "@component", "access": "viewlevels"}, "modules": {"access": "viewlevels"}, "modules_menu": {"moduleid": "modules", "menuid": "menu"}, "user_usergroup_map": {"user_id": "users", "group_id": "usergroups"}, "content_frontpage": {"content_id": "content"}, "fields": {"group_id": "fields_groups", "catid": "categories", "access": "viewlevels"}, "fields_groups": {"access": "viewlevels"}, "tags": {"parent_id": "tags", "created_user_id": "users", "access": "viewlevels"}, "contentitem_tag_map": {"tag_id": "tags", "content_item_id": "content", "type_id": "@content_type"}, # DPCalendar stores a numeric FK into its factory-seeded ISO catalogue. # The country row itself never migrates; this value is translated by code. "dpcalendar_locations": {"country": "dpcalendar_countries", "created_by": "users", "modified_by": "users", "access": "viewlevels"}, } # Semantic identity for client-owned rows. Re-running is safe: a row already on # the target under the same key is reused instead of duplicated. NATURAL_KEY = { "users": ("username",), "usergroups": ("title",), "viewlevels": ("title",), "categories": ("extension", "path"), "content": ("catid", "alias"), "menu": ("menutype", "path"), "menu_types": ("menutype",), "template_styles": ("template", "title"), "modules": ("module", "title", "position"), "fields": ("context", "name"), "fields_groups": ("context", "title"), "tags": ("path",), "user_usergroup_map": ("user_id", "group_id"), "content_frontpage": ("content_id",), "modules_menu": ("moduleid", "menuid"), "contentitem_tag_map": ("content_item_id", "tag_id"), } # Joomla bookkeeping/factory registries handled elsewhere or regenerated. # content_types/extensions are synchronised by the factory pass before phases; # they stay here so the normal copy loop never treats them as entity tables. NEVER_COPY = {"assets", "session", "updates", "update_sites", "update_sites_extensions", "extensions", "action_logs", "user_keys", "schemas", "content_types"} WIDGETKIT_PREFIX = "widgetkit" UPDATE_ON_MATCH = {"template_styles"} ROW_FILTER = {"template_styles": "client_id = 0"} _FILTER_COLS = {"template_styles": ("client_id",)} TREE_TABLES = {"categories", "menu", "tags", "usergroups"} LOOKUPS = { "@component": ("extensions", "extension_id", "element", "element", "type = 'component'"), "@content_type": ("content_types", "type_id", "type_alias", "type_alias", ""), } # --------------------------------------------------------------------------- # schema helpers def tables(conn, prefix): with conn.cursor() as cur: cur.execute("SHOW TABLES") out = set() for row in cur.fetchall(): name = next(iter(row.values())) if name.startswith(prefix): out.add(name[len(prefix):]) return out def columns(conn, table): with conn.cursor() as cur: cur.execute(f"SHOW COLUMNS FROM `{table}`") return {r["Field"]: r for r in cur.fetchall()} def shared_columns(scols, dcols): return [c for c in scols if c in dcols] def row_count(conn, table): with conn.cursor() as cur: try: cur.execute(f"SELECT COUNT(*) AS n FROM `{table}`") return cur.fetchone()["n"] except Exception: return 0 def is_tree(cols): return {"lft", "rgt", "parent_id"} <= set(cols) def _first_col(cols, *names): for name in names: if name in cols: return name return "" def _key(row, cols): return tuple("" if row.get(c) is None else str(row.get(c)) for c in cols) def _select_rows(conn, table, wanted): wanted = [c for c in wanted if c] if not wanted: return [] with conn.cursor() as cur: cur.execute("SELECT " + ", ".join(f"`{c}`" for c in wanted) + f" FROM `{table}`") return list(cur.fetchall()) # --------------------------------------------------------------------------- # target-owned factory/catalog data def sync_factory_table(sconn, dconn, sp, dp, table, idmaps, *, live): """Map source factory identities onto target rows without duplicating them. A policy can also expose narrowly defined client overlays. Joomla's #__extensions row, for example, belongs to the target install, but params / enabled state are site configuration. Mail master templates remain target owned while language-specific override rows are migrated. """ policy = migration_policy.policy(table) if not policy: return {"policy": "none", "matched": 0, "inserted": 0, "updated": 0, "skipped": 0} stabs, dtabs = tables(sconn, sp), tables(dconn, dp) if table not in stabs or table not in dtabs: return {"policy": "target_factory", "available": False, "reason": "table is not present on both source and target", "matched": 0, "inserted": 0, "updated": 0, "skipped": 0} scols, dcols = columns(sconn, sp + table), columns(dconn, dp + table) identity = tuple(policy.get("identity") or ()) missing = [c for c in identity if c not in scols or c not in dcols] if missing: return {"policy": "target_factory", "available": False, "reason": "identity columns missing: " + ", ".join(missing), "matched": 0, "inserted": 0, "updated": 0, "skipped": 0} pk = policy.get("pk") if pk and (pk not in scols or pk not in dcols): return {"policy": "target_factory", "available": False, "reason": f"factory primary key {pk!r} is missing", "matched": 0, "inserted": 0, "updated": 0, "skipped": 0} shared = shared_columns(scols, dcols) overlays = migration_policy.overlay_columns(table, shared) src_cols = list(dict.fromkeys(([pk] if pk else []) + list(identity) + overlays)) dst_cols = list(dict.fromkeys(([pk] if pk else []) + list(identity) + overlays)) source = _select_rows(sconn, sp + table, src_cols) target = _select_rows(dconn, dp + table, dst_cols) target_by_key = {_key(row, identity): row for row in target} idmap = idmaps.setdefault(table, {}) matched = inserted = updated = skipped = 0 source_only = [] with dconn.cursor() as cur: for row in source: key = _key(row, identity) existing = target_by_key.get(key) mode = migration_policy.row_mode(table, row) if existing is not None: matched += 1 if pk and row.get(pk) is not None and existing.get(pk) is not None: idmap[row[pk]] = existing[pk] if mode in ("overlay", "overlay_insert") and overlays: # The target keeps all structural/version fields; only the # explicitly client-owned overlay is copied. if live: sets = ", ".join(f"`{c}` = %s" for c in overlays) where = " AND ".join(f"`{c}` = %s" for c in identity) cur.execute(f"UPDATE `{dp + table}` SET {sets} WHERE {where}", [row.get(c) for c in overlays] + [row.get(c) for c in identity]) updated += 1 continue if mode == "overlay_insert": # A language-specific Joomla mail template override is real # client state. The master row is factory-owned; this row isn't. write_cols = list(dict.fromkeys(list(identity) + overlays)) if live: cols_sql = ", ".join(f"`{c}`" for c in write_cols) marks = ", ".join(["%s"] * len(write_cols)) cur.execute(f"INSERT INTO `{dp + table}` ({cols_sql}) VALUES ({marks})", [row.get(c) for c in write_cols]) inserted += 1 continue # A source factory identity absent from the new version is not # resurrected. Any FK into it becomes unresolved/0 rather than # accidentally pointing at another target row with the same number. skipped += 1 source_only.append(dict(zip(identity, key))) if pk and row.get(pk) is not None: idmap[row[pk]] = 0 if live and (updated or inserted): dconn.commit() target_keys = set(target_by_key) source_keys = {_key(row, identity) for row in source} return { "policy": "target_factory", "available": True, "reason": policy.get("reason", "target install owns these rows"), "identity": list(identity), "source_rows": len(source), "target_rows": len(target), "matched": matched, "inserted": inserted, "updated": updated, "skipped": skipped, "source_only": source_only[:20], "target_only": len(target_keys - source_keys), } def sync_factory_catalogs(sconn, dconn, sp, dp, idmaps, *, live): out = {} for table in migration_policy.TARGET_FACTORY: stat = sync_factory_table(sconn, dconn, sp, dp, table, idmaps, live=live) # Only surface policies that exist somewhere or failed for a real reason. if stat.get("available") or table in (tables(sconn, sp) | tables(dconn, dp)): out[table] = stat return out # --------------------------------------------------------------------------- # Widgetkit historical compatibility gate (kept until generic package scanning # can safely perform its opaque JSON upgrade itself). def is_widgetkit_table(table): return table == WIDGETKIT_PREFIX or table.startswith(WIDGETKIT_PREFIX + "_") def _manifest_version(value): if not value: return "" if isinstance(value, str): try: value = json.loads(value) except Exception: return "" return str((value or {}).get("version") or "").strip() if isinstance(value, dict) else "" def _version_key(version): parts = re.findall(r"\d+|[A-Za-z]+", version or "") return [(0, int(p)) if p.isdigit() else (1, p.lower()) for p in parts] def newest_version(versions): return max((v for v in versions if v), key=_version_key, default="") def widgetkit_extension_rows(conn, prefix): try: ecols = columns(conn, prefix + "extensions") id_col = _first_col(ecols, "extension_id", "id") wanted = [c for c in (id_col, "element", "name", "manifest_cache", "version") if c and c in ecols] if not wanted: return [] rows = [] for row in _select_rows(conn, prefix + "extensions", wanted): text = " ".join(str(row.get(c) or "") for c in ("element", "name")).lower() if WIDGETKIT_PREFIX not in text: continue rows.append({ "extension_id": row.get(id_col) if id_col else None, "element": row.get("element"), "name": row.get("name"), "version": _manifest_version(row.get("manifest_cache")) or str(row.get("version") or "").strip(), }) return rows except Exception: return [] def widgetkit_versions(conn, prefix): return {r["version"] for r in widgetkit_extension_rows(conn, prefix) if r["version"]} def widgetkit_update_sources(conn, prefix): try: present = tables(conn, prefix) if not {"extensions", "update_sites", "update_sites_extensions"} <= present: return [] widget_ids = {r["extension_id"] for r in widgetkit_extension_rows(conn, prefix) if r["extension_id"] is not None} if not widget_ids: return [] bcols = columns(conn, prefix + "update_sites_extensions") ucols = columns(conn, prefix + "update_sites") bridge_ext = _first_col(bcols, "extension_id", "extensionid") bridge_site = _first_col(bcols, "update_site_id", "update_siteid", "id") site_id = _first_col(ucols, "update_site_id", "id") if not (bridge_ext and bridge_site and site_id): return [] bridge = _select_rows(conn, prefix + "update_sites_extensions", [bridge_ext, bridge_site]) site_ids = {r[bridge_site] for r in bridge if r.get(bridge_ext) in widget_ids} if not site_ids: return [] fields = [c for c in (site_id, "name", "type", "location", "enabled") if c in ucols] return [{k: row.get(k) for k in fields if k != site_id} for row in _select_rows(conn, prefix + "update_sites", fields) if row.get(site_id) in site_ids] except Exception: return [] def widgetkit_compatible(sconn, dconn, sp, dp): src, dst = widgetkit_versions(sconn, sp), widgetkit_versions(dconn, dp) return bool(src) and src == dst def widgetkit_status(sconn, dconn, sp, dp): src, dst = widgetkit_versions(sconn, sp), widgetkit_versions(dconn, dp) latest = newest_version(src | dst) status = { "source_versions": sorted(src), "target_versions": sorted(dst), "source_update_sites": widgetkit_update_sources(sconn, sp), "target_update_sites": widgetkit_update_sources(dconn, dp), "newest_version": latest, "compatible": bool(src) and src == dst, } if status["compatible"]: status["action"] = "copy" elif not src: status["action"] = "source_missing_widgetkit" elif not dst: status["action"] = "target_missing_widgetkit" else: needs = [] if latest and latest not in src: needs.append("source") if latest and latest not in dst: needs.append("target") status["action"] = "bring_to_newest_version" status["upgrade_required"] = needs return status # --------------------------------------------------------------------------- # planning / extension ownership def site_resolver(name): return mesh.connect(name), mesh.prefix(name), mesh.env(name).get("JOOMLA_URL", "") def installed_components(conn, prefix): try: with conn.cursor() as cur: cur.execute(f"SELECT element, name FROM `{prefix}extensions` " "WHERE type = 'component' AND element IS NOT NULL") return {r["element"]: (r["name"] or r["element"]) for r in cur.fetchall()} except Exception: return {} def owning_component(table, components): best = None for element in components: stem = element[4:] if element.startswith("com_") else element if not stem: continue if table == stem or table.startswith(stem + "_"): if best is None or len(stem) > len(best[0]): best = (stem, element) return best[1] if best else None def left_behind(sconn, dconn, sp, dp): stabs, dtabs = tables(sconn, sp), tables(dconn, dp) orphans = sorted(stabs - dtabs - NEVER_COPY) if not orphans: return [] src_components = installed_components(sconn, sp) dst_components = installed_components(dconn, dp) grouped = {} for table in orphans: rows = row_count(sconn, sp + table) element = owning_component(table, src_components) key = element or "(no component)" entry = grouped.setdefault(key, { "component": key, "name": src_components.get(element, element or "unrecognised tables"), "installed_on_target": element in dst_components if element else False, "tables": [], "rows": 0}) entry["tables"].append(table) entry["rows"] += rows return sorted(grouped.values(), key=lambda g: -g["rows"]) def plan(src, dst, resolve=site_resolver): sconn, sp, _ = resolve(src) dconn, dp, _ = resolve(dst) try: stabs, dtabs = tables(sconn, sp), tables(dconn, dp) widgetkit = widgetkit_status(sconn, dconn, sp, dp) idmaps = {} factory = sync_factory_catalogs(sconn, dconn, sp, dp, idmaps, live=False) compatibility = extension_compat.compatibility_profile(sconn, dconn, sp, dp) moving, dropped = [], [] for t in sorted(stabs): if t in NEVER_COPY or migration_policy.is_factory(t): continue n = row_count(sconn, sp + t) if t not in dtabs: if n: dropped.append({"table": t, "rows": n, "reason": "no such table on the target site"}) continue if is_widgetkit_table(t) and not widgetkit["compatible"]: if n: dropped.append({"table": t, "rows": n, "reason": "Widgetkit must be at version parity before copying", "widgetkit": widgetkit}) continue scols, dcols = columns(sconn, sp + t), columns(dconn, dp + t) shared = shared_columns(scols, dcols) moving.append({"table": t, "rows": n, "columns": len(shared), "columns_dropped": sorted(set(scols) - set(dcols))}) waiting = left_behind(sconn, dconn, sp, dp) return { "source": src, "target": dst, "moving": [m for m in moving if m["rows"]], "not_moving": dropped, "needs_installing": [w for w in waiting if w["rows"]], "target_factory": factory, "extension_compatibility": compatibility, "target_only": sorted(dtabs - stabs - NEVER_COPY - set(migration_policy.TARGET_FACTORY)), "totals": { "rows_to_move": sum(m["rows"] for m in moving), "rows_left_behind": sum(d["rows"] for d in dropped), "factory_rows_matched": sum(s.get("matched", 0) for s in factory.values()), "factory_rows_not_resurrected": sum(s.get("skipped", 0) for s in factory.values()), }, } finally: sconn.close(); dconn.close() # --------------------------------------------------------------------------- # value transforms def rewrite_urls(value, old_base, new_base): if not isinstance(value, str) or not old_base: return value host = re.sub(r"^https?://", "", old_base.rstrip("/")).lstrip("/") host = re.sub(r"^www\.", "", host) new = new_base.rstrip("/") for pat in (f"https://www.{host}", f"http://www.{host}", f"https://{host}", f"http://{host}", f"//www.{host}", f"//{host}"): value = value.replace(pat, new) return value def remap(value, idmap, strict=False): try: old = int(value) except (TypeError, ValueError): return value if old in idmap: return idmap[old] return 0 if (strict and old) else value ROUTE_IDMAP = { ("com_content", "article"): "content", ("com_content", "category"): "categories", ("com_content", "featured"): "categories", ("com_contact", "contact"): "contact_details", ("com_tags", "tag"): "tags", } _ROUTE_ID_RE = re.compile(r"(?P
[?&]id=)(?P\d+)")


def rewrite_route_ids(value, idmaps):
    if not isinstance(value, str) or "option=com_" not in value:
        return value
    option = re.search(r"option=(com_[\w]+)", value)
    view = re.search(r"view=(\w+)", value)
    if not option:
        return value
    idmap_name = ROUTE_IDMAP.get((option.group(1), view.group(1) if view else ""))
    if not idmap_name:
        return value
    idmap = idmaps.get(idmap_name) or {}
    if not idmap:
        return value

    def swap(match):
        new = idmap.get(int(match.group("id")))
        return match.group("pre") + str(new if new and new > 0 else match.group("id"))
    return _ROUTE_ID_RE.sub(swap, value)


def name_lookup(sconn, dconn, sp, dp, kind):
    table, pk, scol, dcol, where = LOOKUPS[kind]
    cond = f" WHERE {where}" if where else ""
    with sconn.cursor() as scur, dconn.cursor() as dcur:
        scur.execute(f"SELECT `{pk}` AS id, `{scol}` AS k FROM `{sp + table}`{cond}")
        src = {r["id"]: r["k"] for r in scur.fetchall()}
        dcur.execute(f"SELECT `{pk}` AS id, `{dcol}` AS k FROM `{dp + table}`{cond}")
        dst = {r["k"]: r["id"] for r in dcur.fetchall()}
    return {old: dst[key] for old, key in src.items() if key in dst}


# ---------------------------------------------------------------------------
# row copying

def existing_keys(conn, table, key_cols, has_id=True):
    if not key_cols:
        return {}
    cols = ", ".join(f"`{c}`" for c in key_cols)
    sel = f"id, {cols}" if has_id else cols
    with conn.cursor() as cur:
        cur.execute(f"SELECT {sel} FROM `{table}`")
        return {tuple(str(r[c]) for c in key_cols): (r["id"] if has_id else True)
                for r in cur.fetchall()}


def row_savepoint(cur, name="mport_row"):
    cur.execute(f"SAVEPOINT {name}")


def rollback_row(cur, name="mport_row"):
    cur.execute(f"ROLLBACK TO SAVEPOINT {name}")
    cur.execute(f"RELEASE SAVEPOINT {name}")


def release_row(cur, name="mport_row"):
    cur.execute(f"RELEASE SAVEPOINT {name}")


def copy_table(sconn, dconn, sp, dp, table, idmaps, *, live, urls=None, order_by=None):
    scols, dcols = columns(sconn, sp + table), columns(dconn, dp + table)
    shared = [c for c in shared_columns(scols, dcols) if c != "id"]
    if not shared:
        return 0, 0, row_count(sconn, sp + table), []

    has_id = "id" in scols and "id" in dcols
    key_cols = [c for c in NATURAL_KEY.get(table, ()) if c in dcols]
    present = existing_keys(dconn, dp + table, key_cols, has_id)
    taken = set()
    if has_id:
        with dconn.cursor() as c:
            c.execute(f"SELECT id FROM `{dp + table}`")
            taken = {r["id"] for r in c.fetchall()}

    fks = {c: m for c, m in FKS.get(table, {}).items() if c in shared}
    for kind in {m for m in fks.values() if m.startswith("@")}:
        idmaps.setdefault(kind, name_lookup(sconn, dconn, sp, dp, kind))
    idmap = idmaps.setdefault(table, {})

    sel = ", ".join(f"`{c}`" for c in ((["id"] if has_id else []) + shared))
    sql = f"SELECT {sel} FROM `{sp + table}`"
    where = ROW_FILTER.get(table)
    if where and all(c in scols for c in _FILTER_COLS.get(table, ())):
        sql += f" WHERE {where}"
    if order_by and order_by in scols:
        sql += f" ORDER BY `{order_by}`"

    inserted = reused = 0
    failures = []
    unresolved = {}
    with sconn.cursor() as scur, dconn.cursor() as dcur:
        scur.execute(sql)
        for row in scur.fetchall():
            old_id = row.get("id")
            values = {}
            for c in shared:
                v = row.get(c)
                if c in fks:
                    ref = fks[c]
                    was = v
                    strict = ref.startswith("@") or migration_policy.is_factory_reference(ref)
                    v = remap(v, idmaps.setdefault(ref, {}), strict=strict)
                    if strict and v == 0 and was not in (0, None, "0"):
                        unresolved.setdefault(c, set()).add(was)
                if urls:
                    v = rewrite_urls(v, urls[0], urls[1])
                v = rewrite_route_ids(v, idmaps)
                values[c] = v

            key = tuple(str(values.get(c, row.get(c))) for c in key_cols) if key_cols else None
            if key is not None and key in present:
                if has_id:
                    idmap[old_id] = present[key]
                if table in UPDATE_ON_MATCH and live:
                    sets = ", ".join(f"`{c}` = %s" for c in values)
                    try:
                        row_savepoint(dcur)
                        dcur.execute(f"UPDATE `{dp + table}` SET {sets} WHERE id = %s",
                                     list(values.values()) + [present[key]])
                        release_row(dcur)
                    except Exception as exc:
                        rollback_row(dcur)
                        failures.append({"table": table, "source_id": old_id,
                                         "error": f"{type(exc).__name__}: {str(exc)[:180]}"})
                        continue
                reused += 1
                continue

            if not live:
                if has_id:
                    idmap[old_id] = old_id if old_id not in taken else -1
                inserted += 1
                continue

            keep_id = has_id and old_id is not None and old_id not in taken
            write = {"id": old_id, **values} if keep_id else dict(values)
            cols_sql = ", ".join(f"`{c}`" for c in write)
            marks = ", ".join(["%s"] * len(write))
            try:
                row_savepoint(dcur)
                dcur.execute(f"INSERT INTO `{dp + table}` ({cols_sql}) VALUES ({marks})",
                             list(write.values()))
                release_row(dcur)
            except Exception as exc:
                rollback_row(dcur)
                failures.append({"table": table, "source_id": old_id,
                                 "error": f"{type(exc).__name__}: {str(exc)[:180]}"})
                continue
            new_id = (old_id if keep_id else dcur.lastrowid) if has_id else True
            if has_id:
                idmap[old_id] = new_id
                taken.add(new_id)
            if key is not None:
                present[key] = new_id
            inserted += 1

    if live:
        dconn.commit()
    if unresolved and failures:
        failures[0].setdefault("unresolved", {c: sorted(v)[:20] for c, v in unresolved.items()})
    return inserted, reused, 0, failures


# ---------------------------------------------------------------------------
# trees, ACL, workflows

def rebuild_tree(conn, table, *, live):
    with conn.cursor() as cur:
        cur.execute(f"SELECT id, parent_id FROM `{table}` ORDER BY id")
        rows = cur.fetchall()
        ids = {r["id"] for r in rows}
        kids = {}
        for row in rows:
            kids.setdefault(row["parent_id"], []).append(row["id"])
        n, out = [0], {}

        def walk(node, level):
            n[0] += 1
            left = n[0]
            for child in kids.get(node, []):
                walk(child, level + 1)
            n[0] += 1
            out[node] = (left, n[0], level)

        roots = [r["id"] for r in rows if r["parent_id"] in (0, None) or r["parent_id"] not in ids]
        for root in roots:
            walk(root, 0)
        if live:
            for node, (left, right, level) in out.items():
                cur.execute(f"UPDATE `{table}` SET lft=%s, rgt=%s, level=%s WHERE id=%s",
                            (left, right, level, node))
            conn.commit()
        return len(out)


_ASSET_NAME = re.compile(r"^(com_content\.(?:article|category))\.(\d+)$")


def translate_asset_name(name, idmaps):
    match = _ASSET_NAME.match(name or "")
    if not match:
        return name
    kind, old = match.group(1), int(match.group(2))
    table = "content" if kind.endswith("article") else "categories"
    return f"{kind}.{idmaps.get(table, {}).get(old, old)}"


def sync_assets(sconn, sp, dconn, dp, kind, idmap, idmaps, *, live, table):
    if not live or not idmap:
        return {"created": 0, "rules": 0}
    names = {f"{kind}.{old}": f"{kind}.{new}" for old, new in idmap.items()}
    created = ruled = 0
    with sconn.cursor() as scur, dconn.cursor() as dcur:
        scur.execute(
            f"SELECT a.name, a.title, a.rules, p.name AS parent_name "
            f"FROM `{sp}assets` a LEFT JOIN `{sp}assets` p ON p.id = a.parent_id "
            "WHERE a.name IN %s", (tuple(names),))
        source = {r["name"]: r for r in scur.fetchall()}
        dcur.execute(f"SELECT id, name FROM `{dp}assets`")
        target = {r["name"]: r["id"] for r in dcur.fetchall()}
        for old_name, new_name in names.items():
            src = source.get(old_name) or {}
            rules = src.get("rules") or "{}"
            title = src.get("title") or new_name
            parent = target.get(translate_asset_name(src.get("parent_name"), idmaps), 1)
            if new_name in target:
                dcur.execute(f"UPDATE `{dp}assets` SET rules=%s, parent_id=%s WHERE id=%s",
                             (rules, parent, target[new_name]))
            else:
                dcur.execute(f"INSERT INTO `{dp}assets` "
                             "(parent_id, lft, rgt, level, name, title, rules) "
                             "VALUES (%s,%s,%s,%s,%s,%s,%s)",
                             (parent, 0, 0, 2, new_name, title, rules))
                target[new_name] = dcur.lastrowid
                created += 1
            if rules != "{}":
                ruled += 1
        dconn.commit()
        rebuild_tree(dconn, dp + "assets", live=True)
        dcur.execute(f"UPDATE `{dp}{table}` t JOIN `{dp}assets` a "
                     "ON a.name = CONCAT(%s, '.', t.id) SET t.asset_id = a.id "
                     "WHERE t.asset_id <> a.id", (kind,))
        dconn.commit()
    return {"created": created, "rules": ruled}


def ensure_workflow(conn, prefix, ids, *, live, extension="com_content.article"):
    if not live or not ids:
        return 0
    if "workflow_associations" not in tables(conn, prefix):
        return 0
    with conn.cursor() as cur:
        cur.execute(f"SELECT s.id FROM `{prefix}workflow_stages` s "
                    f"JOIN `{prefix}workflows` w ON w.id = s.workflow_id "
                    "WHERE w.extension = %s AND s.published = 1 "
                    "ORDER BY w.`default` DESC, s.`default` DESC, s.id LIMIT 1",
                    (extension,))
        row = cur.fetchone()
        if not row:
            return 0
        stage, made = row["id"], 0
        for item in ids:
            cur.execute(f"SELECT item_id FROM `{prefix}workflow_associations` "
                        "WHERE item_id=%s AND extension=%s", (item, extension))
            if cur.fetchone():
                continue
            cur.execute(f"INSERT INTO `{prefix}workflow_associations` "
                        "(item_id, stage_id, extension) VALUES (%s,%s,%s)",
                        (item, stage, extension))
            made += 1
        conn.commit()
        return made


# ---------------------------------------------------------------------------
# run

def excluded(table, exclude):
    return any(fnmatch.fnmatch(table, p.strip()) for p in exclude if p.strip())


def phase_tables(sconn, dconn, sp, dp, phase, exclude=()):
    stabs, dtabs = tables(sconn, sp), tables(dconn, dp)
    both = {t for t in (stabs & dtabs) - NEVER_COPY
            if not migration_policy.is_factory(t) and not excluded(t, exclude)}
    if not widgetkit_status(sconn, dconn, sp, dp)["compatible"]:
        both = {t for t in both if not is_widgetkit_table(t)}
    named = {
        # ACL reference ids must exist before any entity's access column moves.
        "users":      ["usergroups", "viewlevels", "users", "user_usergroup_map"],
        "styles":     ["template_styles"],
        "categories": ["categories"],
        "articles":   ["content", "content_frontpage"],
        "menus":      ["menu_types", "menu"],
        "modules":    ["modules", "modules_menu"],
        "fields":     ["fields_groups", "fields", "fields_values"],
        "tags":       ["tags", "contentitem_tag_map"],
    }
    if phase != "extensions":
        return [t for t in named[phase] if t in both]
    claimed = {t for group in named.values() for t in group}
    return sorted(both - claimed)


def migrate(src, dst, phases=PHASES, *, live=False, urls=True, resolve=site_resolver,
            url_override=None, on_progress=None, exclude=()):
    def report_progress(**event):
        if on_progress:
            on_progress(event)

    sconn, sp, surl = resolve(src)
    dconn, dp, durl = resolve(dst)
    url_pair = url_override or ((surl, durl) if urls else None)
    widgetkit = widgetkit_status(sconn, dconn, sp, dp)
    idmaps = {}
    report = {"source": src, "target": dst, "live": live,
              "widgetkit": widgetkit, "phases": {}}
    try:
        # First map install-owned coordinates.  This is what prevents the second
        # DPCalendar country catalogue and gives later rows the target country id.
        factory = sync_factory_catalogs(sconn, dconn, sp, dp, idmaps, live=live)
        report["target_factory"] = factory
        report["extension_compatibility"] = extension_compat.compatibility_profile(sconn, dconn, sp, dp)

        total_steps = len(phases) + (1 if factory else 0)
        offset = 0
        if factory:
            offset = 1
            report_progress(event="phase", phase="factory maps", step=1, of=total_steps,
                            tables=len(factory))
            for table, stats in factory.items():
                report_progress(event="table", phase="factory maps", table=table,
                                inserted=stats.get("inserted", 0),
                                reused=stats.get("matched", 0),
                                skipped=stats.get("skipped", 0),
                                policy="target_factory")

        waiting = [w for w in left_behind(sconn, dconn, sp, dp) if w["rows"]]
        report["needs_installing"] = waiting
        for group in waiting:
            report_progress(event="skipped", component=group["component"],
                            name=group["name"], tables=len(group["tables"]), rows=group["rows"])

        for n, phase in enumerate(phases, 1):
            stats = {}
            todo = phase_tables(sconn, dconn, sp, dp, phase, exclude=exclude)
            if phase == "extensions" and not widgetkit["compatible"]:
                skipped = []
                for table in sorted((tables(sconn, sp) & tables(dconn, dp)) - NEVER_COPY):
                    if is_widgetkit_table(table):
                        rows = row_count(sconn, sp + table)
                        if rows:
                            skipped.append({"table": table, "rows": rows})
                if skipped:
                    stats["widgetkit"] = {"skipped": skipped,
                                          "reason": "Widgetkit must be at version parity before copying",
                                          "widgetkit": widgetkit}
            report_progress(event="phase", phase=phase, step=n + offset, of=total_steps,
                            tables=len(todo))
            for table in todo:
                tree = table in TREE_TABLES or is_tree(columns(dconn, dp + table))
                try:
                    ins, reused, skipped, failed = copy_table(
                        sconn, dconn, sp, dp, table, idmaps, live=live, urls=url_pair,
                        order_by="parent_id" if tree else "id")
                except Exception as exc:
                    stats[table] = {"error": f"{type(exc).__name__}: {str(exc)[:200]}"}
                    report_progress(event="table", phase=phase, table=table,
                                    error=stats[table]["error"])
                    continue
                stats[table] = {"inserted": ins, "reused": reused, "skipped": skipped}
                if failed:
                    stats[table]["failed"] = len(failed)
                    stats[table]["errors"] = failed[:5]
                if tree and live:
                    stats[table]["tree_nodes"] = rebuild_tree(dconn, dp + table, live=True)
                report_progress(event="table", phase=phase, table=table, **stats[table])

            if phase == "categories" and live:
                stats["assets"] = sync_assets(sconn, sp, dconn, dp,
                                              "com_content.category",
                                              idmaps.get("categories", {}), idmaps,
                                              live=live, table="categories")
            if phase == "articles" and live:
                article_ids = list(idmaps.get("content", {}).values())
                stats["assets"] = sync_assets(sconn, sp, dconn, dp,
                                              "com_content.article",
                                              idmaps.get("content", {}), idmaps,
                                              live=live, table="content")
                stats["workflow"] = ensure_workflow(dconn, dp, article_ids, live=live)
                report_progress(event="repair", phase=phase,
                                assets=stats["assets"], workflow=stats["workflow"])
            report["phases"][phase] = stats

        report["id_maps"] = {k: len(v) for k, v in idmaps.items()}
        users = idmaps.get("users", {})
        report["users_kept_id"] = sum(1 for old, new in users.items() if old == new)
        report["users_remapped"] = len(users) - report["users_kept_id"]
        report["factory_ids_mapped"] = sum(
            len(idmaps.get(t, {})) for t, p in migration_policy.TARGET_FACTORY.items() if p.get("pk"))
        report_progress(event="done", users_kept_id=report["users_kept_id"],
                        users_remapped=report["users_remapped"],
                        factory_ids_mapped=report["factory_ids_mapped"])
        return report
    finally:
        sconn.close(); dconn.close()


# ---------------------------------------------------------------------------
# media
_MEDIA_RE = re.compile(r'''(?:images|media)/[^"'<>\\]*?\.\w{2,5}''', re.I)


def media_manifest(site, tables_to_scan=("content", "modules", "template_styles"),
                   resolve=site_resolver):
    conn, prefix, _ = resolve(site)
    found = set()
    try:
        present = tables(conn, prefix)
        with conn.cursor() as cur:
            for table in tables_to_scan:
                if table not in present:
                    continue
                for col in columns(conn, prefix + table):
                    if col in ("id", "checked_out"):
                        continue
                    try:
                        cur.execute(f"SELECT `{col}` AS v FROM `{prefix + table}`")
                    except Exception:
                        continue
                    for row in cur.fetchall():
                        value = row["v"]
                        if isinstance(value, str) and ("images/" in value or "media/" in value):
                            found.update(m.group(0).replace("\\/", "/") for m in _MEDIA_RE.finditer(value))
    finally:
        conn.close()
    return sorted(found)


def copy_media(paths, src_dir, dst_dir, *, live):
    import shutil
    from pathlib import Path
    src_root, dst_root = Path(src_dir), Path(dst_dir)
    copied, missing = 0, []
    for rel in paths:
        source = src_root / rel
        if not source.is_file():
            missing.append(rel)
            continue
        if live:
            target = dst_root / rel
            target.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(source, target)
        copied += 1
    return {"copied": copied, "missing": missing[:50], "missing_count": len(missing)}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--from", dest="src", required=True)
    parser.add_argument("--to", dest="dst", required=True)
    parser.add_argument("--phases", default=",".join(PHASES))
    parser.add_argument("--live", action="store_true")
    parser.add_argument("--keep-urls", action="store_true")
    parser.add_argument("--media-from")
    parser.add_argument("--media-to")
    args = parser.parse_args()
    if args.media_from and args.media_to:
        paths = media_manifest(args.dst if args.live else args.src)
        print(json.dumps({"referenced_media": len(paths),
                          **copy_media(paths, args.media_from, args.media_to, live=args.live)}, indent=2))
        return
    if not args.live:
        print(json.dumps(plan(args.src, args.dst), indent=2))
        return
    print(json.dumps(migrate(args.src, args.dst, tuple(args.phases.split(",")),
                             live=True, urls=not args.keep_urls), indent=2))


if __name__ == "__main__":
    main()