# Book and Library The Book feature provides a comprehensive system for creating structured, hierarchical documentation with automatic table of contents numbering, PDF export capabilities, and user access management. The Library content type serves as a container for organizing multiple books. ## Overview The Book system consists of: - **Library** - Container for organizing multiple books - **Book** - Main container with table of contents, custom styling, and PDF export - **Chapter** - Hierarchical content units with automatic numbering - **Paragraph** - Content blocks within chapters Key features include: - Automatic table of contents generation with hierarchical numbering (1, 1.1, 1.1.1, etc.) - PDF export via WeasyPrint integration - Custom CSS styling per book - User registration and access request workflows - Ask the book owner a question (via browser form or REST API) - REST API with enhanced serialization ## Content Types ### Library A container specifically designed for organizing books. **Portal Type:** `Library` **Allowed Content Types:** `Book` only ### Book The main documentation container with chapters and table of contents support. **Portal Type:** `Book` **Allowed Content Types:** `Chapter` only **Schema Fields (via behaviors):** - `custom_css` (Text) - Custom CSS with variable substitution - `include_default_css` (Bool) - Include default book CSS (default: True) - `header_image` (NamedBlobImage) - Header image for PDF export ### Chapter Hierarchical content container that can nest other chapters. **Portal Type:** `Chapter` **Allowed Content Types:** - `Chapter` - Nested chapters (subchapters) - `Paragraph` - Content blocks - `TableBlock` - Table content - `FileListingBlock` - File listings - `MediaFolder` - Media containers - `CommentBlock` - Comments **Schema Fields:** - `hide` (Bool) - Hide from table of contents (default: False) ### Paragraph A content block within chapters with optional TOC visibility. ## Table of Contents The `Toc` class generates hierarchical chapter numbers automatically. ### Number Generation Chapters receive automatic numbers based on their position in the hierarchy: - First chapter: `1` - Second chapter: `2` - First subchapter of chapter 1: `1.1` - Second subchapter of chapter 1: `1.2` - First subchapter of chapter 2: `2.1` - Deep nesting: `1.1.1`, `1.1.2`, `2.1.1`, etc. **Usage:** ```python from wcs.backend.book.toc import Toc # Get chapter number chapter = context # A Chapter object number = Toc(chapter).number() # Returns "1.1.2" or None if hidden ``` ### TOC Visibility Chapters can be hidden from the table of contents: ```python # Hide chapter from TOC chapter.hide = True chapter.reindexObject() ``` Hidden chapters: - Do not appear in table of contents - Return `None` from `Toc(chapter).number()` - Their children still calculate numbers correctly (skipping the hidden parent in numbering) ### Catalog Index The `show_in_toc` index enables efficient TOC queries: ```python # Query chapters visible in TOC from plone import api catalog = api.portal.get_tool('portal_catalog') results = catalog.searchResults( portal_type='Chapter', show_in_toc=True, path='/Plone/my-book' ) ``` ## REST API ### Enhanced Serialization Books and chapters include automatic TOC number calculation in REST API responses. **Book Response:** ```json { "@id": "http://localhost:8080/Plone/my-book", "@type": "Book", "title": "User Manual", "items": [ { "@id": "http://localhost:8080/Plone/my-book/chapter-1", "@type": "Chapter", "title": "Introduction", "number": "1" }, { "@id": "http://localhost:8080/Plone/my-book/chapter-2", "@type": "Chapter", "title": "Getting Started", "number": "2" } ] } ``` **Chapter Response:** ```json { "@id": "http://localhost:8080/Plone/my-book/chapter-1/subchapter-1", "@type": "Chapter", "title": "Installation", "number": "1.1", "items": [ { "@type": "Paragraph", "title": "Prerequisites", "number": "1.1.1" } ] } ``` ### GET @book-toc Returns the full table of contents tree for a book. This endpoint uses catalog queries only (no content object wake-ups), making it significantly faster than fetching the book serialization or context navigation for large books. ```javascript const response = await fetch('https://example.com/my-book/@book-toc', { headers: { 'Accept': 'application/json', 'Authorization': 'Bearer ', }, }); ``` ```python import requests response = requests.get( 'https://example.com/my-book/@book-toc', headers={ 'Accept': 'application/json', 'Authorization': 'Bearer ', }, ) ``` **Response:** ```json { "@id": "http://localhost:8080/Plone/my-book", "title": "User Manual", "number": "", "items": [ { "@id": "http://localhost:8080/Plone/my-book/chapter-1", "title": "Introduction", "description": "An introductory chapter", "review_state": "published", "number": "1", "items": [ { "@id": "http://localhost:8080/Plone/my-book/chapter-1/overview", "title": "Overview", "description": "", "review_state": "published", "number": "1.1", "items": [] } ] }, { "@id": "http://localhost:8080/Plone/my-book/chapter-2", "title": "Getting Started", "description": "", "review_state": "published", "number": "2", "items": [] } ] } ``` Chapters with `hide=True` are excluded from the response. The tree is nested to the full depth of the book hierarchy. ### GET @book-keywords Returns all unique keywords in a book with occurrence counts. ```http GET /Plone/my-book/@book-keywords HTTP/1.1 Host: localhost:8080 Accept: application/json ``` **Response:** ```json { "keywords": [ {"keyword": "Baurecht", "count": 5}, {"keyword": "Eigentum", "count": 3}, {"keyword": "Vertrag", "count": 2} ], "total": 3 } ``` ### GET @book-keyword-search Searches for paragraphs containing a specific keyword within a book. ```http GET /Plone/my-book/@book-keyword-search?keyword=Baurecht HTTP/1.1 Host: localhost:8080 Accept: application/json ``` **Response:** ```json { "@id": "http://localhost:8080/Plone/my-book/@book-keyword-search?keyword=Baurecht", "keyword": "Baurecht", "items": [ { "@id": "http://localhost:8080/Plone/my-book/chapter-1/paragraph-1", "title": "Introduction Paragraph", "UID": "abc123", "parent_chapter": { "@id": "http://localhost:8080/Plone/my-book/chapter-1", "title": "Introduction", "number": "1" } } ], "items_total": 1 } ``` **Pagination:** Supports `b_start` and `b_size` query parameters for pagination. ```http GET /Plone/my-book/@book-keyword-search?keyword=Baurecht&b_size=10&b_start=0 HTTP/1.1 Host: localhost:8080 Accept: application/json ``` ### GET @contextnavigation Enhanced context navigation for books with TOC numbers. ```http GET /Plone/my-book/@contextnavigation?expand.contextnavigation.bottomLevel=3 HTTP/1.1 Host: localhost:8080 Accept: application/json ``` **Response:** ```json { "items": [ { "@id": "http://localhost:8080/Plone/my-book/chapter-1", "title": "Introduction", "number": "1", "items": [ { "@id": "http://localhost:8080/Plone/my-book/chapter-1/overview", "title": "Overview", "number": "1.1" } ] }, { "@id": "http://localhost:8080/Plone/my-book/chapter-2", "title": "Getting Started", "number": "2" } ] } ``` (ask-book-owner-endpoint)= ### POST @ask-book-owner Allows an authenticated user to ask a question to the book owner. The endpoint creates a task in the current user's task container with the book owner (determined by the `IBookOwner` behavior's `moderator_email` or `fallback_email`) as the responsible party. **Requires:** Authentication, `IBookOwner` behavior enabled on the book ```javascript const response = await fetch('https://example.com/my-book/@ask-book-owner', { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer ', }, body: JSON.stringify({ question: 'What does chapter 3 cover?', }), }); ``` ```python import requests response = requests.post( 'https://example.com/my-book/@ask-book-owner', headers={ 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer ', }, json={ 'question': 'What does chapter 3 cover?', }, ) ``` #### Successful Response (201) Returns the created task object (standard plone.restapi task response). #### Request Body | Field | Required | Description | |---|---|---| | `question` | Yes | The question text. Must not be empty or whitespace-only. | #### Error Responses | Status | Error message | Cause | |---|---|---| | 401 | `Authentication required` | Anonymous user. | | 400 | `Property 'question' is required` | Missing or empty `question` field. | | 400 | `Book owner behavior is not enabled for this book` | `IBookOwner` behavior not applied. | | 400 | `No moderator email configured for this book` | Neither `moderator_email` nor `fallback_email` set. | | 400 | `No user found for the configured moderator email` | No Plone user matches the moderator email. | #### Task Details The created task has the following properties: - **Initiator**: The authenticated user who asked the question - **Responsible**: The Plone user matching the book's moderator email - **Related**: The book - **Action**: `frage` - **Text**: HTML containing the asker's name, email, and the question If the current user does not yet have a task container, one is created automatically. ## PDF Export Books support PDF generation via WeasyPrint integration with an external PDF server. ### Configuration Environment variables: - `PDFSERVER_URL` - PDF server URL (default: `http://localhost:8040`) - `PLONE_BACKEND_HOST` - Backend URL for PDF server to fetch content Registry records (see also Email URL Configuration below): - `wcs.backend.admin_url` - Canonical backend URL for moderator email links - `wcs.backend.preview.endpoint` - Frontend URL for user-facing email links ### PDF Server Endpoints **POST @pdfserver-convert** Initiates PDF conversion for the current content. ```http POST /Plone/my-book/@pdfserver-convert HTTP/1.1 Host: localhost:8080 Accept: application/json Content-Type: application/json ``` **Response:** ```json { "uid": "conversion-job-uid", "status": "pending" } ``` **GET @pdfserver-status** Check conversion status. ```http GET /Plone/my-book/@pdfserver-status?uid=conversion-job-uid HTTP/1.1 Host: localhost:8080 Accept: application/json ``` **Response:** ```json { "uid": "conversion-job-uid", "status": "completed" } ``` **GET @@pdfserver-download** Download the generated PDF. ```http GET /Plone/my-book/@@pdfserver-download?uid=conversion-job-uid HTTP/1.1 Host: localhost:8080 ``` **Response:** PDF file download ### WeasyPrint Views The following views render content for PDF generation: - `@@view_weasyprint` - Main WeasyPrint template for books - `@@toc_weasyprint` - Table of contents for PDF - `@@chapter_weasyprint` - Chapter rendering ### Custom CSS Books support custom CSS with variable substitution: ```css /* Available variables: $portal_url, $book_url */ .book-header { background-image: url($book_url/@@images/header_image); } .book-logo { background-image: url($portal_url/++resource++images/logo.png); } ``` CSS is served via: - `@@custom-book.css` - Custom CSS with variable substitution - `@@book-variables.css` - CSS custom properties for dates and images ## User Access Management The Book feature includes a complete user registration and access request workflow. ### Book Owner Behavior Books can have the `IBookOwner` behavior for managing access: **Fields:** - `moderator_email` - Email for access request notifications - `fallback_email` - Fallback email if moderator is unavailable - `group` - Plone group for book access ### Registration Form A custom registration form (`@@register`) allows users to: 1. Create a new account 2. Select books to request access to 3. Trigger email notifications to book moderators **Browser View:** `@@register` (available when `IBackendBookLayer` is active) ### Request Book Access Users can request access to books via the `@request-book-access` endpoint on the site root. The endpoint supports two flows: authenticated users requesting access to additional books, and anonymous users registering a new account while requesting access in a single step. **Browser Form:** `@@request-book-access` (authenticated users only) #### Authenticated User ```javascript const response = await fetch('https://example.com/@request-book-access', { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer ', }, body: JSON.stringify({ books: [''], }), }); ``` ```python import requests response = requests.post( 'https://example.com/@request-book-access', headers={ 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer ', }, json={ 'books': [''], }, ) ``` #### Anonymous User (Registration) An anonymous user submits a list of book UIDs together with their email address and full name. The system creates a new user account, sends a password reset email to the user, and sends an access request email to each book's moderator. When Keycloak is active, user creation is delegated to Keycloak, which handles the password reset / verification email. Without Keycloak, the system sends a standard Plone password reset email. ```javascript const response = await fetch('https://example.com/@request-book-access', { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ books: [''], username: 'user@example.com', fullname: 'John Doe', url: 'https://www.example.com', }), }); ``` ```python import requests response = requests.post( 'https://example.com/@request-book-access', headers={ 'Accept': 'application/json', 'Content-Type': 'application/json', }, json={ 'books': [''], 'username': 'user@example.com', 'fullname': 'John Doe', 'url': 'https://www.example.com', }, ) ``` #### Successful Response (200) ```json { "success": true } ``` #### Request Body | Field | Required | Description | |---|---|---| | `books` | Yes | List of book UIDs to request access to. Must contain at least one entry. Each UID must resolve to an existing book that has a moderator email and a group configured. To build a book selection form, use the `wcs.backend.vocabularies.all.books` vocabulary which returns only books that are properly configured for access requests. | | `username` | Anonymous only | Email address of the new user. Must be a valid email. Must not belong to an existing account. | | `fullname` | Anonymous only | Full name of the new user. | | `url` | No | Redirect URL for the Keycloak verification email. When provided, this URL is used as the redirect target in the Keycloak email link, overriding the `wcs.backend.preview.endpoint` registry record. If omitted, the endpoint falls back to `preview.endpoint`. If neither is set, the Keycloak plugin's default `redirect_uri` is used. | #### Error Responses All error responses return HTTP status **400** with a JSON body containing an `error` key. | Error message | Cause | |---|---| | `At least one book must be selected` | The `books` list is missing or empty. | | `username and fullname are required for registration` | An anonymous request is missing `username` or `fullname`. | | `Invalid email address` | The `username` value is not a valid email address. | | `User already exists. Please log in first.` | An account with the given `username` already exists. | | `Invalid book UID: ` | A book UID does not resolve to an existing book. | | `Book "" has no moderator email configured` | The book exists but has no moderator or fallback email address set. | | `Book "<title>" has no group configured` | The book exists but has no group assigned for user registration. | #### Prerequisites For the anonymous registration flow, the Plone site must allow anonymous users to add portal members (the `Add portal member` permission granted to the `Anonymous` role). Additionally, `plone.use_email_as_login` should be enabled in the registry. ### Approve Access Book moderators can approve access requests via `@@book-share`: ```text /Plone/my-book/@@book-share?userid=john.doe ``` This: 1. Adds the user to the book's configured group 2. Sends a confirmation email to the user ### Email URL Configuration When the book access flow is triggered from the frontend (via the REST API), `absolute_url()` resolves to the API domain, which is not suitable for email links. Two registry records control how email URLs are generated: - `wcs.backend.admin_url` — The canonical backend/admin URL (e.g. `https://backend.example.ch`). Used for the `book-share` link in moderator emails. Falls back to `absolute_url()` if not set (suitable for local development). - `wcs.backend.preview.endpoint` — The frontend URL (e.g. `https://www.example.ch`). Used for user-facing book links when the request originates from the REST API. **How it works:** When the REST API triggers the access request, the `preview.endpoint` value is encoded as a `frontend_url` query parameter on the `book-share` link in the moderator email. When the moderator clicks the link, `ApproveBookParticipant` reads this parameter and passes it through to the access-granted email. This way the frontend URL is preserved across the moderator approval step without relying on the request context at approval time. When the flow starts from the backend (e.g. `@@register` or `@@request-book-access` browser form), no `frontend_url` parameter is added to the share link, so the access-granted email falls back to `absolute_url()`. **Keycloak verification email redirect:** When Keycloak is active and a new user is created via the REST API, Keycloak sends a verification/password-reset email with a redirect link. The redirect URL is determined in this order: 1. The `url` field from the request body (explicit override) 2. The `wcs.backend.preview.endpoint` registry record (frontend URL) 3. The Keycloak plugin's static `redirect_uri` property (fallback) When the flow is triggered via the backend form (`@@register`), the Keycloak plugin's static `redirect_uri` is used directly (no override). **Email URL behavior:** | Email | Recipient | REST API flow | Backend form flow | |---|---|---|---| | Access request (`book-share` link) | Moderator | `admin_url` + `&frontend_url=…` | `admin_url` (no `frontend_url`) | | Access granted | User | `preview.endpoint` (from `frontend_url` param) | `absolute_url()` | | Keycloak verification | User | `url` param → `preview.endpoint` → plugin `redirect_uri` | Plugin `redirect_uri` | ### Email Templates The following email templates are used: - `new-user-access-book-request-mail` - Sent to the book moderator when a user requests access. Contains a `book-share` link pointing to the backend, optionally carrying a `frontend_url` parameter. - `user-access-granted` - Sent to the user when access is approved. Contains a link to the book, using the `frontend_url` from the share link if present, otherwise falling back to `absolute_url()`. - `new-user-created-and-access-to-book-mail` - Sent to newly created users with password reset instructions and a book link. ### PDF Views - `@@pdf_view` - PDF conversion interface - `@@pdfserver-download` - PDF download ## Ask Book Owner The "Ask Owner" feature allows authenticated users to send a question to the book's moderator directly from the book view. The question is submitted as a task in the user's task area, with the book owner set as the responsible party. ### Browser View The `@@ask-owner` view displays a simple form with a text area. It appears as an additional tab ("Ask Owner") in the book navigation bar alongside "Table of Contents", "Keyword Search", and "AI Ask". The tab is only visible when the `IBookOwner` behavior is enabled on the book. On submission, the view: 1. Validates the question is not empty 2. Resolves the book's moderator email to a Plone user 3. Creates a task in the current user's task container 4. Redirects back to the book with a success message ### REST API See [POST @ask-book-owner](ask-book-owner-endpoint) in the REST API section above. ### Prerequisites - The `IBookOwner` behavior must be enabled on the book - A `moderator_email` or `fallback_email` must be configured - The email must correspond to an existing Plone user - The user asking the question must be authenticated ## AI Ask (RAG) Configuration Books can individually enable or disable the AI Ask feature. This allows administrators to control which books expose the AI Ask tab to users. ### Prerequisites - Global RAG must be enabled via the `RAG_ENABLED=true` environment variable - The book must have `rag_enabled` set to `True` When both conditions are met, the "AI Ask" tab appears in the book's navigation alongside "Table of Contents" and "Keyword Search". ### Permissions The `rag_enabled` field requires the `Manage RAG properties` permission to edit. By default, only Manager and Site Administrator roles have this permission. ### REST API The `rag_enabled` field is included in the book's REST API response: ```javascript // JavaScript example using fetch const response = await fetch('/plone/my-book', { headers: { 'Accept': 'application/json', 'Authorization': 'Bearer <token>' } }); const book = await response.json(); console.log(book.rag_enabled); // true or false ``` ### Asking Questions When enabled, the AI Ask feature allows users to ask natural language questions about the book's content. The system searches through the book's chapters and paragraphs using hybrid search (combining keyword and semantic matching) and generates answers using an LLM. The `@rag-ask` endpoint supports a `path` parameter to scope questions to a specific book: ```javascript // Ask a question scoped to a specific book const response = await fetch('/plone/@rag-ask', { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ question: 'What is covered in chapter 3?', path: '/plone/my-book' }) }); ``` See the [RAG](rag.md) documentation for complete details on the RAG system configuration and API. ## Integration Guide ### Querying TOC Structure ```python from plone import api from wcs.backend.book.toc import Toc book = api.content.get(path='/library/my-book') catalog = api.portal.get_tool('portal_catalog') # Get all visible chapters chapters = catalog.searchResults( portal_type='Chapter', path='/'.join(book.getPhysicalPath()), show_in_toc=True, sort_on='getObjPositionInParent' ) for brain in chapters: chapter = brain.getObject() number = Toc(chapter).number() print(f"{number} {chapter.Title()}") ``` ## File Locations **Core Module:** - `wcs/backend/book/content.py` - Content type classes (Library, Book, Chapter, Paragraph) - `wcs/backend/book/behaviors.py` - Behaviors (IShowInToc, IBookCustomCSS, IBookConfiguration, IBookOwner) - `wcs/backend/book/toc.py` - Table of contents generator - `wcs/backend/book/utils.py` - Utility functions - `wcs/backend/book/indexer.py` - Catalog indexers (show_in_toc, book_keywords) - `wcs/backend/book/keyword_utils.py` - Keyword extraction utilities - `wcs/backend/book/configure.zcml` - ZCML configuration **REST API:** - `wcs/backend/book/restapi.py` - Serializers and services **Views:** - `wcs/backend/book/views.py` - Browser views - `wcs/backend/book/templates/` - View templates **PDF Generation:** - `wcs/backend/book/pdfserver_client.py` - PDF server client - `wcs/backend/book/weasyprint/views.py` - WeasyPrint views - `wcs/backend/book/weasyprint/templates/` - PDF templates - `wcs/backend/book/weasyprint/resources/` - PDF CSS resources **User Management:** - `wcs/backend/book/register_form.py` - Registration form - `wcs/backend/book/emails.py` - Email adapters - `wcs/backend/book/templates/emails/` - Email templates **Type Definitions:** - `wcs/backend/profiles/default/types/Library.xml` - `wcs/backend/profiles/default/types/Book.xml` - `wcs/backend/profiles/default/types/Chapter.xml` - `wcs/backend/profiles/default/types/Paragraph.xml`