bookstack-cli 0.1.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.
- bookstack_cli/__init__.py +3 -0
- bookstack_cli/client.py +211 -0
- bookstack_cli/config.py +131 -0
- bookstack_cli/exceptions.py +45 -0
- bookstack_cli/main.py +840 -0
- bookstack_cli/models.py +276 -0
- bookstack_cli/resources/__init__.py +1 -0
- bookstack_cli/resources/attachments.py +90 -0
- bookstack_cli/resources/books.py +64 -0
- bookstack_cli/resources/chapters.py +46 -0
- bookstack_cli/resources/pages.py +365 -0
- bookstack_cli/resources/revisions.py +30 -0
- bookstack_cli/resources/roles.py +33 -0
- bookstack_cli/resources/search.py +21 -0
- bookstack_cli/resources/shelves.py +55 -0
- bookstack_cli/resources/tags.py +15 -0
- bookstack_cli/resources/users.py +39 -0
- bookstack_cli-0.1.0.dist-info/METADATA +227 -0
- bookstack_cli-0.1.0.dist-info/RECORD +22 -0
- bookstack_cli-0.1.0.dist-info/WHEEL +5 -0
- bookstack_cli-0.1.0.dist-info/entry_points.txt +2 -0
- bookstack_cli-0.1.0.dist-info/top_level.txt +1 -0
bookstack_cli/models.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Pydantic models for BookStack API entities."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, BeforeValidator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ------------------------------------------------------------------
|
|
10
|
+
# Shared / Nested
|
|
11
|
+
# ------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Tag(BaseModel):
|
|
15
|
+
"""A key-value tag attached to content entities."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
value: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UserSummary(BaseModel):
|
|
22
|
+
"""Minimal user reference (created_by / updated_by)."""
|
|
23
|
+
|
|
24
|
+
id: int
|
|
25
|
+
name: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _coerce_user_ref(v: Any) -> Any:
|
|
29
|
+
"""BookStack sometimes returns user ID int instead of {id, name} object."""
|
|
30
|
+
if isinstance(v, int):
|
|
31
|
+
return UserSummary(id=v, name="")
|
|
32
|
+
return v
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
UserRef = Annotated[UserSummary | None, BeforeValidator(_coerce_user_ref)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
# Paginated List Response
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PaginatedList(BaseModel):
|
|
44
|
+
"""Wrapper for paginated list responses from the API."""
|
|
45
|
+
|
|
46
|
+
data: list[dict[str, Any]]
|
|
47
|
+
total: int
|
|
48
|
+
count: int
|
|
49
|
+
per_page: int = Field(alias="per_page")
|
|
50
|
+
current_page: int = Field(alias="current_page")
|
|
51
|
+
last_page: int = Field(alias="last_page")
|
|
52
|
+
next_page_url: str | None = Field(default=None, alias="next_page_url")
|
|
53
|
+
prev_page_url: str | None = Field(default=None, alias="prev_page_url")
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Shelves (Bookshelves)
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Shelf(BaseModel):
|
|
64
|
+
"""A bookshelf containing books."""
|
|
65
|
+
|
|
66
|
+
id: int
|
|
67
|
+
name: str
|
|
68
|
+
description: str = ""
|
|
69
|
+
tags: list[Tag] = []
|
|
70
|
+
created_at: datetime | None = None
|
|
71
|
+
updated_at: datetime | None = None
|
|
72
|
+
created_by: UserRef = None
|
|
73
|
+
updated_by: UserRef = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ShelfCreate(BaseModel):
|
|
77
|
+
"""Payload for creating/updating a shelf."""
|
|
78
|
+
|
|
79
|
+
name: str
|
|
80
|
+
description: str = ""
|
|
81
|
+
books: list[int] = []
|
|
82
|
+
tags: list[Tag] = []
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# Books
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Book(BaseModel):
|
|
91
|
+
"""A book containing chapters and pages."""
|
|
92
|
+
|
|
93
|
+
id: int
|
|
94
|
+
name: str
|
|
95
|
+
description: str = ""
|
|
96
|
+
tags: list[Tag] = []
|
|
97
|
+
created_at: datetime | None = None
|
|
98
|
+
updated_at: datetime | None = None
|
|
99
|
+
created_by: UserRef = None
|
|
100
|
+
updated_by: UserRef = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class BookCreate(BaseModel):
|
|
104
|
+
"""Payload for creating/updating a book."""
|
|
105
|
+
|
|
106
|
+
name: str
|
|
107
|
+
description: str = ""
|
|
108
|
+
tags: list[Tag] = []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Chapters
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Chapter(BaseModel):
|
|
117
|
+
"""A chapter within a book."""
|
|
118
|
+
|
|
119
|
+
id: int
|
|
120
|
+
book_id: int
|
|
121
|
+
name: str
|
|
122
|
+
description: str = ""
|
|
123
|
+
tags: list[Tag] = []
|
|
124
|
+
created_at: datetime | None = None
|
|
125
|
+
updated_at: datetime | None = None
|
|
126
|
+
created_by: UserRef = None
|
|
127
|
+
updated_by: UserRef = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ChapterCreate(BaseModel):
|
|
131
|
+
"""Payload for creating/updating a chapter."""
|
|
132
|
+
|
|
133
|
+
book_id: int
|
|
134
|
+
name: str
|
|
135
|
+
description: str = ""
|
|
136
|
+
tags: list[Tag] = []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# Pages
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Page(BaseModel):
|
|
145
|
+
"""A wiki page."""
|
|
146
|
+
|
|
147
|
+
id: int
|
|
148
|
+
book_id: int
|
|
149
|
+
chapter_id: int | None = None
|
|
150
|
+
name: str
|
|
151
|
+
slug: str = ""
|
|
152
|
+
html: str = ""
|
|
153
|
+
markdown: str = ""
|
|
154
|
+
draft: bool = False
|
|
155
|
+
tags: list[Tag] = []
|
|
156
|
+
priority: int = 0
|
|
157
|
+
created_at: datetime | None = None
|
|
158
|
+
updated_at: datetime | None = None
|
|
159
|
+
created_by: UserRef = None
|
|
160
|
+
updated_by: UserRef = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class PageCreate(BaseModel):
|
|
164
|
+
"""Payload for creating a page."""
|
|
165
|
+
|
|
166
|
+
book_id: int
|
|
167
|
+
chapter_id: int | None = None
|
|
168
|
+
name: str
|
|
169
|
+
html: str = ""
|
|
170
|
+
markdown: str = ""
|
|
171
|
+
tags: list[Tag] = []
|
|
172
|
+
draft: bool = False
|
|
173
|
+
priority: int = 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class PageUpdate(BaseModel):
|
|
177
|
+
"""Payload for updating a page."""
|
|
178
|
+
|
|
179
|
+
name: str | None = None
|
|
180
|
+
html: str | None = None
|
|
181
|
+
markdown: str | None = None
|
|
182
|
+
tags: list[Tag] | None = None
|
|
183
|
+
draft: bool | None = None
|
|
184
|
+
priority: int | None = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class PageMove(BaseModel):
|
|
188
|
+
"""Payload for moving a page."""
|
|
189
|
+
|
|
190
|
+
book_id: int
|
|
191
|
+
chapter_id: int | None = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
# Attachments
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Attachment(BaseModel):
|
|
200
|
+
"""A file or link attachment on a page."""
|
|
201
|
+
|
|
202
|
+
id: int
|
|
203
|
+
name: str
|
|
204
|
+
page_id: int = Field(alias="uploaded_to", default=0)
|
|
205
|
+
link: str | None = None
|
|
206
|
+
file_path: str | None = None
|
|
207
|
+
created_at: datetime | None = None
|
|
208
|
+
updated_at: datetime | None = None
|
|
209
|
+
created_by: UserRef = None
|
|
210
|
+
updated_by: UserRef = None
|
|
211
|
+
|
|
212
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class AttachmentCreate(BaseModel):
|
|
216
|
+
"""Payload for creating a link or file attachment."""
|
|
217
|
+
|
|
218
|
+
name: str
|
|
219
|
+
page_id: int
|
|
220
|
+
link: str | None = None
|
|
221
|
+
# For file uploads, use multipart with ``uploaded_file``
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
# Users
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class User(BaseModel):
|
|
230
|
+
"""A BookStack user."""
|
|
231
|
+
|
|
232
|
+
id: int
|
|
233
|
+
name: str
|
|
234
|
+
email: str = ""
|
|
235
|
+
language: str = ""
|
|
236
|
+
created_at: datetime | None = None
|
|
237
|
+
updated_at: datetime | None = None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
# Roles
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class Role(BaseModel):
|
|
246
|
+
"""A user role."""
|
|
247
|
+
|
|
248
|
+
id: int
|
|
249
|
+
name: str
|
|
250
|
+
description: str = ""
|
|
251
|
+
created_at: datetime | None = None
|
|
252
|
+
updated_at: datetime | None = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ------------------------------------------------------------------
|
|
256
|
+
# Search
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class SearchResult(BaseModel):
|
|
261
|
+
"""A single search result."""
|
|
262
|
+
|
|
263
|
+
id: int
|
|
264
|
+
name: str
|
|
265
|
+
type: str # page, book, chapter, shelf
|
|
266
|
+
url: str = ""
|
|
267
|
+
preview_html: dict[str, str] | str = ""
|
|
268
|
+
tags: list[Tag] = []
|
|
269
|
+
score: float = 0.0
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def preview_text(self) -> str:
|
|
273
|
+
"""Get preview text regardless of whether API returns string or object."""
|
|
274
|
+
if isinstance(self.preview_html, dict):
|
|
275
|
+
return self.preview_html.get("content", "")
|
|
276
|
+
return self.preview_html
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Resource API modules for BookStack entities."""
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Attachments resource CRUD."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from bookstack_cli.client import BookStackClient
|
|
7
|
+
from bookstack_cli.models import Attachment, AttachmentCreate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def list_attachments(
|
|
11
|
+
client: BookStackClient,
|
|
12
|
+
page_size: int = 100,
|
|
13
|
+
page_id: int | None = None,
|
|
14
|
+
) -> AsyncIterator[Attachment]:
|
|
15
|
+
"""Iterate all attachments, optionally filtered by page."""
|
|
16
|
+
params: dict[str, Any] = {}
|
|
17
|
+
if page_id is not None:
|
|
18
|
+
params["page_id"] = page_id
|
|
19
|
+
|
|
20
|
+
async for item in client.paginate("attachments", params=params, page_size=page_size):
|
|
21
|
+
yield Attachment(**item)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def get_attachment(client: BookStackClient, attachment_id: int) -> Attachment:
|
|
25
|
+
"""Get a single attachment by ID."""
|
|
26
|
+
data = await client.get(f"attachments/{attachment_id}")
|
|
27
|
+
return Attachment(**data)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def create_attachment_link(
|
|
31
|
+
client: BookStackClient, payload: AttachmentCreate
|
|
32
|
+
) -> Attachment:
|
|
33
|
+
"""Create a link-type attachment."""
|
|
34
|
+
data = await client.post("attachments", json=payload.model_dump(exclude_unset=True))
|
|
35
|
+
return Attachment(**data)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def upload_attachment(
|
|
39
|
+
client: BookStackClient,
|
|
40
|
+
name: str,
|
|
41
|
+
page_id: int,
|
|
42
|
+
file_content: bytes,
|
|
43
|
+
filename: str,
|
|
44
|
+
) -> Attachment:
|
|
45
|
+
"""Upload a file attachment using multipart."""
|
|
46
|
+
data = {
|
|
47
|
+
"name": name,
|
|
48
|
+
"uploaded_to": str(page_id),
|
|
49
|
+
}
|
|
50
|
+
files = {
|
|
51
|
+
"file": (filename, file_content),
|
|
52
|
+
}
|
|
53
|
+
response = await client._request("POST", "attachments", data=data, files=files)
|
|
54
|
+
return Attachment(**response.json())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def update_attachment(
|
|
58
|
+
client: BookStackClient, attachment_id: int, payload: AttachmentCreate
|
|
59
|
+
) -> Attachment:
|
|
60
|
+
"""Update an attachment."""
|
|
61
|
+
data = await client.put(
|
|
62
|
+
f"attachments/{attachment_id}", json=payload.model_dump(exclude_unset=True)
|
|
63
|
+
)
|
|
64
|
+
return Attachment(**data)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def delete_attachment(client: BookStackClient, attachment_id: int) -> None:
|
|
68
|
+
"""Delete an attachment."""
|
|
69
|
+
await client.delete(f"attachments/{attachment_id}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def download_attachment(
|
|
73
|
+
client: BookStackClient,
|
|
74
|
+
attachment_id: int,
|
|
75
|
+
) -> tuple[str, bytes]:
|
|
76
|
+
"""Download an attachment's file content.
|
|
77
|
+
|
|
78
|
+
Returns (filename, file_bytes).
|
|
79
|
+
The API returns file content as base64-encoded string in the ``content`` field.
|
|
80
|
+
"""
|
|
81
|
+
import base64
|
|
82
|
+
|
|
83
|
+
data = await client.get(f"attachments/{attachment_id}")
|
|
84
|
+
name: str = data.get("name", f"attachment-{attachment_id}")
|
|
85
|
+
raw_content: str = data.get("content", "")
|
|
86
|
+
ext: str = data.get("extension", "")
|
|
87
|
+
if ext and not name.endswith(f".{ext}"):
|
|
88
|
+
name = f"{name}.{ext}"
|
|
89
|
+
file_bytes = base64.b64decode(raw_content)
|
|
90
|
+
return (name, file_bytes)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Books resource CRUD."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from bookstack_cli.client import BookStackClient
|
|
7
|
+
from bookstack_cli.models import Book, BookCreate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def list_books(
|
|
11
|
+
client: BookStackClient,
|
|
12
|
+
page_size: int = 100,
|
|
13
|
+
sort: str | None = None,
|
|
14
|
+
order: str | None = None,
|
|
15
|
+
) -> AsyncIterator[Book]:
|
|
16
|
+
"""Iterate all books."""
|
|
17
|
+
params: dict[str, Any] = {}
|
|
18
|
+
if sort:
|
|
19
|
+
params["sort"] = sort
|
|
20
|
+
if order:
|
|
21
|
+
params["order"] = order
|
|
22
|
+
|
|
23
|
+
async for item in client.paginate("books", params=params, page_size=page_size):
|
|
24
|
+
yield Book(**item)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def get_book(client: BookStackClient, book_id: int) -> Book:
|
|
28
|
+
"""Get a single book by ID."""
|
|
29
|
+
data = await client.get(f"books/{book_id}")
|
|
30
|
+
return Book(**data)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def create_book(client: BookStackClient, payload: BookCreate) -> Book:
|
|
34
|
+
"""Create a new book."""
|
|
35
|
+
data = await client.post("books", json=payload.model_dump(exclude_unset=True))
|
|
36
|
+
return Book(**data)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def update_book(client: BookStackClient, book_id: int, payload: BookCreate) -> Book:
|
|
40
|
+
"""Update an existing book."""
|
|
41
|
+
data = await client.put(f"books/{book_id}", json=payload.model_dump(exclude_unset=True))
|
|
42
|
+
return Book(**data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def delete_book(client: BookStackClient, book_id: int) -> None:
|
|
46
|
+
"""Delete a book."""
|
|
47
|
+
await client.delete(f"books/{book_id}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def upload_book_cover(
|
|
51
|
+
client: BookStackClient,
|
|
52
|
+
book_id: int,
|
|
53
|
+
file_content: bytes,
|
|
54
|
+
filename: str,
|
|
55
|
+
) -> dict[str, Any]:
|
|
56
|
+
"""Upload a cover image for a book using multipart PUT."""
|
|
57
|
+
old_ct = client._client.headers.pop("Content-Type", None)
|
|
58
|
+
try:
|
|
59
|
+
files = {"image": (filename, file_content)}
|
|
60
|
+
resp = await client._client.put(f"/api/books/{book_id}", files=files)
|
|
61
|
+
return resp.json()
|
|
62
|
+
finally:
|
|
63
|
+
if old_ct is not None:
|
|
64
|
+
client._client.headers["Content-Type"] = old_ct
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Chapters resource CRUD."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from bookstack_cli.client import BookStackClient
|
|
7
|
+
from bookstack_cli.models import Chapter, ChapterCreate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def list_chapters(
|
|
11
|
+
client: BookStackClient,
|
|
12
|
+
page_size: int = 100,
|
|
13
|
+
book_id: int | None = None,
|
|
14
|
+
) -> AsyncIterator[Chapter]:
|
|
15
|
+
"""Iterate all chapters, optionally filtered by book."""
|
|
16
|
+
params: dict[str, Any] = {}
|
|
17
|
+
if book_id is not None:
|
|
18
|
+
params["book_id"] = book_id
|
|
19
|
+
|
|
20
|
+
async for item in client.paginate("chapters", params=params, page_size=page_size):
|
|
21
|
+
yield Chapter(**item)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def get_chapter(client: BookStackClient, chapter_id: int) -> Chapter:
|
|
25
|
+
"""Get a single chapter by ID."""
|
|
26
|
+
data = await client.get(f"chapters/{chapter_id}")
|
|
27
|
+
return Chapter(**data)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def create_chapter(client: BookStackClient, payload: ChapterCreate) -> Chapter:
|
|
31
|
+
"""Create a new chapter."""
|
|
32
|
+
data = await client.post("chapters", json=payload.model_dump(exclude_unset=True))
|
|
33
|
+
return Chapter(**data)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def update_chapter(
|
|
37
|
+
client: BookStackClient, chapter_id: int, payload: ChapterCreate
|
|
38
|
+
) -> Chapter:
|
|
39
|
+
"""Update an existing chapter."""
|
|
40
|
+
data = await client.put(f"chapters/{chapter_id}", json=payload.model_dump(exclude_unset=True))
|
|
41
|
+
return Chapter(**data)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def delete_chapter(client: BookStackClient, chapter_id: int) -> None:
|
|
45
|
+
"""Delete a chapter."""
|
|
46
|
+
await client.delete(f"chapters/{chapter_id}")
|