docent-cli 1.0.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,1205 @@
1
+ """Reading queue tool: manage what you're reading + Mendeley sync."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from datetime import datetime, date
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, Field, model_validator
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from docent.config import write_setting
16
+ from docent.core import Context, ProgressEvent, Tool, action, register_tool
17
+ from docent.learning import RunLog
18
+ from .reading_store import BannerCounts, ReadingQueueStore
19
+ from .mendeley_cache import MendeleyCache
20
+ from .mendeley_client import (
21
+ list_documents as mendeley_list_documents,
22
+ list_folders as mendeley_list_folders,
23
+ )
24
+ from docent.utils.paths import cache_dir, data_dir
25
+ from docent.utils.prompt import NoInteractiveError, prompt_for_path
26
+
27
+
28
+ _DEFAULT_DATABASE_DIR = "~/Documents/Papers"
29
+ _KNOWN_READING_KEYS = {"database_dir", "queue_collection"}
30
+ # `mendeley_mcp_command` is a list field; config-set only handles strings today.
31
+ # Power users can edit config.toml directly; defaulted to ["uvx", "mendeley-mcp"] in mendeley_client.
32
+
33
+ class QueueEntry(BaseModel):
34
+ id: str
35
+ title: str = "" # Mendeley-owned snapshot; overlay refreshes on read.
36
+ authors: str = "" # Mendeley-owned snapshot; overlay refreshes on read.
37
+ year: int | None = None
38
+ doi: str | None = None
39
+ type: str = "paper" # paper | book | book_chapter; mapped from Mendeley document type on sync.
40
+ added: str # ISO date
41
+ status: str = "queued"
42
+ order: int = 0 # 1-based position in the reading queue; 0 = unordered.
43
+ category: str | None = None # Mendeley sub-collection path, e.g. "CES701" or "CES701/Topic"; None = root.
44
+ deadline: str | None = None # ISO date (YYYY-MM-DD), user-settable.
45
+ tags: list[str] = Field(default_factory=list)
46
+ notes: str = ""
47
+ mendeley_id: str | None = None
48
+ started: str | None = None # ISO timestamp when status -> reading.
49
+ finished: str | None = None # ISO timestamp when status -> done.
50
+
51
+ @model_validator(mode="after")
52
+ def _require_identifier(self) -> "QueueEntry":
53
+ if not self.doi and not self.mendeley_id:
54
+ raise ValueError(
55
+ "QueueEntry requires doi or mendeley_id — identifier-free entries are not allowed."
56
+ )
57
+ return self
58
+
59
+
60
+ class AddInputs(BaseModel):
61
+ pass
62
+
63
+
64
+ class AddResult(BaseModel):
65
+ added: bool
66
+ queue_size: int
67
+ banner: BannerCounts
68
+ message: str
69
+
70
+ def __rich_console__(self, console, options):
71
+ yield Panel(self.message, title="How to add papers", border_style="cyan", padding=(0, 1))
72
+ yield Text(f"Queue: {self.queue_size} entries", style="dim")
73
+
74
+
75
+ class IdOnlyInputs(BaseModel):
76
+ id: str = Field(..., description="Entry id (e.g. 'smith-2024-foo').")
77
+
78
+
79
+ class NextInputs(BaseModel):
80
+ category: str | None = Field(None, description="Restrict to a category prefix (e.g. 'CES701' matches 'CES701' and 'CES701/Topic').")
81
+
82
+
83
+ class SearchInputs(BaseModel):
84
+ query: str = Field(..., description="Case-insensitive substring matched against title, authors, notes, category, id, and tags.")
85
+
86
+
87
+ class StatsInputs(BaseModel):
88
+ pass
89
+
90
+
91
+ class EditInputs(BaseModel):
92
+ id: str = Field(..., description="Entry id to edit.")
93
+ status: str | None = Field(None, description="New status (queued|reading|done).")
94
+ order: int | None = Field(None, description="New reading order position (1 = read first).")
95
+ type: str | None = Field(None, description="Entry type: paper | book | book_chapter.")
96
+ category: str | None = Field(None, description="Override category (e.g. 'CES701'). Normally auto-detected from the Mendeley sub-collection on sync.")
97
+ deadline: str | None = Field(None, description="New deadline (YYYY-MM-DD) or '' to clear.")
98
+ notes: str | None = Field(None, description="New notes.")
99
+ tags: list[str] | None = Field(None, description="Replace tag list.")
100
+
101
+
102
+ class SetDeadlineInputs(BaseModel):
103
+ id: str = Field(..., description="Entry id to update.")
104
+ deadline: str = Field(..., description="ISO date deadline (YYYY-MM-DD). Pass '' to clear the deadline.")
105
+
106
+
107
+ class ExportInputs(BaseModel):
108
+ format: str = Field("json", description="Output format: json | markdown.")
109
+ category: str | None = Field(None, description="Filter by exact category path (e.g. 'CES701' or 'CES701/Topic').")
110
+ status: str | None = Field(None, description="Filter by status (queued|reading|done).")
111
+
112
+
113
+ class ConfigShowInputs(BaseModel):
114
+ pass
115
+
116
+
117
+ class ConfigSetInputs(BaseModel):
118
+ key: str = Field(..., description="Setting key under the [reading] section, e.g. 'database_dir' or 'queue_collection'.")
119
+ value: str = Field(..., description="New value. Use '' to clear. Paths may use '~'.")
120
+
121
+
122
+ class ConfigShowResult(BaseModel):
123
+ config_path: str
124
+ database_dir: str | None
125
+ queue_collection: str
126
+
127
+ def __rich_console__(self, console, options):
128
+ lines = [
129
+ f"[dim]Config:[/dim] {self.config_path}",
130
+ f"[bold]database_dir:[/bold] {self.database_dir or '[dim](not set)[/dim]'}",
131
+ f"[bold]queue_collection:[/bold] {self.queue_collection}",
132
+ ]
133
+ yield Panel("\n".join(lines), title="Reading Config", border_style="cyan", padding=(0, 1))
134
+
135
+
136
+ class ConfigSetResult(BaseModel):
137
+ ok: bool
138
+ key: str
139
+ value: str
140
+ config_path: str
141
+ message: str
142
+
143
+ def __rich_console__(self, console, options):
144
+ style = "green" if self.ok else "red"
145
+ yield Text(self.message, style=style)
146
+
147
+
148
+ class MutationResult(BaseModel):
149
+ ok: bool
150
+ id: str
151
+ entry: QueueEntry | None
152
+ queue_size: int
153
+ banner: BannerCounts
154
+ message: str
155
+
156
+ def __rich_console__(self, console, options):
157
+ if not self.ok or self.entry is None:
158
+ yield Text(f"[FAIL] {self.message}", style="red")
159
+ return
160
+ e = self.entry
161
+ lines: list[str] = [f"[bold]{e.title}[/bold]"]
162
+ meta_parts = [p for p in [e.authors, str(e.year) if e.year else ""] if p and p != "Unknown"]
163
+ if meta_parts:
164
+ lines.append("[dim]" + " · ".join(meta_parts) + "[/dim]")
165
+ detail_parts = [f"[dim]Order:[/dim] {e.order}", f"[dim]Status:[/dim] {e.status}"]
166
+ if e.type and e.type != "paper":
167
+ detail_parts.append(f"[dim]Type:[/dim] {e.type.replace('_', ' ')}")
168
+ if e.category:
169
+ detail_parts.append(f"[dim]Category:[/dim] {e.category}")
170
+ if e.deadline:
171
+ detail_parts.append(f"[dim]Deadline:[/dim] [yellow]{e.deadline}[/yellow]")
172
+ lines.append(" ".join(detail_parts))
173
+ if e.doi:
174
+ lines.append(f"[dim]DOI:[/dim] {e.doi}")
175
+ if e.notes:
176
+ lines.append(f"[dim]Notes:[/dim] {e.notes}")
177
+ yield Panel("\n".join(lines), border_style="cyan", padding=(0, 1))
178
+ if self.message:
179
+ yield Text(self.message, style="dim")
180
+
181
+
182
+ class SearchResult(BaseModel):
183
+ query: str
184
+ matches: list[QueueEntry]
185
+ total: int
186
+ queue_size: int
187
+
188
+ def __rich_console__(self, console, options):
189
+ label = "match" if self.total == 1 else "matches"
190
+ yield Text(f"{self.total} {label} for {self.query!r}", style="bold")
191
+ if not self.matches:
192
+ return
193
+ table = Table(show_header=True, header_style="bold dim", box=None, padding=(0, 1))
194
+ table.add_column("#", style="dim", width=3)
195
+ table.add_column("Title")
196
+ table.add_column("Authors", style="dim")
197
+ table.add_column("Year", style="dim", width=6)
198
+ table.add_column("Type", style="dim")
199
+ table.add_column("Category", style="dim")
200
+ table.add_column("Status", style="dim")
201
+ for e in self.matches:
202
+ etype = (e.type or "paper").replace("_", " ") if (e.type or "paper") != "paper" else ""
203
+ table.add_row(
204
+ str(e.order),
205
+ e.title,
206
+ e.authors if e.authors != "Unknown" else "",
207
+ str(e.year) if e.year else "",
208
+ etype,
209
+ e.category or "",
210
+ e.status,
211
+ )
212
+ yield table
213
+
214
+
215
+ class StatsResult(BaseModel):
216
+ total: int
217
+ by_status: dict[str, int]
218
+ by_category: dict[str, int]
219
+ banner: BannerCounts
220
+
221
+ def __rich_console__(self, console, options):
222
+ lines = [f"[bold]Total:[/bold] {self.total} entries", ""]
223
+ lines.append("[bold]By status[/bold]")
224
+ for k, v in sorted(self.by_status.items()):
225
+ lines.append(f" {k}: {v}")
226
+ if self.by_category:
227
+ lines.append("")
228
+ lines.append("[bold]By category[/bold]")
229
+ for k, v in sorted(self.by_category.items()):
230
+ lines.append(f" {k or '(none)'}: {v}")
231
+ yield Panel("\n".join(lines), title="Reading Queue", border_style="cyan", padding=(0, 1))
232
+
233
+
234
+ class ExportResult(BaseModel):
235
+ format: str
236
+ count: int
237
+ content: str
238
+
239
+ def __rich_console__(self, console, options):
240
+ yield Text(f"Exported {self.count} entries ({self.format})", style="dim")
241
+ yield Text(self.content)
242
+
243
+
244
+ class QueueClearInputs(BaseModel):
245
+ yes: bool = Field(False, description="Confirm: actually clear the queue. Without this, the action reports the size and exits.")
246
+
247
+
248
+ class QueueClearResult(BaseModel):
249
+ cleared: bool
250
+ removed_count: int
251
+ queue_size: int
252
+ banner: BannerCounts
253
+ message: str
254
+
255
+ def __rich_console__(self, console, options):
256
+ style = "green" if self.cleared else "yellow"
257
+ yield Text(self.message, style=style)
258
+
259
+
260
+ class SyncStatusInputs(BaseModel):
261
+ pass
262
+
263
+
264
+ class SyncFromMendeleyInputs(BaseModel):
265
+ dry_run: bool = Field(False, description="Resolve the collection and report what would change without writing the queue.")
266
+
267
+
268
+ class SyncFromMendeleyResult(BaseModel):
269
+ """Per-doc buckets after running sync-from-mendeley.
270
+
271
+ `added`: {id, mendeley_id, title}; `unchanged`: entry ids; `removed`:
272
+ entry ids (status flipped to 'removed'); `failed`: {mendeley_id, error};
273
+ dry-run variants populate only when dry_run=True.
274
+ `message` carries early-exit reasons (collection missing, MCP transport error).
275
+ """
276
+ queue_collection: str
277
+ folder_id: str | None
278
+ added: list[dict[str, str]]
279
+ unchanged: list[str]
280
+ removed: list[str]
281
+ failed: list[dict[str, str]]
282
+ dry_run_added: list[dict[str, str]]
283
+ dry_run_removed: list[str]
284
+ summary: str
285
+ message: str = ""
286
+
287
+ def __rich_console__(self, console, options):
288
+ if self.message:
289
+ yield Text(self.message, style="yellow")
290
+ return
291
+ is_dry = bool(self.dry_run_added or self.dry_run_removed)
292
+ actual_added = self.dry_run_added if is_dry else self.added
293
+ actual_removed = self.dry_run_removed if is_dry else self.removed
294
+ prefix = "[dim](dry run)[/dim] " if is_dry else ""
295
+ lines = [
296
+ f"{prefix}[bold]Collection:[/bold] {self.queue_collection}",
297
+ f" Added: [green]{len(actual_added)}[/green] "
298
+ f"Unchanged: {len(self.unchanged)} "
299
+ f"Removed: [yellow]{len(actual_removed)}[/yellow] "
300
+ f"Failed: [red]{len(self.failed)}[/red]",
301
+ ]
302
+ if actual_added:
303
+ lines.append("")
304
+ lines.append("[bold]Added[/bold]")
305
+ for item in actual_added[:10]:
306
+ lines.append(f" • {item.get('id', '')} — {item.get('title', '')[:60]}")
307
+ if len(actual_added) > 10:
308
+ lines.append(f" … and {len(actual_added) - 10} more")
309
+ if self.failed:
310
+ lines.append("")
311
+ lines.append("[bold red]Failed[/bold red]")
312
+ for item in self.failed:
313
+ lines.append(f" • {item.get('mendeley_id', '')[:12]}: {item.get('error', '')}")
314
+ yield Panel("\n".join(lines), title="Sync from Mendeley", border_style="cyan", padding=(0, 1))
315
+
316
+
317
+ class SyncStatusResult(BaseModel):
318
+ database_dir: str | None
319
+ queue_size: int
320
+ database_pdfs: list[str]
321
+ summary: str
322
+ message: str = ""
323
+
324
+ def __rich_console__(self, console, options):
325
+ lines = [
326
+ f"[bold]Database:[/bold] {self.database_dir or '[dim](not configured)[/dim]'}",
327
+ f"[bold]Queue:[/bold] {self.queue_size} entries",
328
+ f"[bold]PDFs in database:[/bold] {len(self.database_pdfs)}",
329
+ ]
330
+ if self.message:
331
+ lines.append(f"[yellow]{self.message}[/yellow]")
332
+ yield Panel("\n".join(lines), title="Sync Status", border_style="cyan", padding=(0, 1))
333
+
334
+
335
+ class MoveToInputs(BaseModel):
336
+ id: str = Field(..., description="Entry id to move.")
337
+ position: int = Field(..., ge=1, description="New position (1 = read first).")
338
+
339
+
340
+ @register_tool
341
+ class ReadingQueue(Tool):
342
+ """Reading queue + Mendeley sync."""
343
+
344
+ name = "reading"
345
+ description = "Reading queue management and Mendeley sync."
346
+ category = "reading"
347
+
348
+ def __init__(self) -> None:
349
+ self._store = ReadingQueueStore(data_dir() / "reading")
350
+
351
+ @action(
352
+ description="Show how to add papers to your reading queue via Mendeley.",
353
+ input_schema=AddInputs,
354
+ )
355
+ def add(self, inputs: AddInputs, context: Context) -> AddResult:
356
+ collection = context.settings.reading.queue_collection
357
+ return AddResult(
358
+ added=False,
359
+ queue_size=len(self._store.load_index()),
360
+ banner=self._store.banner_counts(),
361
+ message=(
362
+ "Drop the PDF in your reading.database_dir (Mendeley auto-imports it), "
363
+ f"drag it into the '{collection}' collection (or a sub-collection) in Mendeley, "
364
+ "then run `docent reading sync-from-mendeley`. "
365
+ "Category is auto-detected from sub-collections: "
366
+ f"'{collection}/CES701' -> category='CES701', "
367
+ f"'{collection}/CES701/Topic' -> category='CES701/Topic'. "
368
+ "Papers in the root collection get no category."
369
+ ),
370
+ )
371
+
372
+ @action(description="Show the next entry to read (lowest order number among queued).", input_schema=NextInputs)
373
+ def next(self, inputs: NextInputs, context: Context) -> MutationResult:
374
+ queue = self._store.load_queue()
375
+ queue = self._apply_overlay(queue, self._load_mendeley_overlay(context))
376
+ candidates = [e for e in queue if e.get("status") == "queued"]
377
+ if inputs.category:
378
+ cat = inputs.category
379
+ candidates = [
380
+ e for e in candidates
381
+ if e.get("category") == cat or (e.get("category") or "").startswith(cat + "/")
382
+ ]
383
+ if not candidates:
384
+ scope = f" for category {inputs.category!r}" if inputs.category else ""
385
+ return MutationResult(
386
+ ok=False, id="", entry=None, queue_size=len(queue),
387
+ banner=self._store.banner_counts(),
388
+ message=f"No queued entries{scope}.",
389
+ )
390
+ best = sorted(
391
+ candidates,
392
+ key=lambda e: (e.get("order", 0) or 999999, e.get("added", "")),
393
+ )[0]
394
+ return MutationResult(
395
+ ok=True, id=best["id"], entry=QueueEntry(**best),
396
+ queue_size=len(queue), banner=self._store.banner_counts(),
397
+ message=f"Read next: {best['title']!r} (order={best.get('order', 0)}, added {best.get('added', '')}).",
398
+ )
399
+
400
+ @action(description="Show one entry's details.", input_schema=IdOnlyInputs)
401
+ def show(self, inputs: IdOnlyInputs, context: Context) -> MutationResult:
402
+ queue = self._store.load_queue()
403
+ queue = self._apply_overlay(queue, self._load_mendeley_overlay(context))
404
+ entry = self._find_entry(queue, inputs.id)
405
+ if not entry:
406
+ return self._not_found(inputs.id, queue)
407
+ return MutationResult(
408
+ ok=True, id=inputs.id, entry=QueueEntry(**entry),
409
+ queue_size=len(queue), banner=self._store.banner_counts(),
410
+ message=f"Found {inputs.id!r}: {entry['title']!r}.",
411
+ )
412
+
413
+ @action(description="Search the queue for matching entries.", input_schema=SearchInputs)
414
+ def search(self, inputs: SearchInputs, context: Context) -> SearchResult:
415
+ queue = self._store.load_queue()
416
+ queue = self._apply_overlay(queue, self._load_mendeley_overlay(context))
417
+ q = inputs.query.lower()
418
+ matches: list[QueueEntry] = []
419
+ for e in queue:
420
+ haystack = " ".join([
421
+ e.get("title", "") or "",
422
+ e.get("authors", "") or "",
423
+ e.get("notes", "") or "",
424
+ e.get("category") or "",
425
+ e.get("id", "") or "",
426
+ " ".join(e.get("tags") or []),
427
+ ]).lower()
428
+ if q in haystack:
429
+ matches.append(QueueEntry(**e))
430
+ matches.sort(key=lambda e: (e.order or 999999, e.added or ""))
431
+ return SearchResult(query=inputs.query, matches=matches, total=len(matches), queue_size=len(queue))
432
+
433
+ @action(description="Show queue statistics.", input_schema=StatsInputs)
434
+ def stats(self, inputs: StatsInputs, context: Context) -> StatsResult:
435
+ queue = self._store.load_queue()
436
+ by_status: dict[str, int] = {}
437
+ by_category: dict[str, int] = {}
438
+ for e in queue:
439
+ s = e.get("status", "unknown")
440
+ by_status[s] = by_status.get(s, 0) + 1
441
+ cat = e.get("category") or "(root)"
442
+ by_category[cat] = by_category.get(cat, 0) + 1
443
+ return StatsResult(
444
+ total=len(queue), by_status=by_status,
445
+ by_category=by_category,
446
+ banner=self._store.banner_counts(),
447
+ )
448
+
449
+ @action(description="Remove an entry from the queue.", input_schema=IdOnlyInputs)
450
+ def remove(self, inputs: IdOnlyInputs, context: Context) -> MutationResult:
451
+ queue = self._store.load_queue()
452
+ entry = self._find_entry(queue, inputs.id)
453
+ if not entry:
454
+ return self._not_found(inputs.id, queue)
455
+ new_queue = [e for e in queue if e.get("id") != inputs.id]
456
+ self._store.save_queue(new_queue)
457
+ self._log_event("remove", id=inputs.id, title=entry.get("title"))
458
+ return MutationResult(
459
+ ok=True, id=inputs.id, entry=QueueEntry(**entry),
460
+ queue_size=len(new_queue), banner=self._store.banner_counts(),
461
+ message=f"Removed {inputs.id!r}.",
462
+ )
463
+
464
+ @action(description="Edit user-settable fields on an existing entry (Mendeley-owned fields: title/authors/year/doi are not editable here).", input_schema=EditInputs)
465
+ def edit(self, inputs: EditInputs, context: Context) -> MutationResult:
466
+ queue = self._store.load_queue()
467
+ entry = self._find_entry(queue, inputs.id)
468
+ if not entry:
469
+ return self._not_found(inputs.id, queue)
470
+ updates: dict[str, Any] = {}
471
+ if inputs.status is not None:
472
+ updates["status"] = inputs.status
473
+ if inputs.order is not None:
474
+ updates["order"] = inputs.order
475
+ if inputs.type is not None:
476
+ updates["type"] = inputs.type
477
+ if inputs.category is not None:
478
+ updates["category"] = inputs.category or None
479
+ if inputs.deadline is not None:
480
+ updates["deadline"] = inputs.deadline or None
481
+ if inputs.notes is not None:
482
+ updates["notes"] = inputs.notes
483
+ if inputs.tags is not None:
484
+ updates["tags"] = inputs.tags
485
+ if not updates:
486
+ return MutationResult(
487
+ ok=False, id=inputs.id, entry=QueueEntry(**entry),
488
+ queue_size=len(queue), banner=self._store.banner_counts(),
489
+ message="No fields supplied; nothing to edit.",
490
+ )
491
+ entry.update(updates)
492
+ self._store.save_queue(queue)
493
+ self._log_event("edit", id=inputs.id, fields=sorted(updates.keys()))
494
+ return MutationResult(
495
+ ok=True, id=inputs.id, entry=QueueEntry(**entry),
496
+ queue_size=len(queue), banner=self._store.banner_counts(),
497
+ message=f"Updated {inputs.id!r}: {sorted(updates.keys())}.",
498
+ )
499
+
500
+ @action(
501
+ description="Empty the reading queue (irreversible). Re-run with --yes to actually clear.",
502
+ input_schema=QueueClearInputs,
503
+ name="queue-clear",
504
+ )
505
+ def queue_clear(self, inputs: QueueClearInputs, context: Context) -> QueueClearResult:
506
+ queue = self._store.load_queue()
507
+ n = len(queue)
508
+ if not inputs.yes:
509
+ return QueueClearResult(
510
+ cleared=False,
511
+ removed_count=0,
512
+ queue_size=n,
513
+ banner=self._store.banner_counts(),
514
+ message=f"{n} entries in queue. Re-run with --yes to clear.",
515
+ )
516
+ self._store.save_queue([])
517
+ self._log_event("queue_clear", removed=n)
518
+ return QueueClearResult(
519
+ cleared=True,
520
+ removed_count=n,
521
+ queue_size=0,
522
+ banner=self._store.banner_counts(),
523
+ message=f"Cleared {n} entries from the queue.",
524
+ )
525
+
526
+ @action(description="Set or clear a reading deadline on an entry.", input_schema=SetDeadlineInputs, name="set-deadline")
527
+ def set_deadline(self, inputs: SetDeadlineInputs, context: Context) -> MutationResult:
528
+ queue = self._store.load_queue()
529
+ entry = self._find_entry(queue, inputs.id)
530
+ if entry is None:
531
+ return self._not_found(inputs.id, queue)
532
+ entry["deadline"] = inputs.deadline.strip() or None
533
+ self._store.save_queue(queue)
534
+ self._log_event("set_deadline", id=inputs.id, deadline=entry["deadline"])
535
+ return MutationResult(
536
+ ok=True, id=inputs.id, entry=QueueEntry(**entry),
537
+ queue_size=len(queue), banner=self._store.banner_counts(),
538
+ message=f"Deadline {'set to ' + entry['deadline'] if entry['deadline'] else 'cleared'} for {inputs.id!r}.",
539
+ )
540
+
541
+ @action(description="Mark an entry as done.", input_schema=IdOnlyInputs)
542
+ def done(self, inputs: IdOnlyInputs, context: Context) -> MutationResult:
543
+ return self._set_status(inputs.id, "done")
544
+
545
+ @action(description="Mark an entry as in-progress (currently reading).", input_schema=IdOnlyInputs)
546
+ def start(self, inputs: IdOnlyInputs, context: Context) -> MutationResult:
547
+ return self._set_status(inputs.id, "reading")
548
+
549
+ @action(description="Export the queue (or a filtered subset), applying fresh Mendeley metadata.", input_schema=ExportInputs)
550
+ def export(self, inputs: ExportInputs, context: Context) -> ExportResult:
551
+ queue = self._store.load_queue()
552
+ queue = self._apply_overlay(queue, self._load_mendeley_overlay(context))
553
+ filtered = queue
554
+ if inputs.category:
555
+ filtered = [e for e in filtered if e.get("category") == inputs.category]
556
+ if inputs.status:
557
+ filtered = [e for e in filtered if e.get("status") == inputs.status]
558
+ filtered = sorted(filtered, key=lambda e: (e.get("order", 0) or 999999, e.get("added", "")))
559
+ if inputs.format == "json":
560
+ content = json.dumps(filtered, indent=2, ensure_ascii=False)
561
+ elif inputs.format == "markdown":
562
+ lines = [
563
+ "| # | title | authors | year | type | category | status | deadline |",
564
+ "|---|---|---|---|---|---|---|---|",
565
+ ]
566
+ for e in filtered:
567
+ year = e.get("year")
568
+ etype = e.get("type", "paper").replace("_", " ")
569
+ lines.append(
570
+ f"| {e.get('order', 0)} | {e.get('title', '')} | {e.get('authors', '')} | "
571
+ f"{year if year is not None else ''} | {etype} | "
572
+ f"{e.get('category') or ''} | {e.get('status', '')} | "
573
+ f"{e.get('deadline') or ''} |"
574
+ )
575
+ content = "\n".join(lines)
576
+ else:
577
+ raise ValueError(f"Unsupported format: {inputs.format!r}. Use 'json' or 'markdown'.")
578
+ return ExportResult(format=inputs.format, count=len(filtered), content=content)
579
+
580
+ @action(description="Move an entry one position earlier in the reading order.", input_schema=IdOnlyInputs, name="move-up")
581
+ def move_up(self, inputs: IdOnlyInputs, context: Context) -> MutationResult:
582
+ queue = self._store.load_queue()
583
+ entry = self._find_entry(queue, inputs.id)
584
+ if not entry:
585
+ return self._not_found(inputs.id, queue)
586
+ current_order = entry.get("order", 0) or 0
587
+ if current_order <= 1:
588
+ return MutationResult(
589
+ ok=False, id=inputs.id, entry=QueueEntry(**entry),
590
+ queue_size=len(queue), banner=self._store.banner_counts(),
591
+ message=f"{inputs.id!r} is already at position 1; can't move up.",
592
+ )
593
+ self._reorder_move_to(queue, inputs.id, current_order - 1)
594
+ self._store.save_queue(queue)
595
+ updated = self._find_entry(queue, inputs.id)
596
+ return MutationResult(
597
+ ok=True, id=inputs.id, entry=QueueEntry(**updated),
598
+ queue_size=len(queue), banner=self._store.banner_counts(),
599
+ message=f"Moved {inputs.id!r} to position {updated.get('order')}.",
600
+ )
601
+
602
+ @action(description="Move an entry one position later in the reading order.", input_schema=IdOnlyInputs, name="move-down")
603
+ def move_down(self, inputs: IdOnlyInputs, context: Context) -> MutationResult:
604
+ queue = self._store.load_queue()
605
+ entry = self._find_entry(queue, inputs.id)
606
+ if not entry:
607
+ return self._not_found(inputs.id, queue)
608
+ ordered = sorted([e for e in queue if e.get("order", 0) > 0], key=lambda e: e.get("order", 0))
609
+ max_order = ordered[-1].get("order", 1) if ordered else 1
610
+ current_order = entry.get("order", 0) or 0
611
+ if current_order >= max_order:
612
+ return MutationResult(
613
+ ok=False, id=inputs.id, entry=QueueEntry(**entry),
614
+ queue_size=len(queue), banner=self._store.banner_counts(),
615
+ message=f"{inputs.id!r} is already at the last position; can't move down.",
616
+ )
617
+ self._reorder_move_to(queue, inputs.id, current_order + 1)
618
+ self._store.save_queue(queue)
619
+ updated = self._find_entry(queue, inputs.id)
620
+ return MutationResult(
621
+ ok=True, id=inputs.id, entry=QueueEntry(**updated),
622
+ queue_size=len(queue), banner=self._store.banner_counts(),
623
+ message=f"Moved {inputs.id!r} to position {updated.get('order')}.",
624
+ )
625
+
626
+ @action(description="Move an entry to a specific position in the reading order.", input_schema=MoveToInputs, name="move-to")
627
+ def move_to(self, inputs: MoveToInputs, context: Context) -> MutationResult:
628
+ queue = self._store.load_queue()
629
+ entry = self._find_entry(queue, inputs.id)
630
+ if not entry:
631
+ return self._not_found(inputs.id, queue)
632
+ self._reorder_move_to(queue, inputs.id, inputs.position)
633
+ self._store.save_queue(queue)
634
+ updated = self._find_entry(queue, inputs.id)
635
+ return MutationResult(
636
+ ok=True, id=inputs.id, entry=QueueEntry(**updated),
637
+ queue_size=len(queue), banner=self._store.banner_counts(),
638
+ message=f"Moved {inputs.id!r} to position {updated.get('order')}.",
639
+ )
640
+
641
+ @action(description="Show the configured reading settings.", input_schema=ConfigShowInputs, name="config-show")
642
+ def config_show(self, inputs: ConfigShowInputs, context: Context) -> ConfigShowResult:
643
+ from docent.utils.paths import config_file
644
+ rs = context.settings.reading
645
+ db = str(rs.database_dir) if rs.database_dir else None
646
+ return ConfigShowResult(
647
+ config_path=str(config_file()),
648
+ database_dir=db,
649
+ queue_collection=rs.queue_collection,
650
+ )
651
+
652
+ @action(description="Set a reading setting (database_dir, queue_collection).", input_schema=ConfigSetInputs, name="config-set")
653
+ def config_set(self, inputs: ConfigSetInputs, context: Context) -> ConfigSetResult:
654
+ from docent.utils.paths import config_file
655
+ if inputs.key not in _KNOWN_READING_KEYS:
656
+ return ConfigSetResult(
657
+ ok=False, key=inputs.key, value=inputs.value,
658
+ config_path=str(config_file()),
659
+ message=f"Unknown key {inputs.key!r}. Known: {sorted(_KNOWN_READING_KEYS)}.",
660
+ )
661
+ path = write_setting(f"reading.{inputs.key}", inputs.value)
662
+ return ConfigSetResult(
663
+ ok=True, key=inputs.key, value=inputs.value,
664
+ config_path=str(path),
665
+ message=f"Set reading.{inputs.key} = {inputs.value!r} in {path}.",
666
+ )
667
+
668
+ @action(
669
+ description="Report queue size and PDFs sitting in database_dir.",
670
+ input_schema=SyncStatusInputs,
671
+ name="sync-status",
672
+ )
673
+ def sync_status(self, inputs: SyncStatusInputs, context: Context) -> SyncStatusResult:
674
+ empty = SyncStatusResult(database_dir=None, queue_size=0, database_pdfs=[], summary="")
675
+ try:
676
+ database_dir, err = self._require_database_dir(context)
677
+ except NoInteractiveError as e:
678
+ return empty.model_copy(update={"message": (
679
+ f"reading.database_dir not configured. Run "
680
+ f"`docent reading config-set database_dir <path>` or set "
681
+ f"DOCENT_READING__DATABASE_DIR. ({e})"
682
+ )})
683
+ if database_dir is None:
684
+ return empty.model_copy(update={"message": err or "Cancelled — no database folder configured."})
685
+ if not database_dir.is_dir():
686
+ return empty.model_copy(update={
687
+ "database_dir": str(database_dir),
688
+ "message": f"database_dir does not exist: {database_dir}.",
689
+ })
690
+ db_pdfs = ReadingQueueStore.list_database_pdfs(database_dir)
691
+ queue = self._store.load_queue()
692
+ summary = (
693
+ f"{len(queue)} queue entry/entries, "
694
+ f"{len(db_pdfs)} PDF(s) in database_dir."
695
+ )
696
+ return SyncStatusResult(
697
+ database_dir=str(database_dir),
698
+ queue_size=len(queue),
699
+ database_pdfs=sorted(p.name for p in db_pdfs),
700
+ summary=summary,
701
+ )
702
+
703
+ @action(
704
+ description="Reconcile the local reading queue with a Mendeley collection (default 'Docent-Queue').",
705
+ input_schema=SyncFromMendeleyInputs,
706
+ name="sync-from-mendeley",
707
+ )
708
+ def sync_from_mendeley(self, inputs: SyncFromMendeleyInputs, context: Context):
709
+ collection_name = context.settings.reading.queue_collection
710
+ launch_command = context.settings.reading.mendeley_mcp_command
711
+ return self._sync_from_mendeley_run(collection_name, launch_command, inputs.dry_run)
712
+
713
+ @staticmethod
714
+ def _compute_category_path(folder_id: str, root_id: str, folder_map: dict[str, dict]) -> str | None:
715
+ """Return 'ParentName/ChildName' path from root to folder_id (root excluded).
716
+ Returns None when folder_id == root_id (doc is directly in the root collection)."""
717
+ parts: list[str] = []
718
+ cur = folder_id
719
+ while cur and cur != root_id:
720
+ f = folder_map.get(cur)
721
+ if not f:
722
+ break
723
+ parts.insert(0, f.get("name", ""))
724
+ cur = f.get("parent_id")
725
+ return "/".join(parts) if parts else None
726
+
727
+ def _sync_from_mendeley_run(
728
+ self, collection_name: str, launch_command: list[str] | None, dry_run: bool
729
+ ):
730
+ empty = SyncFromMendeleyResult(
731
+ queue_collection=collection_name, folder_id=None,
732
+ added=[], unchanged=[], removed=[], failed=[],
733
+ dry_run_added=[], dry_run_removed=[], summary="",
734
+ )
735
+
736
+ yield ProgressEvent(phase="discover", message=f"Listing Mendeley folders to resolve {collection_name!r}.")
737
+ folders_resp = mendeley_list_folders(launch_command)
738
+ if folders_resp.get("error"):
739
+ err = folders_resp["error"]
740
+ return empty.model_copy(update={"message": (
741
+ f"Could not list Mendeley folders: {self._mendeley_failure_hint(err)}"
742
+ )})
743
+ folders = folders_resp.get("items") or []
744
+ matches = [f for f in folders if isinstance(f, dict) and f.get("name") == collection_name]
745
+ if not matches:
746
+ return empty.model_copy(update={"message": (
747
+ f"Mendeley collection {collection_name!r} not found. "
748
+ f"Create a collection named {collection_name!r} in the Mendeley desktop app, "
749
+ f"drag the papers you want to read into it, then re-run. "
750
+ f"(Or change the configured name with "
751
+ f"`docent reading config-set queue_collection <name>`.)"
752
+ )})
753
+ if len(matches) > 1:
754
+ return empty.model_copy(update={"message": (
755
+ f"Found {len(matches)} Mendeley collections named {collection_name!r}. "
756
+ f"Rename one in Mendeley, or change `reading.queue_collection` to a unique name."
757
+ )})
758
+ folder_id = matches[0].get("id")
759
+ if not isinstance(folder_id, str) or not folder_id:
760
+ return empty.model_copy(update={"message": (
761
+ f"Mendeley collection {collection_name!r} has no usable id; "
762
+ f"try toggling its name in Mendeley to refresh."
763
+ )})
764
+
765
+ # Build folder map + discover sub-collection hierarchy.
766
+ folder_map: dict[str, dict] = {f["id"]: f for f in folders if isinstance(f, dict) and f.get("id")}
767
+ children: dict[str, list[str]] = {}
768
+ for f in folders:
769
+ if isinstance(f, dict):
770
+ pid = f.get("parent_id")
771
+ if pid:
772
+ children.setdefault(pid, []).append(f["id"])
773
+
774
+ # BFS from root to collect all descendant sub-folder ids.
775
+ sub_folder_ids: list[str] = []
776
+ bfs: list[str] = list(children.get(folder_id, []))
777
+ while bfs:
778
+ fid = bfs.pop(0)
779
+ sub_folder_ids.append(fid)
780
+ bfs.extend(children.get(fid, []))
781
+
782
+ # Fetch docs from root (fatal if fails), then each sub-folder (non-fatal).
783
+ yield ProgressEvent(phase="discover", message=f"Reading collection {collection_name!r} ({folder_id[:8]}…).")
784
+ docs_resp = mendeley_list_documents(folder_id=folder_id, launch_command=launch_command)
785
+ if docs_resp.get("error"):
786
+ err = docs_resp["error"]
787
+ return empty.model_copy(update={
788
+ "folder_id": folder_id,
789
+ "message": f"Could not list documents in {collection_name!r}: {self._mendeley_failure_hint(err)}",
790
+ })
791
+
792
+ # doc_with_category: {mendeley_id: (doc, category_path)} — deepest path wins.
793
+ doc_with_category: dict[str, tuple[dict, str | None]] = {}
794
+ _no_id_failed: list[dict[str, str]] = []
795
+
796
+ for doc in [d for d in (docs_resp.get("items") or []) if isinstance(d, dict)]:
797
+ mid = self._extract_mendeley_id(doc)
798
+ if mid:
799
+ doc_with_category[mid] = (doc, None) # None = directly in root
800
+ else:
801
+ _no_id_failed.append({"mendeley_id": "", "error": "doc has no usable id"})
802
+
803
+ for sfid in sub_folder_ids:
804
+ sf_name = folder_map.get(sfid, {}).get("name", sfid)
805
+ cat_path = self._compute_category_path(sfid, folder_id, folder_map)
806
+ yield ProgressEvent(phase="discover", message=f"Reading sub-collection {sf_name!r}…")
807
+ sf_resp = mendeley_list_documents(folder_id=sfid, launch_command=launch_command)
808
+ if sf_resp.get("error"):
809
+ yield ProgressEvent(phase="discover", level="warn",
810
+ message=f"Could not read '{sf_name}': {sf_resp['error']}")
811
+ continue
812
+ for doc in [d for d in (sf_resp.get("items") or []) if isinstance(d, dict)]:
813
+ mid = self._extract_mendeley_id(doc)
814
+ if not mid:
815
+ continue # already captured from root fetch if it appeared there
816
+ existing = doc_with_category.get(mid)
817
+ if existing is None:
818
+ doc_with_category[mid] = (doc, cat_path)
819
+ else:
820
+ _, existing_path = existing
821
+ existing_depth = len(existing_path.split("/")) if existing_path else 0
822
+ new_depth = len(cat_path.split("/")) if cat_path else 0
823
+ if new_depth > existing_depth:
824
+ doc_with_category[mid] = (doc, cat_path)
825
+
826
+ docs_to_process = list(doc_with_category.items())
827
+ yield ProgressEvent(phase="discover", message=f"Found {len(docs_to_process)} doc(s) total.")
828
+
829
+ added: list[dict[str, str]] = []
830
+ unchanged: list[str] = []
831
+ removed: list[str] = []
832
+ failed: list[dict[str, str]] = list(_no_id_failed)
833
+ dry_run_added: list[dict[str, str]] = []
834
+ dry_run_removed: list[str] = []
835
+ category_updates: dict[str, str | None] = {} # entry_id -> new category
836
+
837
+ queue = self._store.load_queue()
838
+ by_mendeley_id: dict[str, dict[str, Any]] = {
839
+ e["mendeley_id"]: e for e in queue if e.get("mendeley_id")
840
+ }
841
+ existing_ids: set[str] = {e.get("id") for e in queue if e.get("id")}
842
+ reserved_ids: set[str] = set()
843
+ in_collection: set[str] = set()
844
+ new_entries: list[dict[str, Any]] = []
845
+ max_order = max((e.get("order", 0) for e in queue), default=0)
846
+
847
+ for i, (mid, (doc, category)) in enumerate(docs_to_process, 1):
848
+ in_collection.add(mid)
849
+ yield ProgressEvent(phase="reconcile", current=i, total=len(docs_to_process), item=doc.get("title", mid)[:60])
850
+
851
+ if mid in by_mendeley_id:
852
+ existing_entry = by_mendeley_id[mid]
853
+ eid = existing_entry.get("id") or mid
854
+ if existing_entry.get("category") != category:
855
+ category_updates[eid] = category
856
+ unchanged.append(eid)
857
+ continue
858
+
859
+ try:
860
+ max_order += 1
861
+ entry = self._build_entry_from_mendeley(doc, mid, existing_ids | reserved_ids, max_order, category=category)
862
+ except Exception as e: # noqa: BLE001
863
+ failed.append({"mendeley_id": mid, "error": str(e)})
864
+ yield ProgressEvent(phase="reconcile", level="error", message=f"{mid[:8]}: {e}")
865
+ continue
866
+
867
+ reserved_ids.add(entry.id)
868
+ if dry_run:
869
+ dry_run_added.append({"id": entry.id, "mendeley_id": mid, "title": entry.title})
870
+ else:
871
+ new_entries.append(entry.model_dump())
872
+ added.append({"id": entry.id, "mendeley_id": mid, "title": entry.title})
873
+
874
+ for e in queue:
875
+ mid = e.get("mendeley_id")
876
+ if not mid or mid in in_collection:
877
+ continue
878
+ if e.get("status") == "removed":
879
+ continue
880
+ if dry_run:
881
+ dry_run_removed.append(e.get("id", mid))
882
+ else:
883
+ removed.append(e.get("id", mid))
884
+
885
+ if not dry_run and (new_entries or removed or category_updates):
886
+ queue = self._store.load_queue()
887
+ by_id = {e.get("id"): e for e in queue}
888
+ for ne in new_entries:
889
+ if ne["id"] not in by_id:
890
+ queue.append(ne)
891
+ removed_set = set(removed)
892
+ for e in queue:
893
+ eid = e.get("id")
894
+ if eid in removed_set:
895
+ e["status"] = "removed"
896
+ if eid in category_updates:
897
+ e["category"] = category_updates[eid]
898
+ self._store.save_queue(queue)
899
+
900
+ if not dry_run:
901
+ self._mendeley_cache().invalidate(folder_id)
902
+
903
+ self._log_event(
904
+ "sync_from_mendeley",
905
+ collection=collection_name,
906
+ folder_id=folder_id,
907
+ in_collection=len(in_collection),
908
+ added=len(added),
909
+ unchanged=len(unchanged),
910
+ removed=len(removed),
911
+ failed=len(failed),
912
+ dry_run=dry_run,
913
+ )
914
+
915
+ summary = (
916
+ f"{len(added)} added, {len(unchanged)} unchanged, "
917
+ f"{len(removed)} removed, {len(failed)} failed"
918
+ + (
919
+ f", {len(dry_run_added)} would-add, {len(dry_run_removed)} would-remove (dry-run)"
920
+ if dry_run else ""
921
+ )
922
+ + "."
923
+ )
924
+ if any("auth:" in f.get("error", "") for f in failed):
925
+ summary += " Auth failure detected — run `mendeley-auth login` and retry."
926
+
927
+ return SyncFromMendeleyResult(
928
+ queue_collection=collection_name,
929
+ folder_id=folder_id,
930
+ added=added,
931
+ unchanged=unchanged,
932
+ removed=removed,
933
+ failed=failed,
934
+ dry_run_added=dry_run_added,
935
+ dry_run_removed=dry_run_removed,
936
+ summary=summary,
937
+ )
938
+
939
+ def _build_entry_from_mendeley(
940
+ self, doc: dict[str, Any], mendeley_id: str, taken_ids: set[str], order: int,
941
+ category: str | None = None,
942
+ ) -> "QueueEntry":
943
+ title = (doc.get("title") or "").strip() or "(untitled)"
944
+ authors = self._normalize_mendeley_authors(doc.get("authors"))
945
+ year = doc.get("year")
946
+ if not isinstance(year, int):
947
+ year = None
948
+ doi: str | None = None
949
+ idents = doc.get("identifiers")
950
+ if isinstance(idents, dict):
951
+ d = idents.get("doi")
952
+ if isinstance(d, str) and d.strip():
953
+ doi = d.strip()
954
+
955
+ mendeley_type = (doc.get("type") or "").lower()
956
+ entry_type = {
957
+ "book": "book",
958
+ "book_section": "book_chapter",
959
+ "edited_book": "book",
960
+ }.get(mendeley_type, "paper")
961
+
962
+ base = self._derive_id(authors, year, title)
963
+ entry_id = f"{base}-{mendeley_id[:8]}" if base in taken_ids else base
964
+
965
+ return QueueEntry(
966
+ id=entry_id,
967
+ title=title,
968
+ authors=authors,
969
+ year=year,
970
+ doi=doi,
971
+ type=entry_type,
972
+ added=datetime.now().date().isoformat(),
973
+ order=order,
974
+ category=category,
975
+ mendeley_id=mendeley_id,
976
+ )
977
+
978
+ @staticmethod
979
+ def _normalize_mendeley_authors(authors: Any) -> str:
980
+ if isinstance(authors, list):
981
+ parts: list[str] = []
982
+ for a in authors:
983
+ if isinstance(a, str) and a.strip():
984
+ parts.append(a.strip())
985
+ elif isinstance(a, dict):
986
+ name = " ".join(filter(None, [a.get("first_name", ""), a.get("last_name", "")])).strip()
987
+ if name:
988
+ parts.append(name)
989
+ if parts:
990
+ return "; ".join(parts)
991
+ if isinstance(authors, str) and authors.strip():
992
+ return authors.strip()
993
+ return "Unknown"
994
+
995
+ @staticmethod
996
+ def _extract_mendeley_id(item: dict[str, Any]) -> str | None:
997
+ for key in ("id", "catalog_id", "document_id", "mendeley_id"):
998
+ v = item.get(key)
999
+ if isinstance(v, str) and v:
1000
+ return v
1001
+ return None
1002
+
1003
+ @staticmethod
1004
+ def _candidate_summary(item: dict[str, Any]) -> dict[str, str]:
1005
+ title = item.get("title") or ""
1006
+ year = item.get("year")
1007
+ authors = item.get("authors") or item.get("author") or ""
1008
+ if isinstance(authors, list):
1009
+ authors = ", ".join(
1010
+ " ".join(filter(None, [a.get("first_name", ""), a.get("last_name", "")])).strip()
1011
+ if isinstance(a, dict) else str(a)
1012
+ for a in authors[:3]
1013
+ )
1014
+ return {
1015
+ "mendeley_id": ReadingQueue._extract_mendeley_id(item) or "",
1016
+ "title": str(title),
1017
+ "year": str(year) if year is not None else "",
1018
+ "authors": str(authors),
1019
+ }
1020
+
1021
+ @staticmethod
1022
+ def _mendeley_failure_hint(error: str) -> str:
1023
+ if error.startswith("auth:"):
1024
+ return f"{error} (run `mendeley-auth login` to refresh tokens)"
1025
+ if "launch command not found" in error:
1026
+ return f"{error} (install with `uv tool install mendeley-mcp` or set reading.mendeley_mcp_command)"
1027
+ return error
1028
+
1029
+ # ------------------------------------------------------------------
1030
+ # Ordering helpers
1031
+ # ------------------------------------------------------------------
1032
+
1033
+ @staticmethod
1034
+ def _reorder_move_to(queue: list[dict[str, Any]], target_id: str, new_position: int) -> None:
1035
+ """Mutate queue in-place: move `target_id` to `new_position`, shifting
1036
+ other ordered entries to maintain contiguous 1-based ordering."""
1037
+ ordered = sorted(
1038
+ [e for e in queue if e.get("order", 0) > 0],
1039
+ key=lambda e: e.get("order", 0),
1040
+ )
1041
+ target = next((e for e in ordered if e.get("id") == target_id), None)
1042
+ if target is None:
1043
+ # Entry has order=0; assign it to new_position and shift others up.
1044
+ new_position = max(1, min(new_position, len(ordered) + 1))
1045
+ for e in ordered:
1046
+ if e.get("order", 0) >= new_position:
1047
+ e["order"] = e["order"] + 1
1048
+ for e in queue:
1049
+ if e.get("id") == target_id:
1050
+ e["order"] = new_position
1051
+ return
1052
+
1053
+ old_position = target["order"]
1054
+ new_position = max(1, min(new_position, len(ordered)))
1055
+ if old_position == new_position:
1056
+ return
1057
+
1058
+ if new_position < old_position:
1059
+ for e in ordered:
1060
+ pos = e.get("order", 0)
1061
+ if new_position <= pos < old_position and e.get("id") != target_id:
1062
+ e["order"] = pos + 1
1063
+ else:
1064
+ for e in ordered:
1065
+ pos = e.get("order", 0)
1066
+ if old_position < pos <= new_position and e.get("id") != target_id:
1067
+ e["order"] = pos - 1
1068
+
1069
+ target["order"] = new_position
1070
+
1071
+ # ------------------------------------------------------------------
1072
+ # Mendeley overlay + cache helpers
1073
+ # ------------------------------------------------------------------
1074
+
1075
+ def _mendeley_cache(self) -> MendeleyCache:
1076
+ return MendeleyCache(
1077
+ cache_dir() / "reading" / "mendeley_collection.json",
1078
+ list_documents=mendeley_list_documents,
1079
+ list_folders=mendeley_list_folders,
1080
+ )
1081
+
1082
+ def _resolve_collection_folder_id_quiet(
1083
+ self, collection_name: str, launch_command: list[str] | None
1084
+ ) -> str | None:
1085
+ return self._mendeley_cache().get_folder_id(collection_name, launch_command)
1086
+
1087
+ def _load_mendeley_overlay(self, context: Context) -> dict[str, dict[str, Any]] | None:
1088
+ rs = context.settings.reading
1089
+ collection_name = rs.queue_collection
1090
+ launch_command = rs.mendeley_mcp_command
1091
+ folder_id = self._resolve_collection_folder_id_quiet(collection_name, launch_command)
1092
+ if folder_id is None:
1093
+ return None
1094
+ return self._mendeley_cache().get_collection(folder_id, launch_command)
1095
+
1096
+ @staticmethod
1097
+ def _overlay_entry(entry: dict[str, Any], doc: dict[str, Any]) -> dict[str, Any]:
1098
+ out = dict(entry)
1099
+ title = (doc.get("title") or "").strip()
1100
+ if title:
1101
+ out["title"] = title
1102
+ authors = ReadingQueue._normalize_mendeley_authors(doc.get("authors"))
1103
+ if authors and authors != "Unknown":
1104
+ out["authors"] = authors
1105
+ year = doc.get("year")
1106
+ if isinstance(year, int):
1107
+ out["year"] = year
1108
+ idents = doc.get("identifiers")
1109
+ if isinstance(idents, dict):
1110
+ d = idents.get("doi")
1111
+ if isinstance(d, str) and d.strip():
1112
+ out["doi"] = d.strip()
1113
+ return out
1114
+
1115
+ def _apply_overlay(
1116
+ self, queue: list[dict[str, Any]], overlay: dict[str, dict[str, Any]] | None
1117
+ ) -> list[dict[str, Any]]:
1118
+ if not overlay:
1119
+ return queue
1120
+ out: list[dict[str, Any]] = []
1121
+ for e in queue:
1122
+ mid = e.get("mendeley_id")
1123
+ if mid and mid in overlay:
1124
+ out.append(self._overlay_entry(e, overlay[mid]))
1125
+ else:
1126
+ out.append(e)
1127
+ return out
1128
+
1129
+ # ------------------------------------------------------------------
1130
+ # Shared helpers
1131
+ # ------------------------------------------------------------------
1132
+
1133
+ def _require_database_dir(self, context: Context) -> tuple[Path | None, str | None]:
1134
+ rs = context.settings.reading
1135
+ if rs.database_dir is not None:
1136
+ return rs.database_dir.expanduser(), None
1137
+ path = prompt_for_path(
1138
+ "Where's your paper database? (path, 'create' for default, or 'cancel')",
1139
+ default=_DEFAULT_DATABASE_DIR,
1140
+ )
1141
+ if path is None:
1142
+ return None, None
1143
+ if not path.is_dir():
1144
+ return None, (
1145
+ f"Path doesn't exist: {path}. Pre-create the folder, type 'create' "
1146
+ f"next time to scaffold the default, or run "
1147
+ f"`docent reading config-set database_dir <path>` once it exists. Not persisted."
1148
+ )
1149
+ write_setting("reading.database_dir", str(path))
1150
+ context.settings.reading.database_dir = path
1151
+ return path, None
1152
+
1153
+ def _find_entry(self, queue: list[dict[str, Any]], entry_id: str) -> dict[str, Any] | None:
1154
+ for e in queue:
1155
+ if e.get("id") == entry_id:
1156
+ return e
1157
+ return None
1158
+
1159
+ def _not_found(self, entry_id: str, queue: list[dict[str, Any]]) -> MutationResult:
1160
+ return MutationResult(
1161
+ ok=False, id=entry_id, entry=None, queue_size=len(queue),
1162
+ banner=self._store.banner_counts(),
1163
+ message=f"No entry with id {entry_id!r}.",
1164
+ )
1165
+
1166
+ def _set_status(self, entry_id: str, status: str) -> MutationResult:
1167
+ queue = self._store.load_queue()
1168
+ entry = self._find_entry(queue, entry_id)
1169
+ if not entry:
1170
+ return self._not_found(entry_id, queue)
1171
+ previous = entry.get("status")
1172
+ entry["status"] = status
1173
+ ts = datetime.now().isoformat()
1174
+ if status == "reading" and not entry.get("started"):
1175
+ entry["started"] = ts
1176
+ elif status == "done" and not entry.get("finished"):
1177
+ entry["finished"] = ts
1178
+ self._store.save_queue(queue)
1179
+ self._log_event("set_status", id=entry_id, status=status, previous=previous)
1180
+ return MutationResult(
1181
+ ok=True, id=entry_id, entry=QueueEntry(**entry),
1182
+ queue_size=len(queue), banner=self._store.banner_counts(),
1183
+ message=f"Set status to {status!r} for {entry_id!r} (was {previous!r}).",
1184
+ )
1185
+
1186
+ def _log_event(self, event: str, **fields: Any) -> None:
1187
+ RunLog(self.name).append({"event": event, **fields})
1188
+
1189
+ @staticmethod
1190
+ def _derive_id(authors: str, year: int | None, title: str) -> str:
1191
+ first_chunk = authors.split(",")[0].split(";")[0].strip() if authors else ""
1192
+ first_word = first_chunk.split()[0] if first_chunk else "unknown"
1193
+ last_name = re.sub(r"[^a-zA-Z0-9]", "", first_word).lower() or "unknown"
1194
+ year_part = str(year) if year else "nd"
1195
+ first_title_word = title.split()[0] if title else "untitled"
1196
+ title_word = re.sub(r"[^a-zA-Z0-9]", "", first_title_word).lower() or "untitled"
1197
+ return f"{last_name}-{year_part}-{title_word}"
1198
+
1199
+
1200
+ def on_startup(context) -> None: # noqa: ARG001
1201
+ from docent.utils.paths import data_dir
1202
+ from docent.ui import get_console
1203
+ from .reading_notify import check_deadlines
1204
+ for alert in check_deadlines(data_dir() / "reading"):
1205
+ get_console().print(f"[yellow]READING DEADLINE:[/] {alert}")