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.
Files changed (68) hide show
  1. {zop_cli-0.2.2 → zop_cli-0.2.3}/PKG-INFO +1 -1
  2. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/_version.py +1 -1
  3. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/adapters/sqlite_reader.py +15 -3
  4. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/collection.py +3 -3
  5. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/collections.py +10 -5
  6. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_collections.py +27 -0
  7. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_library.py +22 -0
  8. {zop_cli-0.2.2 → zop_cli-0.2.3}/.gitattributes +0 -0
  9. {zop_cli-0.2.2 → zop_cli-0.2.3}/.github/workflows/ci.yml +0 -0
  10. {zop_cli-0.2.2 → zop_cli-0.2.3}/.gitignore +0 -0
  11. {zop_cli-0.2.2 → zop_cli-0.2.3}/CONTRIBUTING.md +0 -0
  12. {zop_cli-0.2.2 → zop_cli-0.2.3}/LICENSE +0 -0
  13. {zop_cli-0.2.2 → zop_cli-0.2.3}/README.md +0 -0
  14. {zop_cli-0.2.2 → zop_cli-0.2.3}/docs/ARCHITECTURE.md +0 -0
  15. {zop_cli-0.2.2 → zop_cli-0.2.3}/pyproject.toml +0 -0
  16. {zop_cli-0.2.2 → zop_cli-0.2.3}/skills/zop/README.md +0 -0
  17. {zop_cli-0.2.2 → zop_cli-0.2.3}/skills/zop/SKILL.md +0 -0
  18. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/__init__.py +0 -0
  19. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/__main__.py +0 -0
  20. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/adapters/__init__.py +0 -0
  21. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/adapters/zotero_api.py +0 -0
  22. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/cli.py +0 -0
  23. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/__init__.py +0 -0
  24. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/export.py +0 -0
  25. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/item.py +0 -0
  26. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/library.py +0 -0
  27. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/note.py +0 -0
  28. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/pdf.py +0 -0
  29. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/commands/tag.py +0 -0
  30. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/core/__init__.py +0 -0
  31. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/core/concurrency.py +0 -0
  32. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/core/config.py +0 -0
  33. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/core/envelope.py +0 -0
  34. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/core/errors.py +0 -0
  35. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/models/__init__.py +0 -0
  36. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/models/collection.py +0 -0
  37. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/models/common.py +0 -0
  38. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/models/envelope.py +0 -0
  39. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/models/item.py +0 -0
  40. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/__init__.py +0 -0
  41. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/export.py +0 -0
  42. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/items.py +0 -0
  43. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/library.py +0 -0
  44. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/notes.py +0 -0
  45. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/pdf.py +0 -0
  46. {zop_cli-0.2.2 → zop_cli-0.2.3}/src/zop/services/tags.py +0 -0
  47. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/__init__.py +0 -0
  48. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/conftest.py +0 -0
  49. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/fixtures/test_plan.json +0 -0
  50. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_collection.py +0 -0
  51. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_export.py +0 -0
  52. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_item.py +0 -0
  53. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_library.py +0 -0
  54. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_main.py +0 -0
  55. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_note.py +0 -0
  56. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_plan.py +0 -0
  57. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_cli_tag.py +0 -0
  58. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_config.py +0 -0
  59. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_envelope.py +0 -0
  60. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_export.py +0 -0
  61. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_items_service.py +0 -0
  62. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_notes_service.py +0 -0
  63. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_pdf.py +0 -0
  64. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_tags_service.py +0 -0
  65. {zop_cli-0.2.2 → zop_cli-0.2.3}/tests/test_zotero_api.py +0 -0
  66. {zop_cli-0.2.2 → zop_cli-0.2.3}/tools/inspect_sqlite.py +0 -0
  67. {zop_cli-0.2.2 → zop_cli-0.2.3}/tools/inspect_tables.py +0 -0
  68. {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.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
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.2.2"
5
+ __version__ = "0.2.3"
@@ -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
- return [{"key": r[0], "note": r[1] or "", "date_added": str(r[2]) if r[2] else "",
425
- "date_modified": str(r[3]) if r[3] else ""} for r in rows if r[0]]
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", "parent_name", default=None, help="New parent NAME (omit for top-level).")
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, parent_name: str | None) -> None:
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, parent_name))
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 new_parent is None."""
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