zotcli 0.2.1__tar.gz → 0.2.2__tar.gz

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.
Files changed (120) hide show
  1. {zotcli-0.2.1 → zotcli-0.2.2}/CHANGELOG.md +27 -0
  2. {zotcli-0.2.1 → zotcli-0.2.2}/PKG-INFO +29 -2
  3. {zotcli-0.2.1 → zotcli-0.2.2}/README.md +28 -1
  4. {zotcli-0.2.1 → zotcli-0.2.2}/SKILL.md +31 -1
  5. {zotcli-0.2.1 → zotcli-0.2.2}/docs/commands.md +25 -0
  6. {zotcli-0.2.1 → zotcli-0.2.2}/docs/getting_started.md +8 -0
  7. zotcli-0.2.2/docs/images/fulltext-auth-flow.png +0 -0
  8. {zotcli-0.2.1 → zotcli-0.2.2}/docs/index.md +1 -0
  9. {zotcli-0.2.1 → zotcli-0.2.2}/pyproject.toml +1 -1
  10. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/__init__.py +1 -1
  11. zotcli-0.2.2/src/zotcli/cli/config_cmd.py +101 -0
  12. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/cli/items.py +74 -4
  13. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/cli/main.py +2 -2
  14. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/config.py +82 -5
  15. zotcli-0.2.2/src/zotcli/queries/search.py +292 -0
  16. {zotcli-0.2.1 → zotcli-0.2.2}/tests/conftest.py +7 -0
  17. {zotcli-0.2.1 → zotcli-0.2.2}/tests/test_cli.py +40 -2
  18. {zotcli-0.2.1 → zotcli-0.2.2}/tests/test_queries.py +63 -3
  19. zotcli-0.2.1/src/zotcli/cli/config_cmd.py +0 -55
  20. zotcli-0.2.1/src/zotcli/queries/search.py +0 -133
  21. {zotcli-0.2.1 → zotcli-0.2.2}/.github/workflows/docs.yml +0 -0
  22. {zotcli-0.2.1 → zotcli-0.2.2}/.github/workflows/publish.yml +0 -0
  23. {zotcli-0.2.1 → zotcli-0.2.2}/.gitignore +0 -0
  24. {zotcli-0.2.1 → zotcli-0.2.2}/PLAN_WRITE.md +0 -0
  25. {zotcli-0.2.1 → zotcli-0.2.2}/docs/api_reference.md +0 -0
  26. {zotcli-0.2.1 → zotcli-0.2.2}/docs/architecture-write.md +0 -0
  27. {zotcli-0.2.1 → zotcli-0.2.2}/docs/cli_reference.md +0 -0
  28. {zotcli-0.2.1 → zotcli-0.2.2}/docs/data_models.md +0 -0
  29. {zotcli-0.2.1 → zotcli-0.2.2}/mkdocs.yml +0 -0
  30. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/__main__.py +0 -0
  31. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/cli/__init__.py +0 -0
  32. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/cli/add.py +0 -0
  33. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/cli/attachments.py +0 -0
  34. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/cli/collections.py +0 -0
  35. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/cli/export.py +0 -0
  36. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/cli/render.py +0 -0
  37. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/cli/search.py +0 -0
  38. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/cli/stats.py +0 -0
  39. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/db.py +0 -0
  40. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/export/__init__.py +0 -0
  41. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/export/bibtex.py +0 -0
  42. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/export/csv_.py +0 -0
  43. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/export/json_.py +0 -0
  44. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/export/markdown.py +0 -0
  45. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/logging_setup.py +0 -0
  46. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/models.py +0 -0
  47. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/paths.py +0 -0
  48. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/queries/__init__.py +0 -0
  49. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/queries/attachments.py +0 -0
  50. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/queries/collections.py +0 -0
  51. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/queries/items.py +0 -0
  52. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/queries/tags.py +0 -0
  53. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/__init__.py +0 -0
  54. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/browser.py +0 -0
  55. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/citation_pipeline.py +0 -0
  56. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/collection_assign.py +0 -0
  57. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/connector_client.py +0 -0
  58. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/credentials.py +0 -0
  59. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/csl_json.py +0 -0
  60. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/dedup.py +0 -0
  61. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/identifiers.py +0 -0
  62. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/pdf.py +0 -0
  63. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/preflight.py +0 -0
  64. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/recognize.py +0 -0
  65. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/resolvers/__init__.py +0 -0
  66. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/resolvers/arxiv.py +0 -0
  67. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/resolvers/crossref.py +0 -0
  68. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/resolvers/ieee.py +0 -0
  69. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/resolvers/openalex.py +0 -0
  70. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/resolvers/openlibrary.py +0 -0
  71. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/resolvers/pubmed.py +0 -0
  72. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/resolvers/sciencedirect.py +0 -0
  73. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/resolvers/semantic_scholar.py +0 -0
  74. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/resolvers/unpaywall.py +0 -0
  75. {zotcli-0.2.1 → zotcli-0.2.2}/src/zotcli/write/session.py +0 -0
  76. {zotcli-0.2.1 → zotcli-0.2.2}/test_script.py +0 -0
  77. {zotcli-0.2.1 → zotcli-0.2.2}/tests/__init__.py +0 -0
  78. {zotcli-0.2.1 → zotcli-0.2.2}/tests/fixtures/csl/crossref_numpy.json +0 -0
  79. {zotcli-0.2.1 → zotcli-0.2.2}/tests/fixtures/sample.bib +0 -0
  80. {zotcli-0.2.1 → zotcli-0.2.2}/tests/fixtures/sample.epub +0 -0
  81. {zotcli-0.2.1 → zotcli-0.2.2}/tests/fixtures/sample.pdf +0 -0
  82. {zotcli-0.2.1 → zotcli-0.2.2}/tests/fixtures/sample.ris +0 -0
  83. {zotcli-0.2.1 → zotcli-0.2.2}/tests/integration/__init__.py +0 -0
  84. {zotcli-0.2.1 → zotcli-0.2.2}/tests/integration/test_add_cite.py +0 -0
  85. {zotcli-0.2.1 → zotcli-0.2.2}/tests/integration/test_add_file.py +0 -0
  86. {zotcli-0.2.1 → zotcli-0.2.2}/tests/integration/test_add_import.py +0 -0
  87. {zotcli-0.2.1 → zotcli-0.2.2}/tests/integration/test_add_pipeline.py +0 -0
  88. {zotcli-0.2.1 → zotcli-0.2.2}/tests/integration/test_add_url.py +0 -0
  89. {zotcli-0.2.1 → zotcli-0.2.2}/tests/integration/test_auto_detect.py +0 -0
  90. {zotcli-0.2.1 → zotcli-0.2.2}/tests/integration/test_batch.py +0 -0
  91. {zotcli-0.2.1 → zotcli-0.2.2}/tests/integration/test_connector_client.py +0 -0
  92. {zotcli-0.2.1 → zotcli-0.2.2}/tests/integration/test_verbose.py +0 -0
  93. {zotcli-0.2.1 → zotcli-0.2.2}/tests/integration/test_with_pdf.py +0 -0
  94. {zotcli-0.2.1 → zotcli-0.2.2}/tests/test_db.py +0 -0
  95. {zotcli-0.2.1 → zotcli-0.2.2}/tests/test_export.py +0 -0
  96. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/__init__.py +0 -0
  97. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_browser_optional_dep.py +0 -0
  98. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_citation_pipeline.py +0 -0
  99. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_config_write_section.py +0 -0
  100. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_credentials.py +0 -0
  101. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_csl_json.py +0 -0
  102. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_dedup.py +0 -0
  103. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_identifiers.py +0 -0
  104. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_logging_setup.py +0 -0
  105. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_paths.py +0 -0
  106. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_pdf.py +0 -0
  107. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_recognize.py +0 -0
  108. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_resolvers/__init__.py +0 -0
  109. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_resolvers/test_arxiv.py +0 -0
  110. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_resolvers/test_crossref.py +0 -0
  111. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_resolvers/test_crossref_search.py +0 -0
  112. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_resolvers/test_ieee.py +0 -0
  113. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_resolvers/test_openalex.py +0 -0
  114. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_resolvers/test_openlibrary.py +0 -0
  115. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_resolvers/test_pubmed.py +0 -0
  116. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_resolvers/test_sciencedirect.py +0 -0
  117. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_resolvers/test_semantic_scholar.py +0 -0
  118. {zotcli-0.2.1 → zotcli-0.2.2}/tests/unit/test_resolvers/test_unpaywall.py +0 -0
  119. {zotcli-0.2.1 → zotcli-0.2.2}/zot.png +0 -0
  120. {zotcli-0.2.1 → zotcli-0.2.2}/zotcli.code-workspace +0 -0
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.2.2] — 2026-05-19
6
+
7
+ ### Added
8
+
9
+ - **`zot items fulltext`** — retrieve item full text with a tiered fallback strategy: direct network access (DOI/URL) → configured library credentials → Playwright interactive auth → local Zotero fulltext index → metadata fallback (title/abstract/notes). Supports `--offline`, `--playwright-auth/--no-playwright-auth`, `--max-chars`.
10
+ - **`zot config library-auth`** — store or display per-library institution/username/password credentials used by `zot items fulltext` for paywalled retrieval. Credentials persist under `[library_auth.<id>]` in `config.toml`.
11
+ - **Fulltext search** — `zot search "query" --fulltext` searches against the local Zotero fulltext index.
12
+
13
+ ### Fixed
14
+
15
+ - **`save_config` crashed on `None` values** — when calling `tomli_w.dump`, `None` defaults (e.g. `database.path = None`) raised `TypeError: Object of type 'NoneType' is not TOML serializable`. Now strips `None` entries before serialising.
16
+
17
+ ### Merged
18
+
19
+ - Combined `feat/write-capability` (M1–M6 write subsystem) with PR #1 (network-first fulltext retrieval) onto a single linear history on `master`.
20
+
21
+ ## [0.2.1] — 2026-05-17
22
+
23
+ ### Added
24
+
25
+ - **`zot collections assign`** — assign an existing item to a collection. Writes a single row to the `collectionItems` join table (no sync-critical metadata involved; safe while Zotero is open).
26
+ - **Collection membership check** — `zot collections show` now indicates whether a given item belongs to the collection.
27
+
28
+ ### Fixed
29
+
30
+ - `updateSession` payload — connector requires `target` as a flat string, not a wrapped object; saveItems calls with an empty body no longer fail.
31
+
5
32
  ## [0.2.0] — 2026-05-10
6
33
 
7
34
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zotcli
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: A CLI for browsing, exporting, and adding items to a Zotero library
5
5
  Author-email: MohamedNumair <mo7amednumair@gmail.com>
6
6
  License: MIT
@@ -33,7 +33,7 @@ Description-Content-Type: text/markdown
33
33
  # zotcli (`zot`)
34
34
  ![alt text](zot.png)
35
35
 
36
- A command-line interface for your local [Zotero](https://www.zotero.org/) library. **Default: strictly read-only.** Write capabilities are opt-in and route through Zotero's own connector HTTP server — `zotero.sqlite` is **never** modified directly.
36
+ A "crazy" good command-line interface for your local [Zotero](https://www.zotero.org/) library. Queries `zotero.sqlite` directly — no Zotero app running and no API key required for reads. **Default: strictly read-only.** Write capabilities are opt-in and route through Zotero's own connector HTTP server — `zotero.sqlite` is **never** modified directly.
37
37
 
38
38
  **Library stats on this machine:** 3,771 items · 200 collections · 3,201 tags · 6.2 GB storage
39
39
 
@@ -395,6 +395,22 @@ zot attachments open 5UFZMSLU
395
395
 
396
396
  ---
397
397
 
398
+ ### `zot items fulltext` — retrieve full text
399
+
400
+ ```bash
401
+ zot items fulltext 5UFZMSLU
402
+ zot items fulltext 5UFZMSLU --offline
403
+ ```
404
+
405
+ Retrieval order:
406
+ 1. direct network access from DOI/URL (institution/network-location access),
407
+ 2. configured credentials (`zot config library-auth`),
408
+ 3. Playwright interactive login fallback,
409
+ 4. local Zotero fulltext index,
410
+ 5. metadata fallback (title/abstract/notes).
411
+
412
+ ---
413
+
398
414
  ### `zot export` — export to files
399
415
 
400
416
  **BibTeX:**
@@ -542,6 +558,11 @@ Self-contained config at `<zotcli-home>/config.toml`. Run `zot config path` to f
542
558
  [database]
543
559
  path = "" # empty = auto-detect zotero.sqlite
544
560
 
561
+ [output]
562
+ default_format = "table"
563
+ color = true
564
+ page_size = 50
565
+
545
566
  [write]
546
567
  enabled = false # opt-in; set true once with: zot config set write.enabled true
547
568
  connector_url = "http://127.0.0.1:23119"
@@ -553,6 +574,11 @@ email = ""
553
574
 
554
575
  [browser]
555
576
  headless = false # SSO/captcha needs headed browser
577
+
578
+ [library_auth.1]
579
+ institution = "KU Leuven"
580
+ username = "alice"
581
+ password = "token"
556
582
  ```
557
583
 
558
584
  Override `<zotcli-home>` with the `ZOTCLI_HOME` environment variable.
@@ -563,6 +589,7 @@ Override `<zotcli-home>` with the `ZOTCLI_HOME` environment variable.
563
589
 
564
590
  - Database opened with `sqlite3://…?mode=ro` — the OS-level read-only URI flag makes direct writes impossible
565
591
  - WAL journal detection warns if Zotero is currently open (pending writes may not be visible yet)
592
+ - Network retrieval is optional and only used by `zot items fulltext` unless `--offline` is passed
566
593
  - Write operations route through Zotero's own connector HTTP server — never via direct SQLite mutation
567
594
  - Default is read-only; writes require explicit opt-in (`zot config set write.enabled true`)
568
595
  - No Zotero API key required
@@ -1,7 +1,7 @@
1
1
  # zotcli (`zot`)
2
2
  ![alt text](zot.png)
3
3
 
4
- A command-line interface for your local [Zotero](https://www.zotero.org/) library. **Default: strictly read-only.** Write capabilities are opt-in and route through Zotero's own connector HTTP server — `zotero.sqlite` is **never** modified directly.
4
+ A "crazy" good command-line interface for your local [Zotero](https://www.zotero.org/) library. Queries `zotero.sqlite` directly — no Zotero app running and no API key required for reads. **Default: strictly read-only.** Write capabilities are opt-in and route through Zotero's own connector HTTP server — `zotero.sqlite` is **never** modified directly.
5
5
 
6
6
  **Library stats on this machine:** 3,771 items · 200 collections · 3,201 tags · 6.2 GB storage
7
7
 
@@ -363,6 +363,22 @@ zot attachments open 5UFZMSLU
363
363
 
364
364
  ---
365
365
 
366
+ ### `zot items fulltext` — retrieve full text
367
+
368
+ ```bash
369
+ zot items fulltext 5UFZMSLU
370
+ zot items fulltext 5UFZMSLU --offline
371
+ ```
372
+
373
+ Retrieval order:
374
+ 1. direct network access from DOI/URL (institution/network-location access),
375
+ 2. configured credentials (`zot config library-auth`),
376
+ 3. Playwright interactive login fallback,
377
+ 4. local Zotero fulltext index,
378
+ 5. metadata fallback (title/abstract/notes).
379
+
380
+ ---
381
+
366
382
  ### `zot export` — export to files
367
383
 
368
384
  **BibTeX:**
@@ -510,6 +526,11 @@ Self-contained config at `<zotcli-home>/config.toml`. Run `zot config path` to f
510
526
  [database]
511
527
  path = "" # empty = auto-detect zotero.sqlite
512
528
 
529
+ [output]
530
+ default_format = "table"
531
+ color = true
532
+ page_size = 50
533
+
513
534
  [write]
514
535
  enabled = false # opt-in; set true once with: zot config set write.enabled true
515
536
  connector_url = "http://127.0.0.1:23119"
@@ -521,6 +542,11 @@ email = ""
521
542
 
522
543
  [browser]
523
544
  headless = false # SSO/captcha needs headed browser
545
+
546
+ [library_auth.1]
547
+ institution = "KU Leuven"
548
+ username = "alice"
549
+ password = "token"
524
550
  ```
525
551
 
526
552
  Override `<zotcli-home>` with the `ZOTCLI_HOME` environment variable.
@@ -531,6 +557,7 @@ Override `<zotcli-home>` with the `ZOTCLI_HOME` environment variable.
531
557
 
532
558
  - Database opened with `sqlite3://…?mode=ro` — the OS-level read-only URI flag makes direct writes impossible
533
559
  - WAL journal detection warns if Zotero is currently open (pending writes may not be visible yet)
560
+ - Network retrieval is optional and only used by `zot items fulltext` unless `--offline` is passed
534
561
  - Write operations route through Zotero's own connector HTTP server — never via direct SQLite mutation
535
562
  - Default is read-only; writes require explicit opt-in (`zot config set write.enabled true`)
536
563
  - No Zotero API key required
@@ -241,17 +241,30 @@ zot items list [--type TYPE] [--collection NAME] [--limit N]
241
241
  zot items show <id|key>
242
242
  zot items attachments <id|key>
243
243
  zot items notes <id|key>
244
- zot search "query" [--field title] [--author NAME] [--doi DOI] [--tag TAG] [--year YEAR]
244
+ zot items fulltext <id|key> [--offline] [--playwright-auth/--no-playwright-auth]
245
+
246
+ # Search
247
+ zot search "query" [--field title] [--field abstract] [--type TYPE]
248
+ zot search --author "Name"
249
+ zot search --doi 10.xxxx/yyyy
250
+ zot search --tag "tag-name"
251
+ zot search --year 2020-2024
252
+ zot search "query" --fulltext
253
+
254
+ # Attachments
245
255
  zot attachments list [--missing] [--type pdf]
246
256
  zot attachments path <id|key>
247
257
  zot attachments open <id|key>
248
258
  zot export json|csv|bib|markdown --collection "Name" [--output file]
249
259
  zot export json|csv|bib|markdown --all [--output file]
260
+ zot export markdown --all --notes --output report.md
250
261
 
251
262
  # Config (read-only-safe)
252
263
  zot config path
253
264
  zot config get <key>
254
265
  zot config set <key> <value>
266
+ zot config library-auth --library 1 --institution "KU Leuven" --username "alice" --password "token"
267
+ zot config library-auth --library 1 --show
255
268
 
256
269
  # Write — requires write.enabled=true and Zotero running
257
270
  zot add status
@@ -299,7 +312,24 @@ If it shows unreachable, trigger the protocol above **before** attempting the ad
299
312
 
300
313
  - Database opened in **strict read-only URI mode** (`sqlite3://…?mode=ro`) — impossible to corrupt your Zotero library via direct SQL
301
314
  - WAL journal detection: warns if Zotero is currently running (changes may not be visible yet)
315
+ - Network calls are optional and used only by `zot items fulltext` unless `--offline` is passed
302
316
  - Writes only via Zotero's own connector HTTP server — never via direct SQLite mutation for item data. Default is read-only; writes require explicit opt-in (`zot config set write.enabled true`)
303
317
  - **One exception:** `zot collection assign` (and `zot add … --collection` on a duplicate) writes a single row into `collectionItems`, the join table that records which items belong to which collections. This table carries no sync-critical metadata (no version counters, no timestamps) so the write is safe even while Zotero is open. Zotero will pick up the change on its next UI refresh.
304
318
  - No Zotero API key required
305
319
  - All credentials stored at rest in `<zotcli-home>/credentials.json` (mode 0600 on POSIX)
320
+
321
+ ### Full-text retrieval strategy
322
+
323
+ `zot items fulltext` uses this order:
324
+ 1. direct network access (institution/network location),
325
+ 2. config credentials (`zot config library-auth`),
326
+ 3. Playwright interactive authentication fallback,
327
+ 4. local Zotero fulltext index,
328
+ 5. metadata fallback (title/abstract/notes).
329
+
330
+ ## Running tests
331
+
332
+ ```bash
333
+ python3 -m pytest tests/ -q # unit + integration tests (in-memory DB fixture)
334
+ python3 -m pytest tests/e2e -m e2e # End-to-end against real zotero.sqlite (slow)
335
+ ```
@@ -88,6 +88,8 @@ zot items show AABB0001
88
88
 
89
89
  zot items attachments <id|key> # Attachments with path + existence check
90
90
  zot items notes <id|key> # Attached notes (HTML stripped)
91
+ zot items fulltext <id|key> # Network-first full-text retrieval
92
+ zot items fulltext <id|key> --offline # Only local Zotero fulltext/metadata
91
93
  ```
92
94
 
93
95
  `<id|key>` accepts a numeric item ID or the 8-character Zotero key (e.g. `AABB0001`).
@@ -192,6 +194,29 @@ zot export bib --collection "NLP"
192
194
 
193
195
  ---
194
196
 
197
+ ## `zot config`
198
+
199
+ Manage local CLI configuration values.
200
+
201
+ ```bash
202
+ zot config library-auth --library 1 --institution "KU Leuven" --username "alice" --password "token"
203
+ zot config library-auth --library 1 --show
204
+ ```
205
+
206
+ `library-auth` stores optional per-library institutional/login details used when direct network-location access to full text fails.
207
+
208
+ ### Full-text retrieval strategy (`zot items fulltext`)
209
+
210
+ The command follows this order:
211
+
212
+ 1. **Direct network access** from DOI/URL (works immediately on institutional networks like campus/VPN).
213
+ 2. **Config credentials** from `zot config library-auth` (username/password flow).
214
+ 3. **Playwright-assisted login** (interactive browser fallback).
215
+ 4. **Local Zotero fulltext index**.
216
+ 5. **Metadata fallback** (title/abstract/notes).
217
+
218
+ ---
219
+
195
220
  ## Item types reference
196
221
 
197
222
  Common values for `--type`:
@@ -27,6 +27,10 @@ zot search "bayesian" --field title
27
27
 
28
28
  # Get attachment paths for a specific item
29
29
  zot attachments path <ITEM_ID_OR_KEY>
30
+
31
+ # Retrieve full text (network-first, then auth/local fallbacks)
32
+ zot items fulltext <ITEM_ID_OR_KEY>
33
+ zot items fulltext <ITEM_ID_OR_KEY> --offline
30
34
  ```
31
35
 
32
36
  ## Programmatic Usage (SDK)
@@ -62,3 +66,7 @@ def extract_pdfs():
62
66
  print("Extracting PDFs...")
63
67
  extract_pdfs()
64
68
  ```
69
+
70
+ ### CLI preview (fulltext + auth config)
71
+
72
+ ![Fulltext and auth CLI preview](images/fulltext-auth-flow.png)
@@ -8,6 +8,7 @@
8
8
  - **Read-Only**: Safe to use. Never writes to the database.
9
9
  - **Python SDK**: Rich programmatic access to your Zotero library via Pydantic models.
10
10
  - **CLI Tool**: Powerful `zot` command-line interface for browsing, searching, and exporting.
11
+ - **Full-text Retrieval Flow**: Network-first retrieval with config-auth and Playwright fallback.
11
12
 
12
13
  ## Project Layout
13
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "zotcli"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "A CLI for browsing, exporting, and adding items to a Zotero library"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """zotcli — A read-only CLI for browsing and exporting a Zotero library."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.2.2"
@@ -0,0 +1,101 @@
1
+ """CLI group: zot config — read/write zotcli configuration and per-library auth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from zotcli.cli.main import Context, pass_ctx
10
+ from zotcli.cli.render import make_console
11
+ from zotcli.config import get_library_auth, set_library_auth
12
+
13
+
14
+ @click.group("config")
15
+ def config_cmd():
16
+ """Manage zotcli settings."""
17
+
18
+
19
+ @config_cmd.command("path")
20
+ def config_path_cmd():
21
+ """Print the zotcli home directory path."""
22
+ from zotcli.paths import zotcli_home
23
+ click.echo(str(zotcli_home()))
24
+
25
+
26
+ @config_cmd.command("get")
27
+ @click.argument("key")
28
+ def config_get(key: str):
29
+ """Print the value of KEY (section.key form, e.g. write.enabled).
30
+
31
+ Exits with code 1 if the key is not set.
32
+ """
33
+ from zotcli.config import get_config_value
34
+ val = get_config_value(key)
35
+ if val is None:
36
+ click.echo(f"Key '{key}' is not set.", err=True)
37
+ sys.exit(1)
38
+ click.echo(val)
39
+
40
+
41
+ @config_cmd.command("set")
42
+ @click.argument("key")
43
+ @click.argument("value")
44
+ def config_set(key: str, value: str):
45
+ """Set KEY to VALUE and persist to <zotcli-home>/config.toml.
46
+
47
+ Supported keys include: write.enabled, write.connector_url,
48
+ unpaywall.email, and any other dotted section.key value.
49
+ """
50
+ from zotcli.config import set_config_value
51
+ set_config_value(key, value)
52
+ click.echo(f"Set {key} = {value}")
53
+
54
+
55
+ @config_cmd.command("library-auth")
56
+ @click.option("--library", "library_id", type=int, default=None, help="Library ID (default: current)")
57
+ @click.option("--institution", default=None, help="Institution/library provider name")
58
+ @click.option("--username", default=None, help="Library login username")
59
+ @click.option("--password", default=None, help="Library login password/token")
60
+ @click.option("--show", is_flag=True, help="Show current authentication details")
61
+ @pass_ctx
62
+ def library_auth(
63
+ ctx: Context,
64
+ library_id: int | None,
65
+ institution: str | None,
66
+ username: str | None,
67
+ password: str | None,
68
+ show: bool,
69
+ ):
70
+ """Store or display per-library authentication details for full-text retrieval."""
71
+ console = make_console(ctx.color)
72
+ lib_id = library_id if library_id is not None else ctx.library_id
73
+
74
+ if show:
75
+ auth = get_library_auth(lib_id)
76
+ if not any(auth.values()):
77
+ console.print(f"[dim]No auth details configured for library {lib_id}.[/dim]")
78
+ return
79
+ masked_password = "******" if auth["password"] else ""
80
+ console.print(f"Library: {lib_id}")
81
+ console.print(f"Institution: {auth['institution']}")
82
+ console.print(f"Username: {auth['username']}")
83
+ console.print(f"Password: {masked_password}")
84
+ return
85
+
86
+ institution_value = institution or click.prompt("Institution", default="", show_default=False)
87
+ username_value = username or click.prompt("Username", default="", show_default=False)
88
+ password_value = password or click.prompt(
89
+ "Password / token",
90
+ default="",
91
+ show_default=False,
92
+ hide_input=True,
93
+ )
94
+
95
+ set_library_auth(
96
+ lib_id,
97
+ institution=institution_value.strip(),
98
+ username=username_value.strip(),
99
+ password=password_value,
100
+ )
101
+ console.print(f"[green]Saved auth details for library {lib_id}.[/green]")
@@ -6,10 +6,12 @@ import click
6
6
  from rich.panel import Panel
7
7
  from rich.table import Table
8
8
 
9
- from zotcli.cli.main import pass_ctx, Context
10
- from zotcli.cli.render import make_console, item_panel, items_table
11
- from zotcli.queries.items import get_items, get_item
9
+ from zotcli.cli.main import Context, pass_ctx
10
+ from zotcli.cli.render import item_panel, items_table, make_console
11
+ from zotcli.config import get_library_auth
12
12
  from zotcli.queries.collections import get_collection_by_name, get_items_in_collection
13
+ from zotcli.queries.items import get_item, get_items
14
+ from zotcli.queries.search import get_item_fulltext_with_strategy
13
15
 
14
16
 
15
17
  @click.group()
@@ -60,7 +62,7 @@ def show_item(ctx: Context, id_or_key: str):
60
62
  item = get_item(ctx.db, item_id)
61
63
  if item is None:
62
64
  raise click.ClickException(f"Item not found: {id_or_key!r}")
63
-
65
+
64
66
  from zotcli.queries.collections import get_collection_by_id
65
67
  cols = []
66
68
  if item.collections:
@@ -118,3 +120,71 @@ def item_notes(ctx: Context, id_or_key: str):
118
120
 
119
121
  for note in item.notes:
120
122
  console.print(Panel(note.plain_text[:2000], title=note.title or "Note"))
123
+
124
+
125
+ @items.command("fulltext")
126
+ @click.argument("id_or_key")
127
+ @click.option("--max-chars", default=10000, show_default=True, help="Maximum number of characters to print")
128
+ @click.option("--offline", is_flag=True, help="Skip network/auth retrieval and only use local Zotero data")
129
+ @click.option(
130
+ "--playwright-auth/--no-playwright-auth",
131
+ default=True,
132
+ show_default=True,
133
+ help="Use Playwright login fallback when direct/config retrieval fails",
134
+ )
135
+ @pass_ctx
136
+ def item_fulltext(
137
+ ctx: Context,
138
+ id_or_key: str,
139
+ max_chars: int,
140
+ offline: bool,
141
+ playwright_auth: bool,
142
+ ):
143
+ """Retrieve full text for an item with network/auth/local fallback."""
144
+ console = make_console(ctx.color)
145
+ item_id_val = int(id_or_key) if id_or_key.isdigit() else id_or_key
146
+ auth = get_library_auth(ctx.library_id)
147
+
148
+ def _playwright_fetch(url: str) -> str | None:
149
+ if not playwright_auth:
150
+ return None
151
+
152
+ try:
153
+ from playwright.sync_api import Error as PlaywrightError
154
+ from playwright.sync_api import sync_playwright
155
+ except ImportError:
156
+ console.print(
157
+ "[yellow]Playwright fallback unavailable (install `playwright` + browser binaries).[/yellow]"
158
+ )
159
+ return None
160
+
161
+ console.print(
162
+ "[yellow]Open browser for manual institutional login.[/yellow]\n"
163
+ "Complete authentication in the opened window, then press Enter here."
164
+ )
165
+
166
+ try:
167
+ with sync_playwright() as p:
168
+ browser = p.chromium.launch(headless=False)
169
+ page = browser.new_page()
170
+ page.goto(url, wait_until="domcontentloaded", timeout=45000)
171
+ click.pause()
172
+ text = page.locator("body").inner_text(timeout=5000).strip()
173
+ browser.close()
174
+ return text or None
175
+ except (PlaywrightError, TimeoutError):
176
+ return None
177
+
178
+ text, source = get_item_fulltext_with_strategy(
179
+ ctx.db,
180
+ item_id_val,
181
+ prefer_network=not offline,
182
+ auth=auth,
183
+ playwright_fetcher=_playwright_fetch if playwright_auth and not offline else None,
184
+ )
185
+ if text is None:
186
+ raise click.ClickException(f"Item not found: {id_or_key!r}")
187
+
188
+ title = f"Full text for {id_or_key}"
189
+ console.print(f"[dim]Source: {source}[/dim]")
190
+ console.print(Panel(text[:max_chars], title=title))
@@ -126,6 +126,7 @@ from zotcli.cli import attachments as _att_mod # noqa: E402
126
126
  from zotcli.cli import search as _search_mod # noqa: E402
127
127
  from zotcli.cli import stats as _stats_mod # noqa: E402
128
128
  from zotcli.cli import export as _export_mod # noqa: E402
129
+ from zotcli.cli import config_cmd as _config_mod # noqa: E402
129
130
 
130
131
  cli.add_command(_col_mod.collections)
131
132
  cli.add_command(_items_mod.items)
@@ -133,10 +134,9 @@ cli.add_command(_att_mod.attachments)
133
134
  cli.add_command(_search_mod.search)
134
135
  cli.add_command(_stats_mod.stats)
135
136
  cli.add_command(_export_mod.export)
137
+ cli.add_command(_config_mod.config_cmd)
136
138
 
137
139
  # Import and register write-capability groups (M1+)
138
- from zotcli.cli.config_cmd import config as _config_cmd # noqa: E402
139
140
  from zotcli.cli.add import add as _add_cmd # noqa: E402
140
141
 
141
- cli.add_command(_config_cmd)
142
142
  cli.add_command(_add_cmd)
@@ -2,8 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import os
6
- import sys
5
+ import json
7
6
  from pathlib import Path
8
7
 
9
8
  try:
@@ -34,6 +33,7 @@ _DEFAULTS: dict = {
34
33
  "color": True,
35
34
  "page_size": 50,
36
35
  },
36
+ "library_auth": {},
37
37
  }
38
38
 
39
39
  # Defaults for the [write] section (stored in zotcli-home/config.toml)
@@ -68,11 +68,56 @@ def load_config() -> dict:
68
68
 
69
69
  def save_config(cfg: dict) -> None:
70
70
  """Persist config to disk (TOML format)."""
71
- import tomli_w # type: ignore[import]
72
71
  p = _config_path()
73
72
  p.parent.mkdir(parents=True, exist_ok=True)
74
- with open(p, "wb") as f:
75
- tomli_w.dump(cfg, f)
73
+ try:
74
+ import tomli_w # type: ignore[import]
75
+
76
+ with open(p, "wb") as f:
77
+ tomli_w.dump(_strip_none(cfg), f)
78
+ return
79
+ except ImportError:
80
+ pass
81
+
82
+ lines: list[str] = []
83
+ for section, values in cfg.items():
84
+ if not isinstance(values, dict):
85
+ continue
86
+ lines.append(f"[{section}]")
87
+ for key, value in values.items():
88
+ if isinstance(value, dict):
89
+ lines.append(f"[{section}.{key}]")
90
+ for nested_key, nested_value in value.items():
91
+ if isinstance(nested_value, dict):
92
+ raise ValueError(
93
+ "Fallback TOML writer supports only one level of nested tables; "
94
+ "install tomli_w for deeper nesting."
95
+ )
96
+ if nested_value is None:
97
+ continue
98
+ lines.append(f"{nested_key} = {_toml_literal(nested_value)}")
99
+ else:
100
+ if value is None:
101
+ continue
102
+ lines.append(f"{key} = {_toml_literal(value)}")
103
+ lines.append("")
104
+ p.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
105
+
106
+
107
+ def _strip_none(obj):
108
+ if isinstance(obj, dict):
109
+ return {k: _strip_none(v) for k, v in obj.items() if v is not None}
110
+ return obj
111
+
112
+
113
+ def _toml_literal(value: object) -> str:
114
+ if isinstance(value, bool):
115
+ return "true" if value else "false"
116
+ if isinstance(value, int):
117
+ return str(value)
118
+ if isinstance(value, float):
119
+ return str(value)
120
+ return json.dumps(str(value))
76
121
 
77
122
 
78
123
  def get_db_path(override: str | None = None) -> Path | None:
@@ -93,6 +138,38 @@ def get_library_id(override: int | None = None) -> int:
93
138
  return cfg.get("database", {}).get("library_id", 1)
94
139
 
95
140
 
141
+ def get_library_auth(library_id: int) -> dict[str, str]:
142
+ cfg = load_config()
143
+ auth = cfg.get("library_auth", {}).get(str(library_id), {})
144
+ if not isinstance(auth, dict):
145
+ return {}
146
+ return {
147
+ "institution": str(auth.get("institution", "") or ""),
148
+ "username": str(auth.get("username", "") or ""),
149
+ "password": str(auth.get("password", "") or ""),
150
+ }
151
+
152
+
153
+ def set_library_auth(
154
+ library_id: int,
155
+ *,
156
+ institution: str,
157
+ username: str,
158
+ password: str,
159
+ ) -> None:
160
+ cfg = load_config()
161
+ auth_section = cfg.setdefault("library_auth", {})
162
+ if not isinstance(auth_section, dict):
163
+ auth_section = {}
164
+ cfg["library_auth"] = auth_section
165
+ auth_section[str(library_id)] = {
166
+ "institution": institution,
167
+ "username": username,
168
+ "password": password,
169
+ }
170
+ save_config(cfg)
171
+
172
+
96
173
  # ---------------------------------------------------------------------------
97
174
  # Write-section helpers — config stored in <zotcli-home>/config.toml
98
175
  # ---------------------------------------------------------------------------