zotkit 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """zotkit — headless Zotero library management (Web API + WebDAV)."""
2
+ from .core import Conventions, TagConventionError, Zot, lint_tags, load_conventions
3
+
4
+ __all__ = ["Zot", "lint_tags", "load_conventions", "Conventions", "TagConventionError"]
5
+ __version__ = "0.1.0"
zotkit/cli.py ADDED
@@ -0,0 +1,144 @@
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))
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"), 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))
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())
zotkit/core.py ADDED
@@ -0,0 +1,379 @@
1
+ """zotkit.core — headless Zotero library management (Web API + WebDAV).
2
+
3
+ One class, `Zot`, wraps the Zotero Web API (pyzotero) and, for libraries whose
4
+ attachment files sync to a personal WebDAV server, reads AND writes the WebDAV
5
+ attachment store directly — no desktop app required (see docs/webdav-format.md).
6
+
7
+ Credentials are read from an .env file (see `load_env` for the search order).
8
+ Tag conventions can optionally be enforced in code via a conventions.toml file
9
+ next to the .env (see `load_conventions`); without one, tags are unrestricted.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import io
15
+ import json
16
+ import mimetypes
17
+ import os
18
+ import re
19
+ import tomllib
20
+ import zipfile
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import httpx
25
+ from pyzotero import zotero
26
+
27
+ ENV_VARS = ("ZOTKIT_ENV", "ZOT_ENV")
28
+
29
+
30
+ class TagConventionError(ValueError):
31
+ """Raised when tags violate the configured conventions."""
32
+
33
+
34
+ # ---------- configuration ----------
35
+
36
+ def _env_candidates(path: str | os.PathLike | None = None) -> list[Path]:
37
+ if path:
38
+ return [Path(path)]
39
+ cands = [Path(os.environ[v]) for v in ENV_VARS if os.environ.get(v)]
40
+ cands.append(Path.cwd() / ".env")
41
+ cands.append(Path.home() / ".config" / "zotkit" / "env")
42
+ return cands
43
+
44
+
45
+ def load_env(path: str | os.PathLike | None = None) -> dict[str, str]:
46
+ """Load KEY=VALUE credentials. Search order: explicit path, $ZOTKIT_ENV,
47
+ ./.env, ~/.config/zotkit/env. Symlinks resolve to their target."""
48
+ cands = _env_candidates(path)
49
+ for p in cands:
50
+ if not p.is_file():
51
+ continue
52
+ env: dict[str, str] = {}
53
+ for line in p.read_text().splitlines():
54
+ line = line.strip()
55
+ if line and not line.startswith("#") and "=" in line:
56
+ k, v = line.split("=", 1)
57
+ env[k.strip()] = v.strip()
58
+ env["_env_path"] = str(p.resolve())
59
+ return env
60
+ raise FileNotFoundError(
61
+ "no zotkit config found — looked for: "
62
+ + ", ".join(str(p) for p in cands)
63
+ + ". Copy .env.example to one of these locations (see README → Configure).")
64
+
65
+
66
+ class Conventions:
67
+ """Tag rules loaded from conventions.toml (all sections optional):
68
+
69
+ namespaces = ["field", "topic", "status"] # allowed prefixes
70
+ require = ["field"] # every NEW item needs one such tag
71
+ [closed] # namespaces with a fixed vocabulary
72
+ field = ["physics", "ml", "econ"]
73
+ status = ["to-read", "reading", "read"]
74
+ """
75
+
76
+ def __init__(self, data: dict):
77
+ self.namespaces: set[str] = set(data.get("namespaces", []))
78
+ self.require: set[str] = set(data.get("require", []))
79
+ self.closed: dict[str, set[str]] = {
80
+ ns: {f"{ns}:{v}" for v in vals}
81
+ for ns, vals in data.get("closed", {}).items()}
82
+
83
+
84
+ def load_conventions(env_dir: str | os.PathLike | None = None) -> Conventions | None:
85
+ """Find conventions.toml: $ZOTKIT_CONVENTIONS, next to the .env in use,
86
+ then ~/.config/zotkit/. Returns None (= no enforcement) if absent."""
87
+ cands = []
88
+ if os.environ.get("ZOTKIT_CONVENTIONS"):
89
+ cands.append(Path(os.environ["ZOTKIT_CONVENTIONS"]))
90
+ if env_dir:
91
+ cands.append(Path(env_dir) / "conventions.toml")
92
+ else:
93
+ for p in _env_candidates():
94
+ if p.is_file():
95
+ cands.append(p.resolve().parent / "conventions.toml")
96
+ break
97
+ cands.append(Path.home() / ".config" / "zotkit" / "conventions.toml")
98
+ for p in cands:
99
+ if p.is_file():
100
+ return Conventions(tomllib.loads(p.read_text()))
101
+ return None
102
+
103
+
104
+ def lint_tags(tags: list[str], *, for_new_item: bool = True,
105
+ conventions: Conventions | None = None,
106
+ auto_load: bool = True) -> list[str]:
107
+ """Return a list of convention problems (empty = OK).
108
+ With no conventions configured, everything passes."""
109
+ conv = conventions or (load_conventions() if auto_load else None)
110
+ if conv is None:
111
+ return []
112
+ problems = []
113
+ if for_new_item:
114
+ for ns in sorted(conv.require):
115
+ if not any(t.startswith(f"{ns}:") for t in tags):
116
+ vocab = sorted(conv.closed.get(ns, [])) or f"{ns}:*"
117
+ problems.append(f"missing a '{ns}:' tag (every new item needs one of {vocab})")
118
+ for t in tags:
119
+ if ":" not in t:
120
+ problems.append(f"'{t}' has no namespace (expected one of "
121
+ f"{sorted(conv.namespaces) or 'ns:value'})")
122
+ continue
123
+ ns, _ = t.split(":", 1)
124
+ if conv.namespaces and ns not in conv.namespaces:
125
+ problems.append(f"'{t}' uses unknown namespace '{ns}:'")
126
+ if t != t.lower() or " " in t or "_" in t:
127
+ problems.append(f"'{t}' must be lowercase-hyphenated")
128
+ if ns in conv.closed and t not in conv.closed[ns]:
129
+ problems.append(f"'{t}' not in the configured '{ns}:' vocabulary "
130
+ f"{sorted(conv.closed[ns])}")
131
+ return problems
132
+
133
+
134
+ # ---------- helpers ----------
135
+
136
+ def _norm_title(t: str | None) -> str:
137
+ return re.sub(r"\W+", " ", (t or "").lower()).strip()
138
+
139
+
140
+ def _md5_file(p: Path) -> str:
141
+ h = hashlib.md5()
142
+ with open(p, "rb") as f:
143
+ for chunk in iter(lambda: f.read(1 << 16), b""):
144
+ h.update(chunk)
145
+ return h.hexdigest()
146
+
147
+
148
+ class Zot:
149
+ """Unified library interface: search, create, tag, move, attach, fetch, backup."""
150
+
151
+ def __init__(self, env_path: str | os.PathLike | None = None):
152
+ self.env = load_env(env_path)
153
+ self.home = Path(self.env["_env_path"]).parent # config dir: backups etc.
154
+ self.conventions = load_conventions(self.home)
155
+ self.z = zotero.Zotero(self.env["ZOTERO_LIBRARY_ID"],
156
+ self.env.get("ZOTERO_LIBRARY_TYPE", "user"),
157
+ self.env["ZOTERO_API_KEY"])
158
+ self._cols: list[dict] | None = None
159
+
160
+ def _lint(self, tags: list[str], *, for_new_item: bool) -> list[str]:
161
+ return lint_tags(tags, for_new_item=for_new_item,
162
+ conventions=self.conventions, auto_load=False)
163
+
164
+ # ---------- collections ----------
165
+ def collections(self, refresh: bool = False) -> list[dict]:
166
+ if self._cols is None or refresh:
167
+ self._cols = self.z.everything(self.z.collections())
168
+ return self._cols
169
+
170
+ def collection_key(self, name: str) -> str | None:
171
+ """Resolve a collection by exact name, or 'Parent :: Child'."""
172
+ cols = self.collections()
173
+ if "::" in name:
174
+ parent, child = [s.strip() for s in name.split("::", 1)]
175
+ pk = next((c["key"] for c in cols if c["data"]["name"] == parent
176
+ and not c["data"].get("parentCollection")), None)
177
+ return next((c["key"] for c in cols if c["data"]["name"] == child
178
+ and c["data"].get("parentCollection") == pk), None)
179
+ return next((c["key"] for c in cols if c["data"]["name"] == name), None)
180
+
181
+ def collection_names(self) -> dict[str, str]:
182
+ return {c["key"]: c["data"]["name"] for c in self.collections()}
183
+
184
+ # ---------- search ----------
185
+ def find(self, title: str | None = None, tag: str | None = None,
186
+ collection: str | None = None) -> list[dict]:
187
+ """Search top-level items; returns slim records with key/type/title/collections/tags."""
188
+ ckey = self.collection_key(collection) if collection else None
189
+ if collection and not ckey:
190
+ raise KeyError(f"no collection named '{collection}'")
191
+ cname = self.collection_names()
192
+ out = []
193
+ for it in self.z.everything(self.z.top()):
194
+ d = it["data"]
195
+ tags = [t["tag"] for t in d.get("tags", [])]
196
+ if title and title.lower() not in d.get("title", "").lower():
197
+ continue
198
+ if tag and tag not in tags:
199
+ continue
200
+ if ckey and ckey not in d.get("collections", []):
201
+ continue
202
+ out.append({"key": it["key"], "itemType": d.get("itemType"),
203
+ "title": d.get("title", ""),
204
+ "collections": [cname.get(c, c) for c in d.get("collections", [])],
205
+ "tags": tags, "version": d.get("version")})
206
+ return out
207
+
208
+ # ---------- create ----------
209
+ def create_items(self, items: list[dict[str, Any]], *, dedup: bool = True,
210
+ strict_tags: bool = True) -> list[dict]:
211
+ """Create bibliographic items. Each dict: itemType/title/creators/... plus
212
+ tags: [str], collection: name, file_path: optional (carried through).
213
+ Validates tags against the configured conventions (raise if strict_tags)."""
214
+ existing = self.z.everything(self.z.top()) if dedup else []
215
+ seen_doi = {(it["data"].get("DOI") or "").strip().lower()
216
+ for it in existing if it["data"].get("DOI")}
217
+ seen_title = {_norm_title(it["data"].get("title")) for it in existing}
218
+
219
+ templates: dict[str, dict] = {}
220
+ payloads, meta = [], []
221
+ for d in items:
222
+ tags = d.get("tags", [])
223
+ problems = self._lint(tags, for_new_item=True)
224
+ if problems:
225
+ msg = f"tags for '{d.get('title','')[:50]}': " + "; ".join(problems)
226
+ if strict_tags:
227
+ raise TagConventionError(msg)
228
+ print("WARN", msg)
229
+ doi = (d.get("DOI") or "").strip().lower()
230
+ if dedup and ((doi and doi in seen_doi) or _norm_title(d.get("title")) in seen_title):
231
+ meta.append({"title": d.get("title"), "skipped": "duplicate"})
232
+ continue
233
+ t = d["itemType"]
234
+ templates.setdefault(t, self.z.item_template(t))
235
+ tmpl = json.loads(json.dumps(templates[t]))
236
+ for f, v in d.items():
237
+ if f not in ("tags", "collection", "file_path") and f in tmpl:
238
+ tmpl[f] = v
239
+ tmpl["tags"] = [{"tag": x} for x in tags]
240
+ ck = self.collection_key(d["collection"]) if d.get("collection") else None
241
+ if d.get("collection") and not ck:
242
+ raise KeyError(f"no collection named '{d['collection']}'")
243
+ tmpl["collections"] = [ck] if ck else []
244
+ payloads.append(tmpl)
245
+ meta.append({"title": d.get("title"), "collection": d.get("collection"),
246
+ "file_path": d.get("file_path")})
247
+
248
+ created, mi = [], iter([m for m in meta if "skipped" not in m])
249
+ for i in range(0, len(payloads), 50):
250
+ resp = self.z.create_items(payloads[i:i + 50])
251
+ if resp.get("failed"):
252
+ raise RuntimeError(f"create failed: {resp['failed']}")
253
+ batch_meta = [next(mi) for _ in range(len(payloads[i:i + 50]))]
254
+ for idx, obj in sorted(resp.get("successful", {}).items(), key=lambda x: int(x[0])):
255
+ created.append({**batch_meta[int(idx)], "key": obj["key"]})
256
+ skipped = [m for m in meta if m.get("skipped")]
257
+ return created + skipped
258
+
259
+ # ---------- tags / status / move ----------
260
+ def add_tags(self, item_key: str, *tags: str, strict: bool = True) -> None:
261
+ problems = self._lint(list(tags), for_new_item=False)
262
+ if problems and strict:
263
+ raise TagConventionError("; ".join(problems))
264
+ it = self.z.item(item_key)
265
+ have = {t["tag"] for t in it["data"].get("tags", [])}
266
+ new = [{"tag": t} for t in tags if t not in have]
267
+ if new:
268
+ it["data"]["tags"] = it["data"]["tags"] + new
269
+ self.z.update_item(it)
270
+
271
+ def remove_tags(self, item_key: str, *tags: str) -> None:
272
+ it = self.z.item(item_key)
273
+ it["data"]["tags"] = [t for t in it["data"].get("tags", []) if t["tag"] not in tags]
274
+ self.z.update_item(it)
275
+
276
+ def set_status(self, item_key: str, status: str) -> None:
277
+ """Replace the item's status: tag. Validated against the conventions'
278
+ closed 'status' vocabulary when one is configured."""
279
+ tag = status if status.startswith("status:") else f"status:{status}"
280
+ conv = self.conventions
281
+ if conv and "status" in conv.closed and tag not in conv.closed["status"]:
282
+ raise TagConventionError(f"'{tag}' not in {sorted(conv.closed['status'])}")
283
+ it = self.z.item(item_key)
284
+ kept = [t for t in it["data"].get("tags", []) if not t["tag"].startswith("status:")]
285
+ it["data"]["tags"] = kept + [{"tag": tag}]
286
+ self.z.update_item(it)
287
+
288
+ def move(self, item_key: str, collection: str, *, add: bool = False) -> None:
289
+ """Move item to a collection (replaces homes unless add=True)."""
290
+ ck = self.collection_key(collection)
291
+ if not ck:
292
+ raise KeyError(f"no collection named '{collection}'")
293
+ it = self.z.item(item_key)
294
+ cur = it["data"].get("collections", [])
295
+ it["data"]["collections"] = (cur + [ck]) if (add and ck not in cur) else [ck]
296
+ self.z.update_item(it)
297
+
298
+ # ---------- attachments (WebDAV) ----------
299
+ def _webdav(self) -> tuple[str, tuple[str, str]]:
300
+ try:
301
+ return (self.env["WEBDAV_URL"].rstrip("/"),
302
+ (self.env["WEBDAV_USER"], self.env["WEBDAV_PASS"]))
303
+ except KeyError as e:
304
+ raise KeyError(f"{e.args[0]} missing from .env — attachment operations "
305
+ "need WEBDAV_URL/WEBDAV_USER/WEBDAV_PASS") from None
306
+
307
+ def attach(self, item_key: str, file_path: str | os.PathLike) -> dict:
308
+ """Attach a local file to an item; bytes go to WebDAV (<key>.zip + .prop)."""
309
+ f = Path(file_path)
310
+ filename, md5 = f.name, _md5_file(f)
311
+ mtime = int(os.path.getmtime(f) * 1000)
312
+ ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
313
+ tmpl = self.z.item_template("attachment", "imported_file")
314
+ tmpl.update({"parentItem": item_key, "title": filename, "filename": filename,
315
+ "contentType": ctype, "md5": md5, "mtime": mtime})
316
+ resp = self.z.create_items([tmpl])
317
+ if resp.get("failed"):
318
+ raise RuntimeError(f"attachment item failed: {resp['failed']}")
319
+ key = resp["successful"]["0"]["key"]
320
+ buf = io.BytesIO()
321
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
322
+ z.write(f, arcname=filename)
323
+ prop = (f'<properties version="1"><mtime>{mtime}</mtime>'
324
+ f'<hash>{md5}</hash></properties>')
325
+ base, auth = self._webdav()
326
+ with httpx.Client(auth=auth, timeout=120) as c:
327
+ r1 = c.put(f"{base}/{key}.zip", content=buf.getvalue())
328
+ r2 = c.put(f"{base}/{key}.prop", content=prop.encode())
329
+ ok = r1.status_code in (200, 201, 204) and r2.status_code in (200, 201, 204)
330
+ if not ok:
331
+ raise RuntimeError(f"WebDAV PUT failed: zip={r1.status_code} prop={r2.status_code}")
332
+ return {"attachment_key": key, "filename": filename, "webdav_ok": ok}
333
+
334
+ def has_attachment(self, item_key: str) -> bool:
335
+ return any(k["data"].get("itemType") == "attachment"
336
+ for k in self.z.children(item_key))
337
+
338
+ def fetch(self, item_key: str, out_dir: str | os.PathLike = "downloads") -> list[Path]:
339
+ """Download an item's attachment files from WebDAV; returns saved paths."""
340
+ out = Path(out_dir)
341
+ out.mkdir(parents=True, exist_ok=True)
342
+ base, auth = self._webdav()
343
+ saved = []
344
+ with httpx.Client(auth=auth, timeout=120) as c:
345
+ for ch in self.z.children(item_key):
346
+ d = ch["data"]
347
+ if d.get("itemType") != "attachment" or not d.get("filename"):
348
+ continue
349
+ r = c.get(f"{base}/{ch['key']}.zip")
350
+ if r.status_code != 200:
351
+ continue
352
+ zipfile.ZipFile(io.BytesIO(r.content)).extractall(out)
353
+ saved.append(out / d["filename"])
354
+ return saved
355
+
356
+ # ---------- backup ----------
357
+ def backup(self, out_dir: str | os.PathLike | None = None) -> Path:
358
+ """Full JSON backup (items + collections + membership snapshots).
359
+ Default destination: backups/ next to the .env in use."""
360
+ version = self.z.last_modified_version()
361
+ items = self.z.everything(self.z.items())
362
+ collections = self.z.everything(self.z.collections())
363
+ try:
364
+ searches = self.z.everything(self.z.searches())
365
+ except Exception:
366
+ searches = []
367
+ backup = {
368
+ "last_modified_version": version,
369
+ "items": items, "collections": collections, "searches": searches,
370
+ "item_tags": {it["key"]: [t.get("tag") for t in it["data"].get("tags", [])]
371
+ for it in items if "data" in it},
372
+ "item_collections": {it["key"]: it["data"].get("collections", [])
373
+ for it in items if "data" in it},
374
+ }
375
+ out = Path(out_dir or (self.home / "backups"))
376
+ out.mkdir(parents=True, exist_ok=True)
377
+ p = out / f"zotero_backup_v{version}.json"
378
+ p.write_text(json.dumps(backup, ensure_ascii=False, indent=2))
379
+ return p
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: zotkit
3
+ Version: 0.1.0
4
+ Summary: Headless Zotero library management: Web API CRUD plus direct WebDAV attachment upload/download — no desktop app required
5
+ Author: Shawn
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/oldantique/zotkit
8
+ Project-URL: Issues, https://github.com/oldantique/zotkit/issues
9
+ Keywords: zotero,reference-manager,webdav,bibliography,cli,headless
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: pyzotero
18
+ Requires-Dist: httpx
19
+ Dynamic: license-file
20
+
21
+ # zotkit
22
+
23
+ **Headless Zotero library management — no desktop app required.**
24
+
25
+ zotkit is a Python library + CLI that manages a Zotero library entirely through the
26
+ [Zotero Web API](https://www.zotero.org/support/dev/web_api/v3/start): search, create,
27
+ tag, and organize items from a terminal or a server. Uniquely, if your attachments sync
28
+ to a **personal WebDAV server**, zotkit can **upload and download the files themselves**
29
+ by speaking Zotero's WebDAV storage format directly — the one capability the Web API
30
+ does not provide, and (as far as we know) not offered by any other headless tool.
31
+
32
+ Built for servers, scripts, and **LLM agents**: every write is dry-run by default,
33
+ batched, and version-checked, and you can define a tag taxonomy that is *enforced in
34
+ code* so an agent (or a tired human) can't pollute your library with inconsistent tags.
35
+
36
+ ## Why zotkit
37
+
38
+ | | Desktop app | Other CLI/MCP tools | zotkit |
39
+ |---|---|---|---|
40
+ | Works headless (server, SSH, CI) | ❌ | ✅ read-mostly | ✅ |
41
+ | Write items/tags/collections | ✅ | ⚠️ usually needs the desktop app running | ✅ |
42
+ | Attachment files on **WebDAV** | ✅ | ⚠️ download at best | ✅ **upload + download** |
43
+ | Tag conventions enforced in code | ❌ | ❌ | ✅ optional `conventions.toml` |
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pipx install zotkit # or: uv tool install zotkit / pip install zotkit
49
+ uvx zotkit --help # …or try it without installing anything
50
+ ```
51
+
52
+ ## Configure
53
+
54
+ Copy [`.env.example`](.env.example) to `./.env`, `~/.config/zotkit/env`, or any path in
55
+ `$ZOTKIT_ENV`, and fill in:
56
+
57
+ - **Zotero Web API**: create a key (with write access) at
58
+ <https://www.zotero.org/settings/keys> — your numeric `ZOTERO_LIBRARY_ID` is shown on
59
+ the same page.
60
+ - **WebDAV** (only for `attach`/`fetch`): copy the exact values from the Zotero desktop
61
+ app on any of your machines — **Settings → Sync → File Syncing** — and append
62
+ `/zotero/` to the URL (the desktop does this implicitly).
63
+
64
+ Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
65
+ `conventions.toml` next to your `.env` to define a namespaced tag taxonomy
66
+ (`field:physics`, `status:to-read`, …). With it in place, `zotkit create` / `zotkit tag`
67
+ **reject** violations; without it, tags are unrestricted.
68
+
69
+ ## Quickstart
70
+
71
+ ```bash
72
+ zotkit find --title "boson sampling" # search by title/tag/collection
73
+ zotkit find --tag status:to-read
74
+
75
+ zotkit create --file papers.json # dry-run: shows what would be created
76
+ zotkit create --file papers.json --apply # create (dedups by DOI/title)
77
+ zotkit attach --from papers.created.json --all # upload the PDFs to WebDAV
78
+
79
+ zotkit attach --key AB12CD34 --pdf paper.pdf # single attach
80
+ zotkit fetch --key AB12CD34 --out downloads # download attachment from WebDAV
81
+
82
+ zotkit tag AB12CD34 topic:qaoa prio:high # validated against conventions.toml
83
+ zotkit status AB12CD34 read # replaces the status: tag
84
+ zotkit move AB12CD34 "Algorithms" # or "Parent :: Child"; --add keeps old home
85
+
86
+ zotkit backup # full JSON snapshot -> backups/
87
+ zotkit lint field:physics topic:new-idea # offline tag check
88
+ ```
89
+
90
+ Item JSON for `zotkit create` (a list, one object per reference):
91
+
92
+ ```json
93
+ [{"itemType": "journalArticle", "title": "…",
94
+ "creators": [{"creatorType": "author", "firstName": "A", "lastName": "B"}],
95
+ "date": "2024", "publicationTitle": "…", "DOI": "10.x/y",
96
+ "tags": ["field:physics", "status:to-read"],
97
+ "collection": "Algorithms", "file_path": "/abs/path/paper.pdf"}]
98
+ ```
99
+
100
+ ## From Python
101
+
102
+ ```python
103
+ from zotkit import Zot
104
+
105
+ z = Zot() # reads .env automatically
106
+ z.find(tag="status:to-read")
107
+ z.create_items([...]) # dedup + convention checks
108
+ z.attach("AB12CD34", "paper.pdf") # PDF -> WebDAV
109
+ z.fetch("AB12CD34", "downloads")
110
+ z.set_status("AB12CD34", "read")
111
+ z.backup()
112
+ ```
113
+
114
+ `z.z` is the underlying [pyzotero](https://github.com/urschrei/pyzotero) client for
115
+ anything not wrapped.
116
+
117
+ ## Safety model
118
+
119
+ - `create` is **dry-run by default**; `--apply` to execute.
120
+ - Writes go through fetch→modify→update (carries the item version, so concurrent edits
121
+ fail loudly with 412 instead of clobbering), in batches of ≤ 50.
122
+ - `zotkit backup` snapshots every item, collection, tag, and membership to one JSON
123
+ file — run it before bulk operations.
124
+ - Remember: writes propagate to zotero.org and **all your synced devices**.
125
+
126
+ ## How WebDAV attachments work
127
+
128
+ Zotero's WebDAV storage format is undocumented but simple: each attachment item `K` is
129
+ stored as `K.zip` (the file, zipped) plus `K.prop` (its md5 + mtime). zotkit creates the
130
+ attachment item via the Web API and PUTs both objects directly — after which every
131
+ desktop client syncs the file down normally. Details in
132
+ [`docs/webdav-format.md`](docs/webdav-format.md).
133
+
134
+ The format was determined by interoperability inspection of the author's own library.
135
+ This project is not affiliated with or endorsed by Zotero.
136
+
137
+ ## License
138
+
139
+ [MIT](LICENSE). If you build on the WebDAV implementation, a link back is appreciated.
@@ -0,0 +1,9 @@
1
+ zotkit/__init__.py,sha256=z1RrkPaeu2f3UugtDiDiusqNO72_lg_3f2debonpT1o,267
2
+ zotkit/cli.py,sha256=xMC3v-Cl5IT5Ig8N_3aJrO4s3fI4x6-9E7mT-yWkPIo,5925
3
+ zotkit/core.py,sha256=mq87oAL61UuBxpftfZkHpLndmJMISI8ajRjsYKwbz-U,17294
4
+ zotkit-0.1.0.dist-info/licenses/LICENSE,sha256=mabKMS4Wfqdt7A0wB3chgG4BNfaVYO84xHK-ynNZRJ8,1062
5
+ zotkit-0.1.0.dist-info/METADATA,sha256=xkIA-DRZfWx_aRWCOmg1lteyFmUZgwhC6ENdpiciMM0,5873
6
+ zotkit-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
7
+ zotkit-0.1.0.dist-info/entry_points.txt,sha256=pr3_2uuZH_RtWCHLb2m1Xe3cWxaJ8YqAB52thNNh7XE,43
8
+ zotkit-0.1.0.dist-info/top_level.txt,sha256=rKtf4ccLNKFusfWu5_kFMDKPrktgQgNDhSjMDhjkPmU,7
9
+ zotkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ zotkit = zotkit.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shawn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ zotkit