Versioning

Versioning lets editors snapshot the current state of a content object and later inspect or restore an earlier snapshot. It reuses the staging machinery (see Staging (Working Copies)): a version is essentially a frozen working copy of the content kept in a dedicated, hidden container.

Overview

Versioning is available on content that has the Webcloud7 Versioning support behavior enabled. Content pages and chapters have it enabled by default; existing sites get it through an upgrade step. For such content:

  • Editors can create a new version, which stores a full snapshot of the content (including its Simplelayout blocks and internal links).

  • Up to 10 versions are kept per object. When the limit is reached, creating a new version automatically removes the oldest one.

  • Each version records who created it, when, and an optional comment.

  • A version can be compared with the current state of the content.

  • An earlier version can be restored back onto the live object. Restoring adds a “Restored from Version” entry to the object’s review history (see Website Workflow).

Versions are stored in a site-wide container (cms_versions_container). Inside it, each versioned object gets its own ObjectVersions sub-container holding that object’s snapshots. These containers are excluded from navigation and cannot be moved or copied.

Access control

Two permissions gate versioning:

  • wcs.backend Versioning: Access versions — required to list, view, and compare versions.

  • wcs.backend Versioning: Create versions — required to create, delete, and restore versions.

Access to a stored version is additionally checked against the original object: a user may only see a version if they are allowed to access versions on the content it was taken from. Attempts to reach a version without that permission result in a 404 Not Found.

Browsing versions

The list of versions for an object is rendered by the list_versions view on the content.

GET /Plone/my-page/list_versions HTTP/1.1
Host: localhost:8080

The listing shows, per version, the author’s full name, the localized creation time, and the recorded comment, together with the actions to view, compare with the current version, restore, or delete that version.

Comparing a version

The compare_version view shows how a version differs from the current state of the content. It is opened with the Compare with current version button in the version listing and requires the same access as viewing versions. Versions are addressed by their index (0 is the oldest kept version); a missing or unknown version_id results in a 404 Not Found.

GET /Plone/my-page/compare_version?version_id=0 HTTP/1.1
Host: localhost:8080

The header names the author, creation time, and comment of the selected version, next to a legend of the three kinds of marks (Selected version, Current version, Formatting changed). Below it, only the changes are shown, in page order. Unchanged blocks are left out; a divider tells how many were skipped. A changed block shows its title and only the parts that changed, e.g. its text but not its unchanged image, because the comparison cannot display unchanged parts the way the block’s settings arrange them on the page. Added and removed blocks are shown in full. The changes are marked as follows:

  • Changes are marked inline on whole words: text removed since the version is struck through on red, added text is highlighted on green. A word edited in the middle shows the old word as removed and the new one as added. Changes that only affect whitespace are not reported.

  • Formatting changes to existing text are highlighted on yellow, e.g. text made bold, italic or plain, a link pointing to a new target, or a paragraph turned into a heading. Hovering such a highlight tells what changed, e.g. “Bold added” or the old and new link target. Formatting of added or removed text counts as part of that addition or removal.

  • Changed table cells are marked inside the cell; added or removed list items and table rows are marked as a whole. Images, embedded frames and horizontal rules get a colored frame: green when added, red and faded when removed, and yellow when only their properties changed. A replaced image or frame shows the old one followed by the new one.

  • Every row with changes is framed in the color of its change and starts with a line naming the block’s type, plus the block’s title when the page does not display it, and a label: Changed (yellow) for changed content, Added (green) or Removed (red) for whole blocks, Moved (blue) for blocks that changed position, and Metadata changed (grey) when only the metadata changed. Removed blocks appear where they used to be.

  • Previous change and Next change step through the rows with changes, including rows whose only change is in their metadata, with a counter showing the current position and the total. The counter follows scrolling, so the buttons continue from the change in the middle of the screen.

  • Each block is shown in its own row: its content on the left, and its changed metadata on the right, each with the old and the new value (e.g. whether the title is shown, or an image’s alternative text). Changed metadata of the page itself (e.g. summary, publication dates, related items) comes first, in a row of its own. Related items are shown by their titles, choices by their labels (e.g. the sort order of a listing), and empty values as “—”. A block’s title is compared inline when the block displays it, otherwise it is listed with the metadata. On small screens the metadata appears below its block.

  • Links between blocks of the same page are not reported as changes, even though versions store them differently.

If neither content nor metadata differ, a notice says so. Content types that do not use Simplelayout blocks are compared as a whole.

Note

The comparison has some limitations:

  • A line break on its own, without other changes, is not reported.

  • Custom, non-standard HTML elements inside text are compared as a whole: any change inside one shows the whole element as removed and added again.

  • An image counts as unchanged when its filename and file size match.

  • Files inside a block are compared by their titles only.

  • Column widths and other layout properties are not compared; only the order of the blocks is.

Creating a version

A new snapshot is created by posting to the create_version view on the content. This requires the create versions permission. When the per-object limit of 10 is already reached, the oldest version is dropped to make room.

POST /Plone/my-page/create_version HTTP/1.1
Host: localhost:8080

After creation the user is returned to the version listing.

Restoring a version

Posting to restore_version with a version_id copies the selected snapshot back onto the live object, rewriting internal links and Simplelayout block references so the restored content is consistent. A “Restored from Version” entry is appended to the object’s review history.

Restoring leaves the stored version unchanged. Blocks deleted from the page since the version was taken are restored as copies; restoring the same version again updates those blocks in place instead of duplicating them.

POST /Plone/my-page/restore_version HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded

version_id=0

After restoring, the user is returned to the live object.

Deleting a version

Posting to delete_version with a version_id permanently removes a single stored snapshot.

POST /Plone/my-page/delete_version HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded

version_id=0

Note

The browser views address versions by their position among the kept versions (0 is the oldest). Because the oldest version is dropped once the limit of 10 is exceeded, and deleting a version moves the later ones up, an index refers to “the n-th currently kept version”, not to a stable identifier. The REST API addresses versions by their stable id instead (see REST API).

REST API

The @versions endpoint is available on content with versioning enabled; on all other content it answers 404. It addresses a version by its id, which, unlike the index the browser views use, stays the same for the version’s whole life. Use the @id URLs from the responses instead of building them yourself.

Listing, reading and comparing versions is open to users who may access versions; creating, restoring and deleting to users who may create versions (see Access control). The responses tell which of these actions the calling user may take, so a client knows which buttons to offer.

GET @versions

GET /Plone/my-page/@versions HTTP/1.1
Host: localhost:8080
Accept: application/json
{
    "@id": "http://localhost:8080/Plone/my-page/@versions",
    "can_create": true,
    "items": [
        {
            "@id": "http://localhost:8080/Plone/my-page/@versions/c9f0f895fb98476ba0a1d2e3f4b5c6d7",
            "id": "c9f0f895fb98476ba0a1d2e3f4b5c6d7",
            "title": "My page",
            "created": "2026-09-22T09:12:03.482915+00:00",
            "creator": {"id": "jane.doe", "fullname": "Jane Doe"},
            "comment": "Before the reorganisation",
            "can_restore": true,
            "can_delete": true
        },
        {
            "@id": "http://localhost:8080/Plone/my-page/@versions/8f14e45fceea467a9d5e1c3b2a7d6f01",
            "id": "8f14e45fceea467a9d5e1c3b2a7d6f01",
            "title": "My page",
            "created": "2026-09-15T14:30:41.207153+00:00",
            "creator": {"id": "john.smith", "fullname": "John Smith"},
            "comment": "",
            "can_restore": true,
            "can_delete": true
        }
    ],
    "items_total": 2
}

items is sorted newest first and is not batched, as at most 10 versions are kept. title is the content’s title when the version was taken, created the creation time in UTC. creator.fullname falls back to the user id when the user has no full name, and comment is an empty string when none was given. can_create tells whether the calling user may create versions, can_restore and can_delete whether they may restore or delete this one.

POST @versions

Creates a version of the current state of the content. The JSON body is optional; its comment is stored with the version (null or no comment means none). When 10 versions are already kept, the oldest is dropped. Answers 201 with the new version, in the same shape as a list item.

POST /Plone/my-page/@versions HTTP/1.1
Host: localhost:8080
Accept: application/json
Content-Type: application/json
X-CSRF-TOKEN: <token>

{"comment": "Before the reorganisation"}
{
    "@id": "http://localhost:8080/Plone/my-page/@versions/c9f0f895fb98476ba0a1d2e3f4b5c6d7",
    "id": "c9f0f895fb98476ba0a1d2e3f4b5c6d7",
    "title": "My page",
    "created": "2026-09-22T09:12:03.482915+00:00",
    "creator": {"id": "jane.doe", "fullname": "Jane Doe"},
    "comment": "Before the reorganisation",
    "can_restore": true,
    "can_delete": true
}

GET @versions/{id}

Returns one version, in the same shape as a list item.

GET /Plone/my-page/@versions/c9f0f895fb98476ba0a1d2e3f4b5c6d7 HTTP/1.1
Host: localhost:8080
Accept: application/json

GET @versions/{id}/comparison

Returns how the version differs from the current content, as the rows the comparison view shows (see Comparing a version), in page order.

GET /Plone/my-page/@versions/c9f0f895fb98476ba0a1d2e3f4b5c6d7/comparison HTTP/1.1
Host: localhost:8080
Accept: application/json
{
    "@id": "http://localhost:8080/Plone/my-page/@versions/c9f0f895fb98476ba0a1d2e3f4b5c6d7/comparison",
    "version": {
        "@id": "http://localhost:8080/Plone/my-page/@versions/c9f0f895fb98476ba0a1d2e3f4b5c6d7",
        "id": "c9f0f895fb98476ba0a1d2e3f4b5c6d7",
        "title": "My page",
        "created": "2026-09-22T09:12:03.482915+00:00",
        "creator": {"id": "jane.doe", "fullname": "Jane Doe"},
        "comment": "Before the reorganisation",
        "can_restore": true,
        "can_delete": true
    },
    "changes_total": 3,
    "items": [
        {
            "kind": "page",
            "metadata": [
                {"label": "Summary", "old": "—", "new": "Opening hours and how to reach us"}
            ],
            "anchor": "version-diff-change-1"
        },
        {"kind": "skipped", "count": 2},
        {
            "kind": "block",
            "type": "Block",
            "hidden_title": null,
            "change": "changed",
            "moved": false,
            "html": "<h3>Opening hours</h3><div><div><p>Open <del class=\"bg-danger-subtle text-danger-emphasis me-1\">Monday</del><ins class=\"bg-success-subtle text-success-emphasis text-decoration-none\">Tuesday</ins> to <mark class=\"bg-warning-subtle text-warning-emphasis\" title=\"Bold added\"><strong>Friday</strong></mark>.</p></div></div>",
            "metadata": [],
            "anchor": "version-diff-change-2"
        },
        {
            "kind": "block",
            "type": "Block",
            "hidden_title": "Parking",
            "change": "removed",
            "moved": false,
            "html": "<div><p>Visitor parking is behind the building.</p></div>",
            "metadata": [],
            "anchor": "version-diff-change-3"
        }
    ]
}

version is the compared version, in the same shape as a list item. Each entry in items has a kind:

  • page: the changed metadata of the page itself. Present only when there is some, and always first.

  • block: a block with changes. type is its translated type name, hidden_title its title when the page does not display it (otherwise null), and moved tells whether it changed position. change is changed, added or removed, or null when the block was only moved or only its metadata changed. Removed blocks appear where they used to be.

  • skipped: count unchanged blocks left out at this position.

metadata lists the changed fields as label, old and new, already formatted and translated for display; empty values are “—”. Content without blocks is compared as a whole, as a single block that also carries the page’s metadata.

html is the block’s content, ready to insert. For added and removed blocks it is the whole block without marks; frame it according to change. For changed blocks it holds the title, if the page displays it, and only the parts that changed, marked inline:

  • <del>: only in the selected version, i.e. removed since.

  • <ins>: only in the current content, i.e. added since.

  • <mark title="…">: formatting changed. The title says what, e.g. “Bold added” or “Link target changed from /old to /new”; show it as a tooltip.

Images, embedded frames and horizontal rules inside a <del>, <ins> or <mark> additionally get frame classes (a thick red, green or yellow border), as a background color does not show on them. All classes are Bootstrap 5.3 utilities, so a Bootstrap frontend renders the marks like the comparison view. Other clients may ignore the classes and style del, ins, mark and the images inside them themselves.

changes_total counts the page and block rows. Their anchor values run from version-diff-change-1 to version-diff-change-<changes_total> in row order; use them as element ids to build Previous change / Next change navigation with a position counter. A changes_total of 0 means the version does not differ from the current content; items is then empty.

POST @versions/{id}/restore

Restores the version onto the content, as described in Restoring a version. Takes no body and returns the restored content, the same body a GET of the content returns, so no follow-up request is needed. The version itself is kept.

POST /Plone/my-page/@versions/c9f0f895fb98476ba0a1d2e3f4b5c6d7/restore HTTP/1.1
Host: localhost:8080
Accept: application/json
X-CSRF-TOKEN: <token>

DELETE @versions/{id}

Permanently deletes the version. Returns the updated list, the same payload as GET @versions.

DELETE /Plone/my-page/@versions/8f14e45fceea467a9d5e1c3b2a7d6f01 HTTP/1.1
Host: localhost:8080
Accept: application/json
X-CSRF-TOKEN: <token>

Expanding versions into a content request

Content with versioning advertises the endpoint in its @components:

{
    "@id": "http://localhost:8080/Plone/my-page",
    "@components": {
        "versions": {"@id": "http://localhost:8080/Plone/my-page/@versions"}
    }
}

Add ?expand=versions to pull the list into that same response:

GET /Plone/my-page?expand=versions HTTP/1.1
Accept: application/json

The expanded value is what GET @versions returns. For users who may not access versions it keeps only the @id, so expanding never fails the content request. Content without versioning has no versions entry in @components at all.

CSRF

POST and DELETE require a CSRF token, whatever the authentication method — cookie, Basic auth or JWT alike. Send it as an X-CSRF-TOKEN header; the token is available from the @@authenticator/token view on any context. A write without a valid token answers 403.

The token is bound to the user it was minted for, so fetch it with the same credentials used for the write.

Errors

Status

Cause

400

POST @versions with a body that is not a JSON object, or a comment that is neither a string nor null

401

Not logged in, or not allowed to access versions (reading) or to create versions (writing)

403

Missing or invalid CSRF token

404

Unknown version id or path, or content without versioning

Round trip from JavaScript

List the versions, compare the newest one with the current content, and restore it:

const pageUrl = '/Plone/my-page';

async function csrfToken() {
    const response = await fetch(`${pageUrl}/@@authenticator/token`);
    return (await response.text()).trim();
}

async function call(url, options = {}) {
    const response = await fetch(url, {
        ...options,
        headers: { 'Accept': 'application/json', ...options.headers },
    });
    if (!response.ok) {
        throw new Error(`${url} failed with status ${response.status}`);
    }
    return response.json();
}

const versions = await call(`${pageUrl}/@versions`);
const newest = versions.items[0];

if (newest) {
    const comparison = await call(`${newest['@id']}/comparison`);
    for (const row of comparison.items) {
        if (row.kind === 'skipped') {
            console.log(`${row.count} unchanged block(s)`);
        } else {
            console.log(row.anchor, row.kind === 'page' ? 'Page' : row.type, row.change, row.metadata);
        }
    }

    if (comparison.changes_total && newest.can_restore) {
        const content = await call(`${newest['@id']}/restore`, {
            method: 'POST',
            headers: { 'X-CSRF-TOKEN': await csrfToken() },
        });
        console.log(`Restored "${content.title}"`);
    }
}

The response of the restore is the restored content; render it directly instead of re-fetching.