zop-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zop/__init__.py +5 -0
- zop/__main__.py +6 -0
- zop/_version.py +5 -0
- zop/adapters/__init__.py +6 -0
- zop/adapters/sqlite_reader.py +476 -0
- zop/adapters/zotero_api.py +315 -0
- zop/cli.py +96 -0
- zop/commands/__init__.py +21 -0
- zop/commands/collection.py +221 -0
- zop/commands/export.py +71 -0
- zop/commands/item.py +176 -0
- zop/commands/library.py +63 -0
- zop/commands/note.py +68 -0
- zop/commands/pdf.py +71 -0
- zop/commands/tag.py +94 -0
- zop/core/__init__.py +5 -0
- zop/core/concurrency.py +38 -0
- zop/core/config.py +66 -0
- zop/core/envelope.py +71 -0
- zop/core/errors.py +92 -0
- zop/models/__init__.py +15 -0
- zop/models/collection.py +45 -0
- zop/models/common.py +30 -0
- zop/models/envelope.py +58 -0
- zop/models/item.py +34 -0
- zop/services/__init__.py +19 -0
- zop/services/collections.py +326 -0
- zop/services/export.py +187 -0
- zop/services/items.py +142 -0
- zop/services/library.py +30 -0
- zop/services/notes.py +47 -0
- zop/services/pdf.py +130 -0
- zop/services/tags.py +99 -0
- zop_cli-0.2.0.dist-info/METADATA +96 -0
- zop_cli-0.2.0.dist-info/RECORD +38 -0
- zop_cli-0.2.0.dist-info/WHEEL +4 -0
- zop_cli-0.2.0.dist-info/entry_points.txt +2 -0
- zop_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
zop/core/envelope.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Output envelope helper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from zop.core.errors import ZopError
|
|
11
|
+
from zop.models.envelope import Envelope, Meta
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def emit(
|
|
15
|
+
data: Any = None,
|
|
16
|
+
*,
|
|
17
|
+
error: ZopError | None = None,
|
|
18
|
+
human: bool = False,
|
|
19
|
+
count: int | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Emit a JSON envelope to stdout.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
data: Payload (will be JSON-serialized).
|
|
25
|
+
error: If provided, the envelope is marked as failed and `error` is
|
|
26
|
+
rendered as the error block.
|
|
27
|
+
human: If True, pretty-print; if False (default), compact JSON.
|
|
28
|
+
count: Optional item count for the meta block.
|
|
29
|
+
"""
|
|
30
|
+
start = time.perf_counter_ns()
|
|
31
|
+
if error is not None:
|
|
32
|
+
env = Envelope(ok=False, error=error.to_block())
|
|
33
|
+
else:
|
|
34
|
+
env = Envelope(ok=True, data=data, meta=Meta(count=count))
|
|
35
|
+
latency_ms = int((time.perf_counter_ns() - start) / 1_000_000)
|
|
36
|
+
env = env.model_copy(update={"meta": env.meta.model_copy(update={"latency_ms": latency_ms})})
|
|
37
|
+
|
|
38
|
+
if human:
|
|
39
|
+
sys.stdout.write(env.to_human())
|
|
40
|
+
else:
|
|
41
|
+
sys.stdout.write(env.to_json())
|
|
42
|
+
sys.stdout.write("\n")
|
|
43
|
+
sys.stdout.flush()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def emit_error(err: ZopError, *, human: bool = False) -> None:
|
|
47
|
+
"""Emit a top-level error envelope."""
|
|
48
|
+
emit(error=err, human=human)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def emit_batch(
|
|
52
|
+
successes: list[Any],
|
|
53
|
+
failures: list[tuple[str, ZopError]],
|
|
54
|
+
*,
|
|
55
|
+
human: bool = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Emit a batch result with both successes and per-item failures."""
|
|
58
|
+
data: dict[str, Any] = {
|
|
59
|
+
"succeeded": [s.model_dump() if hasattr(s, "model_dump") else s for s in successes],
|
|
60
|
+
"failed": [
|
|
61
|
+
{"key": k, "error": e.to_block().model_dump()}
|
|
62
|
+
for k, e in failures
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
env = Envelope(ok=not failures, data=data, meta=Meta(count=len(successes) + len(failures)))
|
|
66
|
+
if human:
|
|
67
|
+
sys.stdout.write(json.dumps(env.model_dump(), indent=2, ensure_ascii=False))
|
|
68
|
+
else:
|
|
69
|
+
sys.stdout.write(env.to_json())
|
|
70
|
+
sys.stdout.write("\n")
|
|
71
|
+
sys.stdout.flush()
|
zop/core/errors.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Error types and structured Result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
from zop.models.envelope import ErrorBlock
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ZopError(Exception):
|
|
15
|
+
"""Base error for all zop errors."""
|
|
16
|
+
|
|
17
|
+
code: str = "zop_error"
|
|
18
|
+
retryable: bool = False
|
|
19
|
+
hint: str | None = None
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, *, hint: str | None = None) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.message = message
|
|
24
|
+
if hint is not None:
|
|
25
|
+
self.hint = hint
|
|
26
|
+
|
|
27
|
+
def to_block(self) -> ErrorBlock:
|
|
28
|
+
return ErrorBlock(
|
|
29
|
+
code=self.code,
|
|
30
|
+
message=self.message,
|
|
31
|
+
hint=self.hint,
|
|
32
|
+
retryable=self.retryable,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthError(ZopError):
|
|
37
|
+
code = "auth_missing"
|
|
38
|
+
hint = "Run 'zop config init' or set ZOP_LIBRARY_ID + ZOP_API_KEY env vars"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotFoundError(ZopError):
|
|
42
|
+
code = "not_found"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConflictError(ZopError):
|
|
46
|
+
"""Resource already exists, or operation would create a conflict."""
|
|
47
|
+
|
|
48
|
+
code = "conflict"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ApiError(ZopError):
|
|
52
|
+
code = "api_error"
|
|
53
|
+
retryable = True
|
|
54
|
+
|
|
55
|
+
def __init__(self, status: int, message: str) -> None:
|
|
56
|
+
super().__init__(f"HTTP {status}: {message}")
|
|
57
|
+
self.status = status
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ValidationError(ZopError):
|
|
61
|
+
code = "validation_error"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NetworkError(ZopError):
|
|
65
|
+
code = "network_error"
|
|
66
|
+
retryable = True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class BatchResult[T]:
|
|
71
|
+
"""Result of a batch operation: successes + per-item failures."""
|
|
72
|
+
|
|
73
|
+
successes: list[T] = field(default_factory=list)
|
|
74
|
+
failures: list[tuple[str, ErrorBlock]] = field(default_factory=list) # (key, error)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def ok(self) -> bool:
|
|
78
|
+
return not self.failures
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def count(self) -> int:
|
|
82
|
+
return len(self.successes) + len(self.failures)
|
|
83
|
+
|
|
84
|
+
def add_failure(self, key: str, err: ZopError) -> None:
|
|
85
|
+
self.failures.append((key, err.to_block()))
|
|
86
|
+
|
|
87
|
+
def merge(self, other: BatchResult[T]) -> None:
|
|
88
|
+
self.successes.extend(other.successes)
|
|
89
|
+
self.failures.extend(other.failures)
|
|
90
|
+
|
|
91
|
+
def iter_failures(self) -> Iterable[tuple[str, ErrorBlock]]:
|
|
92
|
+
return iter(self.failures)
|
zop/models/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Data models (pydantic v2)."""
|
|
2
|
+
|
|
3
|
+
from zop.models.collection import Collection, CollectionSummary, CollectionTree
|
|
4
|
+
from zop.models.common import ID_PATTERN, ItemType
|
|
5
|
+
from zop.models.item import Item, ItemSummary
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ID_PATTERN",
|
|
9
|
+
"Collection",
|
|
10
|
+
"CollectionSummary",
|
|
11
|
+
"CollectionTree",
|
|
12
|
+
"Item",
|
|
13
|
+
"ItemSummary",
|
|
14
|
+
"ItemType",
|
|
15
|
+
]
|
zop/models/collection.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Collection models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from zop.models.common import ID_PATTERN
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CollectionSummary(BaseModel):
|
|
13
|
+
"""Minimal collection info (saves tokens for list views)."""
|
|
14
|
+
|
|
15
|
+
model_config = ConfigDict(frozen=True)
|
|
16
|
+
|
|
17
|
+
key: str = Field(pattern=ID_PATTERN)
|
|
18
|
+
name: str
|
|
19
|
+
parent_key: str | None = Field(default=None, pattern=ID_PATTERN)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Collection(CollectionSummary):
|
|
23
|
+
"""Full collection info."""
|
|
24
|
+
|
|
25
|
+
version: int = 0
|
|
26
|
+
item_count: int = 0
|
|
27
|
+
synced: bool = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CollectionTree(BaseModel):
|
|
31
|
+
"""Collection as a node in a parent/child tree."""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(frozen=True)
|
|
34
|
+
|
|
35
|
+
key: str = Field(pattern=ID_PATTERN)
|
|
36
|
+
name: str
|
|
37
|
+
parent_key: str | None = Field(default=None, pattern=ID_PATTERN)
|
|
38
|
+
item_count: int = 0
|
|
39
|
+
children: list[CollectionTree] = Field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
def walk(self) -> Iterator[CollectionTree]:
|
|
42
|
+
"""Pre-order traversal (self before descendants)."""
|
|
43
|
+
yield self
|
|
44
|
+
for child in self.children:
|
|
45
|
+
yield from child.walk()
|
zop/models/common.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Common model types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
# Zotero item keys / collection keys are 8-char alphanumeric
|
|
8
|
+
ID_PATTERN = r"^[A-Z0-9]{8}$"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ItemType(StrEnum):
|
|
12
|
+
"""Subset of Zotero item types (extend as needed)."""
|
|
13
|
+
|
|
14
|
+
BOOK = "book"
|
|
15
|
+
BOOK_SECTION = "bookSection"
|
|
16
|
+
JOURNAL_ARTICLE = "journalArticle"
|
|
17
|
+
CONFERENCE_PAPER = "conferencePaper"
|
|
18
|
+
PREPRINT = "preprint"
|
|
19
|
+
REPORT = "report"
|
|
20
|
+
DOCUMENT = "document"
|
|
21
|
+
DATASET = "dataset"
|
|
22
|
+
WEBPAGE = "webpage"
|
|
23
|
+
COMPUTER_PROGRAM = "computerProgram"
|
|
24
|
+
THESIS = "thesis"
|
|
25
|
+
MANUSCRIPT = "manuscript"
|
|
26
|
+
UNKNOWN = "unknown"
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def _missing_(cls, value: object) -> ItemType:
|
|
30
|
+
return cls.UNKNOWN
|
zop/models/envelope.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""JSON envelope for all CLI output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
from zop import __version__
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Meta(BaseModel):
|
|
14
|
+
"""Response metadata."""
|
|
15
|
+
|
|
16
|
+
model_config = ConfigDict(frozen=True)
|
|
17
|
+
|
|
18
|
+
cli_version: str = __version__
|
|
19
|
+
request_id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
20
|
+
latency_ms: int = 0
|
|
21
|
+
count: int | None = None
|
|
22
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ErrorBlock(BaseModel):
|
|
26
|
+
"""Error information block."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(frozen=True)
|
|
29
|
+
|
|
30
|
+
code: str
|
|
31
|
+
message: str
|
|
32
|
+
hint: str | None = None
|
|
33
|
+
retryable: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Envelope(BaseModel):
|
|
37
|
+
"""Top-level response envelope.
|
|
38
|
+
|
|
39
|
+
All CLI output (success or error) is wrapped in this structure so that
|
|
40
|
+
agents can rely on a stable shape.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(frozen=True)
|
|
44
|
+
|
|
45
|
+
ok: bool
|
|
46
|
+
data: Any = None
|
|
47
|
+
error: ErrorBlock | None = None
|
|
48
|
+
meta: Meta = Field(default_factory=Meta)
|
|
49
|
+
|
|
50
|
+
def to_json(self) -> str:
|
|
51
|
+
"""Serialize to JSON string (compact, deterministic)."""
|
|
52
|
+
return self.model_dump_json(exclude_none=True)
|
|
53
|
+
|
|
54
|
+
def to_human(self) -> str:
|
|
55
|
+
"""Serialize to human-readable text (best-effort)."""
|
|
56
|
+
if not self.ok and self.error:
|
|
57
|
+
return f"Error [{self.error.code}]: {self.error.message}"
|
|
58
|
+
return self.model_dump_json(indent=2, exclude_none=True)
|
zop/models/item.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Item models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from zop.models.common import ID_PATTERN, ItemType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ItemSummary(BaseModel):
|
|
11
|
+
"""Minimal item info (used for list views)."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(frozen=True)
|
|
14
|
+
|
|
15
|
+
key: str = Field(pattern=ID_PATTERN)
|
|
16
|
+
item_type: ItemType
|
|
17
|
+
title: str
|
|
18
|
+
creators: list[str] = Field(default_factory=list) # "Last, First" strings
|
|
19
|
+
year: int | None = None
|
|
20
|
+
date: str | None = None # raw Zotero date string
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Item(ItemSummary):
|
|
24
|
+
"""Full item metadata."""
|
|
25
|
+
|
|
26
|
+
abstract: str | None = None
|
|
27
|
+
doi: str | None = None
|
|
28
|
+
url: str | None = None
|
|
29
|
+
tags: list[str] = Field(default_factory=list)
|
|
30
|
+
collections: list[str] = Field(default_factory=list) # keys
|
|
31
|
+
version: int = 0
|
|
32
|
+
date_added: str | None = None
|
|
33
|
+
date_modified: str | None = None
|
|
34
|
+
extra: dict[str, str] = Field(default_factory=dict)
|
zop/services/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Service layer: business orchestration."""
|
|
2
|
+
|
|
3
|
+
from zop.services.collections import CollectionsService
|
|
4
|
+
from zop.services.export import ExportService
|
|
5
|
+
from zop.services.items import ItemsService
|
|
6
|
+
from zop.services.library import LibraryService
|
|
7
|
+
from zop.services.notes import NotesService
|
|
8
|
+
from zop.services.pdf import PdfService
|
|
9
|
+
from zop.services.tags import TagsService
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CollectionsService",
|
|
13
|
+
"ExportService",
|
|
14
|
+
"ItemsService",
|
|
15
|
+
"LibraryService",
|
|
16
|
+
"NotesService",
|
|
17
|
+
"PdfService",
|
|
18
|
+
"TagsService",
|
|
19
|
+
]
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Collection service: business logic for collection operations.
|
|
2
|
+
|
|
3
|
+
This layer:
|
|
4
|
+
- Validates inputs
|
|
5
|
+
- Resolves collection names <-> keys
|
|
6
|
+
- Coordinates SQLite reads and Web API writes
|
|
7
|
+
- Aggregates per-item failures in batch ops
|
|
8
|
+
- Implements a real dry-run that checks existence + would-create conflicts
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from zop.adapters.sqlite_reader import SqliteReader
|
|
19
|
+
from zop.adapters.zotero_api import ApiCreds, ZoteroApi
|
|
20
|
+
from zop.core.errors import (
|
|
21
|
+
AuthError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
ValidationError,
|
|
24
|
+
ZopError,
|
|
25
|
+
)
|
|
26
|
+
from zop.models.collection import Collection, CollectionTree
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PlanNode:
|
|
31
|
+
"""One entry in a reorg plan.
|
|
32
|
+
|
|
33
|
+
`parent` is a NAME (resolved against current library state during validate).
|
|
34
|
+
`items` are item keys to assign to the new collection.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
parent: str | None = None
|
|
39
|
+
items: list[str] = field(default_factory=list)
|
|
40
|
+
parent_key: str | None = None # set during validate()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class PlanReport:
|
|
45
|
+
"""Result of validating a plan against current library state."""
|
|
46
|
+
|
|
47
|
+
to_create: list[PlanNode] = field(default_factory=list)
|
|
48
|
+
item_assignments: list[tuple[str, str]] = field(default_factory=list) # (item_key, coll_name)
|
|
49
|
+
conflicts: list[str] = field(default_factory=list)
|
|
50
|
+
unresolved_parents: list[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def ok(self) -> bool:
|
|
54
|
+
return not self.conflicts and not self.unresolved_parents
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CollectionsService:
|
|
58
|
+
"""High-level collection operations."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
db_path: Path | str | None = None,
|
|
63
|
+
*,
|
|
64
|
+
creds: ApiCreds | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
if db_path is None:
|
|
67
|
+
raise ValidationError(
|
|
68
|
+
"db_path required (set data_dir in config or pass explicitly)"
|
|
69
|
+
)
|
|
70
|
+
self._db_path = Path(db_path)
|
|
71
|
+
self._creds = creds
|
|
72
|
+
self._reader = SqliteReader(self._db_path)
|
|
73
|
+
|
|
74
|
+
# ---- Read ----
|
|
75
|
+
|
|
76
|
+
def list_all(self) -> list[Collection]:
|
|
77
|
+
return self._reader.list_collections()
|
|
78
|
+
|
|
79
|
+
def list_tree(self) -> list[CollectionTree]:
|
|
80
|
+
return self._reader.build_tree()
|
|
81
|
+
|
|
82
|
+
def get(self, key: str) -> Collection:
|
|
83
|
+
return self._reader.get_collection(key)
|
|
84
|
+
|
|
85
|
+
def items(self, key: str) -> list[str]:
|
|
86
|
+
return [it.key for it in self._reader.list_collection_items(key)]
|
|
87
|
+
|
|
88
|
+
def resolve(self, name: str) -> Collection:
|
|
89
|
+
for c in self.list_all():
|
|
90
|
+
if c.name == name:
|
|
91
|
+
return c
|
|
92
|
+
raise NotFoundError(f"No collection named '{name}'")
|
|
93
|
+
|
|
94
|
+
# ---- Plan validation (real dry-run) ----
|
|
95
|
+
|
|
96
|
+
def validate_plan(self, plan: list[PlanNode]) -> PlanReport:
|
|
97
|
+
"""Check a plan against current state.
|
|
98
|
+
|
|
99
|
+
Validates:
|
|
100
|
+
- No duplicate names (vs current library)
|
|
101
|
+
- All parent references resolve to existing collections OR to other
|
|
102
|
+
nodes in the plan (which will be created first; creation is
|
|
103
|
+
topologically ordered so parents are created before children)
|
|
104
|
+
- All item keys exist locally (best-effort; the API will catch missed ones)
|
|
105
|
+
"""
|
|
106
|
+
existing = {c.name: c for c in self.list_all()}
|
|
107
|
+
report = PlanReport()
|
|
108
|
+
plan_by_name: dict[str, PlanNode] = {n.name: n for n in plan}
|
|
109
|
+
|
|
110
|
+
# First pass: name uniqueness + item existence
|
|
111
|
+
for node in plan:
|
|
112
|
+
if not node.name.strip():
|
|
113
|
+
report.conflicts.append("Empty collection name in plan")
|
|
114
|
+
continue
|
|
115
|
+
if node.name in existing:
|
|
116
|
+
report.conflicts.append(
|
|
117
|
+
f"Collection '{node.name}' already exists (key={existing[node.name].key})"
|
|
118
|
+
)
|
|
119
|
+
continue
|
|
120
|
+
# Defer to second pass for parent resolution
|
|
121
|
+
|
|
122
|
+
# Second pass: parent resolution + item checks (only on nodes that
|
|
123
|
+
# passed the uniqueness check)
|
|
124
|
+
for node in plan:
|
|
125
|
+
if any(node.name in c for c in report.conflicts if "already exists" in c):
|
|
126
|
+
continue # skip nodes that failed uniqueness
|
|
127
|
+
if not node.name.strip():
|
|
128
|
+
continue
|
|
129
|
+
if node.parent:
|
|
130
|
+
if node.parent in existing:
|
|
131
|
+
node.parent_key = existing[node.parent].key
|
|
132
|
+
elif node.parent in plan_by_name:
|
|
133
|
+
# Parent will be created in the same plan; mark deferred.
|
|
134
|
+
node.parent_key = "__PLAN_PARENT__" # resolved at exec time
|
|
135
|
+
else:
|
|
136
|
+
if node.parent not in report.unresolved_parents:
|
|
137
|
+
report.unresolved_parents.append(node.parent)
|
|
138
|
+
continue
|
|
139
|
+
report.to_create.append(node)
|
|
140
|
+
|
|
141
|
+
for item_key in node.items:
|
|
142
|
+
if not self._item_exists_locally(item_key):
|
|
143
|
+
report.conflicts.append(
|
|
144
|
+
f"Item '{item_key}' (for collection '{node.name}') not found locally"
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
report.item_assignments.append((item_key, node.name))
|
|
148
|
+
|
|
149
|
+
return report
|
|
150
|
+
|
|
151
|
+
def _item_exists_locally(self, key: str) -> bool:
|
|
152
|
+
try:
|
|
153
|
+
with self._reader._connect() as con:
|
|
154
|
+
row = con.execute(
|
|
155
|
+
"SELECT 1 FROM items WHERE key = ? LIMIT 1", (key,)
|
|
156
|
+
).fetchone()
|
|
157
|
+
return row is not None
|
|
158
|
+
except Exception:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
# ---- Write (requires API credentials) ----
|
|
162
|
+
|
|
163
|
+
def _require_api(self) -> ZoteroApi:
|
|
164
|
+
if not self._creds or not self._creds.api_key:
|
|
165
|
+
raise AuthError("API credentials required for write operations")
|
|
166
|
+
return ZoteroApi(self._creds)
|
|
167
|
+
|
|
168
|
+
async def create(
|
|
169
|
+
self,
|
|
170
|
+
name: str,
|
|
171
|
+
*,
|
|
172
|
+
parent: str | None = None,
|
|
173
|
+
) -> Collection:
|
|
174
|
+
"""Create one collection. `parent` is the parent NAME."""
|
|
175
|
+
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
|
+
async with api:
|
|
181
|
+
result = await api.create_collections([payload])
|
|
182
|
+
if not result:
|
|
183
|
+
raise ZopError(f"Failed to create '{name}' (empty response)")
|
|
184
|
+
r = result[0]
|
|
185
|
+
return Collection(
|
|
186
|
+
key=r["key"],
|
|
187
|
+
name=r["data"]["name"],
|
|
188
|
+
parent_key=r["data"].get("parentCollection") or None,
|
|
189
|
+
version=r["version"],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
async def create_many(self, plan: list[PlanNode]) -> list[Collection]:
|
|
193
|
+
"""Create all collections in a validated plan (batched POST, topologically ordered).
|
|
194
|
+
|
|
195
|
+
Handles intra-plan parent references: a node whose parent is another
|
|
196
|
+
node in the plan is created after its parent. We process in waves
|
|
197
|
+
(Kahn-style topological order), POSTing each wave as a batch.
|
|
198
|
+
"""
|
|
199
|
+
report = self.validate_plan(plan)
|
|
200
|
+
if not report.ok:
|
|
201
|
+
raise ValidationError(
|
|
202
|
+
f"Plan invalid. conflicts={report.conflicts} unresolved={report.unresolved_parents}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
plan_by_name = {n.name: n for n in report.to_create}
|
|
206
|
+
# Build dependency: who depends on whom
|
|
207
|
+
# remaining[node] = set of node-names this node still waits on
|
|
208
|
+
remaining: dict[str, set[str]] = {}
|
|
209
|
+
for n in report.to_create:
|
|
210
|
+
if n.parent_key == "__PLAN_PARENT__" and n.parent in plan_by_name:
|
|
211
|
+
remaining[n.name] = {n.parent}
|
|
212
|
+
else:
|
|
213
|
+
remaining[n.name] = set()
|
|
214
|
+
|
|
215
|
+
api = self._require_api()
|
|
216
|
+
created: dict[str, Collection] = {} # name -> Collection
|
|
217
|
+
async with api:
|
|
218
|
+
while remaining:
|
|
219
|
+
# Find nodes with no remaining dependencies
|
|
220
|
+
ready = [n for n in report.to_create if n.name in remaining and not remaining[n.name]]
|
|
221
|
+
if not ready:
|
|
222
|
+
raise ValidationError("Plan has a cycle in parent references")
|
|
223
|
+
payloads: list[dict[str, object]] = []
|
|
224
|
+
for n in ready:
|
|
225
|
+
parent_key: str | None = None
|
|
226
|
+
if n.parent:
|
|
227
|
+
if n.parent in created:
|
|
228
|
+
parent_key = created[n.parent].key
|
|
229
|
+
elif n.parent in plan_by_name and n.parent_key == "__PLAN_PARENT__":
|
|
230
|
+
# Should have been picked up already by topo sort
|
|
231
|
+
raise ValidationError(
|
|
232
|
+
f"Parent '{n.parent}' not yet created for '{n.name}'"
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
# Use the resolved key from validate_plan (existing parent)
|
|
236
|
+
parent_key = n.parent_key
|
|
237
|
+
payloads.append(
|
|
238
|
+
{"name": n.name, "parentCollection": parent_key}
|
|
239
|
+
)
|
|
240
|
+
results = await api.create_collections(payloads)
|
|
241
|
+
if len(results) != len(ready):
|
|
242
|
+
raise ZopError(
|
|
243
|
+
f"API returned {len(results)} collections, expected {len(ready)}"
|
|
244
|
+
)
|
|
245
|
+
for n, r in zip(ready, results, strict=True):
|
|
246
|
+
c = Collection(
|
|
247
|
+
key=r["key"],
|
|
248
|
+
name=r["data"]["name"],
|
|
249
|
+
parent_key=r["data"].get("parentCollection") or None,
|
|
250
|
+
version=r["version"],
|
|
251
|
+
)
|
|
252
|
+
created[n.name] = c
|
|
253
|
+
# Remove processed nodes from remaining, decrement dependents
|
|
254
|
+
for n in ready:
|
|
255
|
+
del remaining[n.name]
|
|
256
|
+
for deps in remaining.values():
|
|
257
|
+
deps.difference_update({n.name for n in ready})
|
|
258
|
+
|
|
259
|
+
return list(created.values())
|
|
260
|
+
|
|
261
|
+
async def delete(self, key: str) -> None:
|
|
262
|
+
api = self._require_api()
|
|
263
|
+
async with api:
|
|
264
|
+
await api.delete_collection(key)
|
|
265
|
+
|
|
266
|
+
async def reparent(
|
|
267
|
+
self, key: str, new_parent: str | None, *, version: int | None = None
|
|
268
|
+
) -> Collection:
|
|
269
|
+
"""Move collection under new parent (NAME), or detach if new_parent is None."""
|
|
270
|
+
|
|
271
|
+
api = self._require_api()
|
|
272
|
+
if new_parent is None:
|
|
273
|
+
parent_key: str | None | object = False # detach
|
|
274
|
+
else:
|
|
275
|
+
parent_key = self.resolve(new_parent).key
|
|
276
|
+
async with api:
|
|
277
|
+
r = await api.update_collection(key, parent_key=parent_key, version=version)
|
|
278
|
+
return Collection(
|
|
279
|
+
key=r["key"],
|
|
280
|
+
name=r["data"]["name"],
|
|
281
|
+
parent_key=r["data"].get("parentCollection") or None,
|
|
282
|
+
version=r["version"],
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
async def move_items(
|
|
286
|
+
self,
|
|
287
|
+
item_keys: Sequence[str],
|
|
288
|
+
to_collection_key: str,
|
|
289
|
+
) -> tuple[list[str], list[tuple[str, Exception]]]:
|
|
290
|
+
"""Add items to a collection (preserves existing memberships).
|
|
291
|
+
|
|
292
|
+
Fetches each item's current collections, adds the target, then
|
|
293
|
+
bounded-concurrent PATCH. Per-item failures are isolated.
|
|
294
|
+
"""
|
|
295
|
+
api = self._require_api()
|
|
296
|
+
async with api:
|
|
297
|
+
# Fetch current state + version per item.
|
|
298
|
+
async def _fetch(k: str) -> tuple[str, list[str], int] | tuple[str, Exception]:
|
|
299
|
+
try:
|
|
300
|
+
item = await api.get_item(k)
|
|
301
|
+
colls = list(item["data"].get("collections", []))
|
|
302
|
+
if to_collection_key not in colls:
|
|
303
|
+
colls = [*colls, to_collection_key]
|
|
304
|
+
return (k, colls, item["version"])
|
|
305
|
+
except Exception as e:
|
|
306
|
+
return (k, e)
|
|
307
|
+
|
|
308
|
+
fetched = await asyncio.gather(
|
|
309
|
+
*[_fetch(k) for k in item_keys], return_exceptions=False
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
updates: list[tuple[str, list[str], int | None]] = []
|
|
313
|
+
fetch_failures: list[tuple[str, Exception]] = []
|
|
314
|
+
for r in fetched:
|
|
315
|
+
if len(r) == 3 and isinstance(r[1], list):
|
|
316
|
+
_, colls, ver = r
|
|
317
|
+
updates.append((r[0], colls, ver))
|
|
318
|
+
else:
|
|
319
|
+
fetch_failures.append((r[0], r[1]))
|
|
320
|
+
|
|
321
|
+
ok, move_failures = await api.batch_update_item_collections(updates)
|
|
322
|
+
|
|
323
|
+
return ok, fetch_failures + move_failures
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
__all__ = ["CollectionsService", "PlanNode", "PlanReport"]
|