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.
- {zotkit-0.4.2/zotkit.egg-info → zotkit-0.5.0}/PKG-INFO +29 -2
- {zotkit-0.4.2 → zotkit-0.5.0}/README.md +28 -1
- {zotkit-0.4.2 → zotkit-0.5.0}/pyproject.toml +1 -1
- {zotkit-0.4.2 → zotkit-0.5.0}/zotkit/__init__.py +1 -1
- {zotkit-0.4.2 → zotkit-0.5.0}/zotkit/cli.py +58 -1
- zotkit-0.5.0/zotkit/enrich.py +243 -0
- {zotkit-0.4.2 → zotkit-0.5.0}/zotkit/metadata.py +4 -1
- {zotkit-0.4.2 → zotkit-0.5.0/zotkit.egg-info}/PKG-INFO +29 -2
- {zotkit-0.4.2 → zotkit-0.5.0}/zotkit.egg-info/SOURCES.txt +1 -0
- {zotkit-0.4.2 → zotkit-0.5.0}/LICENSE +0 -0
- {zotkit-0.4.2 → zotkit-0.5.0}/setup.cfg +0 -0
- {zotkit-0.4.2 → zotkit-0.5.0}/zotkit/core.py +0 -0
- {zotkit-0.4.2 → zotkit-0.5.0}/zotkit.egg-info/dependency_links.txt +0 -0
- {zotkit-0.4.2 → zotkit-0.5.0}/zotkit.egg-info/entry_points.txt +0 -0
- {zotkit-0.4.2 → zotkit-0.5.0}/zotkit.egg-info/requires.txt +0 -0
- {zotkit-0.4.2 → zotkit-0.5.0}/zotkit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zotkit
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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"
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|