""" arma_modlist_tools.linker ~~~~~~~~~~~~~~~~~~~~~~~~~ Manage directory links between downloaded mod folders and the Arma 3 Server directory. Works on both Windows (junction links) and Linux (symlinks). Platform behaviour: - **Windows**: junctions via ``cmd /c mklink /J`` — no admin rights required. - **Linux**: symlinks via ``os.symlink()`` — standard directory symlinks. Typical usage:: from arma_modlist_tools.linker import get_link_status, link_group, unlink_group from pathlib import Path arma = Path("/opt/arma3server") group = Path("downloads/shared") status = get_link_status(group, arma) result = link_group(group, arma) """ from __future__ import annotations import os import subprocess from pathlib import Path from .compat import is_windows # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _is_junction(path: Path) -> bool: """ Return ``True`` if *path* is an active directory junction / symlink. - **Windows**: checks ``FILE_ATTRIBUTE_REPARSE_POINT`` (0x400) in ``os.lstat().st_file_attributes``. ``os.path.islink()`` is unreliable for junctions on Windows. - **Linux**: ``os.path.islink()`` correctly identifies symlinks. """ try: if is_windows(): s = os.lstat(str(path)) attrs = getattr(s, "st_file_attributes", 0) return bool(attrs & 0x400) # FILE_ATTRIBUTE_REPARSE_POINT else: return os.path.islink(str(path)) except OSError: return False # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def get_mod_folders(group_dir: Path) -> list[Path]: """ Return a sorted list of ``@*`` subdirectories inside *group_dir*. :param group_dir: The mod category folder (e.g. ``downloads/shared``). """ if not group_dir.is_dir(): return [] return sorted( p for p in group_dir.iterdir() if p.is_dir() and p.name.startswith("@") ) def get_link_status(group_dir: Path, arma_dir: Path) -> list[dict]: """ Return the link status for every ``@Mod`` folder in *group_dir*. :returns: List of dicts with keys: - ``name`` — folder name (e.g. ``@ace``) - ``source_path`` — absolute path of the mod folder - ``link_path`` — where the link would/does live in *arma_dir* - ``is_linked`` — ``True`` if a junction/symlink currently exists """ result = [] for mod in get_mod_folders(group_dir): link_path = arma_dir / mod.name result.append({ "name": mod.name, "source_path": mod.resolve(), "link_path": link_path, "is_linked": _is_junction(link_path), }) return result def create_junction(link_path: Path, target: Path) -> bool: """ Create a directory junction (Windows) or symlink (Linux) at *link_path* pointing to *target*. :returns: ``True`` on success, ``False`` on failure. """ if is_windows(): proc = subprocess.run( ["cmd", "/c", "mklink", "/J", str(link_path), str(target)], capture_output=True, text=True, ) return proc.returncode == 0 else: try: os.symlink(str(target), str(link_path)) return True except OSError: return False def remove_junction(link_path: Path) -> tuple[bool, str]: """ Remove the junction / symlink at *link_path*. - **Windows**: ``os.rmdir()`` removes the junction pointer without touching the target directory's contents. - **Linux**: ``os.unlink()`` removes the symlink without touching the target. :returns: ``(True, "")`` on success, ``(False, error_message)`` on failure. """ try: if is_windows(): os.rmdir(str(link_path)) else: os.unlink(str(link_path)) return True, "" except OSError as exc: return False, str(exc) def link_group(group_dir: Path, arma_dir: Path) -> dict: """ Create links for every unlinked ``@Mod`` in *group_dir*. :returns: ``{"linked": n, "already_linked": n, "failed": n, "errors": {name: msg}}`` """ status = get_link_status(group_dir, arma_dir) linked = already_linked = failed = 0 errors: dict[str, str] = {} for s in status: if s["is_linked"]: already_linked += 1 continue if s["link_path"].exists(): failed += 1 errors[s["name"]] = "path exists but is not a junction/symlink" continue ok = create_junction(s["link_path"], s["source_path"]) if ok: linked += 1 else: failed += 1 errors[s["name"]] = "link creation failed" return {"linked": linked, "already_linked": already_linked, "failed": failed, "errors": errors} def unlink_group(group_dir: Path, arma_dir: Path) -> dict: """ Remove links for every linked ``@Mod`` in *group_dir*. :returns: ``{"unlinked": n, "not_linked": n, "failed": n, "errors": {name: msg}}`` """ status = get_link_status(group_dir, arma_dir) unlinked = not_linked = failed = 0 errors: dict[str, str] = {} for s in status: if not s["is_linked"]: not_linked += 1 continue ok, err = remove_junction(s["link_path"]) if ok: unlinked += 1 else: failed += 1 errors[s["name"]] = err return {"unlinked": unlinked, "not_linked": not_linked, "failed": failed, "errors": errors}