""" Remote paths: `[@]///.../`. The same handle the application shows under every group and puts in its links (docs/MESHBAY_DESIGN.md §8.4, `static/group-link.js`), without the `#/`. A group name may contain `/` and `@` (the hub refuses only control characters and bidirectional overrides); a username contains neither. So the owner is whatever follows the **last** `@` of the first segment, and a `/` inside a name is written `%2F`. Only `%2F` and `%25` are decoded in a typed path — a file called `100%.txt` is typed as it is. A link copied from the application (anything containing `#/`) is decoded as the application encodes it, every segment in full. """ import unicodedata from dataclasses import dataclass from urllib.parse import unquote from ..errors import NOT_FOUND, USAGE, CliError @dataclass class RemotePath: group: str # the first segment, decoded: a name, maybe with @owner parts: list[str] # root, folders, file — decoded from_link: bool = False @property def inner(self) -> str: return "/".join(self.parts) def _typed(segment: str) -> str: return segment.replace("%2F", "/").replace("%2f", "/").replace("%25", "%") def parse(text: str) -> RemotePath | None: """None for "all my groups" (empty, or `/`).""" from_link = "#/" in text if from_link: text = text.split("#", 1)[1] segments = [s for s in text.split("/") if s] if not segments: return None decode = unquote if from_link else _typed decoded = [unicodedata.normalize("NFC", decode(s)) for s in segments] if any(s in (".", "..") for s in decoded[1:]): raise CliError(USAGE, "A remote path cannot contain . or .. segments.") return RemotePath(decoded[0], decoded[1:], from_link) def encode_name(name: str) -> str: """A group or file name as it is typed in a path.""" return name.replace("%", "%25").replace("/", "%2F") def handle(group: dict) -> str: """`name@owner`, ready to be typed back.""" return f"{encode_name(group['name'])}@{group.get('owner_username') or ''}" def _by_owner(named: list[dict], owner: str) -> dict | None: exact = [g for g in named if g.get("owner_username") == owner] if exact: return exact[0] loose = [g for g in named if (g.get("owner_username") or "").lower() == owner.lower()] return loose[0] if len(loose) == 1 else None def resolve_group(groups: list[dict], spec: str) -> dict: """ The group `spec` names among `/v1/groups/mine` rows. Names compare without case, as the hub keeps them unique per owner. With an `@`, `name@owner` is tried first; when it names nothing, the whole segment is tried as a name, since a group name may contain an `@` of its own. """ def named(name: str) -> list[dict]: n = name.lower() return [g for g in groups if (g.get("name") or "").lower() == n] at = spec.rfind("@") if 0 < at < len(spec) - 1: found = _by_owner(named(spec[:at]), spec[at + 1:]) if found is not None: return found candidates = named(spec) if len(candidates) == 1: return candidates[0] if len(candidates) > 1: options = ", ".join(sorted(handle(g) for g in candidates)) raise CliError(USAGE, f"Several of your groups are called {spec!r}; " f"name the owner: {options}") raise CliError(NOT_FOUND, f"You are not a member of a group called {spec!r}.")