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:

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:

# 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:

# 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:

{
    "@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:

{
    "@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.

const response = await fetch('https://example.com/my-book/@book-toc', {
  headers: {
    'Accept': 'application/json',
    'Authorization': 'Bearer <token>',
  },
});
import requests

response = requests.get(
    'https://example.com/my-book/@book-toc',
    headers={
        'Accept': 'application/json',
        'Authorization': 'Bearer <token>',
    },
)

Response:

{
    "@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.

GET /Plone/my-book/@book-keywords HTTP/1.1
Host: localhost:8080
Accept: application/json

Response:

{
    "keywords": [
        {"keyword": "Baurecht", "count": 5},
        {"keyword": "Eigentum", "count": 3},
        {"keyword": "Vertrag", "count": 2}
    ],
    "total": 3
}

GET @contextnavigation

Enhanced context navigation for books with TOC numbers.

GET /Plone/my-book/@contextnavigation?expand.contextnavigation.bottomLevel=3 HTTP/1.1
Host: localhost:8080
Accept: application/json

Response:

{
    "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"
        }
    ]
}

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

const response = await fetch('https://example.com/my-book/@ask-book-owner', {
  method: 'POST',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
    'Authorization': 'Bearer <token>',
  },
  body: JSON.stringify({
    question: 'What does chapter 3 cover?',
  }),
});
import requests

response = requests.post(
    'https://example.com/my-book/@ask-book-owner',
    headers={
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer <token>',
    },
    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.

POST /Plone/my-book/@pdfserver-convert HTTP/1.1
Host: localhost:8080
Accept: application/json
Content-Type: application/json

Response:

{
    "uid": "conversion-job-uid",
    "status": "pending"
}

GET @pdfserver-status

Check conversion status.

GET /Plone/my-book/@pdfserver-status?uid=conversion-job-uid HTTP/1.1
Host: localhost:8080
Accept: application/json

Response:

{
    "uid": "conversion-job-uid",
    "status": "completed"
}

GET @@pdfserver-download

Download the generated PDF.

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:

/* 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

const response = await fetch('https://example.com/@request-book-access', {
  method: 'POST',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
    'Authorization': 'Bearer <token>',
  },
  body: JSON.stringify({
    books: ['<book-uid>'],
  }),
});
import requests

response = requests.post(
    'https://example.com/@request-book-access',
    headers={
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer <token>',
    },
    json={
        'books': ['<book-uid>'],
    },
)

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.

const response = await fetch('https://example.com/@request-book-access', {
  method: 'POST',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    books: ['<book-uid>'],
    username: '[email protected]',
    fullname: 'John Doe',
    url: 'https://www.example.com',
  }),
});
import requests

response = requests.post(
    'https://example.com/@request-book-access',
    headers={
        'Accept': 'application/json',
        'Content-Type': 'application/json',
    },
    json={
        'books': ['<book-uid>'],
        'username': '[email protected]',
        'fullname': 'John Doe',
        'url': 'https://www.example.com',
    },
)

Successful Response (200)

{
    "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: <uid>

A book UID does not resolve to an existing book.

Book "<title>" 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:

/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 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 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:

// 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 documentation for complete details on the RAG system configuration and API.

Integration Guide

Querying TOC Structure

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