deeptutor-notebook 1.7.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.
@@ -0,0 +1,744 @@
1
+ """
2
+ Shared notebook manager.
3
+
4
+ This module keeps the notebook storage format unchanged so Web and CLI
5
+ can operate on the same files under ``data/user``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from contextlib import ExitStack, contextmanager
12
+ from enum import Enum
13
+ import json
14
+ import logging
15
+ from pathlib import Path
16
+ import threading
17
+ import time
18
+ import uuid
19
+
20
+ from pydantic import BaseModel
21
+
22
+ from deeptutor.services.file_io import atomic_write_json
23
+ from deeptutor.services.llm import clean_thinking_tags
24
+ from deeptutor.services.path_service import get_path_service
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class RecordType(str, Enum):
30
+ """Notebook record type."""
31
+
32
+ SOLVE = "solve"
33
+ QUESTION = "question"
34
+ RESEARCH = "research"
35
+ CHAT = "chat"
36
+ CO_WRITER = "co_writer"
37
+ TUTORBOT = "tutorbot"
38
+ READING = "reading"
39
+ VIDEO_LEARNING = "video_learning"
40
+
41
+
42
+ class NotebookRecord(BaseModel):
43
+ """Single record stored in a notebook."""
44
+
45
+ id: str
46
+ type: RecordType
47
+ title: str
48
+ summary: str = ""
49
+ user_query: str
50
+ output: str
51
+ metadata: dict = {}
52
+ created_at: float
53
+ kb_name: str | None = None
54
+
55
+
56
+ class Notebook(BaseModel):
57
+ """Notebook model."""
58
+
59
+ id: str
60
+ name: str
61
+ description: str = ""
62
+ created_at: float
63
+ updated_at: float
64
+ records: list[NotebookRecord] = []
65
+ color: str = "#3B82F6"
66
+ icon: str = "book"
67
+
68
+
69
+ _UNSET = object()
70
+
71
+
72
+ class NotebookCorruptedError(RuntimeError):
73
+ """A notebook file exists on disk but its JSON could not be parsed.
74
+
75
+ Raised instead of silently reporting "no such notebook" so a damaged
76
+ file surfaces as a real error the caller can show, rather than as a
77
+ notebook that appears to have vanished while its data is still there.
78
+ """
79
+
80
+ def __init__(self, notebook_id: str, path: Path, cause: Exception) -> None:
81
+ super().__init__(f"Notebook {notebook_id!r} is unreadable ({path}): {cause}")
82
+ self.notebook_id = notebook_id
83
+ self.path = path
84
+ self.cause = cause
85
+
86
+
87
+ def _clean_record_summary(summary: str) -> str:
88
+ """Remove private model scratchpads before notebook summaries are persisted."""
89
+ return clean_thinking_tags(str(summary or "")).strip()
90
+
91
+
92
+ class NotebookManager:
93
+ """Manage notebook files stored under ``data/user/workspace/notebook``."""
94
+
95
+ def __init__(self, base_dir: str | None = None):
96
+ if base_dir is None:
97
+ path_service = get_path_service()
98
+ base_dir_path = path_service.get_notebook_dir()
99
+ else:
100
+ base_dir_path = Path(base_dir)
101
+
102
+ self.base_dir = base_dir_path
103
+ self.base_dir.mkdir(parents=True, exist_ok=True)
104
+ self.index_file = self.base_dir / "notebooks_index.json"
105
+ # One re-entrant lock per notebook, plus one for the shared index.
106
+ # Every read-modify-write cycle below runs under the matching lock so
107
+ # two concurrent saves cannot both load the same revision and clobber
108
+ # one another. Writes themselves go through ``atomic_write_json``, so
109
+ # a reader never observes a half-written file even across processes;
110
+ # the locks close the remaining same-process lost-update window.
111
+ # Lock order is always notebook-then-index, never the reverse.
112
+ self._locks_guard = threading.Lock()
113
+ self._notebook_locks: dict[str, threading.RLock] = {}
114
+ self._index_lock = threading.RLock()
115
+ self._ensure_index()
116
+
117
+ @contextmanager
118
+ def _locked(self, notebook_id: str) -> Iterator[None]:
119
+ """Hold the per-notebook lock for one read-modify-write cycle."""
120
+ with self._locks_guard:
121
+ lock = self._notebook_locks.get(notebook_id)
122
+ if lock is None:
123
+ lock = threading.RLock()
124
+ self._notebook_locks[notebook_id] = lock
125
+ with lock:
126
+ yield
127
+
128
+ def _ensure_index(self) -> None:
129
+ if not self.index_file.exists():
130
+ atomic_write_json(self.index_file, {"notebooks": []})
131
+
132
+ def _load_index(self) -> dict:
133
+ try:
134
+ with open(self.index_file, encoding="utf-8") as f:
135
+ return json.load(f)
136
+ except FileNotFoundError:
137
+ return {"notebooks": []}
138
+ except Exception as exc:
139
+ # The index is a derived cache: every entry can be rebuilt from the
140
+ # notebook files themselves, so recovering here loses nothing.
141
+ logger.warning("notebook index unreadable (%s); rebuilding", exc)
142
+ return {"notebooks": self._rebuild_index_entries()}
143
+
144
+ def _rebuild_index_entries(self) -> list[dict]:
145
+ """Reconstruct index rows by scanning the notebook files on disk."""
146
+ entries: list[dict] = []
147
+ for path in sorted(self.base_dir.glob("*.json")):
148
+ if path == self.index_file:
149
+ continue
150
+ try:
151
+ with open(path, encoding="utf-8") as f:
152
+ notebook = json.load(f)
153
+ except Exception as exc:
154
+ # The file is left untouched on disk, so the damage stays
155
+ # visible and repairable: list_notebooks() re-adopts it as an
156
+ # ``unreadable`` entry instead of the notebook quietly vanishing.
157
+ logger.warning(
158
+ "notebook file %s is unreadable (%s); excluded from the rebuilt index",
159
+ path,
160
+ exc,
161
+ )
162
+ continue
163
+ entries.append(self._index_row(notebook))
164
+ return entries
165
+
166
+ @staticmethod
167
+ def _index_row(notebook: dict) -> dict:
168
+ """Project a full notebook down to the fields the index carries."""
169
+ return {
170
+ "id": notebook["id"],
171
+ "name": notebook.get("name", ""),
172
+ "description": notebook.get("description", ""),
173
+ "created_at": notebook.get("created_at", 0.0),
174
+ "updated_at": notebook.get("updated_at", 0.0),
175
+ "record_count": len(notebook.get("records", [])),
176
+ "color": notebook.get("color", "#3B82F6"),
177
+ "icon": notebook.get("icon", "book"),
178
+ }
179
+
180
+ def _save_index(self, index: dict) -> None:
181
+ atomic_write_json(self.index_file, index)
182
+
183
+ def _get_notebook_file(self, notebook_id: str) -> Path:
184
+ base = self.base_dir.resolve()
185
+ target = (base / f"{notebook_id}.json").resolve()
186
+ if not target.is_relative_to(base):
187
+ raise ValueError(f"notebook id escapes base directory: {notebook_id!r}")
188
+ return target
189
+
190
+ def _load_notebook(self, notebook_id: str) -> dict | None:
191
+ """Return the notebook, or ``None`` when no such file exists.
192
+
193
+ A file that exists but cannot be parsed raises
194
+ :class:`NotebookCorruptedError` rather than returning ``None`` —
195
+ conflating the two is what made damaged notebooks look deleted.
196
+ """
197
+ filepath = self._get_notebook_file(notebook_id)
198
+ if not filepath.exists():
199
+ return None
200
+ try:
201
+ with open(filepath, encoding="utf-8") as f:
202
+ notebook = json.load(f)
203
+ except FileNotFoundError:
204
+ return None
205
+ except Exception as exc:
206
+ logger.error("notebook %s failed to load from %s: %s", notebook_id, filepath, exc)
207
+ raise NotebookCorruptedError(notebook_id, filepath, exc) from exc
208
+ if self._sanitize_loaded_notebook(notebook):
209
+ try:
210
+ self._save_notebook(notebook_id, notebook)
211
+ except Exception:
212
+ logger.warning("could not persist sanitized notebook %s", notebook_id)
213
+ return notebook
214
+
215
+ def _sanitize_loaded_notebook(self, notebook: dict) -> bool:
216
+ changed = False
217
+ records = notebook.get("records", [])
218
+ if not isinstance(records, list):
219
+ return False
220
+ for record in records:
221
+ if not isinstance(record, dict):
222
+ continue
223
+ raw_summary = record.get("summary", "")
224
+ cleaned = _clean_record_summary(raw_summary)
225
+ if cleaned != raw_summary:
226
+ record["summary"] = cleaned
227
+ changed = True
228
+ return changed
229
+
230
+ def _save_notebook(self, notebook_id: str, notebook: dict) -> None:
231
+ atomic_write_json(self._get_notebook_file(notebook_id), notebook)
232
+
233
+ def _touch_index_entry(self, notebook_id: str, notebook: dict) -> None:
234
+ """Refresh this notebook's index row, re-adding it if it went missing."""
235
+ with self._index_lock:
236
+ index = self._load_index()
237
+ rows = index.setdefault("notebooks", [])
238
+ row = self._index_row(notebook)
239
+ for position, nb_info in enumerate(rows):
240
+ if nb_info.get("id") == notebook_id:
241
+ # Keep created_at from the index when the file lacks it.
242
+ row["created_at"] = notebook.get("created_at", nb_info.get("created_at", 0.0))
243
+ rows[position] = row
244
+ break
245
+ else:
246
+ rows.append(row)
247
+ self._save_index(index)
248
+
249
+ # === Notebook Operations ===
250
+
251
+ def create_notebook(
252
+ self, name: str, description: str = "", color: str = "#3B82F6", icon: str = "book"
253
+ ) -> dict:
254
+ notebook_id = str(uuid.uuid4())[:8]
255
+ now = time.time()
256
+
257
+ notebook = {
258
+ "id": notebook_id,
259
+ "name": name,
260
+ "description": description,
261
+ "created_at": now,
262
+ "updated_at": now,
263
+ "records": [],
264
+ "color": color,
265
+ "icon": icon,
266
+ }
267
+
268
+ with self._locked(notebook_id):
269
+ self._save_notebook(notebook_id, notebook)
270
+ self._touch_index_entry(notebook_id, notebook)
271
+ return notebook
272
+
273
+ def get_or_create_notebook(
274
+ self, name: str, description: str = "", color: str = "#3B82F6", icon: str = "book"
275
+ ) -> dict:
276
+ """Return an existing notebook with this exact name, or create one."""
277
+ with self._index_lock:
278
+ for row in self._load_index().get("notebooks", []):
279
+ if row.get("name") != name:
280
+ continue
281
+ notebook_id = str(row.get("id") or "")
282
+ notebook = self._load_notebook(notebook_id) if notebook_id else None
283
+ if notebook:
284
+ return notebook
285
+ return self.create_notebook(name=name, description=description, color=color, icon=icon)
286
+
287
+ def list_notebooks(self) -> list[dict]:
288
+ """List every notebook on disk, newest first.
289
+
290
+ Rows come from the index rather than from parsing each notebook in
291
+ full, and the index is reconciled against the directory first, so a
292
+ notebook whose index row was lost still shows up. A notebook whose
293
+ file is damaged is reported with ``unreadable`` set instead of being
294
+ dropped — silently omitting it is what made data look deleted.
295
+ """
296
+ damaged: list[dict] = []
297
+ with self._index_lock:
298
+ index = self._load_index()
299
+ rows = {str(row.get("id")): row for row in index.get("notebooks", []) if row.get("id")}
300
+
301
+ on_disk = {
302
+ path.stem for path in self.base_dir.glob("*.json") if path != self.index_file
303
+ }
304
+ # Drop rows whose file is gone; adopt files the index never learned
305
+ # about. Only the unknown files are parsed — indexed ones are taken
306
+ # at their word, which keeps listing O(index) rather than O(content).
307
+ changed = False
308
+ for notebook_id in list(rows):
309
+ if notebook_id not in on_disk:
310
+ rows.pop(notebook_id)
311
+ changed = True
312
+ for notebook_id in sorted(on_disk - rows.keys()):
313
+ try:
314
+ notebook = self._load_notebook(notebook_id)
315
+ except NotebookCorruptedError:
316
+ # Surface it as a placeholder instead of dropping it: the
317
+ # file is still on disk and the user needs to know that.
318
+ damaged.append({"id": notebook_id, "unreadable": True})
319
+ continue
320
+ if notebook:
321
+ rows[notebook_id] = self._index_row(notebook)
322
+ changed = True
323
+ if changed:
324
+ self._save_index({"notebooks": list(rows.values())})
325
+
326
+ notebooks: list[dict] = [
327
+ {
328
+ "id": notebook_id,
329
+ "name": row.get("name", ""),
330
+ "description": row.get("description", ""),
331
+ "created_at": row.get("created_at", 0.0),
332
+ "updated_at": row.get("updated_at", 0.0),
333
+ "record_count": row.get("record_count", 0),
334
+ "color": row.get("color", "#3B82F6"),
335
+ "icon": row.get("icon", "book"),
336
+ }
337
+ for notebook_id, row in rows.items()
338
+ ]
339
+ notebooks.sort(key=lambda x: x["updated_at"], reverse=True)
340
+
341
+ # Damaged notebooks have no trustworthy metadata to sort by, so they
342
+ # ride at the end with just enough for the UI to flag them.
343
+ for entry in damaged:
344
+ notebooks.append(
345
+ {
346
+ "id": entry["id"],
347
+ "name": entry["id"],
348
+ "description": "",
349
+ "created_at": 0.0,
350
+ "updated_at": 0.0,
351
+ "record_count": 0,
352
+ "color": "#3B82F6",
353
+ "icon": "book",
354
+ "unreadable": True,
355
+ }
356
+ )
357
+ return notebooks
358
+
359
+ def get_notebook(self, notebook_id: str) -> dict | None:
360
+ return self._load_notebook(notebook_id)
361
+
362
+ def update_notebook(
363
+ self,
364
+ notebook_id: str,
365
+ name: str | None = None,
366
+ description: str | None = None,
367
+ color: str | None = None,
368
+ icon: str | None = None,
369
+ ) -> dict | None:
370
+ with self._locked(notebook_id):
371
+ notebook = self._load_notebook(notebook_id)
372
+ if not notebook:
373
+ return None
374
+
375
+ if name is not None:
376
+ notebook["name"] = name
377
+ if description is not None:
378
+ notebook["description"] = description
379
+ if color is not None:
380
+ notebook["color"] = color
381
+ if icon is not None:
382
+ notebook["icon"] = icon
383
+
384
+ notebook["updated_at"] = time.time()
385
+ self._save_notebook(notebook_id, notebook)
386
+ self._touch_index_entry(notebook_id, notebook)
387
+ return notebook
388
+
389
+ def delete_notebook(self, notebook_id: str) -> bool:
390
+ with self._locked(notebook_id):
391
+ filepath = self._get_notebook_file(notebook_id)
392
+ if not filepath.exists():
393
+ return False
394
+
395
+ filepath.unlink()
396
+ with self._index_lock:
397
+ index = self._load_index()
398
+ index["notebooks"] = [
399
+ nb for nb in index.get("notebooks", []) if nb.get("id") != notebook_id
400
+ ]
401
+ self._save_index(index)
402
+ return True
403
+
404
+ # === Record Operations ===
405
+
406
+ def add_record(
407
+ self,
408
+ notebook_ids: list[str],
409
+ record_type: RecordType | str,
410
+ title: str,
411
+ user_query: str,
412
+ output: str,
413
+ summary: str = "",
414
+ metadata: dict | None = None,
415
+ kb_name: str | None = None,
416
+ ) -> dict:
417
+ record_id = str(uuid.uuid4())[:8]
418
+ now = time.time()
419
+ # Accept both enum instances and plain string values from callers.
420
+ resolved_type = (
421
+ record_type if isinstance(record_type, RecordType) else RecordType(str(record_type))
422
+ )
423
+
424
+ record = {
425
+ "id": record_id,
426
+ "type": resolved_type,
427
+ "title": title,
428
+ "summary": _clean_record_summary(summary),
429
+ "user_query": user_query,
430
+ "output": output,
431
+ "metadata": metadata or {},
432
+ "created_at": now,
433
+ "kb_name": kb_name,
434
+ }
435
+
436
+ added_to: list[str] = []
437
+ for notebook_id in notebook_ids:
438
+ # One lock per notebook rather than one around the whole loop:
439
+ # the notebooks are independent files and nothing here reads two
440
+ # of them at once, so a single wide lock would only add contention.
441
+ with self._locked(notebook_id):
442
+ try:
443
+ notebook = self._load_notebook(notebook_id)
444
+ except NotebookCorruptedError:
445
+ logger.warning("skipping unreadable notebook %s while saving", notebook_id)
446
+ continue
447
+ if not notebook:
448
+ continue
449
+ # Each notebook stores its own copy. They share a record id so
450
+ # the save is traceable, but editing one does not touch the
451
+ # others — use ``copy_record`` for an explicit independent copy.
452
+ notebook.setdefault("records", []).append(dict(record))
453
+ notebook["updated_at"] = now
454
+ self._save_notebook(notebook_id, notebook)
455
+ self._touch_index_entry(notebook_id, notebook)
456
+ added_to.append(notebook_id)
457
+
458
+ return {"record": record, "added_to_notebooks": added_to}
459
+
460
+ def get_records(self, notebook_id: str, record_ids: list[str] | None = None) -> list[dict]:
461
+ notebook = self._load_notebook(notebook_id)
462
+ if not notebook:
463
+ return []
464
+
465
+ records = list(notebook.get("records", []))
466
+ if not record_ids:
467
+ return records
468
+
469
+ wanted = set(record_ids)
470
+ return [record for record in records if str(record.get("id", "")) in wanted]
471
+
472
+ def get_record(self, notebook_id: str, record_id: str) -> dict | None:
473
+ records = self.get_records(notebook_id, [record_id])
474
+ return records[0] if records else None
475
+
476
+ def update_record(
477
+ self,
478
+ notebook_id: str,
479
+ record_id: str,
480
+ *,
481
+ title: str | None = None,
482
+ summary: str | None = None,
483
+ user_query: str | None = None,
484
+ output: str | None = None,
485
+ metadata: dict | None = None,
486
+ kb_name: str | None | object = _UNSET,
487
+ ) -> dict | None:
488
+ """Edit one record in place. Only this notebook's copy is affected.
489
+
490
+ Every parameter defaults to "leave alone". ``kb_name`` additionally
491
+ distinguishes *omitted* from an explicit ``None``, so a caller that
492
+ only renames a record cannot accidentally drop its knowledge-base
493
+ link — pass ``kb_name=None`` when clearing it is what you mean.
494
+ """
495
+ with self._locked(notebook_id):
496
+ notebook = self._load_notebook(notebook_id)
497
+ if not notebook:
498
+ return None
499
+
500
+ updated_record: dict | None = None
501
+ for record in notebook.get("records", []):
502
+ if str(record.get("id", "")) != str(record_id):
503
+ continue
504
+ if title is not None:
505
+ record["title"] = title
506
+ if summary is not None:
507
+ record["summary"] = _clean_record_summary(summary)
508
+ if user_query is not None:
509
+ record["user_query"] = user_query
510
+ if output is not None:
511
+ record["output"] = output
512
+ if metadata is not None:
513
+ current_metadata = record.get("metadata", {}) or {}
514
+ record["metadata"] = {**current_metadata, **metadata}
515
+ if kb_name is not _UNSET:
516
+ record["kb_name"] = kb_name
517
+ updated_record = record
518
+ break
519
+
520
+ if updated_record is None:
521
+ return None
522
+
523
+ notebook["updated_at"] = time.time()
524
+ self._save_notebook(notebook_id, notebook)
525
+ self._touch_index_entry(notebook_id, notebook)
526
+ return updated_record
527
+
528
+ def get_records_by_references(self, notebook_references: list[dict]) -> list[dict]:
529
+ resolved: list[dict] = []
530
+
531
+ for ref in notebook_references:
532
+ notebook_id = str(ref.get("notebook_id", "") or "").strip()
533
+ if not notebook_id:
534
+ continue
535
+ record_ids = [
536
+ str(record_id).strip()
537
+ for record_id in (ref.get("record_ids") or [])
538
+ if str(record_id).strip()
539
+ ]
540
+ try:
541
+ notebook = self._load_notebook(notebook_id)
542
+ except NotebookCorruptedError:
543
+ # Resolving references feeds a chat turn; one damaged notebook
544
+ # must not take the whole turn down with it.
545
+ logger.warning("skipping unreadable notebook %s while resolving refs", notebook_id)
546
+ continue
547
+ if not notebook:
548
+ continue
549
+
550
+ notebook_name = str(notebook.get("name", "") or notebook_id)
551
+ for record in self.get_records(notebook_id, record_ids):
552
+ resolved.append(
553
+ {
554
+ **record,
555
+ "notebook_id": notebook_id,
556
+ "notebook_name": notebook_name,
557
+ }
558
+ )
559
+
560
+ return resolved
561
+
562
+ def remove_record(self, notebook_id: str, record_id: str) -> bool:
563
+ with self._locked(notebook_id):
564
+ notebook = self._load_notebook(notebook_id)
565
+ if not notebook:
566
+ return False
567
+
568
+ records = notebook.get("records", [])
569
+ original_count = len(records)
570
+ notebook["records"] = [r for r in records if str(r.get("id")) != str(record_id)]
571
+
572
+ if len(notebook["records"]) == original_count:
573
+ return False
574
+
575
+ notebook["updated_at"] = time.time()
576
+ self._save_notebook(notebook_id, notebook)
577
+ self._touch_index_entry(notebook_id, notebook)
578
+ return True
579
+
580
+ def copy_record(
581
+ self, source_notebook_id: str, record_id: str, target_notebook_id: str
582
+ ) -> dict | None:
583
+ """Duplicate a record into another notebook under a fresh id.
584
+
585
+ The copy is independent from the moment it lands: editing either side
586
+ leaves the other untouched. Returns ``None`` when the source record or
587
+ the target notebook does not exist.
588
+ """
589
+ if source_notebook_id == target_notebook_id:
590
+ return None
591
+
592
+ source_record = self.get_record(source_notebook_id, record_id)
593
+ if source_record is None:
594
+ return None
595
+
596
+ copied = dict(source_record)
597
+ copied["id"] = str(uuid.uuid4())[:8]
598
+ copied["metadata"] = {
599
+ **(source_record.get("metadata") or {}),
600
+ "copied_from": {"notebook_id": source_notebook_id, "record_id": record_id},
601
+ }
602
+
603
+ with self._locked(target_notebook_id):
604
+ target = self._load_notebook(target_notebook_id)
605
+ if not target:
606
+ return None
607
+ target.setdefault("records", []).append(copied)
608
+ target["updated_at"] = time.time()
609
+ self._save_notebook(target_notebook_id, target)
610
+ self._touch_index_entry(target_notebook_id, target)
611
+ return copied
612
+
613
+ def locate_record(self, record_id: str, notebook_id: str = "") -> str | None:
614
+ """Find a saved source within this workspace, preferring its original collection."""
615
+ ids = [notebook_id] if notebook_id else []
616
+ ids.extend(item["id"] for item in self.list_notebooks() if item["id"] != notebook_id)
617
+ for candidate in ids:
618
+ try:
619
+ if self.get_record(candidate, record_id) is not None:
620
+ return candidate
621
+ except NotebookCorruptedError:
622
+ continue
623
+ return None
624
+
625
+ def move_record(
626
+ self, source_notebook_id: str, record_id: str, target_notebook_id: str
627
+ ) -> dict | None:
628
+ """Move without changing identity, so existing note citations remain valid.
629
+
630
+ Lock both collections in stable order. Write the destination first so
631
+ an interrupted write can never destroy the only copy of the source.
632
+ """
633
+ if source_notebook_id == target_notebook_id:
634
+ return None
635
+ with ExitStack() as stack:
636
+ for notebook_id in sorted([source_notebook_id, target_notebook_id]):
637
+ stack.enter_context(self._locked(notebook_id))
638
+ source = self._load_notebook(source_notebook_id)
639
+ target = self._load_notebook(target_notebook_id)
640
+ if source is None or target is None:
641
+ return None
642
+ record = next((r for r in source.get("records", []) if r["id"] == record_id), None)
643
+ if record is None:
644
+ return None
645
+ existing = next((r for r in target.get("records", []) if r["id"] == record_id), None)
646
+ if existing is not None and existing != record:
647
+ raise ValueError(
648
+ "The destination already contains a different version of this favorite."
649
+ )
650
+ if existing is None:
651
+ target.setdefault("records", []).append(record)
652
+ target["updated_at"] = time.time()
653
+ self._save_notebook(target_notebook_id, target)
654
+ self._touch_index_entry(target_notebook_id, target)
655
+ source["records"] = [r for r in source.get("records", []) if r["id"] != record_id]
656
+ source["updated_at"] = time.time()
657
+ self._save_notebook(source_notebook_id, source)
658
+ self._touch_index_entry(source_notebook_id, source)
659
+ return record
660
+
661
+ def export_markdown(self, notebook_id: str) -> str | None:
662
+ """Render a whole notebook as one Markdown document."""
663
+ notebook = self._load_notebook(notebook_id)
664
+ if not notebook:
665
+ return None
666
+
667
+ lines: list[str] = [f"# {notebook.get('name', notebook_id)}"]
668
+ description = str(notebook.get("description") or "").strip()
669
+ if description:
670
+ lines.append("")
671
+ lines.append(f"> {description}")
672
+
673
+ for record in notebook.get("records", []):
674
+ lines.append("")
675
+ lines.append("---")
676
+ lines.append("")
677
+ lines.append(f"## {record.get('title') or '(untitled)'}")
678
+
679
+ created_at = record.get("created_at")
680
+ stamp = (
681
+ time.strftime("%Y-%m-%d %H:%M", time.localtime(created_at)) if created_at else ""
682
+ )
683
+ meta_bits = [bit for bit in (str(record.get("type") or ""), stamp) if bit]
684
+ if meta_bits:
685
+ lines.append("")
686
+ lines.append(f"*{' · '.join(meta_bits)}*")
687
+
688
+ summary = str(record.get("summary") or "").strip()
689
+ if summary:
690
+ lines.append("")
691
+ lines.append(f"> {summary}")
692
+
693
+ output = str(record.get("output") or "").strip()
694
+ if output:
695
+ lines.append("")
696
+ lines.append(output)
697
+
698
+ return "\n".join(lines) + "\n"
699
+
700
+ def get_statistics(self) -> dict:
701
+ notebooks = self.list_notebooks()
702
+
703
+ total_records = 0
704
+ # Derived from the enum so a newly added record type is counted the
705
+ # day it is introduced, instead of silently missing from the totals.
706
+ type_counts = {member.value: 0 for member in RecordType}
707
+
708
+ for nb_info in notebooks:
709
+ try:
710
+ notebook = self._load_notebook(nb_info["id"])
711
+ except NotebookCorruptedError:
712
+ continue
713
+ if notebook:
714
+ for record in notebook.get("records", []):
715
+ total_records += 1
716
+ record_type = record.get("type", "")
717
+ if record_type in type_counts:
718
+ type_counts[record_type] += 1
719
+
720
+ return {
721
+ "total_notebooks": len(notebooks),
722
+ "total_records": total_records,
723
+ "records_by_type": type_counts,
724
+ "recent_notebooks": notebooks[:5],
725
+ }
726
+
727
+
728
+ _instances: dict[str, NotebookManager] = {}
729
+
730
+
731
+ def get_notebook_manager() -> NotebookManager:
732
+ base_dir = get_path_service().get_notebook_dir().resolve()
733
+ key = str(base_dir)
734
+ if key not in _instances:
735
+ _instances[key] = NotebookManager(base_dir=str(base_dir))
736
+ return _instances[key]
737
+
738
+
739
+ class _NotebookManagerProxy:
740
+ def __getattr__(self, name: str):
741
+ return getattr(get_notebook_manager(), name)
742
+
743
+
744
+ notebook_manager = _NotebookManagerProxy()