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.
@@ -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}")