zop-cli 0.2.0__tar.gz → 0.2.1__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.
- {zop_cli-0.2.0 → zop_cli-0.2.1}/PKG-INFO +2 -2
- {zop_cli-0.2.0 → zop_cli-0.2.1}/README.md +1 -1
- {zop_cli-0.2.0 → zop_cli-0.2.1}/skills/zop/SKILL.md +11 -5
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/_version.py +1 -1
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/adapters/sqlite_reader.py +16 -6
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/adapters/zotero_api.py +9 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/collection.py +16 -8
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/export.py +14 -6
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/core/config.py +25 -10
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/models/item.py +15 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/collections.py +65 -6
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/export.py +3 -5
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/items.py +41 -3
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/pdf.py +37 -6
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_export.py +21 -3
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_plan.py +53 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_collections.py +101 -0
- zop_cli-0.2.1/tests/test_config.py +54 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_items_service.py +34 -1
- zop_cli-0.2.1/tests/test_library.py +158 -0
- zop_cli-0.2.1/tests/test_pdf.py +44 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_zotero_api.py +22 -0
- zop_cli-0.2.0/tests/test_library.py +0 -90
- {zop_cli-0.2.0 → zop_cli-0.2.1}/.gitattributes +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/.github/workflows/ci.yml +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/.gitignore +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/CONTRIBUTING.md +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/LICENSE +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/docs/ARCHITECTURE.md +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/pyproject.toml +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/skills/zop/README.md +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/__init__.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/__main__.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/adapters/__init__.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/cli.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/__init__.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/item.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/library.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/note.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/pdf.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/tag.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/core/__init__.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/core/concurrency.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/core/envelope.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/core/errors.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/models/__init__.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/models/collection.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/models/common.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/models/envelope.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/__init__.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/library.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/notes.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/tags.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/__init__.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/conftest.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/fixtures/test_plan.json +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_collection.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_item.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_library.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_main.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_note.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_tag.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_envelope.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_export.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_notes_service.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_tags_service.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tools/inspect_sqlite.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/tools/inspect_tables.py +0 -0
- {zop_cli-0.2.0 → zop_cli-0.2.1}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zop-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: High-throughput Zotero CLI focused on batch operations and automation
|
|
5
5
|
Project-URL: Homepage, https://github.com/anomalyco/zop
|
|
6
6
|
Project-URL: Issues, https://github.com/anomalyco/zop/issues
|
|
@@ -50,7 +50,7 @@ uv sync
|
|
|
50
50
|
|
|
51
51
|
## 配置
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
zop 按以下顺序查找配置(首个存在的文件生效):`ZOP_CONFIG` 环境变量 → 平台配置目录(platformdirs)→ `~/.config/zop/config.toml`。平台配置目录:Windows 为 `%LOCALAPPDATA%\zop\`、macOS 为 `~/Library/Application Support/zop/`、Linux 为 `~/.config/zop/`。在任一位置创建 `config.toml`:
|
|
54
54
|
|
|
55
55
|
```toml
|
|
56
56
|
[zotero]
|
|
@@ -22,7 +22,7 @@ uv sync
|
|
|
22
22
|
|
|
23
23
|
## 配置
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
zop 按以下顺序查找配置(首个存在的文件生效):`ZOP_CONFIG` 环境变量 → 平台配置目录(platformdirs)→ `~/.config/zop/config.toml`。平台配置目录:Windows 为 `%LOCALAPPDATA%\zop\`、macOS 为 `~/Library/Application Support/zop/`、Linux 为 `~/.config/zop/`。在任一位置创建 `config.toml`:
|
|
26
26
|
|
|
27
27
|
```toml
|
|
28
28
|
[zotero]
|
|
@@ -15,7 +15,7 @@ Install, then point it at a library:
|
|
|
15
15
|
uv tool install zop-cli # the command is `zop`
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Config
|
|
18
|
+
Config search order: `$ZOP_CONFIG` → platform dir (Windows `%LOCALAPPDATA%\zop\`, macOS `~/Library/Application Support/zop/`, Linux `~/.config/zop/`) → `~/.config/zop/config.toml`. Put it at any of these:
|
|
19
19
|
|
|
20
20
|
```toml
|
|
21
21
|
[zotero]
|
|
@@ -43,6 +43,8 @@ Every command prints exactly one JSON object to stdout:
|
|
|
43
43
|
|
|
44
44
|
Always `json.loads(stdout)` and branch on `ok`. Never string-match the output.
|
|
45
45
|
|
|
46
|
+
**`export` is special**: in non-tty/JSON mode it returns `{ok, data: {format, content, count}}` where `content` is the raw bibtex/ris string or the csl-json array. In tty mode (or with `--out FILE`) it writes the raw format directly (pipe-friendly: `zop export K > refs.bib`).
|
|
47
|
+
|
|
46
48
|
## Commands
|
|
47
49
|
|
|
48
50
|
### Reads (offline, no key needed)
|
|
@@ -67,9 +69,9 @@ Always `json.loads(stdout)` and branch on `ok`. Never string-match the output.
|
|
|
67
69
|
|
|
68
70
|
| Goal | Command |
|
|
69
71
|
|------|---------|
|
|
70
|
-
| Create collection (+ parent
|
|
72
|
+
| Create collection (+ parent) | `zop collection create "Name" [--parent "ParentKeyOrName"]` |
|
|
71
73
|
| Delete collection (cascades) | `zop collection delete <KEY> [-y]` |
|
|
72
|
-
| Move collection under new parent | `zop collection reparent <KEY> [--parent "
|
|
74
|
+
| Move collection under new parent | `zop collection reparent <KEY> [--parent "KeyOrName"]` |
|
|
73
75
|
| Move items into a collection | `zop collection move <K1> <K2> --to <KEY>` |
|
|
74
76
|
| Update item metadata | `zop item update <KEY> [--title ...] [--set K=V]` |
|
|
75
77
|
| Add items by DOI | `zop item add --doi 10.x/y [--doi ...] [--from-file dois.txt]` |
|
|
@@ -78,7 +80,7 @@ Always `json.loads(stdout)` and branch on `ok`. Never string-match the output.
|
|
|
78
80
|
| Remove tags | `zop tag remove <KEY...> --tags t1,t2` |
|
|
79
81
|
| Add a note | `zop note add <KEY> --text "..." [--file note.md]` |
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
Parent references accept a KEY or a NAME in write commands (`--parent "KeyOrName"`); a NAME is resolved locally first, then via the Web API if not yet synced. Accept either from the user.
|
|
82
84
|
|
|
83
85
|
### Batch reorg (zop's highlight)
|
|
84
86
|
|
|
@@ -86,7 +88,7 @@ Author a plan JSON, **dry-run to validate**, then execute:
|
|
|
86
88
|
|
|
87
89
|
```bash
|
|
88
90
|
zop collection plan plan.json --dry-run # checks name conflicts, parent resolution, item existence
|
|
89
|
-
zop collection plan plan.json --execute #
|
|
91
|
+
zop collection plan plan.json --execute # creates collections (topo order), then moves items into them
|
|
90
92
|
```
|
|
91
93
|
|
|
92
94
|
Plan shape:
|
|
@@ -97,6 +99,8 @@ Plan shape:
|
|
|
97
99
|
|
|
98
100
|
Always inspect the dry-run `unresolved_parents` / `conflicts` before `--execute`. Intra-plan parents (a new collection whose parent is another new collection) are supported and created in topological order.
|
|
99
101
|
|
|
102
|
+
On `--execute`, the envelope reports `assignments_done` (`[item_key, coll_key]` pairs actually moved) and `assignments_failed` (`[item_key, error]`). Items move via the API, so they land in the new collections even before Zotero syncs them locally.
|
|
103
|
+
|
|
100
104
|
For every flag of any command, run `zop <command> --help` rather than guessing.
|
|
101
105
|
|
|
102
106
|
## Working as an agent
|
|
@@ -105,3 +109,5 @@ For every flag of any command, run `zop <command> --help` rather than guessing.
|
|
|
105
109
|
- **For multi-step reorgs, author a plan and `--dry-run`** — let zop validate conflicts; don't reimplement the checks.
|
|
106
110
|
- **Batch writes isolate failures**: `collection move`, `tag add/remove`, and `item delete` return per-item success/failure in one envelope and don't abort the batch. Report which keys failed from the `failed` array; exit code `2` means partial failure.
|
|
107
111
|
- **Versions matter for writes**: if a write fails with `conflict`, the item changed server-side — re-read and retry rather than looping blindly.
|
|
112
|
+
- **Reads lag writes briefly**: writes hit the Web API, reads use the local SQLite snapshot. A just-created collection/item isn't visible to read commands until Zotero syncs it (seconds to minutes). To chain creates, pass the returned KEY (e.g. `--parent <KEY>`) rather than the new NAME — or rely on the NAME→API fallback.
|
|
113
|
+
- **stats vs recent count differently**: `stats` is a full overview — it counts annotations/highlights in `total_items` and `by_type`. `recent`/`search`/`duplicates` list only bibliographic items (annotations have no title, so they'd be empty noise in a list). Don't expect the two counts to match.
|
|
@@ -16,7 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
from zop.core.errors import NotFoundError, ValidationError
|
|
17
17
|
from zop.models.collection import Collection, CollectionTree
|
|
18
18
|
from zop.models.common import ItemType
|
|
19
|
-
from zop.models.item import Item, ItemSummary
|
|
19
|
+
from zop.models.item import Item, ItemSummary, parse_year
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
class SqliteReader:
|
|
@@ -51,8 +51,9 @@ class SqliteReader:
|
|
|
51
51
|
c.version, c.synced, p.key AS parent_key,
|
|
52
52
|
(SELECT COUNT(*) FROM collectionItems ci
|
|
53
53
|
JOIN items i ON i.itemID = ci.itemID
|
|
54
|
+
JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
|
|
54
55
|
WHERE ci.collectionID = c.collectionID
|
|
55
|
-
AND
|
|
56
|
+
AND it.typeName NOT IN ('attachment', 'note', 'annotation')) AS item_count
|
|
56
57
|
FROM collections c
|
|
57
58
|
LEFT JOIN collections p ON p.collectionID = c.parentCollectionID
|
|
58
59
|
WHERE c.libraryID = ?
|
|
@@ -200,6 +201,7 @@ class SqliteReader:
|
|
|
200
201
|
tags=tags,
|
|
201
202
|
collections=colls,
|
|
202
203
|
version=version,
|
|
204
|
+
year=parse_year(date),
|
|
203
205
|
date=date,
|
|
204
206
|
date_added=str(date_added) if date_added else None,
|
|
205
207
|
date_modified=str(date_modified) if date_modified else None,
|
|
@@ -259,7 +261,7 @@ class SqliteReader:
|
|
|
259
261
|
FROM items i
|
|
260
262
|
JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
|
|
261
263
|
WHERE i.libraryID = ?
|
|
262
|
-
AND
|
|
264
|
+
AND it.typeName NOT IN ('attachment', 'note', 'annotation')
|
|
263
265
|
AND (
|
|
264
266
|
EXISTS (SELECT 1 FROM itemData id JOIN fields f ON f.fieldID=id.fieldID
|
|
265
267
|
JOIN itemDataValues iv ON iv.valueID=id.valueID
|
|
@@ -281,6 +283,7 @@ class SqliteReader:
|
|
|
281
283
|
item_type=ItemType(r[1]) if r[1] else ItemType.UNKNOWN,
|
|
282
284
|
title=r[4] or "",
|
|
283
285
|
creators=[c.strip() for c in (r[3] or "").split(";") if c.strip()],
|
|
286
|
+
year=parse_year(r[5]),
|
|
284
287
|
date=r[5],
|
|
285
288
|
)
|
|
286
289
|
for r in rows
|
|
@@ -304,7 +307,7 @@ class SqliteReader:
|
|
|
304
307
|
FROM items i
|
|
305
308
|
JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
|
|
306
309
|
WHERE i.libraryID = ?
|
|
307
|
-
AND
|
|
310
|
+
AND it.typeName NOT IN ('attachment', 'note', 'annotation')
|
|
308
311
|
AND i.dateAdded >= datetime('now', ?)
|
|
309
312
|
ORDER BY i.dateAdded DESC
|
|
310
313
|
LIMIT ?
|
|
@@ -317,6 +320,7 @@ class SqliteReader:
|
|
|
317
320
|
item_type=ItemType(r[1]) if r[1] else ItemType.UNKNOWN,
|
|
318
321
|
title=r[4] or "",
|
|
319
322
|
creators=[c.strip() for c in (r[3] or "").split(";") if c.strip()],
|
|
323
|
+
year=parse_year(r[5]),
|
|
320
324
|
date=r[5],
|
|
321
325
|
)
|
|
322
326
|
for r in rows
|
|
@@ -362,14 +366,16 @@ class SqliteReader:
|
|
|
362
366
|
"""Return counts: total items, by type, top tags, collection count, etc."""
|
|
363
367
|
with self._connect() as con:
|
|
364
368
|
total = con.execute(
|
|
365
|
-
"SELECT COUNT(*) FROM items WHERE libraryID=?
|
|
369
|
+
"SELECT COUNT(*) FROM items WHERE libraryID=? "
|
|
370
|
+
"AND itemTypeID NOT IN (SELECT itemTypeID FROM itemTypes "
|
|
371
|
+
"WHERE typeName IN ('attachment', 'note'))",
|
|
366
372
|
(library_id,),
|
|
367
373
|
).fetchone()[0]
|
|
368
374
|
by_type_rows = con.execute(
|
|
369
375
|
"""
|
|
370
376
|
SELECT it.typeName, COUNT(*) FROM items i
|
|
371
377
|
JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
|
|
372
|
-
WHERE i.libraryID=? AND
|
|
378
|
+
WHERE i.libraryID=? AND it.typeName NOT IN ('attachment', 'note')
|
|
373
379
|
GROUP BY it.typeName ORDER BY 2 DESC
|
|
374
380
|
""",
|
|
375
381
|
(library_id,),
|
|
@@ -448,7 +454,9 @@ class SqliteReader:
|
|
|
448
454
|
JOIN fields f ON f.fieldID = id.fieldID
|
|
449
455
|
JOIN itemDataValues iv ON iv.valueID = id.valueID
|
|
450
456
|
JOIN items i ON i.itemID = id.itemID
|
|
457
|
+
JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
|
|
451
458
|
WHERE f.fieldName = 'DOI' AND i.libraryID = ?
|
|
459
|
+
AND it.typeName NOT IN ('attachment', 'note', 'annotation')
|
|
452
460
|
AND iv.value IS NOT NULL AND iv.value != ''
|
|
453
461
|
GROUP BY iv.value
|
|
454
462
|
HAVING COUNT(*) > 1
|
|
@@ -465,7 +473,9 @@ class SqliteReader:
|
|
|
465
473
|
JOIN fields f ON f.fieldID = id.fieldID
|
|
466
474
|
JOIN itemDataValues iv ON iv.valueID = id.valueID
|
|
467
475
|
JOIN items i ON i.itemID = id.itemID
|
|
476
|
+
JOIN itemTypes it ON it.itemTypeID = i.itemTypeID
|
|
468
477
|
WHERE f.fieldName = 'title' AND i.libraryID = ?
|
|
478
|
+
AND it.typeName NOT IN ('attachment', 'note', 'annotation')
|
|
469
479
|
AND iv.value IS NOT NULL AND iv.value != ''
|
|
470
480
|
GROUP BY iv.value
|
|
471
481
|
HAVING COUNT(*) > 1
|
|
@@ -120,6 +120,15 @@ class ZoteroApi:
|
|
|
120
120
|
start += len(data)
|
|
121
121
|
return out
|
|
122
122
|
|
|
123
|
+
async def get_collection(self, key: str) -> dict[str, Any]:
|
|
124
|
+
"""Fetch a single collection by key (GET /collections/{key}).
|
|
125
|
+
|
|
126
|
+
Used to read the current ``version`` for the If-Unmodified-Since-
|
|
127
|
+
Version header before a PATCH/DELETE.
|
|
128
|
+
"""
|
|
129
|
+
resp = await self._client.get(self._coll_url(key))
|
|
130
|
+
return cast("dict[str, Any]", self._check(resp))
|
|
131
|
+
|
|
123
132
|
async def create_collections(
|
|
124
133
|
self, payloads: Sequence[dict[str, Any]]
|
|
125
134
|
) -> list[dict[str, Any]]:
|
|
@@ -13,6 +13,7 @@ from zop.adapters.zotero_api import ApiCreds
|
|
|
13
13
|
from zop.core.config import load_config
|
|
14
14
|
from zop.core.envelope import emit, emit_batch, emit_error
|
|
15
15
|
from zop.core.errors import ZopError
|
|
16
|
+
from zop.models.collection import Collection
|
|
16
17
|
from zop.services.collections import CollectionsService, PlanNode
|
|
17
18
|
|
|
18
19
|
|
|
@@ -73,13 +74,13 @@ def items_cmd(ctx: click.Context, key: str) -> None:
|
|
|
73
74
|
|
|
74
75
|
@collection.command("create")
|
|
75
76
|
@click.argument("name")
|
|
76
|
-
@click.option("--parent", "
|
|
77
|
+
@click.option("--parent", "parent_ref", default=None, help="Parent collection KEY or NAME.")
|
|
77
78
|
@click.pass_context
|
|
78
|
-
def create_cmd(ctx: click.Context, name: str,
|
|
79
|
-
"""Create a collection (NAME). Requires API key."""
|
|
79
|
+
def create_cmd(ctx: click.Context, name: str, parent_ref: str | None) -> None:
|
|
80
|
+
"""Create a collection (NAME). Parent may be a KEY or NAME. Requires API key."""
|
|
80
81
|
try:
|
|
81
82
|
svc = _service(ctx)
|
|
82
|
-
result = asyncio.run(svc.create(name, parent=
|
|
83
|
+
result = asyncio.run(svc.create(name, parent=parent_ref))
|
|
83
84
|
emit([result.model_dump()], human=_human(), count=1)
|
|
84
85
|
except ZopError as e:
|
|
85
86
|
emit_error(e, human=_human())
|
|
@@ -199,17 +200,24 @@ def plan_cmd(
|
|
|
199
200
|
if not report.ok:
|
|
200
201
|
emit(out, human=_human())
|
|
201
202
|
sys.exit(2)
|
|
202
|
-
|
|
203
|
+
async def _execute() -> tuple[list[Collection], list[tuple[str, str]], list[tuple[str, str]]]:
|
|
204
|
+
created = await svc.create_many(plan)
|
|
205
|
+
created_by_name = {c.name: c.key for c in created}
|
|
206
|
+
done, failed = await svc.execute_assignments(
|
|
207
|
+
report.item_assignments, created_by_name
|
|
208
|
+
)
|
|
209
|
+
return created, done, failed
|
|
210
|
+
|
|
211
|
+
created, done, failed = asyncio.run(_execute())
|
|
203
212
|
emit(
|
|
204
213
|
{
|
|
205
214
|
"created": [c.model_dump() for c in created],
|
|
206
|
-
"
|
|
215
|
+
"assignments_done": done,
|
|
216
|
+
"assignments_failed": failed,
|
|
207
217
|
},
|
|
208
218
|
human=_human(),
|
|
209
219
|
count=len(created),
|
|
210
220
|
)
|
|
211
|
-
# Note: item assignment to newly-created collections would go here
|
|
212
|
-
# in a follow-up step (separate API call after collections exist).
|
|
213
221
|
except ZopError as e:
|
|
214
222
|
emit_error(e, human=_human())
|
|
215
223
|
sys.exit(1)
|
|
@@ -59,13 +59,21 @@ def export_cmd(
|
|
|
59
59
|
Path(out).write_text(str(payload), encoding="utf-8")
|
|
60
60
|
emit({"written": out, "count": len(items)}, human=_human())
|
|
61
61
|
else:
|
|
62
|
-
if
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
if _human():
|
|
63
|
+
# Human/tty: raw output (pipe-friendly, e.g. zop export K > refs.bib).
|
|
64
|
+
if fmt == "csl-json":
|
|
65
|
+
import json
|
|
66
|
+
sys.stdout.write(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
67
|
+
sys.stdout.write("\n")
|
|
68
|
+
else:
|
|
69
|
+
sys.stdout.write(str(payload))
|
|
70
|
+
sys.stdout.flush()
|
|
66
71
|
else:
|
|
67
|
-
|
|
68
|
-
|
|
72
|
+
# JSON/agent: wrap in the standard envelope.
|
|
73
|
+
emit(
|
|
74
|
+
{"format": fmt, "content": payload, "count": len(items)},
|
|
75
|
+
human=False,
|
|
76
|
+
)
|
|
69
77
|
except ZopError as e:
|
|
70
78
|
emit_error(e, human=_human())
|
|
71
79
|
sys.exit(1)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Configuration loader.
|
|
2
2
|
|
|
3
|
-
Reads
|
|
4
|
-
``ZOP_CONFIG`` env var
|
|
5
|
-
|
|
3
|
+
Reads config from the first existing of: an explicit path, the
|
|
4
|
+
``ZOP_CONFIG`` env var, the platform-specific user config dir
|
|
5
|
+
(``platformdirs``), or ``~/.config/zop/config.toml`` as a fallback.
|
|
6
|
+
Supports a flat TOML schema only in v0.1 — a profile-based schema is on
|
|
7
|
+
the roadmap.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
from __future__ import annotations
|
|
@@ -42,14 +44,27 @@ def _load_toml(path: Path) -> dict[str, object]:
|
|
|
42
44
|
def load_config(path: Path | None = None) -> AppConfig:
|
|
43
45
|
"""Load configuration from disk.
|
|
44
46
|
|
|
45
|
-
Lookup order:
|
|
46
|
-
1. ``
|
|
47
|
-
2.
|
|
47
|
+
Lookup order (first existing file wins):
|
|
48
|
+
1. explicit ``path`` argument
|
|
49
|
+
2. ``ZOP_CONFIG`` env var (explicit override)
|
|
50
|
+
3. platform-specific user config dir (``platformdirs``: on Windows
|
|
51
|
+
``%LOCALAPPDATA%\\zop``, on macOS ``~/Library/Application Support/zop``,
|
|
52
|
+
on Linux ``~/.config/zop``)
|
|
53
|
+
4. ``~/.config/zop/config.toml`` fallback (Unix convention, cross-platform)
|
|
48
54
|
"""
|
|
49
|
-
if path is None:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
if path is not None:
|
|
56
|
+
data = _load_toml(path)
|
|
57
|
+
else:
|
|
58
|
+
candidates: list[Path] = []
|
|
59
|
+
if "ZOP_CONFIG" in os.environ:
|
|
60
|
+
candidates.append(Path(os.environ["ZOP_CONFIG"]))
|
|
61
|
+
candidates.append(CONFIG_FILE)
|
|
62
|
+
candidates.append(Path.home() / ".config" / "zop" / "config.toml")
|
|
63
|
+
data = {}
|
|
64
|
+
for candidate in candidates:
|
|
65
|
+
if candidate.exists():
|
|
66
|
+
data = _load_toml(candidate)
|
|
67
|
+
break
|
|
53
68
|
|
|
54
69
|
# Flat schema: [zotero] section.
|
|
55
70
|
z = data.get("zotero", {}) if isinstance(data, dict) else {}
|
|
@@ -2,11 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
6
|
+
|
|
5
7
|
from pydantic import BaseModel, ConfigDict, Field
|
|
6
8
|
|
|
7
9
|
from zop.models.common import ID_PATTERN, ItemType
|
|
8
10
|
|
|
9
11
|
|
|
12
|
+
def parse_year(date: str | None) -> int | None:
|
|
13
|
+
"""Extract a 4-digit year from a Zotero date string, else None.
|
|
14
|
+
|
|
15
|
+
Zotero stores dates as free-form strings ("2024-05-01", "May 2024",
|
|
16
|
+
"2024-11-07 2024-11-07", ...). Single source of truth for year parsing —
|
|
17
|
+
both the read path (Item.year) and the export path go through it.
|
|
18
|
+
"""
|
|
19
|
+
if date is None:
|
|
20
|
+
return None
|
|
21
|
+
match = re.search(r"\d{4}", date)
|
|
22
|
+
return int(match.group(0)) if match else None
|
|
23
|
+
|
|
24
|
+
|
|
10
25
|
class ItemSummary(BaseModel):
|
|
11
26
|
"""Minimal item info (used for list views)."""
|
|
12
27
|
|
|
@@ -11,6 +11,7 @@ This layer:
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import asyncio
|
|
14
|
+
import re
|
|
14
15
|
from collections.abc import Sequence
|
|
15
16
|
from dataclasses import dataclass, field
|
|
16
17
|
from pathlib import Path
|
|
@@ -24,6 +25,7 @@ from zop.core.errors import (
|
|
|
24
25
|
ZopError,
|
|
25
26
|
)
|
|
26
27
|
from zop.models.collection import Collection, CollectionTree
|
|
28
|
+
from zop.models.common import ID_PATTERN
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
@dataclass
|
|
@@ -171,13 +173,17 @@ class CollectionsService:
|
|
|
171
173
|
*,
|
|
172
174
|
parent: str | None = None,
|
|
173
175
|
) -> Collection:
|
|
174
|
-
"""Create one collection.
|
|
176
|
+
"""Create one collection.
|
|
177
|
+
|
|
178
|
+
``parent`` may be a collection KEY (8-char) or NAME. KEY is used
|
|
179
|
+
directly; NAME is resolved locally, then (if not yet synced, e.g.
|
|
180
|
+
a just-created parent) via the Web API.
|
|
181
|
+
"""
|
|
175
182
|
api = self._require_api()
|
|
176
|
-
payload: dict[str, object] = {"name": name}
|
|
177
|
-
if parent:
|
|
178
|
-
p = self.resolve(parent)
|
|
179
|
-
payload["parentCollection"] = p.key
|
|
180
183
|
async with api:
|
|
184
|
+
payload: dict[str, object] = {"name": name}
|
|
185
|
+
if parent:
|
|
186
|
+
payload["parentCollection"] = await self._resolve_parent(api, parent)
|
|
181
187
|
result = await api.create_collections([payload])
|
|
182
188
|
if not result:
|
|
183
189
|
raise ZopError(f"Failed to create '{name}' (empty response)")
|
|
@@ -189,6 +195,25 @@ class CollectionsService:
|
|
|
189
195
|
version=r["version"],
|
|
190
196
|
)
|
|
191
197
|
|
|
198
|
+
async def _resolve_parent(self, api: ZoteroApi, parent: str) -> str:
|
|
199
|
+
"""Resolve a parent reference (KEY or NAME) to a collection key.
|
|
200
|
+
|
|
201
|
+
KEY is used as-is. NAME is looked up locally first, then via the
|
|
202
|
+
Web API so a just-created parent (not yet in local SQLite) works.
|
|
203
|
+
"""
|
|
204
|
+
if re.fullmatch(ID_PATTERN, parent):
|
|
205
|
+
return parent
|
|
206
|
+
try:
|
|
207
|
+
return self.resolve(parent).key
|
|
208
|
+
except NotFoundError:
|
|
209
|
+
remote = await api.list_collections()
|
|
210
|
+
for coll in remote:
|
|
211
|
+
if coll.get("data", {}).get("name") == parent:
|
|
212
|
+
key = coll.get("key")
|
|
213
|
+
if key:
|
|
214
|
+
return str(key)
|
|
215
|
+
raise NotFoundError(f"No collection named '{parent}' (local or remote)") from None
|
|
216
|
+
|
|
192
217
|
async def create_many(self, plan: list[PlanNode]) -> list[Collection]:
|
|
193
218
|
"""Create all collections in a validated plan (batched POST, topologically ordered).
|
|
194
219
|
|
|
@@ -261,7 +286,10 @@ class CollectionsService:
|
|
|
261
286
|
async def delete(self, key: str) -> None:
|
|
262
287
|
api = self._require_api()
|
|
263
288
|
async with api:
|
|
264
|
-
|
|
289
|
+
# Zotero requires If-Unmodified-Since-Version for collection
|
|
290
|
+
# DELETE; fetch the current version first (mirrors ItemsService).
|
|
291
|
+
current = await api.get_collection(key)
|
|
292
|
+
await api.delete_collection(key, version=current["version"])
|
|
265
293
|
|
|
266
294
|
async def reparent(
|
|
267
295
|
self, key: str, new_parent: str | None, *, version: int | None = None
|
|
@@ -274,6 +302,9 @@ class CollectionsService:
|
|
|
274
302
|
else:
|
|
275
303
|
parent_key = self.resolve(new_parent).key
|
|
276
304
|
async with api:
|
|
305
|
+
if version is None:
|
|
306
|
+
current = await api.get_collection(key)
|
|
307
|
+
version = current["version"]
|
|
277
308
|
r = await api.update_collection(key, parent_key=parent_key, version=version)
|
|
278
309
|
return Collection(
|
|
279
310
|
key=r["key"],
|
|
@@ -322,5 +353,33 @@ class CollectionsService:
|
|
|
322
353
|
|
|
323
354
|
return ok, fetch_failures + move_failures
|
|
324
355
|
|
|
356
|
+
async def execute_assignments(
|
|
357
|
+
self,
|
|
358
|
+
assignments: list[tuple[str, str]],
|
|
359
|
+
created_by_name: dict[str, str],
|
|
360
|
+
) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
|
|
361
|
+
"""Move items into their target collections after plan creation.
|
|
362
|
+
|
|
363
|
+
Reuses move_items (API GET + bounded-concurrent PATCH), so it works
|
|
364
|
+
even though the newly-created collections aren't in local SQLite
|
|
365
|
+
yet — target keys come from create_many's response, not the snapshot.
|
|
366
|
+
Returns (done, failed) as lists of (item_key, coll_key_or_error).
|
|
367
|
+
"""
|
|
368
|
+
by_coll: dict[str, list[str]] = {}
|
|
369
|
+
unresolved: list[str] = []
|
|
370
|
+
for item_key, coll_name in assignments:
|
|
371
|
+
coll_key = created_by_name.get(coll_name)
|
|
372
|
+
if coll_key is None:
|
|
373
|
+
unresolved.append(item_key)
|
|
374
|
+
continue
|
|
375
|
+
by_coll.setdefault(coll_key, []).append(item_key)
|
|
376
|
+
done: list[tuple[str, str]] = []
|
|
377
|
+
failed: list[tuple[str, str]] = [(it, "collection not created") for it in unresolved]
|
|
378
|
+
for coll_key, items in by_coll.items():
|
|
379
|
+
ok, fails = await self.move_items(items, coll_key)
|
|
380
|
+
done.extend((it, coll_key) for it in ok)
|
|
381
|
+
failed.extend((it, str(e)) for it, e in fails)
|
|
382
|
+
return done, failed
|
|
383
|
+
|
|
325
384
|
|
|
326
385
|
__all__ = ["CollectionsService", "PlanNode", "PlanReport"]
|
|
@@ -7,7 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from zop.adapters.sqlite_reader import SqliteReader
|
|
9
9
|
from zop.core.errors import ZopError
|
|
10
|
-
from zop.models.item import Item
|
|
10
|
+
from zop.models.item import Item, parse_year
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class ExportService:
|
|
@@ -103,10 +103,8 @@ def _escape_bibtex(s: str) -> str:
|
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
def _extract_year(date: str | None) -> str:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
m = re.search(r"\d{4}", date)
|
|
109
|
-
return m.group(0) if m else ""
|
|
106
|
+
year = parse_year(date)
|
|
107
|
+
return str(year) if year is not None else ""
|
|
110
108
|
|
|
111
109
|
|
|
112
110
|
def _make_bibtex_key(item: Item) -> str:
|
|
@@ -4,11 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import Sequence
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
7
8
|
|
|
8
9
|
from zop.adapters.sqlite_reader import SqliteReader
|
|
9
10
|
from zop.adapters.zotero_api import ApiCreds, ZoteroApi
|
|
10
11
|
from zop.core.errors import AuthError, NotFoundError, ZopError
|
|
11
|
-
from zop.models.
|
|
12
|
+
from zop.models.common import ItemType
|
|
13
|
+
from zop.models.item import Item, ItemSummary, parse_year
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
class ItemsService:
|
|
@@ -125,7 +127,7 @@ class ItemsService:
|
|
|
125
127
|
created = await api.create_items([payload])
|
|
126
128
|
if not created:
|
|
127
129
|
raise ZopError(f"DOI '{doi}' not found or rejected by server")
|
|
128
|
-
return self.
|
|
130
|
+
return await self._item_after_create(created[0])
|
|
129
131
|
|
|
130
132
|
async def add_many(self, dois: Sequence[str]) -> list[Item]:
|
|
131
133
|
"""Add multiple items by DOI in a single batched POST."""
|
|
@@ -136,7 +138,43 @@ class ItemsService:
|
|
|
136
138
|
]
|
|
137
139
|
async with api:
|
|
138
140
|
created = await api.create_items(payload)
|
|
139
|
-
return [self.
|
|
141
|
+
return [await self._item_after_create(c) for c in created if c.get("key")]
|
|
142
|
+
|
|
143
|
+
async def _item_after_create(self, created: dict[str, Any]) -> Item:
|
|
144
|
+
"""Return the created item: local DB if synced, else from API response.
|
|
145
|
+
|
|
146
|
+
A just-created item is not in local SQLite until Zotero syncs it
|
|
147
|
+
(BUG-9), so a strict local read would falsely report failure while
|
|
148
|
+
the item already exists server-side. Prefer local; fall back to the
|
|
149
|
+
API response so the caller gets the new key without a false error.
|
|
150
|
+
"""
|
|
151
|
+
key = created.get("key")
|
|
152
|
+
if not key:
|
|
153
|
+
raise ZopError("Server created item but returned no key")
|
|
154
|
+
try:
|
|
155
|
+
return self._reader.get_item(str(key))
|
|
156
|
+
except NotFoundError:
|
|
157
|
+
return self._item_from_api_response(created)
|
|
158
|
+
|
|
159
|
+
def _item_from_api_response(self, created: dict[str, Any]) -> Item:
|
|
160
|
+
"""Build a best-effort Item from a create_items API response.
|
|
161
|
+
|
|
162
|
+
Fields absent from the response (creators, etc.) are left empty;
|
|
163
|
+
they populate after Zotero syncs the new item to local SQLite.
|
|
164
|
+
"""
|
|
165
|
+
data = created.get("data")
|
|
166
|
+
if not isinstance(data, dict):
|
|
167
|
+
data = {}
|
|
168
|
+
date_str = str(data["date"]) if data.get("date") else None
|
|
169
|
+
return Item(
|
|
170
|
+
key=str(created["key"]),
|
|
171
|
+
item_type=ItemType(str(data.get("itemType", ""))),
|
|
172
|
+
title=str(data.get("title", "")),
|
|
173
|
+
doi=str(data["DOI"]) if data.get("DOI") else None,
|
|
174
|
+
url=str(data["url"]) if data.get("url") else None,
|
|
175
|
+
date=date_str,
|
|
176
|
+
year=parse_year(date_str),
|
|
177
|
+
)
|
|
140
178
|
|
|
141
179
|
|
|
142
180
|
__all__ = ["ItemsService"]
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import TypedDict
|
|
6
|
+
from typing import Any, TypedDict
|
|
7
7
|
|
|
8
8
|
from pypdf import PdfReader
|
|
9
9
|
|
|
@@ -20,6 +20,41 @@ class OutlineEntry(TypedDict):
|
|
|
20
20
|
depth: int
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def _resolve_page_number(reader: PdfReader, dest: Any) -> int | None:
|
|
24
|
+
"""Resolve a 1-indexed page number for an outline destination.
|
|
25
|
+
|
|
26
|
+
Tries ``get_destination_page_number`` directly, then falls back to
|
|
27
|
+
named destinations (whose ``/Dest`` is a name string rather than an
|
|
28
|
+
explicit page array — the direct method returns None for those).
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
num = reader.get_destination_page_number(dest)
|
|
32
|
+
except Exception:
|
|
33
|
+
num = None
|
|
34
|
+
if num is not None:
|
|
35
|
+
return num + 1
|
|
36
|
+
# Named-destination fallback: resolve the name, then its page.
|
|
37
|
+
target: object = None
|
|
38
|
+
get = getattr(dest, "get", None)
|
|
39
|
+
if callable(get):
|
|
40
|
+
target = get("/Dest")
|
|
41
|
+
if target is None:
|
|
42
|
+
action = get("/A")
|
|
43
|
+
action_get = getattr(action, "get", None)
|
|
44
|
+
if callable(action_get):
|
|
45
|
+
target = action_get("/D")
|
|
46
|
+
if isinstance(target, str):
|
|
47
|
+
named = reader.named_destinations.get(target)
|
|
48
|
+
if named is not None:
|
|
49
|
+
try:
|
|
50
|
+
num = reader.get_destination_page_number(named)
|
|
51
|
+
except Exception:
|
|
52
|
+
num = None
|
|
53
|
+
if num is not None:
|
|
54
|
+
return num + 1
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
23
58
|
class PdfService:
|
|
24
59
|
"""PDF operations: read text, extract outline."""
|
|
25
60
|
|
|
@@ -74,11 +109,7 @@ class PdfService:
|
|
|
74
109
|
title = str(raw_title.get("/Title", ""))
|
|
75
110
|
elif raw_title is not None:
|
|
76
111
|
title = str(raw_title)
|
|
77
|
-
|
|
78
|
-
raw_page = reader.get_destination_page_number(item) # type: ignore[arg-type]
|
|
79
|
-
page_num: int | None = raw_page + 1 if raw_page is not None else None
|
|
80
|
-
except Exception:
|
|
81
|
-
page_num = None
|
|
112
|
+
page_num = _resolve_page_number(reader, item[0])
|
|
82
113
|
out.append(
|
|
83
114
|
{"section": len(out) + 1, "title": title, "page": page_num, "depth": depth}
|
|
84
115
|
)
|