zotkit 0.5.2__tar.gz → 0.7.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zotkit
3
- Version: 0.5.2
3
+ Version: 0.7.0
4
4
  Summary: Headless Zotero library management: Web API CRUD plus direct WebDAV attachment upload/download — no desktop app required
5
5
  Author: Shawn
6
6
  License-Expression: MIT
@@ -16,6 +16,8 @@ Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: pyzotero
18
18
  Requires-Dist: httpx
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == "dev"
19
21
  Dynamic: license-file
20
22
 
21
23
  # zotkit
@@ -37,7 +39,7 @@ server**, zotkit can **upload and download the files themselves** by speaking
37
39
  Zotero's WebDAV storage format directly — a capability the Web API itself does not
38
40
  provide. The format is documented in [docs/webdav-format.md](docs/webdav-format.md).
39
41
 
40
- Built for servers, scripts, and **LLM agents**: every write is dry-run by default,
42
+ Built for servers, scripts, and **LLM agents**: bulk writes are dry-run by default,
41
43
  batched, and version-checked, and you can define a tag taxonomy that is *enforced in
42
44
  code* so an agent (or a tired human) can't pollute your library with inconsistent tags.
43
45
 
@@ -101,6 +103,8 @@ Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
101
103
  ```bash
102
104
  zotkit find --title "boson sampling" # search by title/tag/collection
103
105
  zotkit find --tag status:to-read
106
+ zotkit show AB12CD34 EF56GH78 # one line per key (read-only);
107
+ # --json for the full item data
104
108
 
105
109
  zotkit create --arxiv 2401.12345 # fetch arXiv metadata, dry-run preview
106
110
  zotkit create --arxiv 2401.12345 --apply --tags field:ai # create + download & attach the PDF
@@ -109,17 +113,26 @@ zotkit create --arxiv 2401.12345 1706.03762 math/0211159 --apply # batch: one
109
113
  zotkit create --doi 10.1038/nature14539 # fetch CrossRef metadata by DOI (no PDF —
110
114
  # usually paywalled; attach manually after)
111
115
 
112
- zotkit create --file papers.json # batch from JSON: dry-run preview
116
+ zotkit create --file papers.json # batch from JSON: dry-run preview —
117
+ # flags items already in the library
113
118
  zotkit create --file papers.json --apply # create (dedups by DOI/title)
114
119
  zotkit attach --from papers.created.json --all # upload the PDFs (WebDAV or Zotero
115
120
  # Storage — auto-detected from .env)
116
121
 
117
122
  zotkit enrich --key AB12CD34 EF56GH78 # fill missing fields on existing items
118
123
  # in place (dry-run; --apply to write)
124
+ zotkit enrich --missing abstract --apply # …or batch: every item missing an
125
+ # abstract (also --missing doi, --all)
126
+ zotkit audit # read-only health report: what's missing
127
+ # where, stamp hygiene (--json for agents)
128
+ zotkit abstract --key AB12CD34 --source cnki --file abs.txt # paste an abstract
129
+ # with provenance (owner's tool)
119
130
 
120
131
  zotkit attach --key AB12CD34 --pdf paper.pdf # single attach
121
132
  zotkit fetch --key AB12CD34 --out downloads # download attachments (same auto-detection)
122
133
 
134
+ zotkit export --key AB12CD34 EF56GH78 -o refs.bib # BibTeX; cite key = item key
135
+
123
136
  zotkit tag AB12CD34 topic:qaoa prio:high # validated against conventions.toml
124
137
  zotkit status AB12CD34 read # replaces the status: tag
125
138
  zotkit move AB12CD34 "Algorithms" # or "Parent :: Child"; --add keeps old home
@@ -141,6 +154,14 @@ daemon-free CLI — no translation-server). CrossRef requests identify themselve
141
154
  to the polite pool with a contact address; that should be reachable, so if you
142
155
  distribute a tool built on zotkit, set your own via `ZOTKIT_MAILTO`.
143
156
 
157
+ **Duplicates**: `create` checks every candidate against the library by exact DOI and
158
+ by normalized title, and skips the ones already there — that has always been the
159
+ `--apply` behavior, and the dry run now runs the same check up front, printing
160
+ `!! already in library as <KEY> — --apply will skip it (use --no-dedup to force)`
161
+ so you see the collision before writing anything. The apply path names the existing
162
+ item's key in its skip messages too. `--no-dedup` turns the check off and creates
163
+ the item regardless.
164
+
144
165
  **Version of record**: when arXiv reports a *journal* DOI (the paper was formally
145
166
  published), `--arxiv` builds the journal record from CrossRef instead of a
146
167
  `preprint`: proper item
@@ -159,6 +180,29 @@ gates `enrich --rebuild-record`. Items whose abstract zotkit
159
180
  wrote also carry an `abstract-source: arxiv|crossref` line in Extra, naming where
160
181
  it actually came from.
161
182
 
183
+ ### Looking items up: `show` and `export`
184
+
185
+ `zotkit show K [K …]` is the read-only lookup by item key: one line per key —
186
+ `KEY · itemType · FirstAuthor Year · Title · DOI-or-arXiv-id` — or `--json` for the
187
+ full item data. An unknown key is reported on stderr and the exit code is 1, while
188
+ the remaining keys still print. This deliberately supersedes requests for a
189
+ `find --key` flag and a `verify --manifest` subcommand: verifying a manifest is a
190
+ loop over `show` on the project side, and manifest-file parsing is too
191
+ project-specific to belong in zotkit.
192
+
193
+ `zotkit export --key K [K …]` emits BibTeX for those items, to stdout or to `-o
194
+ refs.bib`; keys are also accepted on stdin, one per line, so it composes with
195
+ whatever produced them. Every entry's cite key is **always** rewritten to the
196
+ Zotero item key — that is the point of the command: item keys are the identity in
197
+ the manifest model, and author-year cite keys don't join against them. An unknown
198
+ key errors on stderr (exit 1) without stopping the other entries from exporting.
199
+
200
+ `zotkit fetch` still defaults to `./downloads` and prints the absolute path of every
201
+ file it saves. If you omit `--out` while the current directory sits inside a git
202
+ repository, it warns on stderr and suggests an explicit destination: loose PDF
203
+ copies committed into a repo break a library-of-record setup, where the library is
204
+ the one place a file lives.
205
+
162
206
  ### Enriching existing items
163
207
 
164
208
  `zotkit enrich --key K [K …]` completes incomplete items — missing abstracts,
@@ -181,6 +225,23 @@ published (journal DOI present) to the journal record **in the same item**:
181
225
  itemType, published title/date/venue/volume/pages — with `arXiv: <id>` appended
182
226
  to Extra and attachments untouched.
183
227
 
228
+ Instead of `--key`, `--all` runs over every top-level scholarly item and
229
+ `--missing abstract` / `--missing doi` (repeatable) over just the gaps —
230
+ batching and arXiv/CrossRef rate limiting stay internal, so no pacing or
231
+ batch-splitting on the caller's side. **`zotkit audit`** is the read-only
232
+ companion: counts and key lists for missing abstracts, missing DOI/arXiv id,
233
+ missing PDF attachments, and `abstract-source` stamp hygiene (`--json` for
234
+ agents).
235
+
236
+ For text enrich *can't* fetch — CNKI, publisher pages, your own summary —
237
+ **`zotkit abstract --key K --source <slug>`** stores a pasted abstract
238
+ (stdin or `--file`) with provenance: the text is cleaned (hidden tags,
239
+ `&nbsp;`, soft hyphens, hard-wrapped lines) and an `abstract-source: <slug>`
240
+ line records where it came from (`cnki`, `ssrn`, `publisher`, `manual` = your
241
+ own words — any lowercase slug). It is the owner's tool, so unlike enrich it
242
+ may *replace* an existing abstract — but only with `--force`, which also
243
+ rewrites the stamp line in place; all other Extra lines stay untouched.
244
+
184
245
  Item JSON for `zotkit create --file` (a list, one object per reference):
185
246
 
186
247
  ```json
@@ -17,7 +17,7 @@ server**, zotkit can **upload and download the files themselves** by speaking
17
17
  Zotero's WebDAV storage format directly — a capability the Web API itself does not
18
18
  provide. The format is documented in [docs/webdav-format.md](docs/webdav-format.md).
19
19
 
20
- Built for servers, scripts, and **LLM agents**: every write is dry-run by default,
20
+ Built for servers, scripts, and **LLM agents**: bulk writes are dry-run by default,
21
21
  batched, and version-checked, and you can define a tag taxonomy that is *enforced in
22
22
  code* so an agent (or a tired human) can't pollute your library with inconsistent tags.
23
23
 
@@ -81,6 +81,8 @@ Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
81
81
  ```bash
82
82
  zotkit find --title "boson sampling" # search by title/tag/collection
83
83
  zotkit find --tag status:to-read
84
+ zotkit show AB12CD34 EF56GH78 # one line per key (read-only);
85
+ # --json for the full item data
84
86
 
85
87
  zotkit create --arxiv 2401.12345 # fetch arXiv metadata, dry-run preview
86
88
  zotkit create --arxiv 2401.12345 --apply --tags field:ai # create + download & attach the PDF
@@ -89,17 +91,26 @@ zotkit create --arxiv 2401.12345 1706.03762 math/0211159 --apply # batch: one
89
91
  zotkit create --doi 10.1038/nature14539 # fetch CrossRef metadata by DOI (no PDF —
90
92
  # usually paywalled; attach manually after)
91
93
 
92
- zotkit create --file papers.json # batch from JSON: dry-run preview
94
+ zotkit create --file papers.json # batch from JSON: dry-run preview —
95
+ # flags items already in the library
93
96
  zotkit create --file papers.json --apply # create (dedups by DOI/title)
94
97
  zotkit attach --from papers.created.json --all # upload the PDFs (WebDAV or Zotero
95
98
  # Storage — auto-detected from .env)
96
99
 
97
100
  zotkit enrich --key AB12CD34 EF56GH78 # fill missing fields on existing items
98
101
  # in place (dry-run; --apply to write)
102
+ zotkit enrich --missing abstract --apply # …or batch: every item missing an
103
+ # abstract (also --missing doi, --all)
104
+ zotkit audit # read-only health report: what's missing
105
+ # where, stamp hygiene (--json for agents)
106
+ zotkit abstract --key AB12CD34 --source cnki --file abs.txt # paste an abstract
107
+ # with provenance (owner's tool)
99
108
 
100
109
  zotkit attach --key AB12CD34 --pdf paper.pdf # single attach
101
110
  zotkit fetch --key AB12CD34 --out downloads # download attachments (same auto-detection)
102
111
 
112
+ zotkit export --key AB12CD34 EF56GH78 -o refs.bib # BibTeX; cite key = item key
113
+
103
114
  zotkit tag AB12CD34 topic:qaoa prio:high # validated against conventions.toml
104
115
  zotkit status AB12CD34 read # replaces the status: tag
105
116
  zotkit move AB12CD34 "Algorithms" # or "Parent :: Child"; --add keeps old home
@@ -121,6 +132,14 @@ daemon-free CLI — no translation-server). CrossRef requests identify themselve
121
132
  to the polite pool with a contact address; that should be reachable, so if you
122
133
  distribute a tool built on zotkit, set your own via `ZOTKIT_MAILTO`.
123
134
 
135
+ **Duplicates**: `create` checks every candidate against the library by exact DOI and
136
+ by normalized title, and skips the ones already there — that has always been the
137
+ `--apply` behavior, and the dry run now runs the same check up front, printing
138
+ `!! already in library as <KEY> — --apply will skip it (use --no-dedup to force)`
139
+ so you see the collision before writing anything. The apply path names the existing
140
+ item's key in its skip messages too. `--no-dedup` turns the check off and creates
141
+ the item regardless.
142
+
124
143
  **Version of record**: when arXiv reports a *journal* DOI (the paper was formally
125
144
  published), `--arxiv` builds the journal record from CrossRef instead of a
126
145
  `preprint`: proper item
@@ -139,6 +158,29 @@ gates `enrich --rebuild-record`. Items whose abstract zotkit
139
158
  wrote also carry an `abstract-source: arxiv|crossref` line in Extra, naming where
140
159
  it actually came from.
141
160
 
161
+ ### Looking items up: `show` and `export`
162
+
163
+ `zotkit show K [K …]` is the read-only lookup by item key: one line per key —
164
+ `KEY · itemType · FirstAuthor Year · Title · DOI-or-arXiv-id` — or `--json` for the
165
+ full item data. An unknown key is reported on stderr and the exit code is 1, while
166
+ the remaining keys still print. This deliberately supersedes requests for a
167
+ `find --key` flag and a `verify --manifest` subcommand: verifying a manifest is a
168
+ loop over `show` on the project side, and manifest-file parsing is too
169
+ project-specific to belong in zotkit.
170
+
171
+ `zotkit export --key K [K …]` emits BibTeX for those items, to stdout or to `-o
172
+ refs.bib`; keys are also accepted on stdin, one per line, so it composes with
173
+ whatever produced them. Every entry's cite key is **always** rewritten to the
174
+ Zotero item key — that is the point of the command: item keys are the identity in
175
+ the manifest model, and author-year cite keys don't join against them. An unknown
176
+ key errors on stderr (exit 1) without stopping the other entries from exporting.
177
+
178
+ `zotkit fetch` still defaults to `./downloads` and prints the absolute path of every
179
+ file it saves. If you omit `--out` while the current directory sits inside a git
180
+ repository, it warns on stderr and suggests an explicit destination: loose PDF
181
+ copies committed into a repo break a library-of-record setup, where the library is
182
+ the one place a file lives.
183
+
142
184
  ### Enriching existing items
143
185
 
144
186
  `zotkit enrich --key K [K …]` completes incomplete items — missing abstracts,
@@ -161,6 +203,23 @@ published (journal DOI present) to the journal record **in the same item**:
161
203
  itemType, published title/date/venue/volume/pages — with `arXiv: <id>` appended
162
204
  to Extra and attachments untouched.
163
205
 
206
+ Instead of `--key`, `--all` runs over every top-level scholarly item and
207
+ `--missing abstract` / `--missing doi` (repeatable) over just the gaps —
208
+ batching and arXiv/CrossRef rate limiting stay internal, so no pacing or
209
+ batch-splitting on the caller's side. **`zotkit audit`** is the read-only
210
+ companion: counts and key lists for missing abstracts, missing DOI/arXiv id,
211
+ missing PDF attachments, and `abstract-source` stamp hygiene (`--json` for
212
+ agents).
213
+
214
+ For text enrich *can't* fetch — CNKI, publisher pages, your own summary —
215
+ **`zotkit abstract --key K --source <slug>`** stores a pasted abstract
216
+ (stdin or `--file`) with provenance: the text is cleaned (hidden tags,
217
+ `&nbsp;`, soft hyphens, hard-wrapped lines) and an `abstract-source: <slug>`
218
+ line records where it came from (`cnki`, `ssrn`, `publisher`, `manual` = your
219
+ own words — any lowercase slug). It is the owner's tool, so unlike enrich it
220
+ may *replace* an existing abstract — but only with `--force`, which also
221
+ rewrites the stamp line in place; all other Extra lines stay untouched.
222
+
164
223
  Item JSON for `zotkit create --file` (a list, one object per reference):
165
224
 
166
225
  ```json
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zotkit"
7
- version = "0.5.2"
7
+ version = "0.7.0"
8
8
  description = "Headless Zotero library management: Web API CRUD plus direct WebDAV attachment upload/download — no desktop app required"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -20,6 +20,9 @@ classifiers = [
20
20
  ]
21
21
  dependencies = ["pyzotero", "httpx"]
22
22
 
23
+ [project.optional-dependencies]
24
+ dev = ["pytest"]
25
+
23
26
  [project.urls]
24
27
  Repository = "https://github.com/oldantique/zotkit"
25
28
  Issues = "https://github.com/oldantique/zotkit/issues"
@@ -0,0 +1,51 @@
1
+ """Offline tests for zotkit.audit bucketing, on a fixture library."""
2
+ import types
3
+
4
+ from zotkit.audit import audit
5
+
6
+
7
+ def _top(key, itype="journalArticle", ab="", extra="", doi="", url=""):
8
+ return {"key": key, "data": {"itemType": itype, "abstractNote": ab,
9
+ "extra": extra, "DOI": doi, "url": url}}
10
+
11
+
12
+ TOPS = [
13
+ _top("OK111111", ab="a", extra="abstract-source: arxiv", doi="10.1/x"), # healthy
14
+ _top("NOABS111", doi="10.1/y"), # missing abstract
15
+ _top("NOID1111", ab="a"), # unstamped + missing id
16
+ _top("ARXID111", extra="arXiv: 2401.00001"), # id via Extra, missing abstract
17
+ _top("STALE111", extra="abstract-source: cnki", doi="10.1/z"), # stale stamp
18
+ _top("NOTE1111", itype="note"), # excluded
19
+ _top("ATT11111", itype="attachment"), # excluded
20
+ ]
21
+ ATTS = [
22
+ {"key": "A1", "data": {"itemType": "attachment", "parentItem": "OK111111",
23
+ "contentType": "application/pdf"}},
24
+ {"key": "A2", "data": {"itemType": "attachment", "parentItem": "NOABS111",
25
+ "contentType": "text/html"}},
26
+ ]
27
+
28
+
29
+ def _fixture_zot():
30
+ return types.SimpleNamespace(z=types.SimpleNamespace(
31
+ everything=lambda x: x,
32
+ top=lambda: list(TOPS),
33
+ items=lambda itemType=None: list(ATTS)))
34
+
35
+
36
+ def test_audit_buckets():
37
+ rep = audit(_fixture_zot())
38
+ b = rep["buckets"]
39
+ assert rep["total"] == 5 # notes/attachments excluded
40
+ assert b["missing-abstract"] == ["NOABS111", "ARXID111", "STALE111"]
41
+ assert b["missing-identifier"] == ["NOID1111"]
42
+ # an html attachment does not count as a PDF
43
+ assert b["missing-pdf"] == ["NOABS111", "NOID1111", "ARXID111", "STALE111"]
44
+ assert b["unstamped-abstract"] == ["NOID1111"]
45
+ assert b["stale-stamp"] == ["STALE111"]
46
+
47
+
48
+ def test_audit_keys_in_library_order():
49
+ rep = audit(_fixture_zot())
50
+ assert rep["keys"] == ["OK111111", "NOABS111", "NOID1111", "ARXID111",
51
+ "STALE111"]
@@ -0,0 +1,154 @@
1
+ """Offline tests for the create-path dedup predicate: the shared
2
+ `dedup_maps`/`duplicate_key` helper, the existing_key recorded by
3
+ `Zot.create_items`, and the dry-run `!!` notice. No network, no live library —
4
+ the Zotero client is faked."""
5
+ from zotkit.cli import dup_notice
6
+ from zotkit.core import Zot, dedup_maps, duplicate_key
7
+
8
+
9
+ def _lib(*rows):
10
+ """rows: (key, title, DOI) -> pyzotero-shaped top-level items."""
11
+ return [{"key": k, "data": {"title": t, "DOI": doi, "itemType": "journalArticle"}}
12
+ for k, t, doi in rows]
13
+
14
+
15
+ LIBRARY = _lib(
16
+ ("AAAA1111", "Attention Is All You Need", "10.5555/ATT"),
17
+ ("BBBB2222", "Deep Residual Learning, Revisited!", ""),
18
+ ("CCCC3333", "Some Other Paper", "10.1103/PhysRevLett.116.061102"),
19
+ )
20
+
21
+
22
+ # ---------- the helper ----------
23
+
24
+ def test_doi_match_is_case_and_whitespace_insensitive():
25
+ doi_map, title_map = dedup_maps(LIBRARY)
26
+ d = {"title": "totally different title", "DOI": " 10.5555/att "}
27
+ assert duplicate_key(d, doi_map, title_map) == "AAAA1111"
28
+
29
+
30
+ def test_normalized_title_match():
31
+ doi_map, title_map = dedup_maps(LIBRARY)
32
+ d = {"title": "deep residual learning revisited"}
33
+ assert duplicate_key(d, doi_map, title_map) == "BBBB2222"
34
+
35
+
36
+ def test_doi_wins_when_doi_and_title_point_at_different_items():
37
+ doi_map, title_map = dedup_maps(LIBRARY)
38
+ d = {"title": "Some Other Paper", "DOI": "10.5555/ATT"}
39
+ assert duplicate_key(d, doi_map, title_map) == "AAAA1111"
40
+
41
+
42
+ def test_no_match_returns_none():
43
+ doi_map, title_map = dedup_maps(LIBRARY)
44
+ d = {"title": "A Brand New Paper", "DOI": "10.9999/new.1"}
45
+ assert duplicate_key(d, doi_map, title_map) is None
46
+
47
+
48
+ def test_items_without_doi_do_not_poison_the_doi_map():
49
+ doi_map, _ = dedup_maps(LIBRARY)
50
+ assert "" not in doi_map
51
+ assert set(doi_map) == {"10.5555/att", "10.1103/physrevlett.116.061102"}
52
+ # an incoming item with no DOI falls through to the title check only
53
+ _, title_map = dedup_maps(LIBRARY)
54
+ assert duplicate_key({"title": "A Brand New Paper"}, doi_map, title_map) is None
55
+
56
+
57
+ def test_unknown_doi_still_matches_on_title():
58
+ doi_map, title_map = dedup_maps(LIBRARY)
59
+ d = {"title": "attention is all you need", "DOI": "10.9999/unseen"}
60
+ assert duplicate_key(d, doi_map, title_map) == "AAAA1111"
61
+
62
+
63
+ def test_first_writer_wins_on_duplicate_library_rows():
64
+ doi_map, title_map = dedup_maps(_lib(("KEY00001", "T", "10.1/x"),
65
+ ("KEY00002", "T", "10.1/x")))
66
+ assert duplicate_key({"title": "T", "DOI": "10.1/X"}, doi_map, title_map) == "KEY00001"
67
+
68
+
69
+ # ---------- create_items records the existing key ----------
70
+
71
+ class FakeZ:
72
+ def __init__(self, existing):
73
+ self._existing = existing
74
+ self.created_payloads = []
75
+
76
+ def top(self):
77
+ return "TOP"
78
+
79
+ def everything(self, _):
80
+ return self._existing
81
+
82
+ def item_template(self, itemtype):
83
+ return {"itemType": itemtype, "title": "", "DOI": "", "tags": [],
84
+ "collections": []}
85
+
86
+ def create_items(self, payloads):
87
+ self.created_payloads.append(payloads)
88
+ return {"successful": {str(i): {"key": f"NEW0000{i}"}
89
+ for i in range(len(payloads))}, "failed": {}}
90
+
91
+
92
+ def _zot(existing):
93
+ z = Zot.__new__(Zot)
94
+ z.z = FakeZ(existing)
95
+ z.conventions = None
96
+ return z
97
+
98
+
99
+ def test_create_items_records_existing_key_in_skipped_meta():
100
+ z = _zot(LIBRARY)
101
+ out = z.create_items([{"itemType": "journalArticle", "title": "Fresh One"},
102
+ {"itemType": "journalArticle",
103
+ "title": "attention is all you need!"}])
104
+ created = [r for r in out if r.get("key")]
105
+ skipped = [r for r in out if r.get("skipped")]
106
+ assert [c["title"] for c in created] == ["Fresh One"]
107
+ assert skipped == [{"title": "attention is all you need!",
108
+ "skipped": "duplicate", "existing_key": "AAAA1111"}]
109
+
110
+
111
+ def test_create_items_skips_on_doi_and_names_that_item():
112
+ z = _zot(LIBRARY)
113
+ out = z.create_items([{"itemType": "journalArticle", "title": "Renamed Paper",
114
+ "DOI": "10.5555/ATT"}])
115
+ assert out == [{"title": "Renamed Paper", "skipped": "duplicate",
116
+ "existing_key": "AAAA1111"}]
117
+ assert z.z.created_payloads == [] # nothing sent to the API
118
+
119
+
120
+ def test_create_items_no_dedup_creates_everything():
121
+ z = _zot(LIBRARY)
122
+ out = z.create_items([{"itemType": "journalArticle",
123
+ "title": "Attention Is All You Need"}], dedup=False)
124
+ assert len(out) == 1 and out[0]["key"] == "NEW00000"
125
+
126
+
127
+ # ---------- the dry-run notice ----------
128
+
129
+ def test_dup_notice_shape():
130
+ doi_map, title_map = dedup_maps(LIBRARY)
131
+ msg = dup_notice({"title": "Attention Is All You Need"}, doi_map, title_map)
132
+ assert msg == ("!! already in library as AAAA1111 — --apply will skip it "
133
+ "(use --no-dedup to force)")
134
+
135
+
136
+ def test_dup_notice_none_for_new_item():
137
+ doi_map, title_map = dedup_maps(LIBRARY)
138
+ assert dup_notice({"title": "Brand New", "DOI": "10.9/z"}, doi_map, title_map) is None
139
+
140
+
141
+ def test_dup_notice_with_empty_maps_never_warns():
142
+ assert dup_notice({"title": "Attention Is All You Need"}, {}, {}) is None
143
+
144
+
145
+ def test_dup_notice_names_the_doi_match_not_the_title_match():
146
+ doi_map, title_map = dedup_maps(LIBRARY)
147
+ msg = dup_notice({"title": "Some Other Paper", "DOI": "10.5555/att"},
148
+ doi_map, title_map)
149
+ assert "AAAA1111" in msg and "CCCC3333" not in msg
150
+
151
+
152
+ def test_empty_library_yields_empty_maps():
153
+ assert dedup_maps([]) == ({}, {})
154
+ assert duplicate_key({"title": "Anything"}, {}, {}) is None
@@ -0,0 +1,167 @@
1
+ """Offline tests for zotkit.enrich: the version-of-record gate in plan_enrich
2
+ and the set_abstract guard/force/stamp matrix. No network, no live library —
3
+ the Zotero client and CrossRef fetch are faked."""
4
+ import types
5
+
6
+ import pytest
7
+
8
+ from zotkit import enrich
9
+ from zotkit import metadata as md
10
+
11
+ SSRN_ITEM = {"itemType": "journalArticle", "title": "Some Working Paper",
12
+ "publicationTitle": "SSRN Electronic Journal",
13
+ "DOI": "10.2139/ssrn.2407199", "creators": []}
14
+ PRL_ITEM = {"itemType": "journalArticle", "title": "GW150914",
15
+ "publicationTitle": "Physical Review Letters",
16
+ "DOI": "10.1103/PhysRevLett.116.061102", "creators": []}
17
+
18
+ SSRN_PREPRINT = {"itemType": "preprint", "title": "Some Working Paper",
19
+ "abstractNote": "has one", "creators": [],
20
+ "DOI": "10.2139/ssrn.2407199", "extra": "", "url": "",
21
+ "archiveID": "", "tags": [], "collections": [],
22
+ "relations": {}, "repository": "SSRN", "date": ""}
23
+
24
+
25
+ def _read_only_zot(item_data):
26
+ return types.SimpleNamespace(z=types.SimpleNamespace(
27
+ item=lambda k: {"data": dict(item_data), "key": k, "version": 1}))
28
+
29
+
30
+ def _plan(monkeypatch, item_data, fetched, rebuild):
31
+ monkeypatch.setattr(md, "fetch_doi", lambda doi: dict(fetched))
32
+ return enrich.plan_enrich(_read_only_zot(item_data), "KEY00001",
33
+ rebuild_record=rebuild)
34
+
35
+
36
+ # ---------- plan_enrich: version-of-record gate ----------
37
+
38
+ def test_ssrn_doi_never_rebuilds(monkeypatch):
39
+ p = _plan(monkeypatch, SSRN_PREPRINT, SSRN_ITEM, rebuild=True)
40
+ assert p["rebuild"] is None
41
+ assert any("no version-of-record upgrade" in n and "repository DOI" in n
42
+ for n in p["notes"])
43
+
44
+
45
+ def test_repository_doi_still_fills_missing(monkeypatch):
46
+ p = _plan(monkeypatch, SSRN_PREPRINT, dict(SSRN_ITEM, date="2024-01-15"),
47
+ rebuild=False)
48
+ assert p["fills"].get("date") == "2024-01-15"
49
+ assert not any("version-of-record" in n for n in p["notes"])
50
+
51
+
52
+ def test_genuine_journal_doi_rebuilds(monkeypatch):
53
+ real_preprint = dict(SSRN_PREPRINT, DOI="10.1103/PhysRevLett.116.061102",
54
+ repository="arXiv")
55
+ p = _plan(monkeypatch, real_preprint, PRL_ITEM, rebuild=True)
56
+ assert p["rebuild"] == "journalArticle"
57
+
58
+
59
+ def test_layer2_guard_blocks_disguised_record(monkeypatch):
60
+ # non-blacklist DOI prefix, but the fetched record is the SSRN pseudo-journal
61
+ disguised = dict(SSRN_ITEM, DOI="10.9999/fake.1")
62
+ odd_preprint = dict(SSRN_PREPRINT, DOI="10.9999/fake.1")
63
+ p = _plan(monkeypatch, odd_preprint, disguised, rebuild=True)
64
+ assert p["rebuild"] is None
65
+ assert any("pseudo-journal" in n for n in p["notes"])
66
+
67
+
68
+ def test_no_identifier_skips_without_fetching(monkeypatch):
69
+ monkeypatch.setattr(md, "fetch_doi",
70
+ lambda doi: pytest.fail("must not fetch CrossRef"))
71
+ noid = dict(SSRN_PREPRINT, DOI="")
72
+ p = enrich.plan_enrich(_read_only_zot(noid), "KEY00001",
73
+ rebuild_record=False)
74
+ assert p["status"] == "needs-identifier"
75
+
76
+
77
+ # ---------- set_abstract: guard / force / stamp matrix ----------
78
+
79
+ class FakeZ:
80
+ def __init__(self, data):
81
+ self._data = data
82
+ self.updated = None
83
+
84
+ def item(self, key):
85
+ return {"key": key, "version": 7, "data": dict(self._data)}
86
+
87
+ def update_item(self, item):
88
+ self.updated = item
89
+
90
+
91
+ def zot_with(data):
92
+ return types.SimpleNamespace(z=FakeZ(data))
93
+
94
+
95
+ BASE = {"itemType": "journalArticle", "title": "T", "abstractNote": "",
96
+ "extra": "", "key": "K1", "version": 7}
97
+
98
+
99
+ def test_fresh_item_writes_cleaned_abstract_and_stamp():
100
+ z = zot_with(BASE)
101
+ r = enrich.set_abstract(z, "K1", "some text\nwrapped", "cnki")
102
+ d = z.z.updated["data"]
103
+ assert d["abstractNote"] == "some text wrapped" # unwrap applied to paste
104
+ assert d["extra"] == "abstract-source: cnki"
105
+ assert r["replaced"] is False
106
+
107
+
108
+ def test_existing_abstract_refused_without_force():
109
+ z = zot_with(dict(BASE, abstractNote="old text"))
110
+ with pytest.raises(md.MetadataError, match="--force"):
111
+ enrich.set_abstract(z, "K1", "new", "manual")
112
+ assert z.z.updated is None # nothing written
113
+
114
+
115
+ def test_stale_stamp_alone_refused_without_force():
116
+ z = zot_with(dict(BASE, extra="abstract-source: arxiv"))
117
+ with pytest.raises(md.MetadataError, match="--force"):
118
+ enrich.set_abstract(z, "K1", "new", "manual")
119
+ assert z.z.updated is None
120
+
121
+
122
+ def test_force_replaces_abstract_and_rewrites_stamp_in_place():
123
+ z = zot_with(dict(BASE, abstractNote="old",
124
+ extra="arXiv: 2401.00001\nabstract-source: arxiv\n"
125
+ "Citation Key: x"))
126
+ r = enrich.set_abstract(z, "K1", "new text", "publisher", force=True)
127
+ d = z.z.updated["data"]
128
+ assert d["abstractNote"] == "new text"
129
+ assert d["extra"] == ("arXiv: 2401.00001\nabstract-source: publisher\n"
130
+ "Citation Key: x")
131
+ assert d["extra"].count("abstract-source:") == 1
132
+ assert r["replaced"] is True
133
+
134
+
135
+ def test_force_on_unstamped_item_appends_stamp_keeping_lines():
136
+ z = zot_with(dict(BASE, abstractNote="old", extra="Some: line"))
137
+ enrich.set_abstract(z, "K1", "new", "manual", force=True)
138
+ d = z.z.updated["data"]
139
+ assert d["extra"] == "Some: line\nabstract-source: manual"
140
+
141
+
142
+ def test_empty_after_cleaning_errors_and_writes_nothing():
143
+ z = zot_with(BASE)
144
+ with pytest.raises(md.MetadataError, match="empty"):
145
+ enrich.set_abstract(z, "K1", " \n\u00ad ", "manual")
146
+ assert z.z.updated is None
147
+
148
+
149
+ def test_open_vocabulary_slug_accepted():
150
+ z = zot_with(BASE)
151
+ enrich.set_abstract(z, "K1", "x", "some-new-source2")
152
+ assert z.z.updated is not None
153
+
154
+
155
+ @pytest.mark.parametrize("bad", ["CNKI", "with space", "", "-lead", "中文"])
156
+ def test_bad_slug_refused(bad):
157
+ z = zot_with(BASE)
158
+ with pytest.raises(md.MetadataError):
159
+ enrich.set_abstract(z, "K1", "x", bad)
160
+ assert z.z.updated is None
161
+
162
+
163
+ def test_item_type_without_abstract_field_refused():
164
+ z = zot_with({"itemType": "note", "extra": "", "key": "K1"})
165
+ with pytest.raises(md.MetadataError, match="no abstract field"):
166
+ enrich.set_abstract(z, "K1", "x", "manual")
167
+ assert z.z.updated is None