zotkit 0.3.0__tar.gz → 0.4.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.3.0
3
+ Version: 0.4.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
@@ -25,16 +25,17 @@ Dynamic: license-file
25
25
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
26
26
 
27
27
  **Headless Zotero library management — no desktop app required.**
28
- [中文速览 →](README.zh-CN.md)
28
+
29
+ **English** | [简体中文](README.zh-CN.md)
29
30
 
30
31
  "Headless" simply means zotkit never needs the Zotero app (or any window) open: it is a
31
32
  Python library + CLI that talks straight to the
32
33
  [Zotero Web API](https://www.zotero.org/support/dev/web_api/v3/start), so you can
33
34
  search, create, tag, and organize items from any terminal — macOS, Windows, or Linux,
34
- your laptop or a remote server. Uniquely, if your attachments sync to a **personal
35
- WebDAV server**, zotkit can **upload and download the files themselves** by speaking
36
- Zotero's WebDAV storage format directly — the one capability the Web API does not
37
- provide, and (as far as we know) not offered by any other headless tool.
35
+ your laptop or a remote server. If your attachments sync to a **personal WebDAV
36
+ server**, zotkit can **upload and download the files themselves** by speaking
37
+ Zotero's WebDAV storage format directly — a capability the Web API itself does not
38
+ provide. The format is documented in [docs/webdav-format.md](docs/webdav-format.md).
38
39
 
39
40
  Built for servers, scripts, and **LLM agents**: every write is dry-run by default,
40
41
  batched, and version-checked, and you can define a tag taxonomy that is *enforced in
@@ -50,6 +51,18 @@ code* so an agent (or a tired human) can't pollute your library with inconsisten
50
51
  | Attachment files on **WebDAV** | ✅ | ⚠️ download at best | ✅ **upload + download** |
51
52
  | Tag conventions enforced in code | ❌ | ❌ | ✅ optional `conventions.toml` |
52
53
 
54
+ ## The zotkit family
55
+
56
+ Two independent, complementary projects — use either on its own, or both together:
57
+
58
+ | | What it is |
59
+ |---|---|
60
+ | **zotkit** (this repo) | Headless Python CLI + library. Runs anywhere, needs no Zotero app. |
61
+ | **[zotkit-reader](https://github.com/oldantique/zotkit-reader)** | A Zotero Reader sidebar that embeds a Codex/Claude agent beside the PDF you're reading. Maintained by [@ChanceSiyuan](https://github.com/ChanceSiyuan). |
62
+
63
+ They share a name and a philosophy, not a codebase: zotkit-reader can call zotkit over MCP
64
+ for library-wide operations, but neither requires the other to be installed.
65
+
53
66
  ## Install
54
67
 
55
68
  Pure Python (3.11+), no platform-specific bits — the same package works on macOS,
@@ -89,7 +102,12 @@ Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
89
102
  zotkit find --title "boson sampling" # search by title/tag/collection
90
103
  zotkit find --tag status:to-read
91
104
 
92
- zotkit create --file papers.json # dry-run: shows what would be created
105
+ zotkit create --arxiv 2401.12345 # fetch arXiv metadata, dry-run preview
106
+ zotkit create --arxiv 2401.12345 --apply --tags field:ai # create + download & attach the PDF
107
+ zotkit create --doi 10.1038/nature14539 # fetch CrossRef metadata by DOI (no PDF —
108
+ # usually paywalled; attach manually after)
109
+
110
+ zotkit create --file papers.json # batch from JSON: dry-run preview
93
111
  zotkit create --file papers.json --apply # create (dedups by DOI/title)
94
112
  zotkit attach --from papers.created.json --all # upload the PDFs to WebDAV
95
113
 
@@ -104,7 +122,14 @@ zotkit backup # full JSON snapshot -> backups/
104
122
  zotkit lint field:physics topic:new-idea # offline tag check
105
123
  ```
106
124
 
107
- Item JSON for `zotkit create` (a list, one object per reference):
125
+ `--arxiv` takes an id or an abs/pdf URL and maps the full record (all authors,
126
+ abstract, date, DOI, `preprint` item type); `--doi` maps CrossRef records
127
+ (journal articles, conference papers, books, chapters, …) and refuses to guess
128
+ on CrossRef types it doesn't know. Both accept `--collection` and `--tags`, and
129
+ `--no-pdf` skips the arXiv PDF. Fetching metadata from arbitrary web pages is
130
+ out of scope (zotkit stays a daemon-free CLI — no translation-server).
131
+
132
+ Item JSON for `zotkit create --file` (a list, one object per reference):
108
133
 
109
134
  ```json
110
135
  [{"itemType": "journalArticle", "title": "…",
@@ -174,7 +199,9 @@ This project is not affiliated with or endorsed by Zotero.
174
199
  sluggish for many thousands. Server-side search is planned.
175
200
  - Group libraries should work for item operations (untested); WebDAV file sync is
176
201
  personal-libraries-only (a Zotero limitation).
177
- - Planned: an MCP server wrapper, server-side search, DOI/arXiv one-shot import.
202
+ - `--doi`/`--arxiv` import covers arXiv + CrossRef; DataCite-only DOIs and
203
+ arbitrary-URL scraping (translation-server territory) are out of scope.
204
+ - Planned: an MCP server wrapper, server-side search.
178
205
 
179
206
  ## License
180
207
 
@@ -5,16 +5,17 @@
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
6
6
 
7
7
  **Headless Zotero library management — no desktop app required.**
8
- [中文速览 →](README.zh-CN.md)
8
+
9
+ **English** | [简体中文](README.zh-CN.md)
9
10
 
10
11
  "Headless" simply means zotkit never needs the Zotero app (or any window) open: it is a
11
12
  Python library + CLI that talks straight to the
12
13
  [Zotero Web API](https://www.zotero.org/support/dev/web_api/v3/start), so you can
13
14
  search, create, tag, and organize items from any terminal — macOS, Windows, or Linux,
14
- your laptop or a remote server. Uniquely, if your attachments sync to a **personal
15
- WebDAV server**, zotkit can **upload and download the files themselves** by speaking
16
- Zotero's WebDAV storage format directly — the one capability the Web API does not
17
- provide, and (as far as we know) not offered by any other headless tool.
15
+ your laptop or a remote server. If your attachments sync to a **personal WebDAV
16
+ server**, zotkit can **upload and download the files themselves** by speaking
17
+ Zotero's WebDAV storage format directly — a capability the Web API itself does not
18
+ provide. The format is documented in [docs/webdav-format.md](docs/webdav-format.md).
18
19
 
19
20
  Built for servers, scripts, and **LLM agents**: every write is dry-run by default,
20
21
  batched, and version-checked, and you can define a tag taxonomy that is *enforced in
@@ -30,6 +31,18 @@ code* so an agent (or a tired human) can't pollute your library with inconsisten
30
31
  | Attachment files on **WebDAV** | ✅ | ⚠️ download at best | ✅ **upload + download** |
31
32
  | Tag conventions enforced in code | ❌ | ❌ | ✅ optional `conventions.toml` |
32
33
 
34
+ ## The zotkit family
35
+
36
+ Two independent, complementary projects — use either on its own, or both together:
37
+
38
+ | | What it is |
39
+ |---|---|
40
+ | **zotkit** (this repo) | Headless Python CLI + library. Runs anywhere, needs no Zotero app. |
41
+ | **[zotkit-reader](https://github.com/oldantique/zotkit-reader)** | A Zotero Reader sidebar that embeds a Codex/Claude agent beside the PDF you're reading. Maintained by [@ChanceSiyuan](https://github.com/ChanceSiyuan). |
42
+
43
+ They share a name and a philosophy, not a codebase: zotkit-reader can call zotkit over MCP
44
+ for library-wide operations, but neither requires the other to be installed.
45
+
33
46
  ## Install
34
47
 
35
48
  Pure Python (3.11+), no platform-specific bits — the same package works on macOS,
@@ -69,7 +82,12 @@ Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
69
82
  zotkit find --title "boson sampling" # search by title/tag/collection
70
83
  zotkit find --tag status:to-read
71
84
 
72
- zotkit create --file papers.json # dry-run: shows what would be created
85
+ zotkit create --arxiv 2401.12345 # fetch arXiv metadata, dry-run preview
86
+ zotkit create --arxiv 2401.12345 --apply --tags field:ai # create + download & attach the PDF
87
+ zotkit create --doi 10.1038/nature14539 # fetch CrossRef metadata by DOI (no PDF —
88
+ # usually paywalled; attach manually after)
89
+
90
+ zotkit create --file papers.json # batch from JSON: dry-run preview
73
91
  zotkit create --file papers.json --apply # create (dedups by DOI/title)
74
92
  zotkit attach --from papers.created.json --all # upload the PDFs to WebDAV
75
93
 
@@ -84,7 +102,14 @@ zotkit backup # full JSON snapshot -> backups/
84
102
  zotkit lint field:physics topic:new-idea # offline tag check
85
103
  ```
86
104
 
87
- Item JSON for `zotkit create` (a list, one object per reference):
105
+ `--arxiv` takes an id or an abs/pdf URL and maps the full record (all authors,
106
+ abstract, date, DOI, `preprint` item type); `--doi` maps CrossRef records
107
+ (journal articles, conference papers, books, chapters, …) and refuses to guess
108
+ on CrossRef types it doesn't know. Both accept `--collection` and `--tags`, and
109
+ `--no-pdf` skips the arXiv PDF. Fetching metadata from arbitrary web pages is
110
+ out of scope (zotkit stays a daemon-free CLI — no translation-server).
111
+
112
+ Item JSON for `zotkit create --file` (a list, one object per reference):
88
113
 
89
114
  ```json
90
115
  [{"itemType": "journalArticle", "title": "…",
@@ -154,7 +179,9 @@ This project is not affiliated with or endorsed by Zotero.
154
179
  sluggish for many thousands. Server-side search is planned.
155
180
  - Group libraries should work for item operations (untested); WebDAV file sync is
156
181
  personal-libraries-only (a Zotero limitation).
157
- - Planned: an MCP server wrapper, server-side search, DOI/arXiv one-shot import.
182
+ - `--doi`/`--arxiv` import covers arXiv + CrossRef; DataCite-only DOIs and
183
+ arbitrary-URL scraping (translation-server territory) are out of scope.
184
+ - Planned: an MCP server wrapper, server-side search.
158
185
 
159
186
  ## License
160
187
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zotkit"
7
- version = "0.3.0"
7
+ version = "0.4.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.3.0"
5
+ __version__ = "0.4.0"
@@ -1,6 +1,6 @@
1
1
  """zotkit — CLI over zotkit.core.Zot.
2
2
 
3
- Subcommands: find, create, attach, fetch, tag, status, move, backup, lint.
3
+ Subcommands: doctor, find, create, 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
@@ -77,17 +77,30 @@ def main(argv=None):
77
77
  p = sub.add_parser("find", help="search items by title/tag/collection")
78
78
  p.add_argument("--title"); p.add_argument("--tag"); p.add_argument("--collection")
79
79
 
80
- p = sub.add_parser("create", help="create items from a JSON file (dry unless --apply)")
81
- p.add_argument("--file", required=True); p.add_argument("--apply", action="store_true")
80
+ p = sub.add_parser("create", help="create items from a JSON file, an arXiv id, "
81
+ "or a DOI (dry unless --apply)")
82
+ src = p.add_mutually_exclusive_group(required=True)
83
+ src.add_argument("--file")
84
+ src.add_argument("--arxiv", metavar="ID_OR_URL",
85
+ help="arXiv id (2401.12345) or abs/pdf URL; fetches metadata, "
86
+ "and with --apply also downloads + attaches the PDF")
87
+ src.add_argument("--doi", metavar="DOI",
88
+ help="DOI (or doi.org URL); fetches metadata from CrossRef")
89
+ p.add_argument("--apply", action="store_true")
82
90
  p.add_argument("--no-dedup", action="store_true")
83
91
  p.add_argument("--loose-tags", action="store_true", help="warn instead of error on tag violations")
92
+ p.add_argument("--no-pdf", action="store_true",
93
+ help="with --arxiv --apply: skip downloading/attaching the PDF")
94
+ p.add_argument("--collection", help="with --arxiv/--doi: file the item here")
95
+ p.add_argument("--tags", help="with --arxiv/--doi: comma-separated tags, "
96
+ "e.g. field:ml,status:to-read")
84
97
 
85
- p = sub.add_parser("attach", help="attach a local file (WebDAV) to an item")
98
+ p = sub.add_parser("attach", help="attach a local file to an item (WebDAV or Zotero Storage)")
86
99
  p.add_argument("--key"); p.add_argument("--pdf")
87
100
  p.add_argument("--from", dest="src", help="a .created.json list (batch)")
88
101
  p.add_argument("--all", action="store_true")
89
102
 
90
- p = sub.add_parser("fetch", help="download attachment files from WebDAV")
103
+ p = sub.add_parser("fetch", help="download attachment files (WebDAV or Zotero Storage)")
91
104
  p.add_argument("--key"); p.add_argument("--title"); p.add_argument("--collection")
92
105
  p.add_argument("--out", default="downloads")
93
106
 
@@ -128,23 +141,72 @@ def main(argv=None):
128
141
  _print_items(zot.find(a.title, a.tag, a.collection))
129
142
 
130
143
  elif a.cmd == "create":
131
- data = json.load(open(a.file, encoding="utf-8"))
132
- items = data["items"] if isinstance(data, dict) else data
144
+ pdf_url = None
145
+ if a.file:
146
+ data = json.load(open(a.file, encoding="utf-8"))
147
+ items = data["items"] if isinstance(data, dict) else data
148
+ else:
149
+ from .metadata import MetadataError, fetch_arxiv, fetch_doi
150
+ try:
151
+ if a.arxiv:
152
+ item, pdf_url = fetch_arxiv(a.arxiv)
153
+ else:
154
+ item = fetch_doi(a.doi)
155
+ except MetadataError as e:
156
+ print(f"error: {e}", file=sys.stderr)
157
+ return 1
158
+ if a.collection:
159
+ item["collection"] = a.collection
160
+ if a.tags:
161
+ item["tags"] = [t.strip() for t in a.tags.split(",") if t.strip()]
162
+ items = [item]
133
163
  if not a.apply:
134
164
  for d in items:
135
165
  problems = lint_tags(d.get("tags", []), conventions=zot.conventions,
136
166
  auto_load=False)
167
+ if not a.file: # identifier mode: show the full fetched record
168
+ print(json.dumps(d, ensure_ascii=False, indent=2))
169
+ if problems:
170
+ print(" !! " + "; ".join(problems))
171
+ continue
137
172
  flag = (" !! " + "; ".join(problems)) if problems else ""
138
173
  print(f" [dry] {d.get('collection')} | {d.get('title','')[:60]}{flag}")
139
174
  print(f"{len(items)} item(s). DRY — add --apply to create.")
140
175
  return 0
141
176
  created = zot.create_items(items, dedup=not a.no_dedup, strict_tags=not a.loose_tags)
142
177
  new = [c for c in created if c.get("key")]
143
- out = Path(a.file).with_suffix(".created.json")
144
- json.dump(new, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
145
- print(f"created {len(new)} (skipped {len(created)-len(new)} dup) -> {out}")
146
- if any(c.get("file_path") for c in new):
147
- print(f"attach PDFs: zotkit attach --from {out} --all")
178
+ if a.file:
179
+ out = Path(a.file).with_suffix(".created.json")
180
+ json.dump(new, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
181
+ print(f"created {len(new)} (skipped {len(created)-len(new)} dup) -> {out}")
182
+ if any(c.get("file_path") for c in new):
183
+ print(f"attach PDFs: zotkit attach --from {out} --all")
184
+ return 0
185
+ # identifier mode: one item
186
+ if not new:
187
+ print("skipped: an item with this DOI/title already exists (use --no-dedup "
188
+ "to force)")
189
+ return 0
190
+ key = new[0]["key"]
191
+ print(f"created {key} {items[0].get('title', '')[:70]}")
192
+ if pdf_url and not a.no_pdf:
193
+ from .metadata import download_pdf
194
+ import tempfile
195
+ with tempfile.TemporaryDirectory() as td:
196
+ pdf = Path(td) / (items[0]["archiveID"].replace("arXiv:", "arXiv-")
197
+ .replace("/", "_") + ".pdf")
198
+ try:
199
+ download_pdf(pdf_url, pdf)
200
+ except MetadataError as e:
201
+ print(f"item created, but PDF failed: {e}\n"
202
+ f" attach manually: zotkit attach --key {key} --pdf <file>",
203
+ file=sys.stderr)
204
+ return 1
205
+ info = zot.attach(key, pdf)
206
+ print(f"attached {info['filename']} ({info['storage']})")
207
+ elif a.doi:
208
+ print("no PDF downloaded (publisher PDFs are usually paywalled) — "
209
+ f"attach one with: zotkit attach --key {key} --pdf <file>")
148
210
 
149
211
  elif a.cmd == "attach":
150
212
  if a.key and a.pdf:
@@ -0,0 +1,223 @@
1
+ """zotkit.metadata — identifier → item-JSON fetchers (arXiv, CrossRef DOI).
2
+
3
+ Each fetcher returns a plain item dict in the exact shape `zotkit create --file`
4
+ consumes, so the CLI runs every source through the same lint/dry-run/create path.
5
+ Scope is deliberately narrow: stable JSON/Atom APIs only — no translation-server,
6
+ no arbitrary-URL scraping (zotkit stays a daemon-free CLI).
7
+
8
+ Abstracts are stored verbatim (line breaks, LaTeX, JATS markup untouched).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import time
14
+ import xml.etree.ElementTree as ET
15
+ from urllib.parse import quote
16
+
17
+ import httpx
18
+
19
+ from . import __version__
20
+
21
+ CONTACT = "mailto:Eric.M.990909@gmail.com"
22
+ USER_AGENT = f"zotkit/{__version__} (https://github.com/oldantique/zotkit; {CONTACT})"
23
+
24
+ _ATOM = "{http://www.w3.org/2005/Atom}"
25
+ _ARXIV = "{http://arxiv.org/schemas/atom}"
26
+
27
+ # CrossRef work types with a faithful Zotero equivalent. Anything else errors —
28
+ # guessing an itemType would silently corrupt the record.
29
+ CROSSREF_TYPES = {
30
+ "journal-article": "journalArticle",
31
+ "proceedings-article": "conferencePaper",
32
+ "posted-content": "preprint",
33
+ "book": "book",
34
+ "monograph": "book",
35
+ "edited-book": "book",
36
+ "reference-book": "book",
37
+ "book-chapter": "bookSection",
38
+ "book-section": "bookSection",
39
+ "book-part": "bookSection",
40
+ "report": "report",
41
+ "dissertation": "thesis",
42
+ "dataset": "dataset",
43
+ "standard": "standard",
44
+ }
45
+
46
+ # where CrossRef's container-title belongs, per Zotero itemType
47
+ _CONTAINER_FIELD = {
48
+ "journalArticle": "publicationTitle",
49
+ "conferencePaper": "proceedingsTitle",
50
+ "bookSection": "bookTitle",
51
+ }
52
+
53
+
54
+ class MetadataError(RuntimeError):
55
+ """A metadata lookup failed in a way the user must resolve (bad id,
56
+ unknown record, unsupported type, network trouble)."""
57
+
58
+
59
+ def _get(url: str, **kw) -> httpx.Response:
60
+ try:
61
+ r = httpx.get(url, headers={"User-Agent": USER_AGENT}, timeout=30,
62
+ follow_redirects=True, **kw)
63
+ if r.status_code == 429: # rate-limited: honor Retry-After once, then give up
64
+ wait = min(int(r.headers.get("retry-after") or 5), 30)
65
+ time.sleep(wait)
66
+ r = httpx.get(url, headers={"User-Agent": USER_AGENT}, timeout=30,
67
+ follow_redirects=True, **kw)
68
+ if r.status_code == 429:
69
+ raise MetadataError(
70
+ f"{httpx.URL(url).host} is rate-limiting (HTTP 429) — "
71
+ "wait a minute and retry")
72
+ except httpx.HTTPError as e:
73
+ raise MetadataError(f"network error talking to {httpx.URL(url).host}: {e}") from e
74
+ return r
75
+
76
+
77
+ def _squash(s: str) -> str:
78
+ """Collapse whitespace runs — for titles/names, never for abstracts."""
79
+ return re.sub(r"\s+", " ", s or "").strip()
80
+
81
+
82
+ def _person(name: str) -> dict:
83
+ """Split a display name into first/last on the final space; single-token
84
+ names go in lastName alone (matches Zotero's single-field mode)."""
85
+ name = _squash(name)
86
+ if " " in name:
87
+ first, last = name.rsplit(" ", 1)
88
+ return {"creatorType": "author", "firstName": first, "lastName": last}
89
+ return {"creatorType": "author", "firstName": "", "lastName": name}
90
+
91
+
92
+ # ---------- arXiv ----------
93
+
94
+ _ARXIV_ID = re.compile(r"(?:arxiv\.org/(?:abs|pdf)/|arxiv:)?"
95
+ r"((?:\d{4}\.\d{4,5}|[a-z-]+(?:\.[A-Z]{2})?/\d{7})(?:v\d+)?)"
96
+ r"(?:\.pdf)?$", re.IGNORECASE)
97
+
98
+
99
+ def arxiv_id(id_or_url: str) -> str:
100
+ """Accept a bare id (2401.12345, 2401.12345v2, math.GT/0309136) or an
101
+ arxiv.org abs/pdf URL; return the bare id."""
102
+ s = id_or_url.strip().removeprefix("https://").removeprefix("http://").removeprefix("www.")
103
+ m = _ARXIV_ID.search(s)
104
+ if not m:
105
+ raise MetadataError(f"'{id_or_url}' does not look like an arXiv id or URL")
106
+ return m.group(1)
107
+
108
+
109
+ def fetch_arxiv(id_or_url: str) -> tuple[dict, str]:
110
+ """arXiv export API → (item dict, pdf url)."""
111
+ aid = arxiv_id(id_or_url)
112
+ r = _get("https://export.arxiv.org/api/query", params={"id_list": aid})
113
+ if r.status_code != 200:
114
+ raise MetadataError(f"arXiv API returned HTTP {r.status_code} for '{aid}'")
115
+ try:
116
+ feed = ET.fromstring(r.text)
117
+ except ET.ParseError as e:
118
+ raise MetadataError(f"arXiv API returned unparseable XML: {e}") from e
119
+ entry = feed.find(f"{_ATOM}entry")
120
+ title = _squash(entry.findtext(f"{_ATOM}title", "")) if entry is not None else ""
121
+ if entry is None or not title:
122
+ raise MetadataError(f"arXiv has no record for '{aid}' — check the id")
123
+ if title == "Error":
124
+ raise MetadataError(f"arXiv API error for '{aid}': "
125
+ f"{_squash(entry.findtext(f'{_ATOM}summary', ''))}")
126
+
127
+ bare = re.sub(r"v\d+$", "", aid)
128
+ doi = _squash(entry.findtext(f"{_ARXIV}doi", "")) or f"10.48550/arXiv.{bare}"
129
+ item = {
130
+ "itemType": "preprint",
131
+ "title": title,
132
+ "creators": [_person(n.text or "")
133
+ for n in entry.findall(f"{_ATOM}author/{_ATOM}name")],
134
+ "abstractNote": (entry.findtext(f"{_ATOM}summary", "") or "").strip("\n "),
135
+ "date": (entry.findtext(f"{_ATOM}published", "") or "")[:10],
136
+ "url": f"https://arxiv.org/abs/{aid}",
137
+ "DOI": doi,
138
+ "repository": "arXiv",
139
+ "archiveID": f"arXiv:{aid}",
140
+ "libraryCatalog": "arXiv.org",
141
+ }
142
+ return item, f"https://arxiv.org/pdf/{aid}"
143
+
144
+
145
+ def download_pdf(url: str, dest) -> None:
146
+ """Download a PDF to `dest`, refusing anything that isn't actually a PDF."""
147
+ r = _get(url)
148
+ if r.status_code != 200:
149
+ raise MetadataError(f"PDF download failed: HTTP {r.status_code} from {url}")
150
+ if not r.content.startswith(b"%PDF"):
151
+ raise MetadataError(f"{url} did not return a PDF "
152
+ f"(got {r.headers.get('content-type', 'unknown type')})")
153
+ with open(dest, "wb") as f:
154
+ f.write(r.content)
155
+
156
+
157
+ # ---------- CrossRef DOI ----------
158
+
159
+ def _crossref_date(msg: dict) -> str:
160
+ for k in ("issued", "published-print", "published-online", "created"):
161
+ parts = (msg.get(k) or {}).get("date-parts", [[]])[0]
162
+ if parts and parts[0]:
163
+ return "-".join(f"{p:02d}" if i else str(p) for i, p in enumerate(parts))
164
+ return ""
165
+
166
+
167
+ def fetch_doi(doi: str) -> dict:
168
+ """CrossRef works API → item dict. Unknown CrossRef types are an error."""
169
+ doi = re.sub(r"^(https?://(dx\.)?doi\.org/|doi:)", "", doi.strip(), flags=re.IGNORECASE)
170
+ r = _get(f"https://api.crossref.org/works/{quote(doi, safe='')}")
171
+ if r.status_code == 404:
172
+ raise MetadataError(f"CrossRef has no record for DOI '{doi}' — check the DOI "
173
+ "(DataCite-only DOIs, e.g. Zenodo/arXiv, are not in CrossRef)")
174
+ if r.status_code != 200:
175
+ raise MetadataError(f"CrossRef API returned HTTP {r.status_code} for '{doi}'")
176
+ msg = r.json()["message"]
177
+
178
+ ctype = msg.get("type", "")
179
+ itype = CROSSREF_TYPES.get(ctype)
180
+ if not itype:
181
+ raise MetadataError(
182
+ f"CrossRef type '{ctype}' has no zotkit mapping — supported: "
183
+ + ", ".join(sorted(CROSSREF_TYPES)) + ". Create this item via --file instead.")
184
+
185
+ creators = []
186
+ for role, ztype in (("author", "author"), ("editor", "editor")):
187
+ for a in msg.get(role, []):
188
+ if a.get("family"):
189
+ creators.append({"creatorType": ztype,
190
+ "firstName": _squash(a.get("given", "")),
191
+ "lastName": _squash(a["family"])})
192
+ elif a.get("name"):
193
+ creators.append({"creatorType": ztype, "name": _squash(a["name"])})
194
+
195
+ title = _squash((msg.get("title") or [""])[0])
196
+ subtitle = _squash((msg.get("subtitle") or [""])[0])
197
+ if subtitle and subtitle.lower() not in title.lower():
198
+ title = f"{title}: {subtitle}"
199
+ item = {
200
+ "itemType": itype,
201
+ "title": title,
202
+ "creators": creators,
203
+ "abstractNote": msg.get("abstract", ""),
204
+ "date": _crossref_date(msg),
205
+ "DOI": doi,
206
+ "url": msg.get("URL") or f"https://doi.org/{doi}",
207
+ "volume": msg.get("volume", ""),
208
+ "issue": msg.get("issue", ""),
209
+ "pages": msg.get("page", ""),
210
+ "publisher": _squash(msg.get("publisher", "")),
211
+ "language": msg.get("language", ""),
212
+ "ISSN": (msg.get("ISSN") or [""])[0],
213
+ "ISBN": (msg.get("ISBN") or [""])[0],
214
+ "journalAbbreviation": _squash((msg.get("short-container-title") or [""])[0]),
215
+ "libraryCatalog": "CrossRef",
216
+ }
217
+ container = _squash((msg.get("container-title") or [""])[0])
218
+ if container:
219
+ item[_CONTAINER_FIELD.get(itype, "publicationTitle")] = container
220
+ if not item["title"]:
221
+ raise MetadataError(f"CrossRef record for '{doi}' has no title — refusing to "
222
+ "create an empty item")
223
+ return {k: v for k, v in item.items() if v not in ("", [])}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zotkit
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -25,16 +25,17 @@ Dynamic: license-file
25
25
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
26
26
 
27
27
  **Headless Zotero library management — no desktop app required.**
28
- [中文速览 →](README.zh-CN.md)
28
+
29
+ **English** | [简体中文](README.zh-CN.md)
29
30
 
30
31
  "Headless" simply means zotkit never needs the Zotero app (or any window) open: it is a
31
32
  Python library + CLI that talks straight to the
32
33
  [Zotero Web API](https://www.zotero.org/support/dev/web_api/v3/start), so you can
33
34
  search, create, tag, and organize items from any terminal — macOS, Windows, or Linux,
34
- your laptop or a remote server. Uniquely, if your attachments sync to a **personal
35
- WebDAV server**, zotkit can **upload and download the files themselves** by speaking
36
- Zotero's WebDAV storage format directly — the one capability the Web API does not
37
- provide, and (as far as we know) not offered by any other headless tool.
35
+ your laptop or a remote server. If your attachments sync to a **personal WebDAV
36
+ server**, zotkit can **upload and download the files themselves** by speaking
37
+ Zotero's WebDAV storage format directly — a capability the Web API itself does not
38
+ provide. The format is documented in [docs/webdav-format.md](docs/webdav-format.md).
38
39
 
39
40
  Built for servers, scripts, and **LLM agents**: every write is dry-run by default,
40
41
  batched, and version-checked, and you can define a tag taxonomy that is *enforced in
@@ -50,6 +51,18 @@ code* so an agent (or a tired human) can't pollute your library with inconsisten
50
51
  | Attachment files on **WebDAV** | ✅ | ⚠️ download at best | ✅ **upload + download** |
51
52
  | Tag conventions enforced in code | ❌ | ❌ | ✅ optional `conventions.toml` |
52
53
 
54
+ ## The zotkit family
55
+
56
+ Two independent, complementary projects — use either on its own, or both together:
57
+
58
+ | | What it is |
59
+ |---|---|
60
+ | **zotkit** (this repo) | Headless Python CLI + library. Runs anywhere, needs no Zotero app. |
61
+ | **[zotkit-reader](https://github.com/oldantique/zotkit-reader)** | A Zotero Reader sidebar that embeds a Codex/Claude agent beside the PDF you're reading. Maintained by [@ChanceSiyuan](https://github.com/ChanceSiyuan). |
62
+
63
+ They share a name and a philosophy, not a codebase: zotkit-reader can call zotkit over MCP
64
+ for library-wide operations, but neither requires the other to be installed.
65
+
53
66
  ## Install
54
67
 
55
68
  Pure Python (3.11+), no platform-specific bits — the same package works on macOS,
@@ -89,7 +102,12 @@ Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
89
102
  zotkit find --title "boson sampling" # search by title/tag/collection
90
103
  zotkit find --tag status:to-read
91
104
 
92
- zotkit create --file papers.json # dry-run: shows what would be created
105
+ zotkit create --arxiv 2401.12345 # fetch arXiv metadata, dry-run preview
106
+ zotkit create --arxiv 2401.12345 --apply --tags field:ai # create + download & attach the PDF
107
+ zotkit create --doi 10.1038/nature14539 # fetch CrossRef metadata by DOI (no PDF —
108
+ # usually paywalled; attach manually after)
109
+
110
+ zotkit create --file papers.json # batch from JSON: dry-run preview
93
111
  zotkit create --file papers.json --apply # create (dedups by DOI/title)
94
112
  zotkit attach --from papers.created.json --all # upload the PDFs to WebDAV
95
113
 
@@ -104,7 +122,14 @@ zotkit backup # full JSON snapshot -> backups/
104
122
  zotkit lint field:physics topic:new-idea # offline tag check
105
123
  ```
106
124
 
107
- Item JSON for `zotkit create` (a list, one object per reference):
125
+ `--arxiv` takes an id or an abs/pdf URL and maps the full record (all authors,
126
+ abstract, date, DOI, `preprint` item type); `--doi` maps CrossRef records
127
+ (journal articles, conference papers, books, chapters, …) and refuses to guess
128
+ on CrossRef types it doesn't know. Both accept `--collection` and `--tags`, and
129
+ `--no-pdf` skips the arXiv PDF. Fetching metadata from arbitrary web pages is
130
+ out of scope (zotkit stays a daemon-free CLI — no translation-server).
131
+
132
+ Item JSON for `zotkit create --file` (a list, one object per reference):
108
133
 
109
134
  ```json
110
135
  [{"itemType": "journalArticle", "title": "…",
@@ -174,7 +199,9 @@ This project is not affiliated with or endorsed by Zotero.
174
199
  sluggish for many thousands. Server-side search is planned.
175
200
  - Group libraries should work for item operations (untested); WebDAV file sync is
176
201
  personal-libraries-only (a Zotero limitation).
177
- - Planned: an MCP server wrapper, server-side search, DOI/arXiv one-shot import.
202
+ - `--doi`/`--arxiv` import covers arXiv + CrossRef; DataCite-only DOIs and
203
+ arbitrary-URL scraping (translation-server territory) are out of scope.
204
+ - Planned: an MCP server wrapper, server-side search.
178
205
 
179
206
  ## License
180
207
 
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  zotkit/__init__.py
5
5
  zotkit/cli.py
6
6
  zotkit/core.py
7
+ zotkit/metadata.py
7
8
  zotkit.egg-info/PKG-INFO
8
9
  zotkit.egg-info/SOURCES.txt
9
10
  zotkit.egg-info/dependency_links.txt
File without changes
File without changes
File without changes