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
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Pages 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 Page, PageCreate, PageMove, PageUpdate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def list_pages(
|
|
11
|
+
client: BookStackClient,
|
|
12
|
+
page_size: int = 100,
|
|
13
|
+
book_id: int | None = None,
|
|
14
|
+
chapter_id: int | None = None,
|
|
15
|
+
include_drafts: bool = False,
|
|
16
|
+
) -> AsyncIterator[Page]:
|
|
17
|
+
"""Iterate all pages, optionally filtered client-side."""
|
|
18
|
+
params: dict[str, Any] = {}
|
|
19
|
+
if include_drafts:
|
|
20
|
+
params["draft"] = "true"
|
|
21
|
+
|
|
22
|
+
async for item in client.paginate("pages", params=params, page_size=page_size):
|
|
23
|
+
if book_id is not None and item.get("book_id") != book_id:
|
|
24
|
+
continue
|
|
25
|
+
if chapter_id is not None and item.get("chapter_id") != chapter_id:
|
|
26
|
+
continue
|
|
27
|
+
yield Page(**item)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def get_page(client: BookStackClient, page_id: int) -> Page:
|
|
31
|
+
"""Get a single page by ID."""
|
|
32
|
+
data = await client.get(f"pages/{page_id}")
|
|
33
|
+
return Page(**data)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def create_page(client: BookStackClient, payload: PageCreate) -> Page:
|
|
37
|
+
"""Create a new page."""
|
|
38
|
+
data = await client.post("pages", json=payload.model_dump(exclude_unset=True))
|
|
39
|
+
return Page(**data)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def update_page(client: BookStackClient, page_id: int, payload: PageUpdate) -> Page:
|
|
43
|
+
"""Update an existing page (partial update allowed)."""
|
|
44
|
+
data = await client.put(f"pages/{page_id}", json=payload.model_dump(exclude_unset=True))
|
|
45
|
+
return Page(**data)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def move_page(client: BookStackClient, page_id: int, payload: PageMove) -> Page:
|
|
49
|
+
"""Move a page to a different book or chapter."""
|
|
50
|
+
data = await client.put(f"pages/{page_id}/move", json=payload.model_dump(exclude_unset=True))
|
|
51
|
+
return Page(**data)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def copy_page(
|
|
55
|
+
client: BookStackClient,
|
|
56
|
+
page_id: int,
|
|
57
|
+
book_id: int,
|
|
58
|
+
chapter_id: int | None = None,
|
|
59
|
+
new_name: str | None = None,
|
|
60
|
+
) -> Page:
|
|
61
|
+
"""Copy a page to a different book or chapter.
|
|
62
|
+
|
|
63
|
+
Fetches the source page, then creates a new page with the same content
|
|
64
|
+
in the target location. Appends "(copy)" to the name if not overridden.
|
|
65
|
+
"""
|
|
66
|
+
source = await get_page(client, page_id)
|
|
67
|
+
name = new_name or f"{source.name} (copy)"
|
|
68
|
+
payload = PageCreate(
|
|
69
|
+
book_id=book_id,
|
|
70
|
+
chapter_id=chapter_id,
|
|
71
|
+
name=name,
|
|
72
|
+
markdown=source.markdown or "",
|
|
73
|
+
tags=source.tags,
|
|
74
|
+
)
|
|
75
|
+
return await create_page(client, payload)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def delete_page(client: BookStackClient, page_id: int) -> None:
|
|
79
|
+
"""Delete a page."""
|
|
80
|
+
await client.delete(f"pages/{page_id}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def get_page_html(client: BookStackClient, page_id: int) -> str:
|
|
84
|
+
"""Get the rendered HTML content of a page."""
|
|
85
|
+
page = await get_page(client, page_id)
|
|
86
|
+
return page.html
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def get_page_markdown(client: BookStackClient, page_id: int) -> str:
|
|
90
|
+
"""Get the markdown content of a page."""
|
|
91
|
+
page = await get_page(client, page_id)
|
|
92
|
+
return page.markdown
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def resolve_page_url(client: BookStackClient, url: str, instance_url: str | None = None) -> Page:
|
|
96
|
+
"""Resolve a BookStack web URL to a page.
|
|
97
|
+
|
|
98
|
+
URL format: ``{base_url}/books/{book-slug}/page/{page-slug}``
|
|
99
|
+
|
|
100
|
+
Uses the instance URL from config to validate the input URL belongs to this
|
|
101
|
+
BookStack instance. Slug is extracted from the path after the instance base.
|
|
102
|
+
|
|
103
|
+
Raises ValueError if URL can't be parsed, doesn't match the configured
|
|
104
|
+
instance, or page not found.
|
|
105
|
+
"""
|
|
106
|
+
from urllib.parse import urlparse
|
|
107
|
+
|
|
108
|
+
parsed = urlparse(url)
|
|
109
|
+
path = parsed.path.rstrip("/")
|
|
110
|
+
|
|
111
|
+
# Strip the instance base path if it matches (supports internal IP vs public domain mismatch)
|
|
112
|
+
if instance_url:
|
|
113
|
+
base_path = urlparse(instance_url).path.rstrip("/")
|
|
114
|
+
if base_path and path.startswith(base_path):
|
|
115
|
+
path = path.removeprefix(base_path)
|
|
116
|
+
|
|
117
|
+
parts = path.split("/")
|
|
118
|
+
try:
|
|
119
|
+
page_idx = parts.index("page")
|
|
120
|
+
except ValueError:
|
|
121
|
+
raise ValueError(f"URL does not look like a BookStack page: {url}")
|
|
122
|
+
|
|
123
|
+
page_slug = parts[page_idx + 1] if page_idx + 1 < len(parts) else None
|
|
124
|
+
if not page_slug:
|
|
125
|
+
raise ValueError(f"Could not extract page slug from URL: {url}")
|
|
126
|
+
|
|
127
|
+
# Extract book slug for scoping
|
|
128
|
+
import re
|
|
129
|
+
book_slug: str | None = None
|
|
130
|
+
try:
|
|
131
|
+
book_idx = parts.index("books")
|
|
132
|
+
if book_idx + 1 < len(parts) and parts[book_idx + 1] != "page":
|
|
133
|
+
book_slug = parts[book_idx + 1]
|
|
134
|
+
except ValueError:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
# Search using page slug words
|
|
138
|
+
words = page_slug.replace("-", " ").split()
|
|
139
|
+
search_term = words[-1] if len(words) > 1 else page_slug
|
|
140
|
+
|
|
141
|
+
async for result in client.paginate("search", params={"query": search_term}):
|
|
142
|
+
if result.get("type") != "page":
|
|
143
|
+
continue
|
|
144
|
+
rid = result.get("id")
|
|
145
|
+
if not rid:
|
|
146
|
+
continue
|
|
147
|
+
rname = str(result.get("name", ""))
|
|
148
|
+
candidate_slug = re.sub(r"[^a-z0-9-]", "", rname.lower().replace(" ", "-"))
|
|
149
|
+
if candidate_slug == page_slug:
|
|
150
|
+
page = await get_page(client, rid)
|
|
151
|
+
if book_slug:
|
|
152
|
+
book = await client.get(f"books/{page.book_id}")
|
|
153
|
+
if book.get("slug") == book_slug:
|
|
154
|
+
return page
|
|
155
|
+
else:
|
|
156
|
+
return page
|
|
157
|
+
|
|
158
|
+
raise ValueError(f"Page with slug '{page_slug}' not found (URL: {url})")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _find_local_images(markdown: str, base_dir: str) -> list[tuple[str, str, str]]:
|
|
162
|
+
"""Find local image references in markdown.
|
|
163
|
+
|
|
164
|
+
Returns list of (original_path, absolute_path, alt_text) for each local image.
|
|
165
|
+
"""
|
|
166
|
+
import os
|
|
167
|
+
import re
|
|
168
|
+
|
|
169
|
+
results: list[tuple[str, str, str]] = []
|
|
170
|
+
pattern = re.compile(r"!\[([^]]*)\]\(([^)]+)\)")
|
|
171
|
+
for match in pattern.finditer(markdown):
|
|
172
|
+
alt_text, path = match.group(1), match.group(2)
|
|
173
|
+
if path.startswith(("http://", "https://", "data:", "#")):
|
|
174
|
+
continue
|
|
175
|
+
abs_path = path if os.path.isabs(path) else os.path.normpath(os.path.join(base_dir, path))
|
|
176
|
+
if os.path.isfile(abs_path):
|
|
177
|
+
results.append((path, abs_path, alt_text))
|
|
178
|
+
return results
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def import_markdown_file(
|
|
182
|
+
client: BookStackClient,
|
|
183
|
+
file_path: str,
|
|
184
|
+
page_name: str,
|
|
185
|
+
book_id: int,
|
|
186
|
+
chapter_id: int | None = None,
|
|
187
|
+
page_id: int | None = None,
|
|
188
|
+
instance_url: str | None = None,
|
|
189
|
+
) -> dict[str, Any]:
|
|
190
|
+
"""Import a markdown file into a BookStack page.
|
|
191
|
+
|
|
192
|
+
- Reads markdown from file
|
|
193
|
+
- Uploads local image files as attachments
|
|
194
|
+
- Replaces local image paths with data URIs (BookStack has no public
|
|
195
|
+
raw-image serving endpoint for attachments — gallery API unavailable)
|
|
196
|
+
- Creates or updates the page
|
|
197
|
+
|
|
198
|
+
Returns dict with page info and list of uploaded attachments.
|
|
199
|
+
"""
|
|
200
|
+
import base64
|
|
201
|
+
import os
|
|
202
|
+
import re
|
|
203
|
+
|
|
204
|
+
base_dir = os.path.dirname(os.path.abspath(file_path))
|
|
205
|
+
markdown = open(file_path, encoding="utf-8").read()
|
|
206
|
+
|
|
207
|
+
images = _find_local_images(markdown, base_dir)
|
|
208
|
+
uploaded: list[dict[str, Any]] = []
|
|
209
|
+
|
|
210
|
+
for orig_path, abs_path, alt_text in images:
|
|
211
|
+
filename = os.path.basename(abs_path)
|
|
212
|
+
with open(abs_path, "rb") as f:
|
|
213
|
+
content = f.read()
|
|
214
|
+
uploaded.append({
|
|
215
|
+
"orig_path": orig_path,
|
|
216
|
+
"abs_path": abs_path,
|
|
217
|
+
"filename": filename,
|
|
218
|
+
"alt_text": alt_text,
|
|
219
|
+
"content": content,
|
|
220
|
+
})
|
|
221
|
+
placeholder = f"__ATTACH_{filename}__"
|
|
222
|
+
markdown = markdown.replace(f"", placeholder)
|
|
223
|
+
|
|
224
|
+
from bookstack_cli.resources.attachments import upload_attachment
|
|
225
|
+
from bookstack_cli.models import PageCreate, PageUpdate
|
|
226
|
+
|
|
227
|
+
if page_id:
|
|
228
|
+
page = await update_page(client, page_id, PageUpdate(name=page_name, markdown=markdown))
|
|
229
|
+
else:
|
|
230
|
+
if not book_id or book_id <= 0:
|
|
231
|
+
raise ValueError("book_id is required to create a new page")
|
|
232
|
+
create_kwargs: dict[str, Any] = {"book_id": book_id, "name": page_name, "markdown": markdown}
|
|
233
|
+
if chapter_id is not None:
|
|
234
|
+
create_kwargs["chapter_id"] = chapter_id
|
|
235
|
+
page = await create_page(client, PageCreate(**create_kwargs))
|
|
236
|
+
|
|
237
|
+
for img in uploaded:
|
|
238
|
+
attachment = await upload_attachment(
|
|
239
|
+
client, name=img["filename"], page_id=page.id,
|
|
240
|
+
file_content=img["content"], filename=img["filename"],
|
|
241
|
+
)
|
|
242
|
+
img_url = f"{instance_url.rstrip('/')}/attachments/{attachment.id}" if instance_url \
|
|
243
|
+
else f"/attachments/{attachment.id}"
|
|
244
|
+
|
|
245
|
+
placeholder = f"__ATTACH_{img['filename']}__"
|
|
246
|
+
current = await client.get(f"pages/{page.id}")
|
|
247
|
+
new_md = str(current.get("markdown", "")).replace(placeholder, f"![{img['alt_text']}]({img_url})")
|
|
248
|
+
if new_md != current.get("markdown"):
|
|
249
|
+
await client.put(f"pages/{page.id}", json={"markdown": new_md})
|
|
250
|
+
page = await get_page(client, page.id)
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"page": page.model_dump(mode="json"),
|
|
254
|
+
"attachments_uploaded": len(uploaded),
|
|
255
|
+
"note": "Local images uploaded as attachments and referenced via attachment URL. "
|
|
256
|
+
f"BookStack serves /attachments/{{id}} as raw files to web session users, "
|
|
257
|
+
"so images render inline in the page viewer.",
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def export_page(
|
|
262
|
+
client: BookStackClient,
|
|
263
|
+
page_id: int,
|
|
264
|
+
output_dir: str | None = None,
|
|
265
|
+
images_subdir: str = "images",
|
|
266
|
+
) -> dict[str, Any]:
|
|
267
|
+
"""Export a page to a local markdown file, downloading images.
|
|
268
|
+
|
|
269
|
+
- Fetches page markdown content
|
|
270
|
+
- Finds all image references (gallery + attachment URLs)
|
|
271
|
+
- Downloads each image to a local subfolder
|
|
272
|
+
- Replaces URLs with local file paths in the markdown
|
|
273
|
+
- Writes the markdown file
|
|
274
|
+
|
|
275
|
+
Returns dict with file path and list of downloaded images.
|
|
276
|
+
"""
|
|
277
|
+
import base64
|
|
278
|
+
import os
|
|
279
|
+
import re
|
|
280
|
+
from urllib.parse import urlparse
|
|
281
|
+
|
|
282
|
+
from bookstack_cli.resources.attachments import download_attachment
|
|
283
|
+
|
|
284
|
+
page = await get_page(client, page_id)
|
|
285
|
+
markdown = page.markdown or ""
|
|
286
|
+
|
|
287
|
+
base_dir = output_dir if output_dir else os.path.join(os.getcwd(), f"page-{page_id}")
|
|
288
|
+
img_dir = os.path.join(base_dir, images_subdir)
|
|
289
|
+
os.makedirs(img_dir, exist_ok=True)
|
|
290
|
+
|
|
291
|
+
# Step 1: Find all image URLs
|
|
292
|
+
pattern = re.compile(r"!\[([^]]*)\]\(([^)]+)\)")
|
|
293
|
+
matches = list(pattern.finditer(markdown))
|
|
294
|
+
|
|
295
|
+
# Step 2: Download all images
|
|
296
|
+
url_map: dict[str, str] = {} # url -> local relative path
|
|
297
|
+
downloaded: list[dict[str, Any]] = []
|
|
298
|
+
|
|
299
|
+
for m in matches:
|
|
300
|
+
alt_text = m.group(1)
|
|
301
|
+
url = m.group(2)
|
|
302
|
+
parsed = urlparse(url)
|
|
303
|
+
path_part = parsed.path.rstrip("/").split("/")
|
|
304
|
+
filename = path_part[-1] if path_part else "image"
|
|
305
|
+
if "." not in filename:
|
|
306
|
+
filename = f"{filename}.bin"
|
|
307
|
+
local_path = os.path.join(img_dir, filename)
|
|
308
|
+
rel_path = os.path.join(images_subdir, filename)
|
|
309
|
+
|
|
310
|
+
# Skip already-downloaded URLs
|
|
311
|
+
if url in url_map:
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
if "/attachments/" in url:
|
|
316
|
+
# Attachment — download via API (base64)
|
|
317
|
+
aid_match = re.search(r"/attachments/(\d+)", url)
|
|
318
|
+
if aid_match:
|
|
319
|
+
aid = int(aid_match.group(1))
|
|
320
|
+
fname, content = await download_attachment(client, aid)
|
|
321
|
+
local_path = os.path.join(img_dir, fname)
|
|
322
|
+
rel_path = os.path.join(images_subdir, fname)
|
|
323
|
+
with open(local_path, "wb") as f:
|
|
324
|
+
f.write(content)
|
|
325
|
+
downloaded.append({"url": url, "file": fname, "source": "attachment"})
|
|
326
|
+
else:
|
|
327
|
+
# Gallery or external — download via HTTP
|
|
328
|
+
import httpx
|
|
329
|
+
h = {"User-Agent": "Mozilla/5.0"}
|
|
330
|
+
resp = httpx.get(url, headers=h, follow_redirects=True, timeout=30)
|
|
331
|
+
if resp.status_code == 200 and len(resp.content) > 100:
|
|
332
|
+
with open(local_path, "wb") as f:
|
|
333
|
+
f.write(resp.content)
|
|
334
|
+
downloaded.append({"url": url, "file": filename, "source": "http"})
|
|
335
|
+
|
|
336
|
+
url_map[url] = rel_path
|
|
337
|
+
except Exception as e:
|
|
338
|
+
downloaded.append({"url": url, "file": None, "error": str(e)})
|
|
339
|
+
|
|
340
|
+
# Step 3: Replace URLs in markdown
|
|
341
|
+
def _replace(m: re.Match) -> str:
|
|
342
|
+
alt = m.group(1)
|
|
343
|
+
u = m.group(2)
|
|
344
|
+
local = url_map.get(u)
|
|
345
|
+
if local:
|
|
346
|
+
return f""
|
|
347
|
+
return m.group(0) # keep original on failure
|
|
348
|
+
|
|
349
|
+
new_md = pattern.sub(_replace, markdown)
|
|
350
|
+
|
|
351
|
+
# Step 4: Write markdown file
|
|
352
|
+
md_path = os.path.join(base_dir, f"{page.slug or page_id}.md")
|
|
353
|
+
with open(md_path, "w", encoding="utf-8") as f:
|
|
354
|
+
f.write(new_md)
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
"page_id": page_id,
|
|
358
|
+
"name": page.name,
|
|
359
|
+
"slug": page.slug,
|
|
360
|
+
"markdown_file": md_path,
|
|
361
|
+
"images_dir": img_dir,
|
|
362
|
+
"images_downloaded": len([d for d in downloaded if d.get("file")]),
|
|
363
|
+
"images_failed": len([d for d in downloaded if d.get("error")]),
|
|
364
|
+
"images": downloaded,
|
|
365
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Revisions resource (read-only for most)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from bookstack_cli.client import BookStackClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def list_revisions(
|
|
10
|
+
client: BookStackClient,
|
|
11
|
+
page_size: int = 100,
|
|
12
|
+
page_id: int | None = None,
|
|
13
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
14
|
+
"""Iterate page revisions."""
|
|
15
|
+
params: dict[str, Any] = {}
|
|
16
|
+
if page_id is not None:
|
|
17
|
+
params["page_id"] = page_id
|
|
18
|
+
|
|
19
|
+
async for item in client.paginate("revisions", params=params, page_size=page_size):
|
|
20
|
+
yield item
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def get_revision(client: BookStackClient, revision_id: int) -> dict[str, Any]:
|
|
24
|
+
"""Get a single revision by ID."""
|
|
25
|
+
return await client.get(f"revisions/{revision_id}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def delete_revision(client: BookStackClient, revision_id: int) -> None:
|
|
29
|
+
"""Delete a revision."""
|
|
30
|
+
await client.delete(f"revisions/{revision_id}")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Roles resource CRUD (admin-only)."""
|
|
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 Role
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def list_roles(
|
|
11
|
+
client: BookStackClient,
|
|
12
|
+
page_size: int = 100,
|
|
13
|
+
) -> AsyncIterator[Role]:
|
|
14
|
+
"""Iterate all roles (admin)."""
|
|
15
|
+
async for item in client.paginate("roles", page_size=page_size):
|
|
16
|
+
yield Role(**item)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def create_role(client: BookStackClient, payload: dict[str, Any]) -> Role:
|
|
20
|
+
"""Create a role (admin)."""
|
|
21
|
+
data = await client.post("roles", json=payload)
|
|
22
|
+
return Role(**data)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def update_role(client: BookStackClient, role_id: int, payload: dict[str, Any]) -> Role:
|
|
26
|
+
"""Update a role (admin)."""
|
|
27
|
+
data = await client.put(f"roles/{role_id}", json=payload)
|
|
28
|
+
return Role(**data)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def delete_role(client: BookStackClient, role_id: int) -> None:
|
|
32
|
+
"""Delete a role (admin)."""
|
|
33
|
+
await client.delete(f"roles/{role_id}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Search resource."""
|
|
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 SearchResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def search(
|
|
11
|
+
client: BookStackClient,
|
|
12
|
+
query: str,
|
|
13
|
+
page_size: int = 100,
|
|
14
|
+
) -> AsyncIterator[SearchResult]:
|
|
15
|
+
"""Search across all BookStack content.
|
|
16
|
+
|
|
17
|
+
Yields search results matching the query.
|
|
18
|
+
"""
|
|
19
|
+
params: dict[str, Any] = {"query": query}
|
|
20
|
+
async for item in client.paginate("search", params=params, page_size=page_size):
|
|
21
|
+
yield SearchResult(**item)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shelves (bookshelves) resource CRUD."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
|
|
5
|
+
from bookstack_cli.client import BookStackClient
|
|
6
|
+
from bookstack_cli.models import Shelf, ShelfCreate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def list_shelves(
|
|
10
|
+
client: BookStackClient,
|
|
11
|
+
page_size: int = 100,
|
|
12
|
+
) -> AsyncIterator[Shelf]:
|
|
13
|
+
"""Iterate all shelves."""
|
|
14
|
+
async for item in client.paginate("shelves", page_size=page_size):
|
|
15
|
+
yield Shelf(**item)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def get_shelf(client: BookStackClient, shelf_id: int) -> Shelf:
|
|
19
|
+
"""Get a single shelf by ID."""
|
|
20
|
+
data = await client.get(f"shelves/{shelf_id}")
|
|
21
|
+
return Shelf(**data)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def create_shelf(client: BookStackClient, payload: ShelfCreate) -> Shelf:
|
|
25
|
+
"""Create a new shelf."""
|
|
26
|
+
data = await client.post("shelves", json=payload.model_dump(exclude_unset=True))
|
|
27
|
+
return Shelf(**data)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def update_shelf(client: BookStackClient, shelf_id: int, payload: ShelfCreate) -> Shelf:
|
|
31
|
+
"""Update an existing shelf."""
|
|
32
|
+
data = await client.put(f"shelves/{shelf_id}", json=payload.model_dump(exclude_unset=True))
|
|
33
|
+
return Shelf(**data)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def delete_shelf(client: BookStackClient, shelf_id: int) -> None:
|
|
37
|
+
"""Delete a shelf."""
|
|
38
|
+
await client.delete(f"shelves/{shelf_id}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def upload_shelf_cover(
|
|
42
|
+
client: BookStackClient,
|
|
43
|
+
shelf_id: int,
|
|
44
|
+
file_content: bytes,
|
|
45
|
+
filename: str,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Upload a cover image for a shelf using multipart PUT."""
|
|
48
|
+
old_ct = client._client.headers.pop("Content-Type", None)
|
|
49
|
+
try:
|
|
50
|
+
files = {"image": (filename, file_content)}
|
|
51
|
+
resp = await client._client.put(f"/api/shelves/{shelf_id}", files=files)
|
|
52
|
+
return resp.json()
|
|
53
|
+
finally:
|
|
54
|
+
if old_ct is not None:
|
|
55
|
+
client._client.headers["Content-Type"] = old_ct
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Tags resource (read-only list)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from bookstack_cli.client import BookStackClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def list_tags(
|
|
10
|
+
client: BookStackClient,
|
|
11
|
+
page_size: int = 100,
|
|
12
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
13
|
+
"""Iterate all tags across the system."""
|
|
14
|
+
async for item in client.paginate("tags", page_size=page_size):
|
|
15
|
+
yield item
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Users resource CRUD (admin-only for mutations)."""
|
|
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 User
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def list_users(
|
|
11
|
+
client: BookStackClient,
|
|
12
|
+
page_size: int = 100,
|
|
13
|
+
) -> AsyncIterator[User]:
|
|
14
|
+
"""Iterate all users (admin)."""
|
|
15
|
+
async for item in client.paginate("users", page_size=page_size):
|
|
16
|
+
yield User(**item)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def get_user(client: BookStackClient, user_id: int) -> User:
|
|
20
|
+
"""Get a single user by ID."""
|
|
21
|
+
data = await client.get(f"users/{user_id}")
|
|
22
|
+
return User(**data)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def create_user(client: BookStackClient, payload: dict[str, Any]) -> User:
|
|
26
|
+
"""Create a user (admin)."""
|
|
27
|
+
data = await client.post("users", json=payload)
|
|
28
|
+
return User(**data)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def update_user(client: BookStackClient, user_id: int, payload: dict[str, Any]) -> User:
|
|
32
|
+
"""Update a user (admin)."""
|
|
33
|
+
data = await client.put(f"users/{user_id}", json=payload)
|
|
34
|
+
return User(**data)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def delete_user(client: BookStackClient, user_id: int) -> None:
|
|
38
|
+
"""Delete a user (admin)."""
|
|
39
|
+
await client.delete(f"users/{user_id}")
|