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.
Files changed (69) hide show
  1. {zop_cli-0.2.0 → zop_cli-0.2.1}/PKG-INFO +2 -2
  2. {zop_cli-0.2.0 → zop_cli-0.2.1}/README.md +1 -1
  3. {zop_cli-0.2.0 → zop_cli-0.2.1}/skills/zop/SKILL.md +11 -5
  4. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/_version.py +1 -1
  5. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/adapters/sqlite_reader.py +16 -6
  6. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/adapters/zotero_api.py +9 -0
  7. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/collection.py +16 -8
  8. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/export.py +14 -6
  9. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/core/config.py +25 -10
  10. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/models/item.py +15 -0
  11. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/collections.py +65 -6
  12. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/export.py +3 -5
  13. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/items.py +41 -3
  14. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/pdf.py +37 -6
  15. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_export.py +21 -3
  16. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_plan.py +53 -0
  17. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_collections.py +101 -0
  18. zop_cli-0.2.1/tests/test_config.py +54 -0
  19. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_items_service.py +34 -1
  20. zop_cli-0.2.1/tests/test_library.py +158 -0
  21. zop_cli-0.2.1/tests/test_pdf.py +44 -0
  22. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_zotero_api.py +22 -0
  23. zop_cli-0.2.0/tests/test_library.py +0 -90
  24. {zop_cli-0.2.0 → zop_cli-0.2.1}/.gitattributes +0 -0
  25. {zop_cli-0.2.0 → zop_cli-0.2.1}/.github/workflows/ci.yml +0 -0
  26. {zop_cli-0.2.0 → zop_cli-0.2.1}/.gitignore +0 -0
  27. {zop_cli-0.2.0 → zop_cli-0.2.1}/CONTRIBUTING.md +0 -0
  28. {zop_cli-0.2.0 → zop_cli-0.2.1}/LICENSE +0 -0
  29. {zop_cli-0.2.0 → zop_cli-0.2.1}/docs/ARCHITECTURE.md +0 -0
  30. {zop_cli-0.2.0 → zop_cli-0.2.1}/pyproject.toml +0 -0
  31. {zop_cli-0.2.0 → zop_cli-0.2.1}/skills/zop/README.md +0 -0
  32. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/__init__.py +0 -0
  33. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/__main__.py +0 -0
  34. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/adapters/__init__.py +0 -0
  35. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/cli.py +0 -0
  36. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/__init__.py +0 -0
  37. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/item.py +0 -0
  38. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/library.py +0 -0
  39. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/note.py +0 -0
  40. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/pdf.py +0 -0
  41. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/commands/tag.py +0 -0
  42. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/core/__init__.py +0 -0
  43. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/core/concurrency.py +0 -0
  44. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/core/envelope.py +0 -0
  45. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/core/errors.py +0 -0
  46. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/models/__init__.py +0 -0
  47. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/models/collection.py +0 -0
  48. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/models/common.py +0 -0
  49. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/models/envelope.py +0 -0
  50. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/__init__.py +0 -0
  51. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/library.py +0 -0
  52. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/notes.py +0 -0
  53. {zop_cli-0.2.0 → zop_cli-0.2.1}/src/zop/services/tags.py +0 -0
  54. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/__init__.py +0 -0
  55. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/conftest.py +0 -0
  56. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/fixtures/test_plan.json +0 -0
  57. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_collection.py +0 -0
  58. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_item.py +0 -0
  59. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_library.py +0 -0
  60. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_main.py +0 -0
  61. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_note.py +0 -0
  62. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_cli_tag.py +0 -0
  63. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_envelope.py +0 -0
  64. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_export.py +0 -0
  65. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_notes_service.py +0 -0
  66. {zop_cli-0.2.0 → zop_cli-0.2.1}/tests/test_tags_service.py +0 -0
  67. {zop_cli-0.2.0 → zop_cli-0.2.1}/tools/inspect_sqlite.py +0 -0
  68. {zop_cli-0.2.0 → zop_cli-0.2.1}/tools/inspect_tables.py +0 -0
  69. {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.0
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
- 新建 `~/.config/zop/config.toml`:
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
- 新建 `~/.config/zop/config.toml`:
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 at `~/.config/zop/config.toml` (or `$ZOP_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 by name) | `zop collection create "Name" [--parent "ParentName"]` |
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 "Name"]` |
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
- Name→key resolution is automatic in write commands (`--parent "Name"`), so accept either from the user.
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 # topologically creates collections in waves
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.
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.2.1"
@@ -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 i.itemTypeID NOT IN (1, 14)) AS item_count
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 i.itemTypeID NOT IN (1, 14) -- exclude attachments & notes
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 i.itemTypeID NOT IN (1, 14)
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=? AND itemTypeID NOT IN (1,14)",
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 i.itemTypeID NOT IN (1,14)
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", "parent_name", default=None, help="Parent collection NAME.")
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, parent_name: str | None) -> None:
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=parent_name))
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
- created = asyncio.run(svc.create_many(plan))
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
- "assignments_pending": report.item_assignments,
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 fmt == "csl-json":
63
- import json
64
- sys.stdout.write(json.dumps(payload, indent=2, ensure_ascii=False))
65
- sys.stdout.write("\n")
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
- sys.stdout.write(str(payload))
68
- sys.stdout.flush()
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 ``~/.config/zop/config.toml`` (or an explicit path from the
4
- ``ZOP_CONFIG`` env var). Supports a flat TOML schema only in v0.1 — a
5
- profile-based schema is on the roadmap.
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. ``ZOP_CONFIG`` env var (explicit override path)
47
- 2. ``<config_dir>/zop/config.toml`` (default location)
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
- path = Path(os.environ["ZOP_CONFIG"]) if "ZOP_CONFIG" in os.environ else None
51
-
52
- data = _load_toml(path) if path is not None else _load_toml(CONFIG_FILE)
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. `parent` is the parent NAME."""
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
- await api.delete_collection(key)
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
- if date is None:
107
- return ""
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.item import Item, ItemSummary
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.get(created[0]["key"])
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.get(c["key"]) for c in created if c.get("key")]
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
- try:
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
  )