personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / tests / test_kindle_importer.py
16 kB 429 lines
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"