msgsearch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msgsearch/__init__.py +18 -0
- msgsearch/attributed_body.py +59 -0
- msgsearch/chunk.py +268 -0
- msgsearch/cli.py +304 -0
- msgsearch/config.py +107 -0
- msgsearch/contacts.py +281 -0
- msgsearch/doctor.py +245 -0
- msgsearch/embedder.py +126 -0
- msgsearch/explore.py +157 -0
- msgsearch/extract.py +246 -0
- msgsearch/index.py +298 -0
- msgsearch/py.typed +0 -0
- msgsearch/search.py +447 -0
- msgsearch/tagging.py +123 -0
- msgsearch-0.1.0.dist-info/METADATA +303 -0
- msgsearch-0.1.0.dist-info/RECORD +19 -0
- msgsearch-0.1.0.dist-info/WHEEL +4 -0
- msgsearch-0.1.0.dist-info/entry_points.txt +2 -0
- msgsearch-0.1.0.dist-info/licenses/LICENSE +21 -0
msgsearch/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Search your iMessage history by meaning, entirely on your own machine.
|
|
2
|
+
|
|
3
|
+
The pipeline, in the order data flows through it:
|
|
4
|
+
|
|
5
|
+
extract -> chat.db rows become readable messages, decoding the
|
|
6
|
+
`attributedBody` blob that holds ~86% of them
|
|
7
|
+
chunk -> messages become conversation windows, then the smaller
|
|
8
|
+
passages that actually get embedded
|
|
9
|
+
index -> passages become vectors; windows become a keyword index
|
|
10
|
+
search -> a query becomes ranked windows, via keyword search, vector
|
|
11
|
+
search, rank fusion and optional reranking
|
|
12
|
+
|
|
13
|
+
Nothing here sends message text anywhere. Both models run locally.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Decode the `message.attributedBody` blob (Apple NSArchiver 'typedstream').
|
|
2
|
+
|
|
3
|
+
~86% of rows in chat.db have text IS NULL and carry their content here instead.
|
|
4
|
+
|
|
5
|
+
Layout, per hexdump of a real row:
|
|
6
|
+
|
|
7
|
+
04 0b "streamtyped" 81 e8 03 ... "NSString" 01 94 84 01 2b <len> <utf8 bytes>
|
|
8
|
+
'+' ^ varint length
|
|
9
|
+
|
|
10
|
+
The 0x2b ('+') marker introduces a length-prefixed byte string. Length is one
|
|
11
|
+
byte when < 0x80, otherwise 0x81 => uint16 LE, 0x82 => uint32 LE.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import struct
|
|
15
|
+
|
|
16
|
+
_MARKER = b"NSString"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _read_len(buf, i):
|
|
20
|
+
"""Return (length, next_index) for the varint length at buf[i]."""
|
|
21
|
+
n = buf[i]
|
|
22
|
+
if n < 0x80:
|
|
23
|
+
return n, i + 1
|
|
24
|
+
if n == 0x81:
|
|
25
|
+
return struct.unpack_from("<H", buf, i + 1)[0], i + 3
|
|
26
|
+
if n == 0x82:
|
|
27
|
+
return struct.unpack_from("<I", buf, i + 1)[0], i + 5
|
|
28
|
+
return None, i + 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def decode(blob):
|
|
32
|
+
"""Extract the message text from an attributedBody blob, or None."""
|
|
33
|
+
if not blob:
|
|
34
|
+
return None
|
|
35
|
+
buf = bytes(blob)
|
|
36
|
+
|
|
37
|
+
start = buf.find(_MARKER)
|
|
38
|
+
if start == -1:
|
|
39
|
+
return None
|
|
40
|
+
i = buf.find(b"\x2b", start + len(_MARKER))
|
|
41
|
+
if i == -1:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
length, i = _read_len(buf, i + 1)
|
|
45
|
+
if not length or i + length > len(buf):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
raw = buf[i : i + length]
|
|
49
|
+
# Runs of the string are UTF-8; a leading BOM-ish 0xff 0xfe marks UTF-16.
|
|
50
|
+
if raw[:2] == b"\xff\xfe":
|
|
51
|
+
return raw[2:].decode("utf-16-le", errors="replace")
|
|
52
|
+
return raw.decode("utf-8", errors="replace")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def message_text(text, blob):
|
|
56
|
+
"""Prefer the plain `text` column, fall back to decoding the blob."""
|
|
57
|
+
if text is not None:
|
|
58
|
+
return text
|
|
59
|
+
return decode(blob)
|
msgsearch/chunk.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Group messages into conversation windows.
|
|
2
|
+
|
|
3
|
+
Embedding messages one at a time does not work for this problem. The thing you
|
|
4
|
+
want to find is often a reply whose text has nothing in common with your query:
|
|
5
|
+
someone asks for a login, and two minutes later a message arrives containing an
|
|
6
|
+
email address and a password but neither the word "login" nor the name of the
|
|
7
|
+
service. Searched alone, that message is unreachable. Searched as part of the
|
|
8
|
+
exchange it belongs to, it is easy to find.
|
|
9
|
+
|
|
10
|
+
So the unit of retrieval is a window: a run of messages in one conversation with
|
|
11
|
+
no long pause in it. Windows are cut where a real pause happened, and only split
|
|
12
|
+
further when they are too long for the embedding model to read.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from itertools import pairwise
|
|
22
|
+
|
|
23
|
+
from . import config, tagging
|
|
24
|
+
from .extract import Message
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Window:
|
|
29
|
+
window_id: str
|
|
30
|
+
chat_id: int
|
|
31
|
+
chat_label: str
|
|
32
|
+
start: datetime
|
|
33
|
+
end: datetime
|
|
34
|
+
messages: tuple[Message, ...]
|
|
35
|
+
|
|
36
|
+
# What the embedding model reads. Link-only messages are left out because
|
|
37
|
+
# a bare URL has no meaning for the model to work with.
|
|
38
|
+
embed_text: str
|
|
39
|
+
|
|
40
|
+
# What keyword search reads, and what is shown to the user. Nothing is left
|
|
41
|
+
# out here, so a URL someone sent is still findable by typing part of it.
|
|
42
|
+
search_text: str
|
|
43
|
+
|
|
44
|
+
# (message rowid, start offset, end offset) into search_text, so a result can
|
|
45
|
+
# highlight the message that matched while displaying its whole context.
|
|
46
|
+
offsets: tuple[tuple[int, int, int], ...]
|
|
47
|
+
|
|
48
|
+
speakers: tuple[str, ...]
|
|
49
|
+
tags: frozenset
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_one_sided(self) -> bool:
|
|
53
|
+
"""True if only one person spoke, which is usually a link dump."""
|
|
54
|
+
return len(set(self.speakers)) <= 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _line_cost(message: Message) -> int:
|
|
58
|
+
"""Characters a message contributes once rendered as `Speaker: text`."""
|
|
59
|
+
return len(message.speaker) + 2 + len(message.text) + 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _rendered_cost(messages: Sequence[Message]) -> int:
|
|
63
|
+
"""Length of these messages once rendered, including the date headers.
|
|
64
|
+
|
|
65
|
+
The headers have to be counted here. Leaving them out lets a window creep
|
|
66
|
+
past the budget by a few characters per day it spans.
|
|
67
|
+
"""
|
|
68
|
+
total = 0
|
|
69
|
+
day = None
|
|
70
|
+
for message in messages:
|
|
71
|
+
this_day = message.timestamp.strftime("%Y-%m-%d")
|
|
72
|
+
if this_day != day:
|
|
73
|
+
total += len(f"[{this_day}]\n")
|
|
74
|
+
day = this_day
|
|
75
|
+
total += _line_cost(message)
|
|
76
|
+
return total
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _render(messages: Sequence[Message]) -> tuple[str, tuple[tuple[int, int, int], ...]]:
|
|
80
|
+
"""Render messages as dated, speaker-labelled lines.
|
|
81
|
+
|
|
82
|
+
Returns the text and, for each message, where its body starts and ends inside
|
|
83
|
+
that text.
|
|
84
|
+
"""
|
|
85
|
+
parts: list[str] = []
|
|
86
|
+
offsets: list[tuple[int, int, int]] = []
|
|
87
|
+
position = 0
|
|
88
|
+
current_day = None
|
|
89
|
+
|
|
90
|
+
for message in messages:
|
|
91
|
+
day = message.timestamp.strftime("%Y-%m-%d")
|
|
92
|
+
if day != current_day:
|
|
93
|
+
header = f"[{day}]\n"
|
|
94
|
+
parts.append(header)
|
|
95
|
+
position += len(header)
|
|
96
|
+
current_day = day
|
|
97
|
+
|
|
98
|
+
prefix = f"{message.speaker}: "
|
|
99
|
+
line = f"{prefix}{message.text}\n"
|
|
100
|
+
body_start = position + len(prefix)
|
|
101
|
+
parts.append(line)
|
|
102
|
+
position += len(line)
|
|
103
|
+
offsets.append((message.rowid, body_start, position - 1))
|
|
104
|
+
|
|
105
|
+
return "".join(parts), tuple(offsets)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _split_on_pauses(
|
|
109
|
+
messages: Sequence[Message], gap_seconds: int
|
|
110
|
+
) -> list[list[Message]]:
|
|
111
|
+
"""Cut a conversation wherever nobody spoke for longer than `gap_seconds`."""
|
|
112
|
+
groups: list[list[Message]] = []
|
|
113
|
+
current: list[Message] = [messages[0]]
|
|
114
|
+
|
|
115
|
+
for previous, message in pairwise(messages):
|
|
116
|
+
if (message.timestamp - previous.timestamp).total_seconds() > gap_seconds:
|
|
117
|
+
groups.append(current)
|
|
118
|
+
current = []
|
|
119
|
+
current.append(message)
|
|
120
|
+
|
|
121
|
+
groups.append(current)
|
|
122
|
+
return groups
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _split_oversized(
|
|
126
|
+
messages: Sequence[Message], budget_chars: int, overlap: int
|
|
127
|
+
) -> list[list[Message]]:
|
|
128
|
+
"""Break a window that is too long for the embedding model to read.
|
|
129
|
+
|
|
130
|
+
Only windows over the budget are touched, which on real data is around 1% of
|
|
131
|
+
them. Each piece after the first repeats the last couple of messages of the
|
|
132
|
+
previous piece, so an exchange sitting on a boundary stays intact in one of
|
|
133
|
+
the two pieces.
|
|
134
|
+
"""
|
|
135
|
+
if _rendered_cost(messages) <= budget_chars:
|
|
136
|
+
return [list(messages)]
|
|
137
|
+
|
|
138
|
+
pieces: list[list[Message]] = []
|
|
139
|
+
current: list[Message] = []
|
|
140
|
+
|
|
141
|
+
for message in messages:
|
|
142
|
+
# Cost is recomputed against the real rendering rather than tracked
|
|
143
|
+
# incrementally, because a date header appears only on the first message
|
|
144
|
+
# of each day and that depends on where the piece boundaries fall. These
|
|
145
|
+
# windows are small, so the repeated work is not worth optimising away.
|
|
146
|
+
if current and _rendered_cost([*current, message]) > budget_chars:
|
|
147
|
+
pieces.append(current)
|
|
148
|
+
|
|
149
|
+
# Carry a little context forward, but never so much that the overlap
|
|
150
|
+
# alone fills the next piece.
|
|
151
|
+
tail = list(current[-overlap:]) if overlap else []
|
|
152
|
+
if _rendered_cost(tail) > budget_chars // 2:
|
|
153
|
+
tail = []
|
|
154
|
+
current = tail
|
|
155
|
+
|
|
156
|
+
current.append(message)
|
|
157
|
+
|
|
158
|
+
if current:
|
|
159
|
+
pieces.append(current)
|
|
160
|
+
return pieces
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_window(messages: Sequence[Message]) -> Window:
|
|
164
|
+
search_text, offsets = _render(messages)
|
|
165
|
+
|
|
166
|
+
# Link-only messages are dropped from the embedded text only. They stay in
|
|
167
|
+
# search_text, in the offsets and in the displayed result.
|
|
168
|
+
speaking = [m for m in messages if not tagging.is_bare_url(m.text)]
|
|
169
|
+
embed_text, _ = _render(speaking) if speaking else ("", ())
|
|
170
|
+
|
|
171
|
+
first = messages[0]
|
|
172
|
+
return Window(
|
|
173
|
+
window_id=f"{first.chat_id}:{first.rowid}",
|
|
174
|
+
chat_id=first.chat_id,
|
|
175
|
+
chat_label=first.chat_label,
|
|
176
|
+
start=first.timestamp,
|
|
177
|
+
end=messages[-1].timestamp,
|
|
178
|
+
messages=tuple(messages),
|
|
179
|
+
embed_text=embed_text,
|
|
180
|
+
search_text=search_text,
|
|
181
|
+
offsets=offsets,
|
|
182
|
+
speakers=tuple(m.speaker for m in messages),
|
|
183
|
+
tags=frozenset().union(*(tagging.tags(m.text) for m in messages)),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class Passage:
|
|
189
|
+
"""A small slice of a window, and the thing that actually gets embedded.
|
|
190
|
+
|
|
191
|
+
A window is the right unit to show a person, because it carries the context
|
|
192
|
+
that makes a result make sense. It is the wrong unit to embed, because a
|
|
193
|
+
single vector for thirty messages on eight topics represents none of them
|
|
194
|
+
well. Passages slide across the window in small overlapping steps so that
|
|
195
|
+
every message sits near the middle of at least one of them, and each passage
|
|
196
|
+
remembers which window it belongs to.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
passage_id: str
|
|
200
|
+
window_id: str
|
|
201
|
+
chat_id: int
|
|
202
|
+
text: str
|
|
203
|
+
rowids: tuple[int, ...]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def passages(
|
|
207
|
+
window: Window, size: int | None = None, stride: int | None = None
|
|
208
|
+
) -> list[Passage]:
|
|
209
|
+
"""Slice a window into overlapping passages for embedding."""
|
|
210
|
+
size = size if size is not None else config.PASSAGE_MESSAGES
|
|
211
|
+
stride = stride if stride is not None else config.PASSAGE_STRIDE
|
|
212
|
+
|
|
213
|
+
# Link-only messages are dropped here for the same reason they are dropped
|
|
214
|
+
# from embed_text: a bare URL gives the model nothing to work with.
|
|
215
|
+
speaking = [m for m in window.messages if not tagging.is_bare_url(m.text)]
|
|
216
|
+
if not speaking:
|
|
217
|
+
return []
|
|
218
|
+
|
|
219
|
+
if len(speaking) <= size:
|
|
220
|
+
starts = [0]
|
|
221
|
+
else:
|
|
222
|
+
starts = list(range(0, len(speaking) - size + 1, stride))
|
|
223
|
+
# Make sure the final messages are covered even when the stride does not
|
|
224
|
+
# divide the window evenly.
|
|
225
|
+
last = len(speaking) - size
|
|
226
|
+
if starts[-1] != last:
|
|
227
|
+
starts.append(last)
|
|
228
|
+
|
|
229
|
+
out = []
|
|
230
|
+
for start in starts:
|
|
231
|
+
selected = speaking[start : start + size]
|
|
232
|
+
text, _ = _render(selected)
|
|
233
|
+
out.append(
|
|
234
|
+
Passage(
|
|
235
|
+
passage_id=f"{window.window_id}#{start}",
|
|
236
|
+
window_id=window.window_id,
|
|
237
|
+
chat_id=window.chat_id,
|
|
238
|
+
text=text,
|
|
239
|
+
rowids=tuple(m.rowid for m in selected),
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def windows(
|
|
246
|
+
messages: Iterable[Message],
|
|
247
|
+
gap_seconds: int | None = None,
|
|
248
|
+
token_budget: int | None = None,
|
|
249
|
+
overlap: int | None = None,
|
|
250
|
+
) -> Iterator[Window]:
|
|
251
|
+
"""Group messages into conversation windows, oldest first."""
|
|
252
|
+
gap_seconds = gap_seconds if gap_seconds is not None else config.WINDOW_GAP_SECONDS
|
|
253
|
+
token_budget = (
|
|
254
|
+
token_budget if token_budget is not None else config.WINDOW_TOKEN_BUDGET
|
|
255
|
+
)
|
|
256
|
+
overlap = overlap if overlap is not None else config.WINDOW_OVERLAP_MESSAGES
|
|
257
|
+
budget_chars = token_budget * config.CHARS_PER_TOKEN
|
|
258
|
+
|
|
259
|
+
by_chat: dict[int, list[Message]] = defaultdict(list)
|
|
260
|
+
for message in messages:
|
|
261
|
+
by_chat[message.chat_id].append(message)
|
|
262
|
+
|
|
263
|
+
for chat_id in sorted(by_chat):
|
|
264
|
+
conversation = sorted(by_chat[chat_id], key=lambda m: m.timestamp)
|
|
265
|
+
for group in _split_on_pauses(conversation, gap_seconds):
|
|
266
|
+
for piece in _split_oversized(group, budget_chars, overlap):
|
|
267
|
+
if piece:
|
|
268
|
+
yield build_window(piece)
|
msgsearch/cli.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
All argument parsing lives here so the other modules stay importable libraries
|
|
4
|
+
with no opinion about how they are invoked. One command with subcommands, rather
|
|
5
|
+
than a directory of scripts, because it gives people a single thing to remember
|
|
6
|
+
and a single place to find help:
|
|
7
|
+
|
|
8
|
+
msgsearch doctor check this machine is set up correctly
|
|
9
|
+
msgsearch explore what is in your database
|
|
10
|
+
msgsearch index build the search index
|
|
11
|
+
msgsearch search "a query" search it
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import __version__, config
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _add_index_command(subparsers) -> None:
|
|
24
|
+
parser = subparsers.add_parser(
|
|
25
|
+
"index",
|
|
26
|
+
help="build the search index",
|
|
27
|
+
description="Read the message database, group messages into conversation "
|
|
28
|
+
"windows, embed the passages inside them, and write the index.",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--chat",
|
|
32
|
+
default=config.TESTBED_CHAT,
|
|
33
|
+
metavar="ID",
|
|
34
|
+
help="restrict to one conversation, by phone number or email "
|
|
35
|
+
"(default: $MSGSEARCH_TESTBED_CHAT, or all conversations)",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument("--index-dir", metavar="DIR", help="where to write the index")
|
|
38
|
+
parser.add_argument("--batch-size", type=int, default=32, metavar="N")
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--rebuild",
|
|
41
|
+
action="store_true",
|
|
42
|
+
help="re-embed everything instead of reusing unchanged passages",
|
|
43
|
+
)
|
|
44
|
+
parser.set_defaults(handler=_run_index)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _run_index(args) -> int:
|
|
48
|
+
from .index import build
|
|
49
|
+
|
|
50
|
+
build(
|
|
51
|
+
chat_identifier=args.chat,
|
|
52
|
+
index_dir=args.index_dir,
|
|
53
|
+
batch_size=args.batch_size,
|
|
54
|
+
rebuild=args.rebuild,
|
|
55
|
+
)
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _add_search_command(subparsers) -> None:
|
|
60
|
+
parser = subparsers.add_parser(
|
|
61
|
+
"search",
|
|
62
|
+
help="search the index",
|
|
63
|
+
description="Find conversations by meaning as well as by keyword.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument("query", nargs="+", help="what you are looking for")
|
|
66
|
+
parser.add_argument("--limit", type=int, default=config.DEFAULT_LIMIT, metavar="N")
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--chat", metavar="TEXT", help="restrict to matching conversations"
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--from", dest="from_", metavar="WHO", help="restrict to a speaker"
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument("--after", metavar="YYYY-MM-DD")
|
|
74
|
+
parser.add_argument("--before", metavar="YYYY-MM-DD")
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--type",
|
|
77
|
+
metavar="TAG",
|
|
78
|
+
help="restrict by shape: credential, credential_talk, email, phone, url, address",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument("--index-dir", metavar="DIR")
|
|
81
|
+
parser.add_argument("--snippet", type=int, default=1200, metavar="CHARS")
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--full", action="store_true", help="show the whole window, not just the match"
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--rerank",
|
|
87
|
+
dest="no_rerank",
|
|
88
|
+
action="store_false",
|
|
89
|
+
default=not config.RERANK_ENABLED,
|
|
90
|
+
help="run the cross-encoder (off by default; it measurably hurts)",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument("--no-rerank", dest="no_rerank", action="store_true")
|
|
93
|
+
parser.add_argument("--no-dense", action="store_true", help="keyword search only")
|
|
94
|
+
parser.add_argument("--no-bm25", action="store_true", help="vector search only")
|
|
95
|
+
parser.set_defaults(handler=_run_search)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _run_search(args) -> int:
|
|
99
|
+
from .search import format_result, search
|
|
100
|
+
|
|
101
|
+
query = " ".join(args.query)
|
|
102
|
+
try:
|
|
103
|
+
results = search(query, args)
|
|
104
|
+
except FileNotFoundError as error:
|
|
105
|
+
print(error, file=sys.stderr)
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
if not results:
|
|
109
|
+
print("no results")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
print(f"{len(results)} result(s) for {query!r}")
|
|
113
|
+
for position, result in enumerate(results, start=1):
|
|
114
|
+
print(format_result(position, result, args.snippet, full=args.full))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _add_contacts_command(subparsers) -> None:
|
|
119
|
+
parser = subparsers.add_parser(
|
|
120
|
+
"contacts",
|
|
121
|
+
help="map phone numbers and emails to names",
|
|
122
|
+
description="Speakers appear by name rather than by phone number, which "
|
|
123
|
+
"reads better and embeds better. Names come from an alias file you "
|
|
124
|
+
"control; this command creates and inspects it.",
|
|
125
|
+
)
|
|
126
|
+
parser.add_argument(
|
|
127
|
+
"--import",
|
|
128
|
+
dest="import_path",
|
|
129
|
+
metavar="FILE.vcf",
|
|
130
|
+
help="merge names from a vCard export (Contacts.app: File -> Export)",
|
|
131
|
+
)
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--from-addressbook",
|
|
134
|
+
action="store_true",
|
|
135
|
+
help="merge names from macOS Contacts (needs Contacts permission)",
|
|
136
|
+
)
|
|
137
|
+
parser.add_argument(
|
|
138
|
+
"--template",
|
|
139
|
+
type=int,
|
|
140
|
+
nargs="?",
|
|
141
|
+
const=25,
|
|
142
|
+
metavar="N",
|
|
143
|
+
help="write an alias file stub for the N busiest unnamed handles, for you "
|
|
144
|
+
"to fill in (default 25)",
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--unresolved",
|
|
148
|
+
type=int,
|
|
149
|
+
nargs="?",
|
|
150
|
+
const=25,
|
|
151
|
+
metavar="N",
|
|
152
|
+
help="list the N busiest handles that still have no name (default 25)",
|
|
153
|
+
)
|
|
154
|
+
parser.set_defaults(handler=_run_contacts)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _run_contacts(args) -> int:
|
|
158
|
+
from . import contacts as contacts_module
|
|
159
|
+
from .extract import connect
|
|
160
|
+
|
|
161
|
+
path = contacts_module.aliases_path()
|
|
162
|
+
mapping = contacts_module.load_mapping()
|
|
163
|
+
|
|
164
|
+
imported = {}
|
|
165
|
+
if args.import_path:
|
|
166
|
+
imported = contacts_module.read_vcard_file(Path(args.import_path))
|
|
167
|
+
print(f"read {len(imported):,} handles from {args.import_path}")
|
|
168
|
+
elif args.from_addressbook:
|
|
169
|
+
try:
|
|
170
|
+
imported = contacts_module.read_addressbook()
|
|
171
|
+
except (PermissionError, FileNotFoundError) as error:
|
|
172
|
+
print(error, file=sys.stderr)
|
|
173
|
+
return 1
|
|
174
|
+
print(f"read {len(imported):,} handles from macOS Contacts")
|
|
175
|
+
|
|
176
|
+
if imported:
|
|
177
|
+
added = {k: v for k, v in imported.items() if k not in mapping}
|
|
178
|
+
mapping.update(imported)
|
|
179
|
+
contacts_module.save(mapping, path)
|
|
180
|
+
print(f"wrote {len(mapping):,} names to {path} ({len(added):,} new)")
|
|
181
|
+
|
|
182
|
+
# Report each source separately, because "no names" has two very different
|
|
183
|
+
# causes: the Contacts permission is not granted, or it is granted and simply
|
|
184
|
+
# has no entry for these handles.
|
|
185
|
+
try:
|
|
186
|
+
from_contacts = contacts_module.read_addressbook()
|
|
187
|
+
contacts_status = f"{len(from_contacts):,} entries"
|
|
188
|
+
except PermissionError:
|
|
189
|
+
from_contacts = {}
|
|
190
|
+
contacts_status = (
|
|
191
|
+
"not permitted — grant Contacts access to the app running msgsearch "
|
|
192
|
+
"under System Settings > Privacy & Security > Contacts"
|
|
193
|
+
)
|
|
194
|
+
except (FileNotFoundError, OSError) as error:
|
|
195
|
+
from_contacts = {}
|
|
196
|
+
contacts_status = str(error)
|
|
197
|
+
|
|
198
|
+
lookup = contacts_module.Contacts.load()
|
|
199
|
+
db = connect()
|
|
200
|
+
try:
|
|
201
|
+
volumes = contacts_module.handle_volumes(db)
|
|
202
|
+
finally:
|
|
203
|
+
db.close()
|
|
204
|
+
|
|
205
|
+
named = [(h, n) for h, n in volumes if lookup.name(h)]
|
|
206
|
+
unnamed = [(h, n) for h, n in volumes if not lookup.name(h)]
|
|
207
|
+
covered = sum(n for _, n in named)
|
|
208
|
+
total = sum(n for _, n in volumes) or 1
|
|
209
|
+
|
|
210
|
+
overrides = {k: v for k, v in mapping.items() if v}
|
|
211
|
+
print(f"\nmacOS Contacts : {contacts_status}")
|
|
212
|
+
print(f"alias file : {len(overrides):,} names in {path}")
|
|
213
|
+
print(
|
|
214
|
+
f"resolved : {len(named):,} of {len(volumes):,} handles, "
|
|
215
|
+
f"covering {100 * covered / total:.1f}% of received messages"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if args.template:
|
|
219
|
+
stub = dict(mapping)
|
|
220
|
+
for handle, _ in unnamed[: args.template]:
|
|
221
|
+
stub.setdefault(handle, "")
|
|
222
|
+
contacts_module.save(stub, path)
|
|
223
|
+
print(
|
|
224
|
+
f"\nwrote a stub for {min(args.template, len(unnamed))} handles to {path}"
|
|
225
|
+
"\nFill in the names and re-run 'msgsearch index'. Entries left empty "
|
|
226
|
+
"are ignored."
|
|
227
|
+
)
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
if args.unresolved:
|
|
231
|
+
print(f"\nbusiest handles with no name (top {args.unresolved}):")
|
|
232
|
+
for handle, count in unnamed[: args.unresolved]:
|
|
233
|
+
print(f" {count:>7,} {handle}")
|
|
234
|
+
print(f"\nAdd them to {path} as a JSON object of handle -> name.")
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _add_doctor_command(subparsers) -> None:
|
|
239
|
+
parser = subparsers.add_parser(
|
|
240
|
+
"doctor",
|
|
241
|
+
help="check that this machine is set up correctly",
|
|
242
|
+
description="Verify the interpreter, PyTorch, the message database, the "
|
|
243
|
+
"embedding model and the index, and say how to fix whatever is wrong. "
|
|
244
|
+
"Prints no message content.",
|
|
245
|
+
)
|
|
246
|
+
parser.set_defaults(handler=_run_doctor)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _run_doctor(args) -> int:
|
|
250
|
+
from .doctor import run
|
|
251
|
+
|
|
252
|
+
return run()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _add_explore_command(subparsers) -> None:
|
|
256
|
+
parser = subparsers.add_parser(
|
|
257
|
+
"explore",
|
|
258
|
+
help="report what is in a message database",
|
|
259
|
+
description="Structural reconnaissance: message counts, how many rows hide "
|
|
260
|
+
"their text in attributedBody, the date range, and the busiest chats. "
|
|
261
|
+
"Prints no message content.",
|
|
262
|
+
)
|
|
263
|
+
parser.set_defaults(handler=_run_explore)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _run_explore(args) -> int:
|
|
267
|
+
from .explore import main as explore_main
|
|
268
|
+
|
|
269
|
+
explore_main()
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
274
|
+
parser = argparse.ArgumentParser(
|
|
275
|
+
prog="msgsearch",
|
|
276
|
+
description="Search your iMessage history by meaning, entirely on your "
|
|
277
|
+
"own machine.",
|
|
278
|
+
epilog="Run 'msgsearch <command> --help' for details of a command.",
|
|
279
|
+
)
|
|
280
|
+
parser.add_argument("--version", action="version", version=f"msgsearch {__version__}")
|
|
281
|
+
|
|
282
|
+
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
|
283
|
+
_add_contacts_command(subparsers)
|
|
284
|
+
_add_doctor_command(subparsers)
|
|
285
|
+
_add_explore_command(subparsers)
|
|
286
|
+
_add_index_command(subparsers)
|
|
287
|
+
_add_search_command(subparsers)
|
|
288
|
+
return parser
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main(argv: list[str] | None = None) -> int:
|
|
292
|
+
parser = build_parser()
|
|
293
|
+
args = parser.parse_args(argv)
|
|
294
|
+
if not getattr(args, "handler", None):
|
|
295
|
+
parser.print_help()
|
|
296
|
+
return 1
|
|
297
|
+
try:
|
|
298
|
+
return args.handler(args)
|
|
299
|
+
except KeyboardInterrupt:
|
|
300
|
+
return 130
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__":
|
|
304
|
+
raise SystemExit(main())
|