getnotes-cli 0.1.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.
getnotes_cli/cli.py ADDED
@@ -0,0 +1,723 @@
1
+ """CLI 入口 — 使用 typer + rich 构建命令行界面"""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from getnotes_cli import __version__
11
+ from getnotes_cli.config import DEFAULT_LIMIT, DEFAULT_OUTPUT_DIR, PAGE_SIZE, REQUEST_DELAY
12
+ from getnotes_cli.settings import resolve_delay, resolve_output, resolve_page_size
13
+
14
+ console = Console()
15
+ app = typer.Typer(
16
+ name="getnotes",
17
+ help="🗂️ GetNotes CLI — 得到笔记批量下载工具",
18
+ no_args_is_help=True,
19
+ rich_markup_mode="rich",
20
+ )
21
+
22
+ # ========================================================================
23
+ # login 命令
24
+ # ========================================================================
25
+
26
+
27
+ @app.command()
28
+ def login(
29
+ token: Optional[str] = typer.Option(
30
+ None, "--token", "-t",
31
+ help="手动输入 Bearer token(跳过浏览器登录)",
32
+ ),
33
+ ) -> None:
34
+ """🔐 登录得到笔记 — 通过浏览器自动获取 token"""
35
+ from getnotes_cli.auth import get_or_refresh_token, login_with_token
36
+
37
+ if token:
38
+ # 手动输入 token
39
+ auth = login_with_token(token)
40
+ console.print(f"\n[green]✓[/green] Token 已保存!")
41
+ console.print(f" Authorization: {auth.authorization[:50]}...")
42
+ return
43
+
44
+ # 自动浏览器登录
45
+ console.print("[bold]🌐 启动浏览器登录...[/bold]")
46
+ console.print("[dim]将打开 Chrome 并导航到得到笔记页面。[/dim]")
47
+ console.print("[dim]请在浏览器中登录,登录后浏览笔记时 token 将自动捕获。[/dim]\n")
48
+
49
+ try:
50
+ auth = get_or_refresh_token(force_login=True)
51
+ console.print(f"\n[green]✓[/green] 登录成功!")
52
+ console.print(f" Authorization: {auth.authorization[:50]}...")
53
+ if auth.csrf_token:
54
+ console.print(f" CSRF Token: {auth.csrf_token[:20]}...")
55
+ console.print(f" Token 已缓存到: ~/.getnotes-cli/auth.json")
56
+ except RuntimeError as e:
57
+ console.print(f"\n[red]✗[/red] {e}")
58
+ raise typer.Exit(1)
59
+
60
+
61
+ # ========================================================================
62
+ # download 命令
63
+ # ========================================================================
64
+
65
+
66
+ @app.command()
67
+ def download(
68
+ all_notes: bool = typer.Option(
69
+ False, "--all",
70
+ help="下载全部笔记",
71
+ ),
72
+ limit: int = typer.Option(
73
+ DEFAULT_LIMIT, "--limit", "-l",
74
+ help=f"下载笔记数量限制(默认 {DEFAULT_LIMIT},--all 时忽略)",
75
+ ),
76
+ output: Optional[str] = typer.Option(
77
+ None, "--output", "-o",
78
+ help="输出目录(可通过 config set 持久化)",
79
+ ),
80
+ delay: Optional[float] = typer.Option(
81
+ None, "--delay", "-d",
82
+ help="请求间隔秒数(可通过 config set 持久化)",
83
+ ),
84
+ page_size: Optional[int] = typer.Option(
85
+ None, "--page-size",
86
+ help="每页拉取数量(可通过 config set 持久化)",
87
+ ),
88
+ force: bool = typer.Option(
89
+ False, "--force", "-f",
90
+ help="强制重新下载,忽略缓存",
91
+ ),
92
+ save_json: bool = typer.Option(
93
+ False, "--save-json", "-j",
94
+ help="保存原始 JSON 数据等技术文件(默认仅保存 Markdown 和附件)",
95
+ ),
96
+ token: Optional[str] = typer.Option(
97
+ None, "--token", "-t",
98
+ help="直接传入 Bearer token(跳过缓存检查)",
99
+ ),
100
+ ) -> None:
101
+ """📥 下载笔记 — 批量下载得到笔记并保存为 Markdown"""
102
+ from getnotes_cli.auth import AuthToken, get_or_refresh_token, login_with_token
103
+ from getnotes_cli.downloader import NoteDownloader
104
+
105
+ # 获取 token
106
+ if token:
107
+ auth = login_with_token(token)
108
+ else:
109
+ try:
110
+ auth = get_or_refresh_token()
111
+ except RuntimeError as e:
112
+ console.print(f"\n[red]✗[/red] {e}")
113
+ console.print("[dim]请先运行 `getnotes login` 登录。[/dim]")
114
+ raise typer.Exit(1)
115
+
116
+ max_notes = None if all_notes else limit
117
+ output_dir = Path(resolve_output(output, str(DEFAULT_OUTPUT_DIR)))
118
+ final_delay = resolve_delay(delay, REQUEST_DELAY)
119
+ final_page_size = resolve_page_size(page_size, PAGE_SIZE)
120
+
121
+ downloader = NoteDownloader(
122
+ token=auth,
123
+ output_dir=output_dir,
124
+ limit=max_notes,
125
+ page_size=final_page_size,
126
+ delay=final_delay,
127
+ force=force,
128
+ save_json=save_json,
129
+ )
130
+ downloader.run()
131
+
132
+
133
+ # ========================================================================
134
+ # cache 命令组
135
+ # ========================================================================
136
+
137
+ cache_app = typer.Typer(
138
+ help="💾 缓存管理",
139
+ no_args_is_help=True,
140
+ )
141
+
142
+
143
+ @cache_app.command("check")
144
+ def cache_check() -> None:
145
+ """📊 查看缓存状态"""
146
+ from getnotes_cli.cache import CacheManager
147
+
148
+ cm = CacheManager(Path(resolve_output(None, str(DEFAULT_OUTPUT_DIR))))
149
+ info = cm.check()
150
+
151
+ if not info["exists"]:
152
+ console.print("[dim]暂无缓存数据。[/dim]")
153
+ console.print(f"缓存路径: {info['path']}")
154
+ return
155
+
156
+ console.print(f"[bold]💾 缓存统计[/bold]")
157
+ console.print(f" 📁 路径: {info['path']}")
158
+ console.print(f" 📝 已缓存笔记: [cyan]{info['count']}[/cyan] 条\n")
159
+
160
+ if info["count"] > 0 and info["count"] <= 20:
161
+ table = Table(title="缓存条目")
162
+ table.add_column("标题", style="cyan", max_width=50)
163
+ table.add_column("创建时间", style="dim")
164
+ for nid, note in info["notes"].items():
165
+ table.add_row(note["title"], note["created_at"])
166
+ console.print(table)
167
+
168
+
169
+ @cache_app.command("clear")
170
+ def cache_clear(
171
+ confirm: bool = typer.Option(
172
+ False, "--confirm", "-y",
173
+ help="跳过确认提示",
174
+ ),
175
+ ) -> None:
176
+ """🗑️ 清除缓存"""
177
+ from getnotes_cli.cache import CacheManager
178
+
179
+ cm = CacheManager(Path(resolve_output(None, str(DEFAULT_OUTPUT_DIR))))
180
+ info = cm.check()
181
+
182
+ if not info["exists"]:
183
+ console.print("[dim]暂无缓存数据。[/dim]")
184
+ return
185
+
186
+ if not confirm:
187
+ typer.confirm(f"确认清除 {info['count']} 条缓存记录?", abort=True)
188
+
189
+ count = cm.clear()
190
+ console.print(f"[green]✓[/green] 已清除 {count} 条缓存记录。")
191
+
192
+
193
+ app.add_typer(cache_app, name="cache")
194
+
195
+
196
+ # ========================================================================
197
+ # notebook 命令组
198
+ # ========================================================================
199
+
200
+ notebook_app = typer.Typer(
201
+ help="📚 知识库管理 — 查看与下载知识库笔记",
202
+ no_args_is_help=True,
203
+ )
204
+
205
+
206
+ def _get_auth(token: str | None) -> "AuthToken":
207
+ """获取认证 token 的通用逻辑"""
208
+ from getnotes_cli.auth import AuthToken, get_or_refresh_token, login_with_token
209
+
210
+ if token:
211
+ return login_with_token(token)
212
+ try:
213
+ return get_or_refresh_token()
214
+ except RuntimeError as e:
215
+ console.print(f"\n[red]✗[/red] {e}")
216
+ console.print("[dim]请先运行 `getnotes login` 登录。[/dim]")
217
+ raise typer.Exit(1)
218
+
219
+
220
+ @notebook_app.command("list")
221
+ def notebook_list(
222
+ token: Optional[str] = typer.Option(
223
+ None, "--token", "-t",
224
+ help="直接传入 Bearer token",
225
+ ),
226
+ ) -> None:
227
+ """📋 列出所有知识库"""
228
+ from getnotes_cli.notebook import fetch_notebooks
229
+
230
+ auth = _get_auth(token)
231
+ console.print("\n[bold]📚 正在获取知识库列表...[/bold]\n")
232
+
233
+ try:
234
+ notebooks = fetch_notebooks(auth)
235
+ except Exception as e:
236
+ console.print(f"[red]✗[/red] 获取失败: {e}")
237
+ raise typer.Exit(1)
238
+
239
+ if not notebooks:
240
+ console.print("[dim]暂无知识库。[/dim]")
241
+ return
242
+
243
+ table = Table(title=f"我的知识库 (共 {len(notebooks)} 个)")
244
+ table.add_column("#", style="dim", width=4)
245
+ table.add_column("知识库名称", style="cyan", max_width=30)
246
+ table.add_column("内容数", justify="right", style="green")
247
+ table.add_column("更新时间", style="dim")
248
+ table.add_column("ID", style="dim", max_width=12)
249
+
250
+ for i, nb in enumerate(notebooks, 1):
251
+ name = nb.get("name", "(未命名)")
252
+ count = nb.get("extend_data", {}).get("all_resource_count", 0)
253
+ update_desc = nb.get("last_update_time_desc", "")
254
+ id_alias = nb.get("id_alias", "")
255
+ table.add_row(str(i), name, str(count), update_desc, id_alias)
256
+
257
+ console.print(table)
258
+ console.print("\n[dim]使用 `getnotes notebook download --name <名称>` 下载指定知识库[/dim]")
259
+ console.print("[dim]使用 `getnotes notebook download-all` 下载全部知识库[/dim]")
260
+
261
+
262
+ @notebook_app.command("download")
263
+ def notebook_download(
264
+ name: Optional[str] = typer.Option(
265
+ None, "--name", "-n",
266
+ help="知识库名称(模糊匹配)",
267
+ ),
268
+ nb_id: Optional[str] = typer.Option(
269
+ None, "--id",
270
+ help="知识库 ID (id_alias)",
271
+ ),
272
+ output: Optional[str] = typer.Option(
273
+ None, "--output", "-o",
274
+ help="输出目录(可通过 config set 持久化)",
275
+ ),
276
+ delay: Optional[float] = typer.Option(
277
+ None, "--delay", "-d",
278
+ help="请求间隔秒数(可通过 config set 持久化)",
279
+ ),
280
+ force: bool = typer.Option(
281
+ False, "--force", "-f",
282
+ help="强制重新下载,忽略已有文件",
283
+ ),
284
+ save_json: bool = typer.Option(
285
+ False, "--save-json", "-j",
286
+ help="保存原始 JSON 数据等技术文件(默认仅保存 Markdown 和附件)",
287
+ ),
288
+ token: Optional[str] = typer.Option(
289
+ None, "--token", "-t",
290
+ help="直接传入 Bearer token",
291
+ ),
292
+ ) -> None:
293
+ """📥 下载指定知识库的笔记"""
294
+ from getnotes_cli.notebook import fetch_notebooks
295
+ from getnotes_cli.notebook_downloader import NotebookDownloader
296
+
297
+ if not name and not nb_id:
298
+ console.print("[red]✗[/red] 请指定 --name 或 --id")
299
+ console.print("[dim]使用 `getnotes notebook list` 查看可用知识库[/dim]")
300
+ raise typer.Exit(1)
301
+
302
+ auth = _get_auth(token)
303
+
304
+ # 获取知识库列表并匹配
305
+ console.print("\n[bold]📚 正在获取知识库列表...[/bold]")
306
+ notebooks = fetch_notebooks(auth)
307
+
308
+ target = None
309
+ if nb_id:
310
+ target = next((nb for nb in notebooks if nb.get("id_alias") == nb_id), None)
311
+ if not target:
312
+ console.print(f"[red]✗[/red] 未找到 ID 为 '{nb_id}' 的知识库")
313
+ raise typer.Exit(1)
314
+ elif name:
315
+ # 模糊匹配
316
+ matches = [nb for nb in notebooks if name.lower() in nb.get("name", "").lower()]
317
+ if not matches:
318
+ console.print(f"[red]✗[/red] 未找到名称包含 '{name}' 的知识库")
319
+ console.print("[dim]可用知识库:[/dim]")
320
+ for nb in notebooks:
321
+ console.print(f" - {nb.get('name', '')}")
322
+ raise typer.Exit(1)
323
+ if len(matches) > 1:
324
+ console.print(f"[yellow]⚠[/yellow] 找到 {len(matches)} 个匹配:")
325
+ for nb in matches:
326
+ console.print(f" - {nb.get('name', '')} (ID: {nb.get('id_alias', '')})")
327
+ console.print("[dim]请使用 --id 精确指定[/dim]")
328
+ raise typer.Exit(1)
329
+ target = matches[0]
330
+
331
+ console.print(f"[green]✓[/green] 目标知识库: {target.get('name', '')}")
332
+
333
+ downloader = NotebookDownloader(
334
+ token=auth,
335
+ output_dir=Path(resolve_output(output, str(DEFAULT_OUTPUT_DIR))),
336
+ delay=resolve_delay(delay, REQUEST_DELAY),
337
+ force=force,
338
+ save_json=save_json,
339
+ )
340
+ downloader.download_notebook(target)
341
+
342
+
343
+ @notebook_app.command("download-all")
344
+ def notebook_download_all(
345
+ output: Optional[str] = typer.Option(
346
+ None, "--output", "-o",
347
+ help="输出目录(可通过 config set 持久化)",
348
+ ),
349
+ delay: Optional[float] = typer.Option(
350
+ None, "--delay", "-d",
351
+ help="请求间隔秒数(可通过 config set 持久化)",
352
+ ),
353
+ force: bool = typer.Option(
354
+ False, "--force", "-f",
355
+ help="强制重新下载",
356
+ ),
357
+ save_json: bool = typer.Option(
358
+ False, "--save-json", "-j",
359
+ help="保存原始 JSON 数据等技术文件(默认仅保存 Markdown 和附件)",
360
+ ),
361
+ token: Optional[str] = typer.Option(
362
+ None, "--token", "-t",
363
+ help="直接传入 Bearer token",
364
+ ),
365
+ ) -> None:
366
+ """📥 下载所有知识库的笔记"""
367
+ from getnotes_cli.notebook import fetch_notebooks
368
+ from getnotes_cli.notebook_downloader import NotebookDownloader
369
+
370
+ auth = _get_auth(token)
371
+
372
+ console.print("\n[bold]📚 正在获取知识库列表...[/bold]")
373
+ notebooks = fetch_notebooks(auth)
374
+
375
+ if not notebooks:
376
+ console.print("[dim]暂无知识库。[/dim]")
377
+ return
378
+
379
+ console.print(f"[green]✓[/green] 共找到 {len(notebooks)} 个知识库:\n")
380
+ for i, nb in enumerate(notebooks, 1):
381
+ name = nb.get("name", "(未命名)")
382
+ count = nb.get("extend_data", {}).get("all_resource_count", 0)
383
+ console.print(f" {i}. {name} ({count} 个内容)")
384
+
385
+ if not typer.confirm(f"\n确认下载全部 {len(notebooks)} 个知识库?"):
386
+ raise typer.Exit()
387
+
388
+ downloader = NotebookDownloader(
389
+ token=auth,
390
+ output_dir=Path(resolve_output(output, str(DEFAULT_OUTPUT_DIR))),
391
+ delay=resolve_delay(delay, REQUEST_DELAY),
392
+ force=force,
393
+ save_json=save_json,
394
+ )
395
+ downloader.download_all(notebooks)
396
+
397
+
398
+ app.add_typer(notebook_app, name="notebook")
399
+
400
+
401
+ # ========================================================================
402
+ # subscribe 命令组
403
+ # ========================================================================
404
+
405
+ subscribe_app = typer.Typer(
406
+ help="📬 订阅知识库管理 — 查看与下载订阅的知识库笔记",
407
+ no_args_is_help=True,
408
+ )
409
+
410
+
411
+ @subscribe_app.command("list")
412
+ def subscribe_list(
413
+ token: Optional[str] = typer.Option(
414
+ None, "--token", "-t",
415
+ help="直接传入 Bearer token",
416
+ ),
417
+ ) -> None:
418
+ """📋 列出所有已订阅的知识库"""
419
+ from getnotes_cli.notebook import fetch_subscribed_notebooks
420
+
421
+ auth = _get_auth(token)
422
+ console.print("\n[bold]📬 正在获取订阅知识库列表...[/bold]\n")
423
+
424
+ try:
425
+ notebooks = fetch_subscribed_notebooks(auth)
426
+ except Exception as e:
427
+ console.print(f"[red]✗[/red] 获取失败: {e}")
428
+ raise typer.Exit(1)
429
+
430
+ if not notebooks:
431
+ console.print("[dim]暂无订阅知识库。[/dim]")
432
+ return
433
+
434
+ table = Table(title=f"已订阅知识库 (共 {len(notebooks)} 个)")
435
+ table.add_column("#", style="dim", width=4)
436
+ table.add_column("知识库名称", style="cyan", max_width=30)
437
+ table.add_column("创建者", style="yellow", max_width=12)
438
+ table.add_column("内容数", justify="right", style="green")
439
+ table.add_column("订阅数", justify="right", style="magenta")
440
+ table.add_column("更新时间", style="dim")
441
+ table.add_column("ID", style="dim", max_width=12)
442
+
443
+ for i, nb in enumerate(notebooks, 1):
444
+ name = nb.get("name", "(未命名)")
445
+ creator = nb.get("creator", "")
446
+ extend = nb.get("extend_data", {})
447
+ count = extend.get("all_resource_count", 0)
448
+ sub_count = extend.get("subscribe_count", 0)
449
+ update_desc = nb.get("last_update_time_desc", "")
450
+ id_alias = nb.get("id_alias", "")
451
+ table.add_row(str(i), name, creator, str(count), str(sub_count), update_desc, id_alias)
452
+
453
+ console.print(table)
454
+ console.print("\n[dim]使用 `getnotes subscribe download --name <名称>` 下载指定订阅知识库[/dim]")
455
+ console.print("[dim]使用 `getnotes subscribe download-all` 下载全部订阅知识库[/dim]")
456
+
457
+
458
+ @subscribe_app.command("download")
459
+ def subscribe_download(
460
+ name: Optional[str] = typer.Option(
461
+ None, "--name", "-n",
462
+ help="知识库名称(模糊匹配)",
463
+ ),
464
+ nb_id: Optional[str] = typer.Option(
465
+ None, "--id",
466
+ help="知识库 ID (id_alias)",
467
+ ),
468
+ output: Optional[str] = typer.Option(
469
+ None, "--output", "-o",
470
+ help="输出目录(可通过 config set 持久化)",
471
+ ),
472
+ delay: Optional[float] = typer.Option(
473
+ None, "--delay", "-d",
474
+ help="请求间隔秒数(可通过 config set 持久化)",
475
+ ),
476
+ force: bool = typer.Option(
477
+ False, "--force", "-f",
478
+ help="强制重新下载,忽略已有文件",
479
+ ),
480
+ save_json: bool = typer.Option(
481
+ False, "--save-json", "-j",
482
+ help="保存原始 JSON 数据等技术文件(默认仅保存 Markdown 和附件)",
483
+ ),
484
+ token: Optional[str] = typer.Option(
485
+ None, "--token", "-t",
486
+ help="直接传入 Bearer token",
487
+ ),
488
+ ) -> None:
489
+ """📥 下载指定订阅知识库的笔记"""
490
+ from getnotes_cli.notebook import fetch_subscribed_notebooks
491
+ from getnotes_cli.notebook_downloader import NotebookDownloader
492
+
493
+ if not name and not nb_id:
494
+ console.print("[red]✗[/red] 请指定 --name 或 --id")
495
+ console.print("[dim]使用 `getnotes subscribe list` 查看已订阅知识库[/dim]")
496
+ raise typer.Exit(1)
497
+
498
+ auth = _get_auth(token)
499
+
500
+ console.print("\n[bold]📬 正在获取订阅知识库列表...[/bold]")
501
+ notebooks = fetch_subscribed_notebooks(auth)
502
+
503
+ target = None
504
+ if nb_id:
505
+ target = next((nb for nb in notebooks if nb.get("id_alias") == nb_id), None)
506
+ if not target:
507
+ console.print(f"[red]✗[/red] 未找到 ID 为 '{nb_id}' 的订阅知识库")
508
+ raise typer.Exit(1)
509
+ elif name:
510
+ matches = [nb for nb in notebooks if name.lower() in nb.get("name", "").lower()]
511
+ if not matches:
512
+ console.print(f"[red]✗[/red] 未找到名称包含 '{name}' 的订阅知识库")
513
+ console.print("[dim]已订阅知识库:[/dim]")
514
+ for nb in notebooks:
515
+ console.print(f" - {nb.get('name', '')} (by {nb.get('creator', '')})")
516
+ raise typer.Exit(1)
517
+ if len(matches) > 1:
518
+ console.print(f"[yellow]⚠[/yellow] 找到 {len(matches)} 个匹配:")
519
+ for nb in matches:
520
+ console.print(f" - {nb.get('name', '')} (ID: {nb.get('id_alias', '')})")
521
+ console.print("[dim]请使用 --id 精确指定[/dim]")
522
+ raise typer.Exit(1)
523
+ target = matches[0]
524
+
525
+ console.print(f"[green]✓[/green] 目标订阅知识库: {target.get('name', '')} (by {target.get('creator', '')})")
526
+
527
+ downloader = NotebookDownloader(
528
+ token=auth,
529
+ output_dir=Path(resolve_output(output, str(DEFAULT_OUTPUT_DIR))),
530
+ delay=resolve_delay(delay, REQUEST_DELAY),
531
+ force=force,
532
+ save_json=save_json,
533
+ )
534
+ downloader.download_notebook(target)
535
+
536
+
537
+ @subscribe_app.command("download-all")
538
+ def subscribe_download_all(
539
+ output: Optional[str] = typer.Option(
540
+ None, "--output", "-o",
541
+ help="输出目录(可通过 config set 持久化)",
542
+ ),
543
+ delay: Optional[float] = typer.Option(
544
+ None, "--delay", "-d",
545
+ help="请求间隔秒数(可通过 config set 持久化)",
546
+ ),
547
+ force: bool = typer.Option(
548
+ False, "--force", "-f",
549
+ help="强制重新下载",
550
+ ),
551
+ save_json: bool = typer.Option(
552
+ False, "--save-json", "-j",
553
+ help="保存原始 JSON 数据等技术文件(默认仅保存 Markdown 和附件)",
554
+ ),
555
+ token: Optional[str] = typer.Option(
556
+ None, "--token", "-t",
557
+ help="直接传入 Bearer token",
558
+ ),
559
+ ) -> None:
560
+ """📥 下载所有订阅知识库的笔记"""
561
+ from getnotes_cli.notebook import fetch_subscribed_notebooks
562
+ from getnotes_cli.notebook_downloader import NotebookDownloader
563
+
564
+ auth = _get_auth(token)
565
+
566
+ console.print("\n[bold]📬 正在获取订阅知识库列表...[/bold]")
567
+ notebooks = fetch_subscribed_notebooks(auth)
568
+
569
+ if not notebooks:
570
+ console.print("[dim]暂无订阅知识库。[/dim]")
571
+ return
572
+
573
+ console.print(f"[green]✓[/green] 共找到 {len(notebooks)} 个订阅知识库:\n")
574
+ for i, nb in enumerate(notebooks, 1):
575
+ name = nb.get("name", "(未命名)")
576
+ creator = nb.get("creator", "")
577
+ count = nb.get("extend_data", {}).get("all_resource_count", 0)
578
+ console.print(f" {i}. {name} by {creator} ({count} 个内容)")
579
+
580
+ if not typer.confirm(f"\n确认下载全部 {len(notebooks)} 个订阅知识库?"):
581
+ raise typer.Exit()
582
+
583
+ downloader = NotebookDownloader(
584
+ token=auth,
585
+ output_dir=Path(resolve_output(output, str(DEFAULT_OUTPUT_DIR))),
586
+ delay=resolve_delay(delay, REQUEST_DELAY),
587
+ force=force,
588
+ save_json=save_json,
589
+ )
590
+ downloader.download_all(notebooks)
591
+
592
+
593
+ app.add_typer(subscribe_app, name="subscribe")
594
+
595
+
596
+ # ========================================================================
597
+ # config 命令组
598
+ # ========================================================================
599
+
600
+ config_app = typer.Typer(
601
+ help="⚙️ 配置管理 — 持久化 output、delay、page-size 等参数",
602
+ no_args_is_help=True,
603
+ )
604
+
605
+
606
+ @config_app.command("set")
607
+ def config_set(
608
+ key: str = typer.Argument(
609
+ ...,
610
+ help="配置项名称(output / delay / page-size)",
611
+ ),
612
+ value: str = typer.Argument(
613
+ ...,
614
+ help="配置值",
615
+ ),
616
+ ) -> None:
617
+ """✏️ 设置配置项"""
618
+ from getnotes_cli.settings import UserSettings
619
+
620
+ settings = UserSettings()
621
+ try:
622
+ converted = settings.set(key, value)
623
+ console.print(f"[green]✓[/green] 已保存: {key} = {converted}")
624
+ except KeyError as e:
625
+ console.print(f"[red]✗[/red] {e}")
626
+ raise typer.Exit(1)
627
+ except ValueError:
628
+ console.print(f"[red]✗[/red] 值 '{value}' 无法转换为 {key} 所需的类型")
629
+ raise typer.Exit(1)
630
+
631
+
632
+ @config_app.command("get")
633
+ def config_get(
634
+ key: Optional[str] = typer.Argument(
635
+ None,
636
+ help="配置项名称(留空显示全部)",
637
+ ),
638
+ ) -> None:
639
+ """📋 查看配置"""
640
+ from getnotes_cli.settings import UserSettings, CONFIG_FILE
641
+
642
+ settings = UserSettings()
643
+
644
+ if key:
645
+ from getnotes_cli.settings import CLI_KEY_MAP
646
+ canon_key = CLI_KEY_MAP.get(key, key)
647
+ val = settings.get(canon_key)
648
+ if val is None:
649
+ console.print(f"[dim]{key} 未设置(使用默认值)[/dim]")
650
+ else:
651
+ console.print(f"{key} = [cyan]{val}[/cyan]")
652
+ return
653
+
654
+ all_cfg = settings.all()
655
+ if not all_cfg:
656
+ console.print("[dim]暂无自定义配置,所有参数使用默认值。[/dim]")
657
+ console.print(f"[dim]配置文件路径: {CONFIG_FILE}[/dim]")
658
+ return
659
+
660
+ console.print("[bold]⚙️ 当前配置[/bold]\n")
661
+ table = Table()
662
+ table.add_column("配置项", style="cyan")
663
+ table.add_column("值", style="green")
664
+ for k, v in all_cfg.items():
665
+ table.add_row(k, str(v))
666
+ console.print(table)
667
+ console.print(f"\n[dim]配置文件: {CONFIG_FILE}[/dim]")
668
+
669
+
670
+ @config_app.command("reset")
671
+ def config_reset(
672
+ confirm: bool = typer.Option(
673
+ False, "--confirm", "-y",
674
+ help="跳过确认提示",
675
+ ),
676
+ ) -> None:
677
+ """🗑️ 清除所有配置"""
678
+ from getnotes_cli.settings import UserSettings
679
+
680
+ settings = UserSettings()
681
+ all_cfg = settings.all()
682
+
683
+ if not all_cfg:
684
+ console.print("[dim]暂无自定义配置。[/dim]")
685
+ return
686
+
687
+ if not confirm:
688
+ console.print("当前配置:")
689
+ for k, v in all_cfg.items():
690
+ console.print(f" {k} = {v}")
691
+ typer.confirm("确认清除所有配置?", abort=True)
692
+
693
+ count = settings.clear()
694
+ console.print(f"[green]✓[/green] 已清除 {count} 项配置。")
695
+
696
+
697
+ app.add_typer(config_app, name="config")
698
+
699
+
700
+
701
+
702
+ @app.callback(invoke_without_command=True)
703
+ def main_callback(
704
+ ctx: typer.Context,
705
+ version: bool = typer.Option(
706
+ False, "--version", "-v",
707
+ help="显示版本号",
708
+ ),
709
+ ) -> None:
710
+ if version:
711
+ console.print(f"getnotes-cli v{__version__}")
712
+ raise typer.Exit()
713
+ if ctx.invoked_subcommand is None:
714
+ console.print(ctx.get_help())
715
+
716
+
717
+ def main():
718
+ """CLI 主入口"""
719
+ app()
720
+
721
+
722
+ if __name__ == "__main__":
723
+ main()