summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/commands/search.py
blob: dbd0db1d7ea5f5140a2dbf0125f7569f9e4ce2a2 (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
"""
`search`: files across every group of the account, by name, folder or tags.

Each group's node is dialled (six negotiations at most, the pool's rule) and its
index matched here, as the application's Search does: the node serves the same
index to Search and to the group page. A group its operator left out of Search
(`search_listed`, in the sealed ack) is skipped before its index is asked for —
a listing preference, not a protection (docs/MESHBAY_DESIGN.md §9.11). A file
several groups hold is one result: entries are keyed by their content hash,
and every place it can be fetched from is kept. A group whose node does not
answer is reported and costs the others nothing (exit 4).
"""

import asyncio
import json
import sys

from meshbay_common.paths import fold

from ..client.groups import my_groups
from ..client.paths import encode_name, handle
from ..client.pool import Pool
from ..client.session import DeviceLinkPending
from ..client.tree import Tree
from ..errors import NOT_FOUND, PARTIAL, USAGE, CliError
from ..output import date, err, human_size, safe, sentence
from .context import Context

HELP = "find files in all your groups by name, folder, title, artist or album"

TYPES = ("video", "audio", "image", "document", "archive", "other")
# What a word is looked for in: the Search page's fields.
FIELDS = ("name", "path", "display_title", "artist", "album")


def add_parser(sub, parent) -> None:
    p = sub.add_parser("search", parents=[parent], help=HELP, description=sentence(HELP)
                       + ". Every word must be found, without regard to case. A file "
                       "several groups hold is listed once. Groups their operator "
                       "left out of Search are not searched.")
    p.add_argument("words", nargs="+", metavar="WORD")
    p.add_argument("-t", "--type", choices=TYPES, help="only files of this kind")
    p.add_argument("-l", dest="long", action="store_true",
                   help="type, size and date, and how many other groups hold it")


def matches(entry: dict, words: list[str]) -> bool:
    text = [fold(str(entry.get(f) or "")) for f in FIELDS]
    return all(any(w in t for t in text) for w in words)


def remote_path(group: dict, entry: dict) -> str:
    """The path `get` takes: the group as `groups` prints it, then the file."""
    parts = [p for p in entry["path"].split("/") if p] + [entry["name"]]
    return "/".join([handle(group)] + [encode_name(p) for p in parts])


async def run(ctx: Context) -> int:
    await ctx.hub.check_version()
    account = ctx.account()
    # A group that needs this machine approved is reported, not waited for:
    # a search does not hold every other group's results behind one code.
    account.on_device_code = lambda group, pending, *, first: False
    pool = Pool(account)
    try:
        return await _run(ctx, account, pool)
    finally:
        await pool.close()
        await account.close()


async def _search_group(pool: Pool, group: dict, words: list[str], kind: str | None):
    """(matching entries, own node) — or None when the group is left out of Search."""
    session = await pool.session(group)
    if session.ack.get("search_listed") is False:
        return None
    tree = Tree.from_index(await session.index())
    found = [e for entries in tree.files.values() for e in entries
             if tree.root_available(e["path"]) and (kind is None or e.get("type") == kind)
             and matches(e, words)]
    return found, bool(session.ack.get("is_node_admin"))


async def _run(ctx: Context, account, pool: Pool) -> int:
    a = ctx.args
    words = [fold(w) for w in " ".join(a.words).split() if w]
    if not words:
        raise CliError(USAGE, "Say what to look for.")
    groups = [g for g in await my_groups(account) if g.get("hosted", True)]

    async def one(group):
        try:
            return group, await _search_group(pool, group, words, a.type), None
        except DeviceLinkPending as e:
            return group, None, f"this machine waits to be approved there ({e.link_code})"
        except CliError as e:
            return group, None, e.message

    results = await asyncio.gather(*(one(g) for g in groups))

    # One result per content hash; its first place is the reader's own node if
    # one holds it, then the group names in order — stable from run to run.
    by_id: dict[str, dict] = {}
    unreachable = []
    for group, found, problem in sorted(results, key=lambda r: (r[0]["name"].lower(),
                                                                r[0].get("owner_username"))):
        if problem is not None:
            unreachable.append(f"{handle(group)}: {problem}")
            continue
        if found is None:
            continue
        entries, own = found
        for e in entries:
            hit = by_id.setdefault(e["id"], {"entry": e, "places": []})
            place = remote_path(group, e)
            if own:
                hit["places"].insert(0, place)
            else:
                hit["places"].append(place)

    hits = sorted(by_id.values(), key=lambda h: (fold(h["entry"]["name"]), h["places"][0]))
    if a.json:
        json.dump([{"name": h["entry"]["name"], "id": h["entry"]["id"],
                    "type": h["entry"].get("type"), "size": h["entry"].get("size"),
                    "added_at": h["entry"].get("added_at"), "path": h["places"][0],
                    "also": h["places"][1:]} for h in hits],
                  sys.stdout, indent=1, ensure_ascii=False)
        print()
    else:
        for h in hits:
            e, path = h["entry"], safe(h["places"][0])
            if a.long:
                others = len(h["places"]) - 1
                note = f"  (+{others} other group{'s' if others != 1 else ''})" if others else ""
                print(f"{e.get('type') or '':<9}{human_size(int(e.get('size') or 0)):>10}  "
                      f"{date(int(e.get('added_at') or 0)):<16}  {path}{note}")
            else:
                print(path)
    for u in unreachable:
        err(f"meshbay-cli: not searched: {safe(u)}")
    if not hits:
        if not a.quiet and not a.json:
            err("Nothing found.")
        return PARTIAL if unreachable else NOT_FOUND
    return PARTIAL if unreachable else 0