#!/usr/bin/env python3 """ BookStack API Integration Full CRUD for books, chapters, pages, shelves + search """ import argparse import json import os import sys import urllib.request import urllib.error import urllib.parse # Configuration from environment BASE_URL = os.getenv('BOOKSTACK_URL', '').rstrip('/') TOKEN_ID = os.getenv('BOOKSTACK_TOKEN_ID', '') TOKEN_SECRET = os.getenv('BOOKSTACK_TOKEN_SECRET', '') def api_call(method, endpoint, data=None, params=None): """Make API call to BookStack""" if not BASE_URL or not TOKEN_ID or not TOKEN_SECRET: print("❌ Error: BOOKSTACK_URL, BOOKSTACK_TOKEN_ID, and BOOKSTACK_TOKEN_SECRET required!") print(" Set them as environment variables or in your gateway config.") sys.exit(1) url = f"{BASE_URL}/api/{endpoint}" if params: url += '?' + urllib.parse.urlencode(params) try: req = urllib.request.Request( url, headers={ "Authorization": f"Token {TOKEN_ID}:{TOKEN_SECRET}", "Content-Type": "application/json", "Accept": "application/json" }, method=method ) if data: data = {k: v for k, v in data.items() if v is not None} req.data = json.dumps(data).encode() with urllib.request.urlopen(req, timeout=30) as response: if response.status == 204: return None return json.loads(response.read().decode()) except urllib.error.HTTPError as e: try: error_data = json.loads(e.read().decode()) print(f"❌ HTTP {e.code}: {error_data.get('error', {}).get('message', 'Unknown error')}") except: print(f"❌ HTTP {e.code}: {e.reason}") sys.exit(1) except urllib.error.URLError as e: print(f"❌ Connection error: {e.reason}") sys.exit(1) except Exception as e: print(f"❌ Error: {e}") sys.exit(1) # ============ BOOKS ============ def list_books(args): """List all books""" params = {'count': args.count} if args.count else {} result = api_call("GET", "books", params=params) if not result.get('data'): print("📚 No books found") return print(f"📚 {result.get('total', len(result['data']))} Books:\n") for book in result['data']: desc = book.get('description', '')[:50] + '...' if book.get('description') else '' print(f" [{book['id']}] {book['name']}") if desc: print(f" {desc}") def get_book(args): """Get book details""" result = api_call("GET", f"books/{args.id}") print(f"📚 Book: {result['name']}") print(f" ID: {result['id']}") print(f" Slug: {result['slug']}") if result.get('description'): print(f" Description: {result['description']}") print(f" Created: {result['created_at']}") print(f" Updated: {result['updated_at']}") if result.get('contents'): print(f"\n Contents ({len(result['contents'])} items):") for item in result['contents'][:10]: icon = '📑' if item['type'] == 'chapter' else '📄' print(f" {icon} [{item['id']}] {item['name']}") def create_book(args): """Create a new book""" data = { "name": args.name, "description": args.description } result = api_call("POST", "books", data) print(f"✅ Book created: {result['name']} (ID: {result['id']})") def update_book(args): """Update a book""" data = {} if args.name: data['name'] = args.name if args.description: data['description'] = args.description if not data: print("❌ Nothing to update. Use --name or --description") sys.exit(1) result = api_call("PUT", f"books/{args.id}", data) print(f"✅ Book updated: {result['name']}") def delete_book(args): """Delete a book""" api_call("DELETE", f"books/{args.id}") print(f"✅ Book {args.id} deleted") # ============ CHAPTERS ============ def list_chapters(args): """List all chapters""" params = {'count': args.count} if args.count else {} result = api_call("GET", "chapters", params=params) if not result.get('data'): print("📑 No chapters found") return print(f"📑 {result.get('total', len(result['data']))} Chapters:\n") for ch in result['data']: print(f" [{ch['id']}] {ch['name']} (Book: {ch.get('book_id', '?')})") def get_chapter(args): """Get chapter details""" result = api_call("GET", f"chapters/{args.id}") print(f"📑 Chapter: {result['name']}") print(f" ID: {result['id']}") print(f" Book ID: {result['book_id']}") if result.get('description'): print(f" Description: {result['description']}") if result.get('pages'): print(f"\n Pages ({len(result['pages'])}):") for page in result['pages'][:10]: print(f" 📄 [{page['id']}] {page['name']}") def create_chapter(args): """Create a new chapter""" data = { "book_id": args.book_id, "name": args.name, "description": args.description } result = api_call("POST", "chapters", data) print(f"✅ Chapter created: {result['name']} (ID: {result['id']})") def update_chapter(args): """Update a chapter""" data = {} if args.name: data['name'] = args.name if args.description: data['description'] = args.description if args.book_id: data['book_id'] = args.book_id if not data: print("❌ Nothing to update") sys.exit(1) result = api_call("PUT", f"chapters/{args.id}", data) print(f"✅ Chapter updated: {result['name']}") def delete_chapter(args): """Delete a chapter""" api_call("DELETE", f"chapters/{args.id}") print(f"✅ Chapter {args.id} deleted") # ============ PAGES ============ def list_pages(args): """List all pages""" params = {'count': args.count} if args.count else {} result = api_call("GET", "pages", params=params) if not result.get('data'): print("📄 No pages found") return print(f"📄 {result.get('total', len(result['data']))} Pages:\n") for page in result['data']: location = f"Chapter {page['chapter_id']}" if page.get('chapter_id') else f"Book {page['book_id']}" print(f" [{page['id']}] {page['name']} ({location})") def get_page(args): """Get page with full content""" result = api_call("GET", f"pages/{args.id}") print(f"📄 Page: {result['name']}") print(f" ID: {result['id']}") print(f" Book ID: {result['book_id']}") if result.get('chapter_id'): print(f" Chapter ID: {result['chapter_id']}") print(f" Editor: {result.get('editor', 'unknown')}") print(f" Created: {result['created_at']}") print(f" Updated: {result['updated_at']}") if args.content: print(f"\n--- Content (HTML) ---") print(result.get('html', '')) elif args.markdown: print(f"\n--- Content (Markdown) ---") print(result.get('markdown', result.get('html', ''))) else: # Show preview html = result.get('html', '') if html: # Strip HTML tags for preview import re text = re.sub('<[^<]+?>', '', html) text = ' '.join(text.split())[:200] print(f"\n Preview: {text}...") def create_page(args): """Create a new page""" data = { "name": args.name, } if args.book_id: data['book_id'] = args.book_id if args.chapter_id: data['chapter_id'] = args.chapter_id if not args.book_id and not args.chapter_id: print("❌ Either --book-id or --chapter-id required") sys.exit(1) if args.html: data['html'] = args.html elif args.markdown: data['markdown'] = args.markdown elif args.content: # Auto-detect: if starts with # or no HTML tags, treat as markdown if args.content.startswith('#') or '<' not in args.content: data['markdown'] = args.content else: data['html'] = args.content result = api_call("POST", "pages", data) print(f"✅ Page created: {result['name']} (ID: {result['id']})") def update_page(args): """Update a page""" data = {} if args.name: data['name'] = args.name if args.html: data['html'] = args.html if args.markdown: data['markdown'] = args.markdown if args.content: if args.content.startswith('#') or '<' not in args.content: data['markdown'] = args.content else: data['html'] = args.content if args.book_id: data['book_id'] = args.book_id if args.chapter_id: data['chapter_id'] = args.chapter_id if not data: print("❌ Nothing to update") sys.exit(1) result = api_call("PUT", f"pages/{args.id}", data) print(f"✅ Page updated: {result['name']}") def delete_page(args): """Delete a page""" api_call("DELETE", f"pages/{args.id}") print(f"✅ Page {args.id} deleted") # ============ SHELVES ============ def list_shelves(args): """List all shelves""" params = {'count': args.count} if args.count else {} result = api_call("GET", "shelves", params=params) if not result.get('data'): print("📁 No shelves found") return print(f"📁 {result.get('total', len(result['data']))} Shelves:\n") for shelf in result['data']: print(f" [{shelf['id']}] {shelf['name']}") def get_shelf(args): """Get shelf details""" result = api_call("GET", f"shelves/{args.id}") print(f"📁 Shelf: {result['name']}") print(f" ID: {result['id']}") if result.get('description'): print(f" Description: {result['description']}") if result.get('books'): print(f"\n Books ({len(result['books'])}):") for book in result['books'][:10]: print(f" 📚 [{book['id']}] {book['name']}") def create_shelf(args): """Create a new shelf""" data = { "name": args.name, "description": args.description } result = api_call("POST", "shelves", data) print(f"✅ Shelf created: {result['name']} (ID: {result['id']})") # ============ SEARCH ============ def search(args): """Search content""" params = { 'query': args.query, 'count': args.count or 20 } if args.type: params['query'] = f"{{{args.type}}} {args.query}" result = api_call("GET", "search", params=params) if not result.get('data'): print(f"🔍 No results for: {args.query}") return print(f"🔍 {result.get('total', len(result['data']))} Results for '{args.query}':\n") for item in result['data']: icon = {'page': '📄', 'chapter': '📑', 'book': '📚', 'bookshelf': '📁'}.get(item['type'], '📎') print(f" {icon} [{item['type']}:{item['id']}] {item['name']}") if item.get('preview_html'): import re preview = re.sub('<[^<]+?>', '', item['preview_html'].get('content', '')) preview = ' '.join(preview.split())[:80] if preview: print(f" {preview}...") # ============ MAIN ============ def main(): parser = argparse.ArgumentParser(description='BookStack API CLI') subparsers = parser.add_subparsers(dest='command', help='Commands') # Books p = subparsers.add_parser('list_books', help='List all books') p.add_argument('--count', type=int, help='Max results') p.set_defaults(func=list_books) p = subparsers.add_parser('get_book', help='Get book details') p.add_argument('id', type=int) p.set_defaults(func=get_book) p = subparsers.add_parser('create_book', help='Create a book') p.add_argument('name') p.add_argument('description', nargs='?') p.set_defaults(func=create_book) p = subparsers.add_parser('update_book', help='Update a book') p.add_argument('id', type=int) p.add_argument('--name') p.add_argument('--description') p.set_defaults(func=update_book) p = subparsers.add_parser('delete_book', help='Delete a book') p.add_argument('id', type=int) p.set_defaults(func=delete_book) # Chapters p = subparsers.add_parser('list_chapters', help='List all chapters') p.add_argument('--count', type=int) p.set_defaults(func=list_chapters) p = subparsers.add_parser('get_chapter', help='Get chapter details') p.add_argument('id', type=int) p.set_defaults(func=get_chapter) p = subparsers.add_parser('create_chapter', help='Create a chapter') p.add_argument('--book-id', type=int, required=True) p.add_argument('--name', required=True) p.add_argument('--description') p.set_defaults(func=create_chapter) p = subparsers.add_parser('update_chapter', help='Update a chapter') p.add_argument('id', type=int) p.add_argument('--name') p.add_argument('--description') p.add_argument('--book-id', type=int) p.set_defaults(func=update_chapter) p = subparsers.add_parser('delete_chapter', help='Delete a chapter') p.add_argument('id', type=int) p.set_defaults(func=delete_chapter) # Pages p = subparsers.add_parser('list_pages', help='List all pages') p.add_argument('--count', type=int) p.set_defaults(func=list_pages) p = subparsers.add_parser('get_page', help='Get page with content') p.add_argument('id', type=int) p.add_argument('--content', action='store_true', help='Show full HTML') p.add_argument('--markdown', action='store_true', help='Show as markdown') p.set_defaults(func=get_page) p = subparsers.add_parser('create_page', help='Create a page') p.add_argument('--name', required=True) p.add_argument('--book-id', type=int) p.add_argument('--chapter-id', type=int) p.add_argument('--content', help='Content (auto-detect HTML/MD)') p.add_argument('--html', help='HTML content') p.add_argument('--markdown', help='Markdown content') p.set_defaults(func=create_page) p = subparsers.add_parser('update_page', help='Update a page') p.add_argument('id', type=int) p.add_argument('--name') p.add_argument('--content') p.add_argument('--html') p.add_argument('--markdown') p.add_argument('--book-id', type=int) p.add_argument('--chapter-id', type=int) p.set_defaults(func=update_page) p = subparsers.add_parser('delete_page', help='Delete a page') p.add_argument('id', type=int) p.set_defaults(func=delete_page) # Shelves p = subparsers.add_parser('list_shelves', help='List all shelves') p.add_argument('--count', type=int) p.set_defaults(func=list_shelves) p = subparsers.add_parser('get_shelf', help='Get shelf details') p.add_argument('id', type=int) p.set_defaults(func=get_shelf) p = subparsers.add_parser('create_shelf', help='Create a shelf') p.add_argument('name') p.add_argument('description', nargs='?') p.set_defaults(func=create_shelf) # Search p = subparsers.add_parser('search', help='Search content') p.add_argument('query') p.add_argument('--type', choices=['page', 'chapter', 'book', 'shelf']) p.add_argument('--count', type=int) p.set_defaults(func=search) args = parser.parse_args() if not args.command: parser.print_help() sys.exit(1) args.func(args) if __name__ == '__main__': main()