personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4"""Tests for think.importers.kindle — Kindle My Clippings.txt importer."""
5
6import os
7import tempfile
8from pathlib import Path
9
10from solstone.think.entities.observations import load_observations
11from solstone.think.importers.kindle import KindleImporter, _parse_block, _parse_date
12
13importer = KindleImporter()
14
15
16def _make_clipping(
17 title: str = "Test Book (Author Name)",
18 meta: str = "- Your Highlight on page 42 | location 100-101 | Added on Saturday, March 15, 2025 10:30:00 AM",
19 content: str = "This is a highlighted passage.",
20) -> str:
21 return f"{title}\n{meta}\n\n{content}\n"
22
23
24def _make_clippings_file(clippings: list[str]) -> str:
25 return "==========\n".join(clippings) + "==========\n"
26
27
28# --- Unit tests for helpers ---
29
30
31def test_parse_date():
32 result = _parse_date("Saturday, March 15, 2025 10:30:00 AM")
33 assert result is not None
34 assert result.month == 3
35 assert result.day == 15
36
37
38def test_parse_block_basic():
39 block = _make_clipping()
40 entry = _parse_block(block)
41 assert entry is not None
42 assert entry["type"] == "highlight"
43 assert entry["book_title"] == "Test Book"
44 assert entry["author"] == "Author Name"
45 assert entry["content"] == "This is a highlighted passage."
46 assert entry["page"] == 42
47 assert entry["location"] == "100-101"
48
49
50def test_parse_block_note():
51 block = _make_clipping(
52 meta="- Your Note on page 10 | Added on Saturday, March 15, 2025 10:30:00 AM",
53 content="My personal note",
54 )
55 entry = _parse_block(block)
56 assert entry is not None
57 assert entry["clip_type"] == "note"
58
59
60def test_parse_block_no_author():
61 block = _make_clipping(title="Title Without Author")
62 entry = _parse_block(block)
63 assert entry is not None
64 assert entry["book_title"] == "Title Without Author"
65 assert entry["author"] == ""
66
67
68# --- Detection tests ---
69
70
71def test_detect_valid():
72 content = _make_clippings_file([_make_clipping()])
73 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
74 f.write(content)
75 f.flush()
76 try:
77 assert importer.detect(Path(f.name)) is True
78 finally:
79 os.unlink(f.name)
80
81
82def test_detect_wrong_format():
83 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
84 f.write("Just some random text file.\n")
85 f.flush()
86 try:
87 assert importer.detect(Path(f.name)) is False
88 finally:
89 os.unlink(f.name)
90
91
92# --- Preview tests ---
93
94
95def test_preview():
96 content = _make_clippings_file(
97 [
98 _make_clipping(),
99 _make_clipping(
100 title="Another Book (Jane Doe)",
101 meta="- Your Note on page 5 | Added on Sunday, March 16, 2025 02:00:00 PM",
102 content="A note",
103 ),
104 ]
105 )
106 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
107 f.write(content)
108 f.flush()
109 try:
110 preview = importer.preview(Path(f.name))
111 assert preview.item_count == 2
112 assert preview.entity_count > 0
113 assert "2 books" in preview.summary
114 finally:
115 os.unlink(f.name)
116
117
118# --- Process tests ---
119
120
121def test_process_basic(monkeypatch):
122 content = _make_clippings_file(
123 [
124 _make_clipping(),
125 _make_clipping(
126 meta="- Your Highlight on page 43 | Added on Saturday, March 15, 2025 10:31:00 AM",
127 content="Another highlight from same session.",
128 ),
129 ]
130 )
131 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
132 f.write(content)
133 f.flush()
134 try:
135 with tempfile.TemporaryDirectory() as journal:
136 monkeypatch.setenv("SOLSTONE_JOURNAL", journal)
137 result = importer.process(Path(f.name), Path(journal))
138 assert result.entries_written == 2
139 assert result.errors == []
140 assert result.segments is not None
141 assert len(result.segments) >= 1
142
143 md_path = Path(result.files_created[0])
144 assert md_path.exists()
145 assert md_path.name == "highlights_transcript.md"
146 md = md_path.read_text()
147 assert "Test Book" in md
148 assert "> This is a highlighted passage." in md
149 assert "Page 42" in md
150 finally:
151 os.unlink(f.name)
152
153
154def test_process_multiple_windows(monkeypatch):
155 """Highlights more than 5 minutes apart land in different segments."""
156 content = _make_clippings_file(
157 [
158 _make_clipping(
159 meta="- Your Highlight on page 1 | Added on Saturday, March 15, 2025 10:00:00 AM",
160 content="First highlight",
161 ),
162 _make_clipping(
163 meta="- Your Highlight on page 2 | Added on Saturday, March 15, 2025 10:10:00 AM",
164 content="Second highlight, 10 min later",
165 ),
166 ]
167 )
168 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
169 f.write(content)
170 f.flush()
171 try:
172 with tempfile.TemporaryDirectory() as journal:
173 monkeypatch.setenv("SOLSTONE_JOURNAL", journal)
174 result = importer.process(Path(f.name), Path(journal))
175 assert result.entries_written == 2
176 assert result.segments is not None
177 assert len(result.segments) == 2
178 assert len(result.files_created) == 2
179 finally:
180 os.unlink(f.name)
181
182
183def test_process_note_markdown(monkeypatch):
184 """Notes render with Note: prefix instead of blockquote."""
185 content = _make_clippings_file(
186 [
187 _make_clipping(
188 meta="- Your Note on page 10 | Added on Saturday, March 15, 2025 10:30:00 AM",
189 content="My personal note",
190 ),
191 ]
192 )
193 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
194 f.write(content)
195 f.flush()
196 try:
197 with tempfile.TemporaryDirectory() as journal:
198 monkeypatch.setenv("SOLSTONE_JOURNAL", journal)
199 result = importer.process(Path(f.name), Path(journal))
200 md = Path(result.files_created[0]).read_text()
201 assert "Note: My personal note" in md
202 finally:
203 os.unlink(f.name)
204
205
206def test_observations_author_of(tmp_path, monkeypatch):
207 content = _make_clippings_file([_make_clipping()])
208 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
209 f.write(content)
210 f.flush()
211 try:
212 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
213 importer.process(Path(f.name), tmp_path, facet="test.kindle")
214 author_obs = load_observations("test.kindle", "Author Name")
215 author_contents = [o["content"] for o in author_obs]
216 assert "Author of Test Book (via Kindle, 2025-03-15)" in author_contents
217 finally:
218 os.unlink(f.name)
219
220
221def test_observations_by_author(tmp_path, monkeypatch):
222 content = _make_clippings_file([_make_clipping()])
223 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
224 f.write(content)
225 f.flush()
226 try:
227 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
228 importer.process(Path(f.name), tmp_path, facet="test.kindle")
229 book_obs = load_observations("test.kindle", "Test Book")
230 book_contents = [o["content"] for o in book_obs]
231 assert "By Author Name (via Kindle, 2025-03-15)" in book_contents
232 finally:
233 os.unlink(f.name)
234
235
236def test_observations_engagement(tmp_path, monkeypatch):
237 content = _make_clippings_file(
238 [
239 _make_clipping(
240 meta="- Your Highlight on page 42 | location 100-101 | Added on Saturday, March 15, 2025 10:30:00 AM",
241 ),
242 _make_clipping(
243 meta="- Your Highlight on page 43 | location 102-103 | Added on Saturday, March 15, 2025 10:31:00 AM",
244 content="Second highlight.",
245 ),
246 _make_clipping(
247 meta="- Your Note on page 44 | location 104 | Added on Saturday, March 15, 2025 10:32:00 AM",
248 content="A note.",
249 ),
250 ]
251 )
252 highlights_only = _make_clippings_file(
253 [
254 _make_clipping(
255 meta="- Your Highlight on page 42 | location 100-101 | Added on Saturday, March 15, 2025 10:30:00 AM",
256 ),
257 _make_clipping(
258 meta="- Your Highlight on page 43 | location 102-103 | Added on Saturday, March 15, 2025 10:31:00 AM",
259 content="Second highlight.",
260 ),
261 ]
262 )
263
264 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
265 f.write(content)
266 f.flush()
267 try:
268 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
269 importer.process(Path(f.name), tmp_path, facet="test.kindle")
270 book_obs = load_observations("test.kindle", "Test Book")
271 book_contents = [o["content"] for o in book_obs]
272 assert "2 highlights, 1 notes (via Kindle, 2025-03-15)" in book_contents
273 finally:
274 os.unlink(f.name)
275
276 second_tmp_path = tmp_path / "highlights_only"
277 second_tmp_path.mkdir()
278 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
279 f.write(highlights_only)
280 f.flush()
281 try:
282 monkeypatch.setenv("SOLSTONE_JOURNAL", str(second_tmp_path))
283 importer.process(Path(f.name), second_tmp_path, facet="test.kindle")
284 book_obs = load_observations("test.kindle", "Test Book")
285 book_contents = [o["content"] for o in book_obs]
286 assert "2 highlights (via Kindle, 2025-03-15)" in book_contents
287 finally:
288 os.unlink(f.name)
289
290
291def test_observations_multi_book_author(tmp_path, monkeypatch):
292 content = _make_clippings_file(
293 [
294 _make_clipping(title="Test Book (Author Name)"),
295 _make_clipping(title="Second Book (Author Name)"),
296 ]
297 )
298 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
299 f.write(content)
300 f.flush()
301 try:
302 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
303 importer.process(Path(f.name), tmp_path, facet="test.kindle")
304 author_obs = load_observations("test.kindle", "Author Name")
305 author_contents = [o["content"] for o in author_obs]
306 assert "Author of Test Book (via Kindle, 2025-03-15)" in author_contents
307 assert "Author of Second Book (via Kindle, 2025-03-15)" in author_contents
308 finally:
309 os.unlink(f.name)
310
311
312def test_observations_no_author(tmp_path, monkeypatch):
313 content = _make_clippings_file([_make_clipping(title="Title Without Author")])
314 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
315 f.write(content)
316 f.flush()
317 try:
318 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
319 importer.process(Path(f.name), tmp_path, facet="test.kindle")
320 book_obs = load_observations("test.kindle", "Title Without Author")
321 book_contents = [o["content"] for o in book_obs]
322 assert not any(c.startswith("By ") for c in book_contents)
323 author_entities_dir = tmp_path / "facets" / "test.kindle" / "entities"
324 if author_entities_dir.exists():
325 entity_names = {
326 entity_dir.name
327 for entity_dir in author_entities_dir.iterdir()
328 if entity_dir.is_dir()
329 }
330 assert "author_name" not in entity_names
331 finally:
332 os.unlink(f.name)
333
334
335def test_observations_dedup_on_reimport(tmp_path, monkeypatch):
336 content = _make_clippings_file([_make_clipping()])
337 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
338 f.write(content)
339 f.flush()
340 try:
341 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
342 importer.process(Path(f.name), tmp_path, facet="test.kindle")
343 first = load_observations("test.kindle", "Test Book")
344 first_by_author = [
345 o
346 for o in first
347 if o["content"] == "By Author Name (via Kindle, 2025-03-15)"
348 ]
349 assert len(first_by_author) == 1
350
351 importer.process(Path(f.name), tmp_path, facet="test.kindle")
352 second = load_observations("test.kindle", "Test Book")
353 second_by_author = [
354 o
355 for o in second
356 if o["content"] == "By Author Name (via Kindle, 2025-03-15)"
357 ]
358 assert len(second_by_author) == 1
359 finally:
360 os.unlink(f.name)
361
362
363def test_observations_engagement_notes_only(tmp_path, monkeypatch):
364 """Notes-only book gets notes count without highlights."""
365 content = _make_clippings_file(
366 [
367 _make_clipping(
368 meta="- Your Note on page 10 | Added on Saturday, March 15, 2025 10:30:00 AM",
369 content="A note.",
370 ),
371 _make_clipping(
372 meta="- Your Note on page 11 | Added on Saturday, March 15, 2025 10:31:00 AM",
373 content="Another note.",
374 ),
375 ]
376 )
377 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
378 f.write(content)
379 f.flush()
380 try:
381 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
382 importer.process(Path(f.name), tmp_path, facet="test.kindle")
383 book_obs = load_observations("test.kindle", "Test Book")
384 book_contents = [o["content"] for o in book_obs]
385 assert "2 notes (via Kindle, 2025-03-15)" in book_contents
386 # No highlights count should appear
387 assert not any("highlights" in c for c in book_contents)
388 finally:
389 os.unlink(f.name)
390
391
392def test_observations_engagement_excludes_bookmarks(tmp_path, monkeypatch):
393 content = _make_clippings_file(
394 [
395 _make_clipping(
396 meta="- Your Highlight on page 42 | location 100-101 | Added on Saturday, March 15, 2025 10:30:00 AM",
397 ),
398 _make_clipping(
399 meta="- Your Bookmark on page 43 | location 102-103 | Added on Saturday, March 15, 2025 10:31:00 AM",
400 content="Saved bookmark.",
401 ),
402 ]
403 )
404 with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
405 f.write(content)
406 f.flush()
407 try:
408 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
409 importer.process(Path(f.name), tmp_path, facet="test.kindle")
410 book_obs = load_observations("test.kindle", "Test Book")
411 book_contents = [o["content"] for o in book_obs]
412 assert "1 highlights (via Kindle, 2025-03-15)" in book_contents
413 finally:
414 os.unlink(f.name)
415
416
417# --- Registry test ---
418
419
420def test_registered_in_registry():
421 from solstone.think.importers.file_importer import (
422 FILE_IMPORTER_REGISTRY,
423 get_file_importer,
424 )
425
426 assert "kindle" in FILE_IMPORTER_REGISTRY
427 imp = get_file_importer("kindle")
428 assert imp is not None
429 assert imp.name == "kindle"