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.
- docent/__init__.py +1 -0
- docent/bundled_plugins/__init__.py +0 -0
- docent/bundled_plugins/reading/__init__.py +1205 -0
- docent/bundled_plugins/reading/mendeley_cache.py +183 -0
- docent/bundled_plugins/reading/mendeley_client.py +132 -0
- docent/bundled_plugins/reading/reading_notify.py +78 -0
- docent/bundled_plugins/reading/reading_store.py +105 -0
- docent/cli.py +310 -0
- docent/config/__init__.py +4 -0
- docent/config/loader.py +76 -0
- docent/config/settings.py +51 -0
- docent/core/__init__.py +19 -0
- docent/core/context.py +14 -0
- docent/core/events.py +35 -0
- docent/core/plugin_loader.py +99 -0
- docent/core/registry.py +96 -0
- docent/core/tool.py +90 -0
- docent/execution/__init__.py +3 -0
- docent/execution/executor.py +69 -0
- docent/learning/__init__.py +3 -0
- docent/learning/run_log.py +69 -0
- docent/llm/__init__.py +3 -0
- docent/llm/client.py +60 -0
- docent/mcp_server.py +187 -0
- docent/tools/__init__.py +17 -0
- docent/ui/__init__.py +3 -0
- docent/ui/console.py +28 -0
- docent/ui/theme.py +16 -0
- docent/utils/__init__.py +0 -0
- docent/utils/paths.py +36 -0
- docent/utils/prompt.py +63 -0
- docent_cli-1.0.0.dist-info/METADATA +174 -0
- docent_cli-1.0.0.dist-info/RECORD +35 -0
- docent_cli-1.0.0.dist-info/WHEEL +4 -0
- docent_cli-1.0.0.dist-info/entry_points.txt +3 -0
|
@@ -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}")
|