zotkit 0.2.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.
- {zotkit-0.2.0/zotkit.egg-info → zotkit-0.4.0}/PKG-INFO +50 -7
- {zotkit-0.2.0 → zotkit-0.4.0}/README.md +49 -6
- {zotkit-0.2.0 → zotkit-0.4.0}/pyproject.toml +1 -1
- {zotkit-0.2.0 → zotkit-0.4.0}/zotkit/__init__.py +1 -1
- zotkit-0.4.0/zotkit/cli.py +258 -0
- zotkit-0.4.0/zotkit/metadata.py +223 -0
- {zotkit-0.2.0 → zotkit-0.4.0/zotkit.egg-info}/PKG-INFO +50 -7
- {zotkit-0.2.0 → zotkit-0.4.0}/zotkit.egg-info/SOURCES.txt +1 -0
- zotkit-0.2.0/zotkit/cli.py +0 -144
- {zotkit-0.2.0 → zotkit-0.4.0}/LICENSE +0 -0
- {zotkit-0.2.0 → zotkit-0.4.0}/setup.cfg +0 -0
- {zotkit-0.2.0 → zotkit-0.4.0}/zotkit/core.py +0 -0
- {zotkit-0.2.0 → zotkit-0.4.0}/zotkit.egg-info/dependency_links.txt +0 -0
- {zotkit-0.2.0 → zotkit-0.4.0}/zotkit.egg-info/entry_points.txt +0 -0
- {zotkit-0.2.0 → zotkit-0.4.0}/zotkit.egg-info/requires.txt +0 -0
- {zotkit-0.2.0 → zotkit-0.4.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.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
|
|
@@ -20,16 +20,22 @@ Dynamic: license-file
|
|
|
20
20
|
|
|
21
21
|
# zotkit
|
|
22
22
|
|
|
23
|
+
[](https://pypi.org/project/zotkit/)
|
|
24
|
+
[](https://pypi.org/project/zotkit/)
|
|
25
|
+
[](LICENSE)
|
|
26
|
+
|
|
23
27
|
**Headless Zotero library management — no desktop app required.**
|
|
24
28
|
|
|
29
|
+
**English** | [简体中文](README.zh-CN.md)
|
|
30
|
+
|
|
25
31
|
"Headless" simply means zotkit never needs the Zotero app (or any window) open: it is a
|
|
26
32
|
Python library + CLI that talks straight to the
|
|
27
33
|
[Zotero Web API](https://www.zotero.org/support/dev/web_api/v3/start), so you can
|
|
28
34
|
search, create, tag, and organize items from any terminal — macOS, Windows, or Linux,
|
|
29
|
-
your laptop or a remote server.
|
|
30
|
-
|
|
31
|
-
Zotero's WebDAV storage format directly —
|
|
32
|
-
provide
|
|
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).
|
|
33
39
|
|
|
34
40
|
Built for servers, scripts, and **LLM agents**: every write is dry-run by default,
|
|
35
41
|
batched, and version-checked, and you can define a tag taxonomy that is *enforced in
|
|
@@ -45,6 +51,18 @@ code* so an agent (or a tired human) can't pollute your library with inconsisten
|
|
|
45
51
|
| Attachment files on **WebDAV** | ✅ | ⚠️ download at best | ✅ **upload + download** |
|
|
46
52
|
| Tag conventions enforced in code | ❌ | ❌ | ✅ optional `conventions.toml` |
|
|
47
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
|
+
|
|
48
66
|
## Install
|
|
49
67
|
|
|
50
68
|
Pure Python (3.11+), no platform-specific bits — the same package works on macOS,
|
|
@@ -70,6 +88,9 @@ Copy [`.env.example`](.env.example) to `./.env`, `~/.config/zotkit/env`, or any
|
|
|
70
88
|
`attach`/`fetch` automatically use Zotero Storage through the Web API's upload/download
|
|
71
89
|
endpoints instead. The storage mode is detected from your `.env`, nothing to configure.
|
|
72
90
|
|
|
91
|
+
After filling it in, run **`zotkit doctor`** — it validates the config file, API
|
|
92
|
+
access, and attachment storage, and tells you exactly what to fix if anything fails.
|
|
93
|
+
|
|
73
94
|
Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
|
|
74
95
|
`conventions.toml` next to your `.env` to define a namespaced tag taxonomy
|
|
75
96
|
(`field:physics`, `status:to-read`, …). With it in place, `zotkit create` / `zotkit tag`
|
|
@@ -81,7 +102,12 @@ Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
|
|
|
81
102
|
zotkit find --title "boson sampling" # search by title/tag/collection
|
|
82
103
|
zotkit find --tag status:to-read
|
|
83
104
|
|
|
84
|
-
zotkit create --
|
|
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
|
|
85
111
|
zotkit create --file papers.json --apply # create (dedups by DOI/title)
|
|
86
112
|
zotkit attach --from papers.created.json --all # upload the PDFs to WebDAV
|
|
87
113
|
|
|
@@ -96,7 +122,14 @@ zotkit backup # full JSON snapshot -> backups/
|
|
|
96
122
|
zotkit lint field:physics topic:new-idea # offline tag check
|
|
97
123
|
```
|
|
98
124
|
|
|
99
|
-
|
|
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):
|
|
100
133
|
|
|
101
134
|
```json
|
|
102
135
|
[{"itemType": "journalArticle", "title": "…",
|
|
@@ -160,6 +193,16 @@ Details in [`docs/webdav-format.md`](docs/webdav-format.md).
|
|
|
160
193
|
The format was determined by interoperability inspection of the author's own library.
|
|
161
194
|
This project is not affiliated with or endorsed by Zotero.
|
|
162
195
|
|
|
196
|
+
## Limits & roadmap
|
|
197
|
+
|
|
198
|
+
- `find` currently lists the library client-side — instant for hundreds of items,
|
|
199
|
+
sluggish for many thousands. Server-side search is planned.
|
|
200
|
+
- Group libraries should work for item operations (untested); WebDAV file sync is
|
|
201
|
+
personal-libraries-only (a Zotero limitation).
|
|
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.
|
|
205
|
+
|
|
163
206
|
## License
|
|
164
207
|
|
|
165
208
|
[MIT](LICENSE). If you build on the WebDAV implementation, a link back is appreciated.
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
# zotkit
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/zotkit/)
|
|
4
|
+
[](https://pypi.org/project/zotkit/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
3
7
|
**Headless Zotero library management — no desktop app required.**
|
|
4
8
|
|
|
9
|
+
**English** | [简体中文](README.zh-CN.md)
|
|
10
|
+
|
|
5
11
|
"Headless" simply means zotkit never needs the Zotero app (or any window) open: it is a
|
|
6
12
|
Python library + CLI that talks straight to the
|
|
7
13
|
[Zotero Web API](https://www.zotero.org/support/dev/web_api/v3/start), so you can
|
|
8
14
|
search, create, tag, and organize items from any terminal — macOS, Windows, or Linux,
|
|
9
|
-
your laptop or a remote server.
|
|
10
|
-
|
|
11
|
-
Zotero's WebDAV storage format directly —
|
|
12
|
-
provide
|
|
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).
|
|
13
19
|
|
|
14
20
|
Built for servers, scripts, and **LLM agents**: every write is dry-run by default,
|
|
15
21
|
batched, and version-checked, and you can define a tag taxonomy that is *enforced in
|
|
@@ -25,6 +31,18 @@ code* so an agent (or a tired human) can't pollute your library with inconsisten
|
|
|
25
31
|
| Attachment files on **WebDAV** | ✅ | ⚠️ download at best | ✅ **upload + download** |
|
|
26
32
|
| Tag conventions enforced in code | ❌ | ❌ | ✅ optional `conventions.toml` |
|
|
27
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
|
+
|
|
28
46
|
## Install
|
|
29
47
|
|
|
30
48
|
Pure Python (3.11+), no platform-specific bits — the same package works on macOS,
|
|
@@ -50,6 +68,9 @@ Copy [`.env.example`](.env.example) to `./.env`, `~/.config/zotkit/env`, or any
|
|
|
50
68
|
`attach`/`fetch` automatically use Zotero Storage through the Web API's upload/download
|
|
51
69
|
endpoints instead. The storage mode is detected from your `.env`, nothing to configure.
|
|
52
70
|
|
|
71
|
+
After filling it in, run **`zotkit doctor`** — it validates the config file, API
|
|
72
|
+
access, and attachment storage, and tells you exactly what to fix if anything fails.
|
|
73
|
+
|
|
53
74
|
Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
|
|
54
75
|
`conventions.toml` next to your `.env` to define a namespaced tag taxonomy
|
|
55
76
|
(`field:physics`, `status:to-read`, …). With it in place, `zotkit create` / `zotkit tag`
|
|
@@ -61,7 +82,12 @@ Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
|
|
|
61
82
|
zotkit find --title "boson sampling" # search by title/tag/collection
|
|
62
83
|
zotkit find --tag status:to-read
|
|
63
84
|
|
|
64
|
-
zotkit create --
|
|
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
|
|
65
91
|
zotkit create --file papers.json --apply # create (dedups by DOI/title)
|
|
66
92
|
zotkit attach --from papers.created.json --all # upload the PDFs to WebDAV
|
|
67
93
|
|
|
@@ -76,7 +102,14 @@ zotkit backup # full JSON snapshot -> backups/
|
|
|
76
102
|
zotkit lint field:physics topic:new-idea # offline tag check
|
|
77
103
|
```
|
|
78
104
|
|
|
79
|
-
|
|
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):
|
|
80
113
|
|
|
81
114
|
```json
|
|
82
115
|
[{"itemType": "journalArticle", "title": "…",
|
|
@@ -140,6 +173,16 @@ Details in [`docs/webdav-format.md`](docs/webdav-format.md).
|
|
|
140
173
|
The format was determined by interoperability inspection of the author's own library.
|
|
141
174
|
This project is not affiliated with or endorsed by Zotero.
|
|
142
175
|
|
|
176
|
+
## Limits & roadmap
|
|
177
|
+
|
|
178
|
+
- `find` currently lists the library client-side — instant for hundreds of items,
|
|
179
|
+
sluggish for many thousands. Server-side search is planned.
|
|
180
|
+
- Group libraries should work for item operations (untested); WebDAV file sync is
|
|
181
|
+
personal-libraries-only (a Zotero limitation).
|
|
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.
|
|
185
|
+
|
|
143
186
|
## License
|
|
144
187
|
|
|
145
188
|
[MIT](LICENSE). If you build on the WebDAV implementation, a link back is appreciated.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "zotkit"
|
|
7
|
-
version = "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"
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""zotkit — CLI over zotkit.core.Zot.
|
|
2
|
+
|
|
3
|
+
Subcommands: doctor, find, create, attach, fetch, tag, status, move, backup, lint.
|
|
4
|
+
Write commands print what they did; `create` is dry-run unless --apply.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .core import Zot, lint_tags, load_conventions, load_env
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _print_items(rows):
|
|
18
|
+
for r in rows:
|
|
19
|
+
print(f"{r['key']} [{r['itemType']}] {r['title'][:75]}")
|
|
20
|
+
print(f" collections: {r['collections']}")
|
|
21
|
+
print(f" tags: {r['tags']}")
|
|
22
|
+
print(f"\n{len(rows)} match(es)")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _doctor() -> int:
|
|
26
|
+
"""Validate the whole setup; each failing check explains how to fix it."""
|
|
27
|
+
import httpx
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
print(f"zotkit {__version__}")
|
|
30
|
+
try:
|
|
31
|
+
env = load_env()
|
|
32
|
+
except FileNotFoundError as e:
|
|
33
|
+
print(f"✗ config: {e}")
|
|
34
|
+
return 1
|
|
35
|
+
print(f"✓ config: {env['_env_path']}")
|
|
36
|
+
conv = load_conventions()
|
|
37
|
+
print(f"✓ conventions: {'enforced' if conv else 'none configured (tags unrestricted)'}")
|
|
38
|
+
try:
|
|
39
|
+
z = Zot()
|
|
40
|
+
access = z.z.key_info().get("access", {}).get("user", {})
|
|
41
|
+
n = z.z.count_items()
|
|
42
|
+
print(f"✓ Zotero API: library {env['ZOTERO_LIBRARY_ID']} reachable, {n} items, "
|
|
43
|
+
f"write access: {bool(access.get('write'))}")
|
|
44
|
+
except Exception as e:
|
|
45
|
+
print(f"✗ Zotero API: {e}\n check ZOTERO_LIBRARY_ID (numeric userID) and "
|
|
46
|
+
f"ZOTERO_API_KEY at https://www.zotero.org/settings/keys")
|
|
47
|
+
return 1
|
|
48
|
+
if "WEBDAV_URL" in env:
|
|
49
|
+
base, auth = z._webdav()
|
|
50
|
+
host = urlsplit(base).netloc
|
|
51
|
+
try:
|
|
52
|
+
with httpx.Client(auth=auth, timeout=30) as c:
|
|
53
|
+
r1 = c.put(f"{base}/zotkit-doctor-probe.txt", content=b"zotkit doctor probe")
|
|
54
|
+
c.delete(f"{base}/zotkit-doctor-probe.txt")
|
|
55
|
+
if r1.status_code in (200, 201, 204):
|
|
56
|
+
print(f"✓ WebDAV: {host} writable — attachments use WebDAV")
|
|
57
|
+
else:
|
|
58
|
+
print(f"✗ WebDAV: PUT returned {r1.status_code} — check credentials and "
|
|
59
|
+
f"that WEBDAV_URL ends with /zotero/")
|
|
60
|
+
return 1
|
|
61
|
+
except Exception as e:
|
|
62
|
+
print(f"✗ WebDAV: {e}")
|
|
63
|
+
return 1
|
|
64
|
+
else:
|
|
65
|
+
print("✓ attachments: Zotero Storage mode (no WEBDAV_* configured)")
|
|
66
|
+
print("all good ✓")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(argv=None):
|
|
71
|
+
ap = argparse.ArgumentParser(prog="zotkit", description="Headless Zotero library CLI")
|
|
72
|
+
ap.add_argument("--version", action="version", version=f"zotkit {__version__}")
|
|
73
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
74
|
+
|
|
75
|
+
sub.add_parser("doctor", help="validate setup: config, API access, attachment storage")
|
|
76
|
+
|
|
77
|
+
p = sub.add_parser("find", help="search items by title/tag/collection")
|
|
78
|
+
p.add_argument("--title"); p.add_argument("--tag"); p.add_argument("--collection")
|
|
79
|
+
|
|
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")
|
|
90
|
+
p.add_argument("--no-dedup", action="store_true")
|
|
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")
|
|
97
|
+
|
|
98
|
+
p = sub.add_parser("attach", help="attach a local file to an item (WebDAV or Zotero Storage)")
|
|
99
|
+
p.add_argument("--key"); p.add_argument("--pdf")
|
|
100
|
+
p.add_argument("--from", dest="src", help="a .created.json list (batch)")
|
|
101
|
+
p.add_argument("--all", action="store_true")
|
|
102
|
+
|
|
103
|
+
p = sub.add_parser("fetch", help="download attachment files (WebDAV or Zotero Storage)")
|
|
104
|
+
p.add_argument("--key"); p.add_argument("--title"); p.add_argument("--collection")
|
|
105
|
+
p.add_argument("--out", default="downloads")
|
|
106
|
+
|
|
107
|
+
p = sub.add_parser("tag", help="add/remove tags on an item")
|
|
108
|
+
p.add_argument("key"); p.add_argument("tags", nargs="+")
|
|
109
|
+
p.add_argument("--rm", action="store_true", help="remove instead of add")
|
|
110
|
+
|
|
111
|
+
p = sub.add_parser("status", help="set reading status (replaces the status: tag)")
|
|
112
|
+
p.add_argument("key"); p.add_argument("value", help="e.g. to-read / reading / read")
|
|
113
|
+
|
|
114
|
+
p = sub.add_parser("move", help="move item to a collection (name or 'Parent :: Child')")
|
|
115
|
+
p.add_argument("key"); p.add_argument("collection")
|
|
116
|
+
p.add_argument("--add", action="store_true", help="add as extra home instead of replacing")
|
|
117
|
+
|
|
118
|
+
sub.add_parser("backup", help="full JSON backup to backups/ next to your .env")
|
|
119
|
+
|
|
120
|
+
p = sub.add_parser("lint", help="check tags against conventions.toml (no API calls)")
|
|
121
|
+
p.add_argument("tags", nargs="+")
|
|
122
|
+
|
|
123
|
+
a = ap.parse_args(argv)
|
|
124
|
+
|
|
125
|
+
if a.cmd == "doctor":
|
|
126
|
+
return _doctor()
|
|
127
|
+
|
|
128
|
+
if a.cmd == "lint":
|
|
129
|
+
conv = load_conventions()
|
|
130
|
+
if conv is None:
|
|
131
|
+
print("no conventions configured — add a conventions.toml next to your .env "
|
|
132
|
+
"(see README); nothing to check")
|
|
133
|
+
return 0
|
|
134
|
+
problems = lint_tags(a.tags, conventions=conv, auto_load=False)
|
|
135
|
+
print("\n".join(problems) if problems else "OK")
|
|
136
|
+
return 1 if problems else 0
|
|
137
|
+
|
|
138
|
+
zot = Zot()
|
|
139
|
+
|
|
140
|
+
if a.cmd == "find":
|
|
141
|
+
_print_items(zot.find(a.title, a.tag, a.collection))
|
|
142
|
+
|
|
143
|
+
elif a.cmd == "create":
|
|
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]
|
|
163
|
+
if not a.apply:
|
|
164
|
+
for d in items:
|
|
165
|
+
problems = lint_tags(d.get("tags", []), conventions=zot.conventions,
|
|
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
|
|
172
|
+
flag = (" !! " + "; ".join(problems)) if problems else ""
|
|
173
|
+
print(f" [dry] {d.get('collection')} | {d.get('title','')[:60]}{flag}")
|
|
174
|
+
print(f"{len(items)} item(s). DRY — add --apply to create.")
|
|
175
|
+
return 0
|
|
176
|
+
created = zot.create_items(items, dedup=not a.no_dedup, strict_tags=not a.loose_tags)
|
|
177
|
+
new = [c for c in created if c.get("key")]
|
|
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>")
|
|
210
|
+
|
|
211
|
+
elif a.cmd == "attach":
|
|
212
|
+
if a.key and a.pdf:
|
|
213
|
+
print(zot.attach(a.key, a.pdf))
|
|
214
|
+
elif a.src:
|
|
215
|
+
rows = json.load(open(a.src, encoding="utf-8"))
|
|
216
|
+
done = 0
|
|
217
|
+
for m in rows:
|
|
218
|
+
if not m.get("file_path") or not Path(m["file_path"]).exists():
|
|
219
|
+
print(f" SKIP no file: {str(m.get('title'))[:55]}"); continue
|
|
220
|
+
if zot.has_attachment(m["key"]):
|
|
221
|
+
print(f" SKIP attached: {str(m.get('title'))[:55]}"); continue
|
|
222
|
+
if not a.all and done >= 1:
|
|
223
|
+
break
|
|
224
|
+
print(f"- {str(m.get('title'))[:60]}")
|
|
225
|
+
zot.attach(m["key"], m["file_path"]); done += 1
|
|
226
|
+
print(f"attached {done}")
|
|
227
|
+
else:
|
|
228
|
+
ap.error("attach needs --key + --pdf, or --from <created.json>")
|
|
229
|
+
|
|
230
|
+
elif a.cmd == "fetch":
|
|
231
|
+
keys = [a.key] if a.key else [r["key"] for r in zot.find(a.title, None, a.collection)]
|
|
232
|
+
n = 0
|
|
233
|
+
for k in keys:
|
|
234
|
+
for p_ in zot.fetch(k, a.out):
|
|
235
|
+
print(f" saved: {p_}"); n += 1
|
|
236
|
+
print(f"downloaded {n} file(s) to {a.out}")
|
|
237
|
+
|
|
238
|
+
elif a.cmd == "tag":
|
|
239
|
+
if a.rm:
|
|
240
|
+
zot.remove_tags(a.key, *a.tags); print(f"removed {a.tags} from {a.key}")
|
|
241
|
+
else:
|
|
242
|
+
zot.add_tags(a.key, *a.tags); print(f"added {a.tags} to {a.key}")
|
|
243
|
+
|
|
244
|
+
elif a.cmd == "status":
|
|
245
|
+
zot.set_status(a.key, a.value); print(f"{a.key} -> status:{a.value}")
|
|
246
|
+
|
|
247
|
+
elif a.cmd == "move":
|
|
248
|
+
zot.move(a.key, a.collection, add=a.add)
|
|
249
|
+
print(f"{a.key} -> '{a.collection}'{' (added)' if a.add else ''}")
|
|
250
|
+
|
|
251
|
+
elif a.cmd == "backup":
|
|
252
|
+
print("backup ->", zot.backup())
|
|
253
|
+
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
sys.exit(main())
|
|
@@ -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
|
+
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
|
|
@@ -20,16 +20,22 @@ Dynamic: license-file
|
|
|
20
20
|
|
|
21
21
|
# zotkit
|
|
22
22
|
|
|
23
|
+
[](https://pypi.org/project/zotkit/)
|
|
24
|
+
[](https://pypi.org/project/zotkit/)
|
|
25
|
+
[](LICENSE)
|
|
26
|
+
|
|
23
27
|
**Headless Zotero library management — no desktop app required.**
|
|
24
28
|
|
|
29
|
+
**English** | [简体中文](README.zh-CN.md)
|
|
30
|
+
|
|
25
31
|
"Headless" simply means zotkit never needs the Zotero app (or any window) open: it is a
|
|
26
32
|
Python library + CLI that talks straight to the
|
|
27
33
|
[Zotero Web API](https://www.zotero.org/support/dev/web_api/v3/start), so you can
|
|
28
34
|
search, create, tag, and organize items from any terminal — macOS, Windows, or Linux,
|
|
29
|
-
your laptop or a remote server.
|
|
30
|
-
|
|
31
|
-
Zotero's WebDAV storage format directly —
|
|
32
|
-
provide
|
|
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).
|
|
33
39
|
|
|
34
40
|
Built for servers, scripts, and **LLM agents**: every write is dry-run by default,
|
|
35
41
|
batched, and version-checked, and you can define a tag taxonomy that is *enforced in
|
|
@@ -45,6 +51,18 @@ code* so an agent (or a tired human) can't pollute your library with inconsisten
|
|
|
45
51
|
| Attachment files on **WebDAV** | ✅ | ⚠️ download at best | ✅ **upload + download** |
|
|
46
52
|
| Tag conventions enforced in code | ❌ | ❌ | ✅ optional `conventions.toml` |
|
|
47
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
|
+
|
|
48
66
|
## Install
|
|
49
67
|
|
|
50
68
|
Pure Python (3.11+), no platform-specific bits — the same package works on macOS,
|
|
@@ -70,6 +88,9 @@ Copy [`.env.example`](.env.example) to `./.env`, `~/.config/zotkit/env`, or any
|
|
|
70
88
|
`attach`/`fetch` automatically use Zotero Storage through the Web API's upload/download
|
|
71
89
|
endpoints instead. The storage mode is detected from your `.env`, nothing to configure.
|
|
72
90
|
|
|
91
|
+
After filling it in, run **`zotkit doctor`** — it validates the config file, API
|
|
92
|
+
access, and attachment storage, and tells you exactly what to fix if anything fails.
|
|
93
|
+
|
|
73
94
|
Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
|
|
74
95
|
`conventions.toml` next to your `.env` to define a namespaced tag taxonomy
|
|
75
96
|
(`field:physics`, `status:to-read`, …). With it in place, `zotkit create` / `zotkit tag`
|
|
@@ -81,7 +102,12 @@ Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
|
|
|
81
102
|
zotkit find --title "boson sampling" # search by title/tag/collection
|
|
82
103
|
zotkit find --tag status:to-read
|
|
83
104
|
|
|
84
|
-
zotkit create --
|
|
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
|
|
85
111
|
zotkit create --file papers.json --apply # create (dedups by DOI/title)
|
|
86
112
|
zotkit attach --from papers.created.json --all # upload the PDFs to WebDAV
|
|
87
113
|
|
|
@@ -96,7 +122,14 @@ zotkit backup # full JSON snapshot -> backups/
|
|
|
96
122
|
zotkit lint field:physics topic:new-idea # offline tag check
|
|
97
123
|
```
|
|
98
124
|
|
|
99
|
-
|
|
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):
|
|
100
133
|
|
|
101
134
|
```json
|
|
102
135
|
[{"itemType": "journalArticle", "title": "…",
|
|
@@ -160,6 +193,16 @@ Details in [`docs/webdav-format.md`](docs/webdav-format.md).
|
|
|
160
193
|
The format was determined by interoperability inspection of the author's own library.
|
|
161
194
|
This project is not affiliated with or endorsed by Zotero.
|
|
162
195
|
|
|
196
|
+
## Limits & roadmap
|
|
197
|
+
|
|
198
|
+
- `find` currently lists the library client-side — instant for hundreds of items,
|
|
199
|
+
sluggish for many thousands. Server-side search is planned.
|
|
200
|
+
- Group libraries should work for item operations (untested); WebDAV file sync is
|
|
201
|
+
personal-libraries-only (a Zotero limitation).
|
|
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.
|
|
205
|
+
|
|
163
206
|
## License
|
|
164
207
|
|
|
165
208
|
[MIT](LICENSE). If you build on the WebDAV implementation, a link back is appreciated.
|
zotkit-0.2.0/zotkit/cli.py
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
"""zotkit — CLI over zotkit.core.Zot.
|
|
2
|
-
|
|
3
|
-
Subcommands: find, create, attach, fetch, tag, status, move, backup, lint.
|
|
4
|
-
Write commands print what they did; `create` is dry-run unless --apply.
|
|
5
|
-
"""
|
|
6
|
-
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
import argparse
|
|
9
|
-
import json
|
|
10
|
-
import sys
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
from .core import Zot, lint_tags, load_conventions
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _print_items(rows):
|
|
17
|
-
for r in rows:
|
|
18
|
-
print(f"{r['key']} [{r['itemType']}] {r['title'][:75]}")
|
|
19
|
-
print(f" collections: {r['collections']}")
|
|
20
|
-
print(f" tags: {r['tags']}")
|
|
21
|
-
print(f"\n{len(rows)} match(es)")
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def main(argv=None):
|
|
25
|
-
ap = argparse.ArgumentParser(prog="zotkit", description="Headless Zotero library CLI")
|
|
26
|
-
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
27
|
-
|
|
28
|
-
p = sub.add_parser("find", help="search items by title/tag/collection")
|
|
29
|
-
p.add_argument("--title"); p.add_argument("--tag"); p.add_argument("--collection")
|
|
30
|
-
|
|
31
|
-
p = sub.add_parser("create", help="create items from a JSON file (dry unless --apply)")
|
|
32
|
-
p.add_argument("--file", required=True); p.add_argument("--apply", action="store_true")
|
|
33
|
-
p.add_argument("--no-dedup", action="store_true")
|
|
34
|
-
p.add_argument("--loose-tags", action="store_true", help="warn instead of error on tag violations")
|
|
35
|
-
|
|
36
|
-
p = sub.add_parser("attach", help="attach a local file (WebDAV) to an item")
|
|
37
|
-
p.add_argument("--key"); p.add_argument("--pdf")
|
|
38
|
-
p.add_argument("--from", dest="src", help="a .created.json list (batch)")
|
|
39
|
-
p.add_argument("--all", action="store_true")
|
|
40
|
-
|
|
41
|
-
p = sub.add_parser("fetch", help="download attachment files from WebDAV")
|
|
42
|
-
p.add_argument("--key"); p.add_argument("--title"); p.add_argument("--collection")
|
|
43
|
-
p.add_argument("--out", default="downloads")
|
|
44
|
-
|
|
45
|
-
p = sub.add_parser("tag", help="add/remove tags on an item")
|
|
46
|
-
p.add_argument("key"); p.add_argument("tags", nargs="+")
|
|
47
|
-
p.add_argument("--rm", action="store_true", help="remove instead of add")
|
|
48
|
-
|
|
49
|
-
p = sub.add_parser("status", help="set reading status (replaces the status: tag)")
|
|
50
|
-
p.add_argument("key"); p.add_argument("value", help="e.g. to-read / reading / read")
|
|
51
|
-
|
|
52
|
-
p = sub.add_parser("move", help="move item to a collection (name or 'Parent :: Child')")
|
|
53
|
-
p.add_argument("key"); p.add_argument("collection")
|
|
54
|
-
p.add_argument("--add", action="store_true", help="add as extra home instead of replacing")
|
|
55
|
-
|
|
56
|
-
sub.add_parser("backup", help="full JSON backup to backups/ next to your .env")
|
|
57
|
-
|
|
58
|
-
p = sub.add_parser("lint", help="check tags against conventions.toml (no API calls)")
|
|
59
|
-
p.add_argument("tags", nargs="+")
|
|
60
|
-
|
|
61
|
-
a = ap.parse_args(argv)
|
|
62
|
-
|
|
63
|
-
if a.cmd == "lint":
|
|
64
|
-
conv = load_conventions()
|
|
65
|
-
if conv is None:
|
|
66
|
-
print("no conventions configured — add a conventions.toml next to your .env "
|
|
67
|
-
"(see README); nothing to check")
|
|
68
|
-
return 0
|
|
69
|
-
problems = lint_tags(a.tags, conventions=conv, auto_load=False)
|
|
70
|
-
print("\n".join(problems) if problems else "OK")
|
|
71
|
-
return 1 if problems else 0
|
|
72
|
-
|
|
73
|
-
zot = Zot()
|
|
74
|
-
|
|
75
|
-
if a.cmd == "find":
|
|
76
|
-
_print_items(zot.find(a.title, a.tag, a.collection))
|
|
77
|
-
|
|
78
|
-
elif a.cmd == "create":
|
|
79
|
-
data = json.load(open(a.file, encoding="utf-8"))
|
|
80
|
-
items = data["items"] if isinstance(data, dict) else data
|
|
81
|
-
if not a.apply:
|
|
82
|
-
for d in items:
|
|
83
|
-
problems = lint_tags(d.get("tags", []), conventions=zot.conventions,
|
|
84
|
-
auto_load=False)
|
|
85
|
-
flag = (" !! " + "; ".join(problems)) if problems else ""
|
|
86
|
-
print(f" [dry] {d.get('collection')} | {d.get('title','')[:60]}{flag}")
|
|
87
|
-
print(f"{len(items)} item(s). DRY — add --apply to create.")
|
|
88
|
-
return 0
|
|
89
|
-
created = zot.create_items(items, dedup=not a.no_dedup, strict_tags=not a.loose_tags)
|
|
90
|
-
new = [c for c in created if c.get("key")]
|
|
91
|
-
out = Path(a.file).with_suffix(".created.json")
|
|
92
|
-
json.dump(new, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
|
93
|
-
print(f"created {len(new)} (skipped {len(created)-len(new)} dup) -> {out}")
|
|
94
|
-
if any(c.get("file_path") for c in new):
|
|
95
|
-
print(f"attach PDFs: zotkit attach --from {out} --all")
|
|
96
|
-
|
|
97
|
-
elif a.cmd == "attach":
|
|
98
|
-
if a.key and a.pdf:
|
|
99
|
-
print(zot.attach(a.key, a.pdf))
|
|
100
|
-
elif a.src:
|
|
101
|
-
rows = json.load(open(a.src, encoding="utf-8"))
|
|
102
|
-
done = 0
|
|
103
|
-
for m in rows:
|
|
104
|
-
if not m.get("file_path") or not Path(m["file_path"]).exists():
|
|
105
|
-
print(f" SKIP no file: {str(m.get('title'))[:55]}"); continue
|
|
106
|
-
if zot.has_attachment(m["key"]):
|
|
107
|
-
print(f" SKIP attached: {str(m.get('title'))[:55]}"); continue
|
|
108
|
-
if not a.all and done >= 1:
|
|
109
|
-
break
|
|
110
|
-
print(f"- {str(m.get('title'))[:60]}")
|
|
111
|
-
zot.attach(m["key"], m["file_path"]); done += 1
|
|
112
|
-
print(f"attached {done}")
|
|
113
|
-
else:
|
|
114
|
-
ap.error("attach needs --key + --pdf, or --from <created.json>")
|
|
115
|
-
|
|
116
|
-
elif a.cmd == "fetch":
|
|
117
|
-
keys = [a.key] if a.key else [r["key"] for r in zot.find(a.title, None, a.collection)]
|
|
118
|
-
n = 0
|
|
119
|
-
for k in keys:
|
|
120
|
-
for p_ in zot.fetch(k, a.out):
|
|
121
|
-
print(f" saved: {p_}"); n += 1
|
|
122
|
-
print(f"downloaded {n} file(s) to {a.out}")
|
|
123
|
-
|
|
124
|
-
elif a.cmd == "tag":
|
|
125
|
-
if a.rm:
|
|
126
|
-
zot.remove_tags(a.key, *a.tags); print(f"removed {a.tags} from {a.key}")
|
|
127
|
-
else:
|
|
128
|
-
zot.add_tags(a.key, *a.tags); print(f"added {a.tags} to {a.key}")
|
|
129
|
-
|
|
130
|
-
elif a.cmd == "status":
|
|
131
|
-
zot.set_status(a.key, a.value); print(f"{a.key} -> status:{a.value}")
|
|
132
|
-
|
|
133
|
-
elif a.cmd == "move":
|
|
134
|
-
zot.move(a.key, a.collection, add=a.add)
|
|
135
|
-
print(f"{a.key} -> '{a.collection}'{' (added)' if a.add else ''}")
|
|
136
|
-
|
|
137
|
-
elif a.cmd == "backup":
|
|
138
|
-
print("backup ->", zot.backup())
|
|
139
|
-
|
|
140
|
-
return 0
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if __name__ == "__main__":
|
|
144
|
-
sys.exit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|