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
96
97
98
99
100
101
102
103
104
|
"""
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 group_arg(text: str) -> str:
"""A group named on the command line, as `groups` prints it (`%2F` for a `/`),
or as a link to it: the name, decoded, with its `@owner` if given."""
path = parse(text)
if path is None or path.parts:
raise CliError(USAGE, f"Name a group, not a path: {text!r}")
return path.group
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}.")
|