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,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"![{alt_text}]({orig_path})", 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"![{alt}]({local})"
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}")