summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/paths.py
blob: 1086085972d07d3e5b18177ee43e1e25cd6acca8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
"""
Remote paths: `<group>[@<owner>]/<root>/<folder>/.../<file>`.

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}.")