zotkit 0.4.2__tar.gz → 0.5.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.4.2
3
+ Version: 0.5.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
@@ -113,6 +113,9 @@ zotkit create --file papers.json # batch from JSON: dry-run preview
113
113
  zotkit create --file papers.json --apply # create (dedups by DOI/title)
114
114
  zotkit attach --from papers.created.json --all # upload the PDFs to WebDAV
115
115
 
116
+ zotkit enrich --key AB12CD34 EF56GH78 # fill missing fields on existing items
117
+ # in place (dry-run; --apply to write)
118
+
116
119
  zotkit attach --key AB12CD34 --pdf paper.pdf # single attach
117
120
  zotkit fetch --key AB12CD34 --out downloads # download attachment from WebDAV
118
121
 
@@ -144,7 +147,31 @@ request layer — batches use one arXiv metadata request and space PDF downloads
144
147
  per arXiv's terms of use, so callers (humans or agents) never pace themselves. A
145
148
  bad id fails alone, not the batch; the exit code is non-zero only if something
146
149
  failed. Fetching metadata from arbitrary web pages is out of scope (zotkit stays
147
- a daemon-free CLI — no translation-server).
150
+ a daemon-free CLI — no translation-server). CrossRef requests identify themselves
151
+ to the polite pool with a contact address; that should be reachable, so if you
152
+ distribute a tool built on zotkit, set your own via `ZOTKIT_MAILTO`.
153
+
154
+ ### Enriching existing items
155
+
156
+ `zotkit enrich --key K [K …]` completes incomplete items — missing abstracts,
157
+ DOIs, truncated author lists — from the same arXiv/CrossRef sources, **in
158
+ place**. The item key never changes (keys are the stable handle downstream
159
+ tooling references; delete-and-recreate is not an option), and writes carry the
160
+ item version, so a concurrent edit fails that item loudly instead of clobbering.
161
+
162
+ The merge is deliberately conservative: only empty fields are filled; creators
163
+ are extended only when the current list is a same-order prefix of the
164
+ authoritative one (the classic truncated-list case — any other difference is
165
+ reported, not touched); tags, collections, relations, and attachments are never
166
+ modified; Extra only gains lines. Abstracts zotkit writes are stamped
167
+ `abstract-source: …` in Extra — a stamp with no abstract means someone
168
+ deliberately removed it, and enrich will not re-add it (reported as NEEDS
169
+ OWNER). Items with no DOI or arXiv id are reported as needs-identifier.
170
+
171
+ `--rebuild-record` additionally upgrades a preprint whose paper has since been
172
+ published (journal DOI present) to the journal record **in the same item**:
173
+ itemType, published title/date/venue/volume/pages — with `arXiv: <id>` appended
174
+ to Extra and attachments untouched.
148
175
 
149
176
  Item JSON for `zotkit create --file` (a list, one object per reference):
150
177
 
@@ -93,6 +93,9 @@ zotkit create --file papers.json # batch from JSON: dry-run preview
93
93
  zotkit create --file papers.json --apply # create (dedups by DOI/title)
94
94
  zotkit attach --from papers.created.json --all # upload the PDFs to WebDAV
95
95
 
96
+ zotkit enrich --key AB12CD34 EF56GH78 # fill missing fields on existing items
97
+ # in place (dry-run; --apply to write)
98
+
96
99
  zotkit attach --key AB12CD34 --pdf paper.pdf # single attach
97
100
  zotkit fetch --key AB12CD34 --out downloads # download attachment from WebDAV
98
101
 
@@ -124,7 +127,31 @@ request layer — batches use one arXiv metadata request and space PDF downloads
124
127
  per arXiv's terms of use, so callers (humans or agents) never pace themselves. A
125
128
  bad id fails alone, not the batch; the exit code is non-zero only if something
126
129
  failed. Fetching metadata from arbitrary web pages is out of scope (zotkit stays
127
- a daemon-free CLI — no translation-server).
130
+ a daemon-free CLI — no translation-server). CrossRef requests identify themselves
131
+ to the polite pool with a contact address; that should be reachable, so if you
132
+ distribute a tool built on zotkit, set your own via `ZOTKIT_MAILTO`.
133
+
134
+ ### Enriching existing items
135
+
136
+ `zotkit enrich --key K [K …]` completes incomplete items — missing abstracts,
137
+ DOIs, truncated author lists — from the same arXiv/CrossRef sources, **in
138
+ place**. The item key never changes (keys are the stable handle downstream
139
+ tooling references; delete-and-recreate is not an option), and writes carry the
140
+ item version, so a concurrent edit fails that item loudly instead of clobbering.
141
+
142
+ The merge is deliberately conservative: only empty fields are filled; creators
143
+ are extended only when the current list is a same-order prefix of the
144
+ authoritative one (the classic truncated-list case — any other difference is
145
+ reported, not touched); tags, collections, relations, and attachments are never
146
+ modified; Extra only gains lines. Abstracts zotkit writes are stamped
147
+ `abstract-source: …` in Extra — a stamp with no abstract means someone
148
+ deliberately removed it, and enrich will not re-add it (reported as NEEDS
149
+ OWNER). Items with no DOI or arXiv id are reported as needs-identifier.
150
+
151
+ `--rebuild-record` additionally upgrades a preprint whose paper has since been
152
+ published (journal DOI present) to the journal record **in the same item**:
153
+ itemType, published title/date/venue/volume/pages — with `arXiv: <id>` appended
154
+ to Extra and attachments untouched.
128
155
 
129
156
  Item JSON for `zotkit create --file` (a list, one object per reference):
130
157
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zotkit"
7
- version = "0.4.2"
7
+ version = "0.5.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"
@@ -2,4 +2,4 @@
2
2
  from .core import Conventions, TagConventionError, Zot, lint_tags, load_conventions
3
3
 
4
4
  __all__ = ["Zot", "lint_tags", "load_conventions", "Conventions", "TagConventionError"]
5
- __version__ = "0.4.2"
5
+ __version__ = "0.5.0"
@@ -1,6 +1,6 @@
1
1
  """zotkit — CLI over zotkit.core.Zot.
2
2
 
3
- Subcommands: doctor, find, create, attach, fetch, tag, status, move, backup, lint.
3
+ Subcommands: doctor, find, create, enrich, attach, fetch, tag, status, move, backup, lint.
4
4
  Write commands print what they did; `create` is dry-run unless --apply.
5
5
  """
6
6
  from __future__ import annotations
@@ -97,6 +97,15 @@ def main(argv=None):
97
97
  p.add_argument("--tags", help="with --arxiv/--doi: comma-separated tags, "
98
98
  "e.g. field:ml,status:to-read")
99
99
 
100
+ p = sub.add_parser("enrich", help="fill missing fields on existing items from "
101
+ "arXiv/CrossRef, in place — the item key never "
102
+ "changes (dry unless --apply)")
103
+ p.add_argument("--key", nargs="+", required=True, metavar="KEY")
104
+ p.add_argument("--apply", action="store_true")
105
+ p.add_argument("--rebuild-record", action="store_true",
106
+ help="also upgrade preprint items with a journal DOI to the "
107
+ "journal record, in place (version of record)")
108
+
100
109
  p = sub.add_parser("attach", help="attach a local file to an item (WebDAV or Zotero Storage)")
101
110
  p.add_argument("--key"); p.add_argument("--pdf")
102
111
  p.add_argument("--from", dest="src", help="a .created.json list (batch)")
@@ -241,6 +250,54 @@ def main(argv=None):
241
250
  print(f"batch: created {len(new)}, skipped {dup} dup, failed {len(failures)}")
242
251
  return 1 if failures else 0
243
252
 
253
+ elif a.cmd == "enrich":
254
+ from .enrich import apply_plan, plan_enrich
255
+ counts = {"enriched": 0, "needs-identifier": 0, "stale-stamp": 0,
256
+ "up-to-date": 0, "failed": 0}
257
+ for key in a.key:
258
+ try:
259
+ p_ = plan_enrich(zot, key, rebuild_record=a.rebuild_record)
260
+ except Exception as e: # bad key, metadata lookup failure, …
261
+ counts["failed"] += 1
262
+ print(f"{key} FAILED: {e}", file=sys.stderr)
263
+ continue
264
+ print(f"{key} {p_['title'][:60]}")
265
+ for n in p_["notes"]:
266
+ print(f" {n}")
267
+ if p_["status"] in ("needs-identifier", "stale-stamp", "up-to-date"):
268
+ if p_["status"] == "up-to-date":
269
+ print(" up-to-date")
270
+ counts[p_["status"]] += 1
271
+ continue
272
+ if p_["rebuild"]:
273
+ print(f" rebuild: {p_['item']['data']['itemType']} → "
274
+ f"{p_['rebuild']} ({p_['auth'].get('DOI', '')})")
275
+ if p_["fills"]:
276
+ shown = [f + (f" (source: {p_['abstract_source']})"
277
+ if f == "abstractNote" and p_["abstract_source"] else "")
278
+ for f in p_["fills"]]
279
+ print(f" will fill: {', '.join(shown)}")
280
+ for line in p_["extra_lines"]:
281
+ print(f" extra += {line!r}")
282
+ if not a.apply:
283
+ continue
284
+ try:
285
+ apply_plan(zot, p_)
286
+ counts["enriched"] += 1
287
+ print(" enriched ✓")
288
+ except Exception as e:
289
+ counts["failed"] += 1
290
+ print(f"{key} FAILED to write: {e}", file=sys.stderr)
291
+ if not a.apply:
292
+ print("DRY — add --apply to write.")
293
+ skipped = (counts["needs-identifier"] + counts["stale-stamp"]
294
+ + counts["up-to-date"])
295
+ print(f"enriched {counts['enriched']} / skipped {skipped} "
296
+ f"(needs-identifier {counts['needs-identifier']}, "
297
+ f"stale-stamp {counts['stale-stamp']}, "
298
+ f"up-to-date {counts['up-to-date']}) / failed {counts['failed']}")
299
+ return 1 if counts["failed"] else 0
300
+
244
301
  elif a.cmd == "attach":
245
302
  if a.key and a.pdf:
246
303
  print(zot.attach(a.key, a.pdf))
@@ -0,0 +1,243 @@
1
+ """zotkit.enrich — complete existing items in place from arXiv/CrossRef.
2
+
3
+ The core invariant: **the item key never changes**. Item keys are referenced by
4
+ downstream manifests, so delete-and-recreate is never acceptable; every change
5
+ here is an in-place fetch→modify→update carrying the item version (a concurrent
6
+ edit fails that one item with 412 instead of clobbering).
7
+
8
+ Merge policy:
9
+ - only empty fields are filled — existing non-empty values are never overwritten
10
+ (exception: --rebuild-record, which deliberately adopts the published title,
11
+ date, and venue when upgrading a preprint to its version of record);
12
+ - creators are extended only when the current list is a same-order prefix of the
13
+ authoritative one (the classic truncated-author-list case); any other
14
+ difference is reported, not touched;
15
+ - an `abstract-source:` stamp with no abstract means an owner deliberately
16
+ removed the abstract — never re-add it (reported as NEEDS OWNER);
17
+ - Extra only ever gains lines (arXiv: / abstract-source: / Repository:),
18
+ existing lines are never rewritten;
19
+ - tags, collections, relations, and child items are never touched.
20
+
21
+ Network requests reuse zotkit.metadata's fetchers and its per-host rate limit.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ import time
27
+ from typing import Any
28
+
29
+ from . import metadata as md
30
+
31
+ # fields the plain fill pass may write; everything else needs a dedicated rule
32
+ _PROTECTED = {"itemType", "title", "creators", "abstractNote", "extra",
33
+ "tags", "collections", "relations",
34
+ "key", "version", "dateAdded", "dateModified"}
35
+
36
+ _ARXIV_IN_TEXT = re.compile(
37
+ r"arxiv:\s*((?:\d{4}\.\d{4,5}|[a-z-]+(?:\.[A-Z]{2})?/\d{7})(?:v\d+)?)",
38
+ re.IGNORECASE)
39
+ _STAMP = re.compile(r"^abstract-source:\s*(\w+)", re.IGNORECASE | re.MULTILINE)
40
+
41
+
42
+ def _find_arxiv_id(d: dict) -> str | None:
43
+ """arXiv identity of an item: archiveID, an `arXiv:` Extra line, an
44
+ arxiv.org url, or the id embedded in arXiv's own DataCite DOI."""
45
+ for text in (d.get("archiveID", ""), d.get("extra", "")):
46
+ m = _ARXIV_IN_TEXT.search(text)
47
+ if m:
48
+ return m.group(1)
49
+ if "arxiv.org/" in d.get("url", ""):
50
+ try:
51
+ return md.arxiv_id(d["url"])
52
+ except md.MetadataError:
53
+ pass
54
+ doi = (d.get("DOI") or "").strip()
55
+ if doi.lower().startswith("10.48550/arxiv."):
56
+ return doi[len("10.48550/arxiv."):]
57
+ return None
58
+
59
+
60
+ def _norm(s: str | None) -> str:
61
+ return re.sub(r"\s+", " ", s or "").strip().casefold()
62
+
63
+
64
+ def _same_creator(c: dict, a: dict) -> bool:
65
+ """Loose identity: equal last name plus compatible first name (initials
66
+ match full names), so 'A. Einstein' matches 'Albert Einstein'."""
67
+ if c.get("creatorType") != a.get("creatorType"):
68
+ return False
69
+ if c.get("name") or a.get("name"):
70
+ full = lambda x: _norm(x.get("name")
71
+ or f"{x.get('firstName', '')} {x.get('lastName', '')}")
72
+ return full(c) == full(a)
73
+ if _norm(c.get("lastName")) != _norm(a.get("lastName")):
74
+ return False
75
+ cf, af = _norm(c.get("firstName")), _norm(a.get("firstName"))
76
+ return not cf or not af or cf[0] == af[0]
77
+
78
+
79
+ def _real_creators(creators: list[dict]) -> list[dict]:
80
+ """Drop all-empty placeholder creators (an untouched item template ships
81
+ one empty author)."""
82
+ return [c for c in creators
83
+ if any(v for k, v in c.items() if k != "creatorType")]
84
+
85
+
86
+ def _fetch_authoritative(d: dict) -> tuple[dict | None, str | None, str | None]:
87
+ """→ (authoritative record, arXiv id, skip reason). Journal DOI wins
88
+ (version of record); arXiv otherwise. The arXiv fetch itself may upgrade
89
+ to a journal record per v0.4.2 rules — equally welcome here."""
90
+ doi = (d.get("DOI") or "").strip()
91
+ aid = _find_arxiv_id(d)
92
+ journal_doi = doi if doi and not doi.lower().startswith("10.48550/") else None
93
+ if journal_doi:
94
+ auth = md.fetch_doi(journal_doi)
95
+ # CrossRef abstracts are often absent; borrow arXiv's when we can
96
+ if not auth.get("abstractNote") and aid and not d.get("abstractNote"):
97
+ r = md.fetch_arxiv_batch([aid])[0]
98
+ pre = r.get("item") or {}
99
+ if pre.get("abstractNote"):
100
+ auth["abstractNote"] = pre["abstractNote"]
101
+ md._add_extra(auth, "abstract-source: arxiv")
102
+ return auth, aid, None
103
+ if aid:
104
+ r = md.fetch_arxiv_batch([aid])[0]
105
+ if r.get("error"):
106
+ raise md.MetadataError(r["error"])
107
+ return r["item"], aid, None
108
+ return None, None, "no DOI or arXiv id on the item"
109
+
110
+
111
+ def plan_enrich(zot, key: str, *, rebuild_record: bool = False) -> dict[str, Any]:
112
+ """Compute what enriching `key` would change. Returns a plan dict:
113
+ status 'plan' (something to do), 'up-to-date', 'needs-identifier',
114
+ 'stale-stamp', with fills / creators / extra_lines / rebuild / notes.
115
+ Raises MetadataError (lookup) or pyzotero errors (bad key) — callers
116
+ turn those into per-item failures."""
117
+ item = zot.z.item(key)
118
+ d = item["data"]
119
+ p: dict[str, Any] = {"key": key, "title": d.get("title", ""), "item": item,
120
+ "fills": {}, "extra_lines": [], "creators": None,
121
+ "rebuild": None, "notes": [], "abstract_source": None}
122
+
123
+ auth, aid, skip = _fetch_authoritative(d)
124
+ if auth is None:
125
+ p["status"] = "needs-identifier"
126
+ p["notes"].append(f"SKIP needs-identifier: {skip}")
127
+ return p
128
+ p["auth"], p["aid"] = auth, aid
129
+
130
+ # ---- abstract (stamp rules) ----
131
+ stamped = _STAMP.search(d.get("extra", ""))
132
+ auth_ab_src = (_STAMP.search(auth.get("extra", "")) or [None, None])[1]
133
+ if not d.get("abstractNote"):
134
+ if stamped:
135
+ p["notes"].append("NEEDS OWNER stale-stamp: abstract-source stamp "
136
+ "present but abstract empty — not re-adding")
137
+ p["stale_stamp"] = True
138
+ elif auth.get("abstractNote"):
139
+ p["fills"]["abstractNote"] = auth["abstractNote"]
140
+ p["abstract_source"] = (auth_ab_src or "").lower() or None
141
+ if p["abstract_source"]:
142
+ p["extra_lines"].append(f"abstract-source: {p['abstract_source']}")
143
+
144
+ # ---- plain fields: fill only what's empty ----
145
+ for f, v in auth.items():
146
+ if f in _PROTECTED or not v:
147
+ continue
148
+ if f in d and not d.get(f):
149
+ p["fills"][f] = v
150
+ if not d.get("title") and auth.get("title"):
151
+ p["fills"]["title"] = auth["title"]
152
+
153
+ # ---- creators: whole-fill or prefix extension only ----
154
+ cur = _real_creators(d.get("creators", []))
155
+ authc = auth.get("creators", [])
156
+ if authc:
157
+ if not cur:
158
+ p["creators"] = authc
159
+ elif (len(cur) < len(authc)
160
+ and all(_same_creator(c, a) for c, a in zip(cur, authc))):
161
+ p["creators"] = authc
162
+ p["notes"].append(f"creators: {len(cur)} → {len(authc)} "
163
+ "(authoritative prefix extension)")
164
+ elif not (len(cur) == len(authc)
165
+ and all(_same_creator(c, a) for c, a in zip(cur, authc))):
166
+ p["notes"].append(f"creators differ from authoritative record "
167
+ f"({len(cur)} vs {len(authc)}) — left untouched")
168
+
169
+ # ---- version-of-record rebuild (explicit opt-in) ----
170
+ if (rebuild_record and d.get("itemType") == "preprint"
171
+ and auth.get("itemType") not in (None, "preprint")):
172
+ p["rebuild"] = auth["itemType"]
173
+
174
+ # ---- arXiv identity line ----
175
+ if aid:
176
+ # a rebuild drops archiveID (no such field on journal types), so only
177
+ # Extra itself counts as "already recorded" in that case
178
+ known = d.get("extra", "") + ("" if p["rebuild"]
179
+ else " " + d.get("archiveID", ""))
180
+ if f"arxiv:{aid.lower()}" not in known.lower().replace(" ", ""):
181
+ p["extra_lines"].append(f"arXiv: {aid}")
182
+
183
+ p["status"] = ("plan" if (p["fills"] or p["creators"] is not None
184
+ or p["extra_lines"] or p["rebuild"])
185
+ else "stale-stamp" if p.get("stale_stamp")
186
+ else "up-to-date")
187
+ return p
188
+
189
+
190
+ def apply_plan(zot, p: dict[str, Any]) -> None:
191
+ """Execute a 'plan' plan in place. The item key never changes; the write
192
+ carries the fetched version, so a concurrent edit fails loudly (412)."""
193
+ item, d, auth = p["item"], p["item"]["data"], p.get("auth", {})
194
+ children_before = len(zot.z.children(p["key"])) if p["rebuild"] else None
195
+
196
+ if p["rebuild"]:
197
+ tmpl = zot.z.item_template(p["rebuild"])
198
+ old = dict(d)
199
+ keep = {k: old[k] for k in ("key", "version", "dateAdded", "dateModified")
200
+ if k in old}
201
+ new: dict[str, Any] = {}
202
+ for f in tmpl:
203
+ if f in ("tags", "collections", "relations"):
204
+ new[f] = old.get(f, tmpl[f]) # never touched
205
+ elif f == "extra":
206
+ new[f] = old.get("extra", "")
207
+ elif f == "creators":
208
+ new[f] = (p["creators"] if p["creators"] is not None
209
+ else old.get("creators", []))
210
+ elif f == "abstractNote": # stale stamp forbids re-adding here too
211
+ new[f] = old.get("abstractNote") or (
212
+ "" if p.get("stale_stamp") else auth.get("abstractNote", ""))
213
+ else:
214
+ # version of record wins: published title/date/venue over preprint's
215
+ new[f] = auth.get(f) or old.get(f) or tmpl[f]
216
+ if old.get("repository") and not p.get("aid"):
217
+ md._add_extra(new, f"Repository: {old['repository']}")
218
+ d.clear()
219
+ d.update(keep)
220
+ d.update(new)
221
+ else:
222
+ d.update(p["fills"])
223
+ if p["creators"] is not None:
224
+ d["creators"] = p["creators"]
225
+
226
+ for line in p["extra_lines"]:
227
+ md._add_extra(d, line)
228
+
229
+ zot.z.update_item(item)
230
+
231
+ if children_before is not None:
232
+ # the API can serve a stale (empty) children list right after the PUT —
233
+ # tolerate brief read-after-write lag before declaring a real mismatch
234
+ for attempt in range(4):
235
+ children_after = len(zot.z.children(p["key"]))
236
+ if children_after == children_before:
237
+ break
238
+ time.sleep(2)
239
+ if children_after != children_before:
240
+ raise RuntimeError(
241
+ f"child-item count changed during rebuild of {p['key']} "
242
+ f"({children_before} → {children_after}) — investigate before "
243
+ "trusting this item")
@@ -14,6 +14,7 @@ batch loops — never need to pace themselves.
14
14
  """
15
15
  from __future__ import annotations
16
16
 
17
+ import os
17
18
  import re
18
19
  import time
19
20
  import xml.etree.ElementTree as ET
@@ -23,7 +24,9 @@ import httpx
23
24
 
24
25
  from . import __version__
25
26
 
26
- CONTACT = "mailto:Eric.M.990909@gmail.com"
27
+ # CrossRef's polite pool wants a reachable contact; $ZOTKIT_MAILTO overrides
28
+ # the default (distributors of zotkit-based tools: please set your own).
29
+ CONTACT = f"mailto:{os.environ.get('ZOTKIT_MAILTO', 'ccmuuc@gmail.com')}"
27
30
  USER_AGENT = f"zotkit/{__version__} (https://github.com/oldantique/zotkit; {CONTACT})"
28
31
 
29
32
  _ATOM = "{http://www.w3.org/2005/Atom}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zotkit
3
- Version: 0.4.2
3
+ Version: 0.5.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
@@ -113,6 +113,9 @@ zotkit create --file papers.json # batch from JSON: dry-run preview
113
113
  zotkit create --file papers.json --apply # create (dedups by DOI/title)
114
114
  zotkit attach --from papers.created.json --all # upload the PDFs to WebDAV
115
115
 
116
+ zotkit enrich --key AB12CD34 EF56GH78 # fill missing fields on existing items
117
+ # in place (dry-run; --apply to write)
118
+
116
119
  zotkit attach --key AB12CD34 --pdf paper.pdf # single attach
117
120
  zotkit fetch --key AB12CD34 --out downloads # download attachment from WebDAV
118
121
 
@@ -144,7 +147,31 @@ request layer — batches use one arXiv metadata request and space PDF downloads
144
147
  per arXiv's terms of use, so callers (humans or agents) never pace themselves. A
145
148
  bad id fails alone, not the batch; the exit code is non-zero only if something
146
149
  failed. Fetching metadata from arbitrary web pages is out of scope (zotkit stays
147
- a daemon-free CLI — no translation-server).
150
+ a daemon-free CLI — no translation-server). CrossRef requests identify themselves
151
+ to the polite pool with a contact address; that should be reachable, so if you
152
+ distribute a tool built on zotkit, set your own via `ZOTKIT_MAILTO`.
153
+
154
+ ### Enriching existing items
155
+
156
+ `zotkit enrich --key K [K …]` completes incomplete items — missing abstracts,
157
+ DOIs, truncated author lists — from the same arXiv/CrossRef sources, **in
158
+ place**. The item key never changes (keys are the stable handle downstream
159
+ tooling references; delete-and-recreate is not an option), and writes carry the
160
+ item version, so a concurrent edit fails that item loudly instead of clobbering.
161
+
162
+ The merge is deliberately conservative: only empty fields are filled; creators
163
+ are extended only when the current list is a same-order prefix of the
164
+ authoritative one (the classic truncated-list case — any other difference is
165
+ reported, not touched); tags, collections, relations, and attachments are never
166
+ modified; Extra only gains lines. Abstracts zotkit writes are stamped
167
+ `abstract-source: …` in Extra — a stamp with no abstract means someone
168
+ deliberately removed it, and enrich will not re-add it (reported as NEEDS
169
+ OWNER). Items with no DOI or arXiv id are reported as needs-identifier.
170
+
171
+ `--rebuild-record` additionally upgrades a preprint whose paper has since been
172
+ published (journal DOI present) to the journal record **in the same item**:
173
+ itemType, published title/date/venue/volume/pages — with `arXiv: <id>` appended
174
+ to Extra and attachments untouched.
148
175
 
149
176
  Item JSON for `zotkit create --file` (a list, one object per reference):
150
177
 
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  zotkit/__init__.py
5
5
  zotkit/cli.py
6
6
  zotkit/core.py
7
+ zotkit/enrich.py
7
8
  zotkit/metadata.py
8
9
  zotkit.egg-info/PKG-INFO
9
10
  zotkit.egg-info/SOURCES.txt
File without changes
File without changes
File without changes