zop-cli 0.2.2__tar.gz → 0.2.3__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.2 → zop_cli-0.2.3}/PKG-INFO +1 -1
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/_version.py +1 -1
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/adapters/sqlite_reader.py +15 -3
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/collection.py +3 -3
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/collections.py +10 -5
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_collections.py +27 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_library.py +22 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/.gitattributes +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/.github/workflows/ci.yml +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/.gitignore +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/CONTRIBUTING.md +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/LICENSE +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/README.md +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/docs/ARCHITECTURE.md +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/pyproject.toml +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/skills/zop/README.md +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/skills/zop/SKILL.md +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/__init__.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/__main__.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/adapters/__init__.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/adapters/zotero_api.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/cli.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/__init__.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/export.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/item.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/library.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/note.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/pdf.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/tag.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/core/__init__.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/core/concurrency.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/core/config.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/core/envelope.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/core/errors.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/models/__init__.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/models/collection.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/models/common.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/models/envelope.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/models/item.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/__init__.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/export.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/items.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/library.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/notes.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/pdf.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/tags.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/__init__.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/conftest.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/fixtures/test_plan.json +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_collection.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_export.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_item.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_library.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_main.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_note.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_plan.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_tag.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_config.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_envelope.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_export.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_items_service.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_notes_service.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_pdf.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_tags_service.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_zotero_api.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tools/inspect_sqlite.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/tools/inspect_tables.py +0 -0
- {zop_cli-0.2.2 → zop_cli-0.2.3}/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.3
|
|
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
|
|
@@ -412,7 +412,7 @@ class SqliteReader:
|
|
|
412
412
|
with self._connect() as con:
|
|
413
413
|
rows = con.execute(
|
|
414
414
|
"""
|
|
415
|
-
SELECT i.key, n.note, i.dateAdded, i.dateModified
|
|
415
|
+
SELECT i.key, n.note, i.dateAdded, i.dateModified, parent.key
|
|
416
416
|
FROM itemNotes n
|
|
417
417
|
JOIN items i ON i.itemID = n.itemID
|
|
418
418
|
JOIN items parent ON parent.itemID = n.parentItemID
|
|
@@ -421,8 +421,20 @@ class SqliteReader:
|
|
|
421
421
|
""",
|
|
422
422
|
(item_key, library_id),
|
|
423
423
|
).fetchall()
|
|
424
|
-
|
|
425
|
-
|
|
424
|
+
# parent_key: the parent item's key. The JOIN to `parent` already existed
|
|
425
|
+
# but the SELECT never read parent.key, so callers couldn't attribute a
|
|
426
|
+
# note to its item (#2).
|
|
427
|
+
return [
|
|
428
|
+
{
|
|
429
|
+
"key": r[0],
|
|
430
|
+
"note": r[1] or "",
|
|
431
|
+
"date_added": str(r[2]) if r[2] else "",
|
|
432
|
+
"date_modified": str(r[3]) if r[3] else "",
|
|
433
|
+
"parent_key": r[4],
|
|
434
|
+
}
|
|
435
|
+
for r in rows
|
|
436
|
+
if r[0]
|
|
437
|
+
]
|
|
426
438
|
|
|
427
439
|
def list_all_tags(self, library_id: int = 1) -> list[dict[str, int | str]]:
|
|
428
440
|
with self._connect() as con:
|
|
@@ -106,13 +106,13 @@ def delete_cmd(ctx: click.Context, key: str, yes: bool) -> None:
|
|
|
106
106
|
|
|
107
107
|
@collection.command("reparent")
|
|
108
108
|
@click.argument("key")
|
|
109
|
-
@click.option("--parent", "
|
|
109
|
+
@click.option("--parent", "parent_ref", default=None, help="New parent KEY or NAME (omit for top-level).")
|
|
110
110
|
@click.pass_context
|
|
111
|
-
def reparent_cmd(ctx: click.Context, key: str,
|
|
111
|
+
def reparent_cmd(ctx: click.Context, key: str, parent_ref: str | None) -> None:
|
|
112
112
|
"""Move a collection under a new parent (or to top-level). Requires API key."""
|
|
113
113
|
try:
|
|
114
114
|
svc = _service(ctx)
|
|
115
|
-
result = asyncio.run(svc.reparent(key,
|
|
115
|
+
result = asyncio.run(svc.reparent(key, parent_ref))
|
|
116
116
|
emit([result.model_dump()], human=_human(), count=1)
|
|
117
117
|
except ZopError as e:
|
|
118
118
|
emit_error(e, human=_human())
|
|
@@ -294,14 +294,19 @@ class CollectionsService:
|
|
|
294
294
|
async def reparent(
|
|
295
295
|
self, key: str, new_parent: str | None, *, version: int | None = None
|
|
296
296
|
) -> Collection:
|
|
297
|
-
"""Move collection under new parent (NAME), or detach if
|
|
297
|
+
"""Move collection under a new parent (KEY or NAME), or detach if None.
|
|
298
298
|
|
|
299
|
+
``new_parent`` may be a collection KEY (8-char) or NAME, resolved the same
|
|
300
|
+
way as ``create``'s ``--parent`` (KEY first, then local name, then the
|
|
301
|
+
Web API for a parent not yet in local SQLite). This matches SKILL.md's
|
|
302
|
+
``--parent "KeyOrName"`` contract shared by all collection write commands.
|
|
303
|
+
"""
|
|
299
304
|
api = self._require_api()
|
|
300
|
-
if new_parent is None:
|
|
301
|
-
parent_key: str | None | object = False # detach
|
|
302
|
-
else:
|
|
303
|
-
parent_key = self.resolve(new_parent).key
|
|
304
305
|
async with api:
|
|
306
|
+
if new_parent is None:
|
|
307
|
+
parent_key: str | None | object = False # detach to top-level
|
|
308
|
+
else:
|
|
309
|
+
parent_key = await self._resolve_parent(api, new_parent)
|
|
305
310
|
if version is None:
|
|
306
311
|
current = await api.get_collection(key)
|
|
307
312
|
version = current["version"]
|
|
@@ -268,6 +268,33 @@ async def test_reparent_returns_updated_state_after_empty_patch(
|
|
|
268
268
|
assert result.version == 6
|
|
269
269
|
|
|
270
270
|
|
|
271
|
+
async def test_reparent_accepts_parent_key_directly(
|
|
272
|
+
fake_db: Path,
|
|
273
|
+
creds: ApiCreds,
|
|
274
|
+
fake_api: AsyncMock,
|
|
275
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""#1: reparent --parent must accept an 8-char KEY (like create), not just a NAME.
|
|
278
|
+
Passing a KEY short-circuits name lookup — no list_collections scan. Regression
|
|
279
|
+
for ``reparent <KEY> --parent <父KEY>`` failing with ``No collection named '<KEY>'``.
|
|
280
|
+
"""
|
|
281
|
+
fake_api.update_collection.return_value = {} # 204 No Content
|
|
282
|
+
fake_api.get_collection.return_value = {
|
|
283
|
+
"key": "KEY00001",
|
|
284
|
+
"version": 6,
|
|
285
|
+
"data": {"name": "child", "parentCollection": "PARENT01"},
|
|
286
|
+
}
|
|
287
|
+
monkeypatch.setattr(CollectionsService, "_require_api", lambda self: fake_api)
|
|
288
|
+
svc = CollectionsService(db_path=fake_db, creds=creds)
|
|
289
|
+
|
|
290
|
+
await svc.reparent("KEY00001", "PARENT01") # an 8-char KEY, not a NAME
|
|
291
|
+
|
|
292
|
+
# KEY path must not scan collection names via the API
|
|
293
|
+
fake_api.list_collections.assert_not_awaited()
|
|
294
|
+
# update_collection must receive the raw KEY as parent_key
|
|
295
|
+
assert fake_api.update_collection.call_args.kwargs["parent_key"] == "PARENT01"
|
|
296
|
+
|
|
297
|
+
|
|
271
298
|
# ---- create: --parent accepts KEY + API fallback (BUG-7) ----
|
|
272
299
|
|
|
273
300
|
|
|
@@ -45,6 +45,7 @@ def fake_db(tmp_path: Path) -> Iterator[Path]:
|
|
|
45
45
|
CREATE TABLE fields (fieldID INTEGER PRIMARY KEY, fieldName TEXT);
|
|
46
46
|
CREATE TABLE itemCreators (itemID INT, creatorID INT, orderIndex INT);
|
|
47
47
|
CREATE TABLE creators (creatorID INTEGER PRIMARY KEY, firstName TEXT, lastName TEXT);
|
|
48
|
+
CREATE TABLE itemNotes (itemID INT, parentItemID INT, note TEXT);
|
|
48
49
|
|
|
49
50
|
-- Deliberately non-standard itemTypeID mapping (exposes BUG-1).
|
|
50
51
|
INSERT INTO itemTypes VALUES
|
|
@@ -73,6 +74,11 @@ def fake_db(tmp_path: Path) -> Iterator[Path]:
|
|
|
73
74
|
INSERT INTO tags VALUES (1, 'cs.AI'), (2, 'to-read');
|
|
74
75
|
INSERT INTO collections VALUES (1, 1, 'COLL0001');
|
|
75
76
|
|
|
77
|
+
-- A child note item (itemID 10) attached to JOURN001 (itemID 1) — for #2.
|
|
78
|
+
INSERT INTO items (itemID, itemTypeID, dateAdded, dateModified, libraryID, key) VALUES
|
|
79
|
+
(10, 28, datetime('now','-1 days'), datetime('now'), 1, 'NOTEX001');
|
|
80
|
+
INSERT INTO itemNotes VALUES (10, 1, 'A note on Paper One');
|
|
81
|
+
|
|
76
82
|
-- Titles (for duplicates by title + recent display).
|
|
77
83
|
INSERT INTO itemDataValues VALUES (10, 'Paper One');
|
|
78
84
|
INSERT INTO itemData VALUES (1, 1, 10);
|
|
@@ -156,3 +162,19 @@ def test_get_item_populates_year(fake_db: Path) -> None:
|
|
|
156
162
|
reader = SqliteReader(fake_db)
|
|
157
163
|
item = reader.get_item("JOURN001")
|
|
158
164
|
assert item.year == 2024
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_get_item_notes_includes_parent_key(fake_db: Path) -> None:
|
|
168
|
+
"""#2: note list must surface parent_key so callers can attribute a note to
|
|
169
|
+
its item. The JOIN to the parent item already exists; the SELECT must include
|
|
170
|
+
parent.key and the returned dict must carry it (not be silently absent).
|
|
171
|
+
"""
|
|
172
|
+
reader = SqliteReader(fake_db)
|
|
173
|
+
notes = reader.get_item_notes("JOURN001")
|
|
174
|
+
assert len(notes) == 1
|
|
175
|
+
note = notes[0]
|
|
176
|
+
assert note["key"] == "NOTEX001"
|
|
177
|
+
assert note["note"] == "A note on Paper One"
|
|
178
|
+
assert note["parent_key"] == "JOURN001" # the fix
|
|
179
|
+
assert note["date_added"] # non-empty
|
|
180
|
+
assert note["date_modified"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|