aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/commands/rm.py
blob: 7f4a5c6cd9b06ed71ea3ea993882986b037347b0 (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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
"""
`rm`: delete files from groups, and with -r folders and all they hold. Asked
first; `--yes` answers for a script.

Paths and patterns are resolved against each group's index, as `get` resolves
them, and the whole run is checked before anything is asked or sent: a file
this account may not delete — not uploaded by it, and this machine's key not
the node's operator — refuses the run (exit 8), as a folder does without -r.

With -r, a folder is everything below it: its files first, then its folders,
deepest first. The node removes only empty folders, and only for its operator's
key, so -r on a folder is the operator's, and the confirmation counts every
file before one is deleted. A group's root is never removed. The node checks
again on every file and folder; one it refuses is reported and the others go
on (exit 4) — a folder still holding a file it refused stays.
"""

import json

from ..client.groups import my_groups
from ..client.paths import handle, parse, resolve_group
from ..client.pool import Pool
from ..client.remove import delete_dir, delete_file, may_delete
from ..client.tree import Tree
from ..errors import NODE, NOT_FOUND, PARTIAL, REFUSED, USAGE, CliError
from ..output import confirm, err, human_size, safe, sentence
from .context import Context

HELP = ("delete files you uploaded (or any, from the node's operator key); with -r, "
        "folders too, from the operator's key")

# How many names the confirmation shows before it counts the rest.
SHOWN = 10


def add_parser(sub, parent) -> None:
    p = sub.add_parser("rm", parents=[parent], help=HELP, description=sentence(HELP)
                       + ". Wildcards (* ? [...]) are matched against the group, so "
                       "quote them. Being the group's owner is not enough: the node "
                       "lets its operator's key, and the uploader's devices, delete.")
    p.add_argument("paths", nargs="+", metavar="PATH", help="group[@owner]/root/.../file")
    p.add_argument("-r", "-R", dest="recursive", action="store_true",
                   help="folders, with everything in them (the node's operator only)")
    p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")


async def run(ctx: Context) -> int:
    await ctx.hub.check_version()
    account = ctx.account()
    pool = Pool(account)
    try:
        return await _run(ctx, account, pool)
    finally:
        await pool.close()
        await account.close()


async def _run(ctx: Context, account, pool: Pool) -> int:
    a = ctx.args
    groups = await my_groups(account)
    trees: dict[str, Tree] = {}
    targets = []           # (session, entry, shown)
    dirs = []              # (session, path, shown)
    missing, folders, not_yours, roots, not_operator = [], [], [], [], []
    for text in a.paths:
        remote = parse(text)
        if remote is None or not remote.parts:
            raise CliError(USAGE, "Name the files to delete, inside a group.")
        group = resolve_group(groups, remote.group)
        session = await pool.session(group)
        if group["id"] not in trees:
            trees[group["id"]] = Tree.from_index(await session.index())
        tree = trees[group["id"]]
        found = tree.expand(remote.parts)
        if not found and tree.file(remote.inner):
            found = [remote.inner]              # a literal name with brackets in it
        if not found:
            missing.append(text)
        for path in found:
            shown = f"{handle(group)}/{path}"
            entry = tree.file(path)
            if entry is not None:
                if not may_delete(session, entry, account.user_id):
                    not_yours.append(shown)
                elif all(e["id"] != entry["id"] for _, e, _ in targets):
                    targets.append((session, entry, shown))
                continue
            if not a.recursive:
                folders.append(shown)
            elif "/" not in path:
                roots.append(shown)
            elif session.role != "operator":
                not_operator.append(shown)
            elif not tree.root_available(path):
                raise CliError(REFUSED, f"{safe(shown)} is on a drive the node does not see "
                                        "now; nothing was deleted.")
            else:
                for folder, kids in tree.walk(path):
                    if all(d[1] != folder or d[0] is not session for d in dirs):
                        dirs.append((session, folder, f"{handle(group)}/{folder}"))
                    for kid in kids:
                        if not kid.is_dir and all(e["id"] != kid.entry["id"]
                                                  for _, e, _ in targets):
                            targets.append((session, kid.entry,
                                            f"{handle(group)}/{kid.path}"))
    if missing:
        raise CliError(NOT_FOUND, "Nothing in your groups matches: "
                                  + ", ".join(safe(m) for m in missing))
    if folders:
        raise CliError(USAGE, "These are folders; -r deletes them with everything in "
                              "them: " + ", ".join(safe(f) for f in folders))
    if roots:
        raise CliError(REFUSED, "A group's root is not deleted here — its operator removes "
                                "it from the node: " + ", ".join(safe(r) for r in roots))
    if not_operator:
        raise CliError(REFUSED, "Not deleted — removing a folder is the node operator's, "
                                "and this machine's key is not:\n  "
                                + "\n  ".join(safe(n) for n in not_operator))
    if not_yours:
        raise CliError(REFUSED, "Not deleted — you did not upload these, and this machine's "
                                "key is not the node's operator:\n  "
                                + "\n  ".join(safe(n) for n in not_yours))

    names = [shown for _, _, shown in targets]
    # The deepest first: each one is empty by the time it is asked for.
    dirs.sort(key=lambda d: d[1].count("/"), reverse=True)
    tops = [shown for _, path, shown in dirs
            if not any(path.startswith(other + "/") for _, other, _ in dirs)]
    shown_names = tops + [n for n in names if not any(n.startswith(t + "/") for t in tops)]
    listed = "\n  ".join(safe(n) + ("/" if n in tops else "") for n in shown_names[:SHOWN])
    more = (f"\n  ... and {len(shown_names) - SHOWN} more"
            if len(shown_names) > SHOWN else "")
    size = sum(int(e.get("size", 0)) for _, e, _ in targets)
    what = f"{len(names)} file{'s' if len(names) != 1 else ''}"
    if dirs:
        what += (f" ({human_size(size)}) and {len(dirs)} folder"
                 f"{'s' if len(dirs) != 1 else ''}")
    confirm(f"Delete {what} for every member of the group?\n  {listed}{more}\n", a.yes)

    deleted, removed, failed = [], [], []
    for session, entry, shown in targets:
        try:
            await delete_file(session, entry["id"])
            deleted.append(shown)
        except CliError as e:
            if e.code not in (NODE, REFUSED):
                raise
            failed.append(f"{shown}: {e.message}")
    for session, path, shown in dirs:
        try:
            await delete_dir(session, path)
            removed.append(shown)
        except CliError as e:
            if e.code not in (NODE, REFUSED):
                raise
            failed.append(f"{shown}/: {e.message}")
    if a.json:
        print(json.dumps({"deleted": deleted, "folders_removed": removed, "failed": failed},
                         ensure_ascii=False))
    elif not a.quiet:
        n = len(deleted)
        err(f"Deleted {n} file{'s' if n != 1 else ''}"
            + (f" and {len(removed)} folder{'s' if len(removed) != 1 else ''}"
               if dirs else "") + ".")
    for f in failed:
        err(f"meshbay-cli: {safe(f)}")
    return PARTIAL if failed else 0