Files
RenewCMS/docs/api/article-localization.md
T

24 KiB

Article versions, translations, revisions and story move

Contract for features/article_translation_create.feature, features/article_edit_revision.feature and features/article_story_lifecycle.feature. The SPA mocks /v1 from this page. The article object itself is described in articles.md, the languages of a site in site-languages.md.

Vocabulary

  • A story groups the language versions of one article inside one site. A version is one article row: it has its own id, lang, title, slug, content, publication and created_by. A story holds at most one live version per language.
  • revision is the edit counter of a version, starting at 1, raised by 1 per saved change (see What raises revision). An internal content counter is also kept; it is never exposed on a version, only as source.revision.
  • A reader of a version is a caller who passes the existing read rule: an online version is readable by everyone, an offline one by a verified caller whose content.read covers it (all, or own and the caller created it).
  • Missing means no readable version. Everything an article read says about sibling versions (translations, source, error bodies, headers, counts) is limited to the versions the caller can read. No count, placeholder, total, header or error text ever names, counts or hints at a version the caller cannot read.

Errors

Errors are plain text. The body is the code (or an existing message) followed by a line feed, and extra data travels in response headers. A refused request changes nothing.

Header Set on Value
X-Current-Revision 409 revision_conflict the current revision of the version, an integer
X-Existing-Article-Id 409 language_exists id of the readable version that already holds the language
X-Conflicting-Articles 409 slug_conflict ids of the readable versions whose address is taken at the destination, comma separated, in creation order. Absent when none of them is readable
X-Missing-Languages 422 language_not_enabled on a move or on PATCH site_id tags missing or disabled at the destination, comma separated, sorted, readable versions only. Absent when empty
X-Slug-Suggestion 409 slug_taken first free <slug>-N (existing)
X-Missing-Permission 403 missing_permission the permission key (existing)

The headers are set on those responses only. Same-origin SPA code reads them directly; cross-origin scripts do not see them (Access-Control-Expose-Headers is unchanged).

Status Body Meaning
400 The request cannot be processed due to a mismatch in the format of the body. undecodable or invalid body, missing copy, copy: false with a source, if_revision invalid, an unknown or empty update
400 The server expects the ID to be in the format of an unsigned 32-bit integer (uint32). invalid id in the path
400 the validator text (for example Key: 'PostTranslation.Title' Error:Field validation for 'Title' failed on the 'min' tag) lang or title missing, title shorter than 3 or longer than 200 characters on a create
400 Article title must be between 3 and 200 characters. a PATCH title outside 3 to 200 runes
400 invalid_slug a PATCH slug that is not a slug
401 You must be logged in to access this resource. no valid session or key
403 missing_permission the caller lacks the permission (X-Missing-Permission)
404 Article not found the version, or the story, does not exist or the caller cannot read it
404 source_unavailable the chosen source does not exist or is not readable; the same answer for both
404 site_not_found POST /v1/story/{story_id}/move with an unknown site key
409 revision_conflict if_revision is not the current revision (X-Current-Revision)
409 language_exists the language already has a readable version (X-Existing-Article-Id)
409 language_unavailable one neutral answer: the language is disabled on the site, or its version exists but is not readable by the caller, or a concurrent request created it first
409 setup_required the version has no language yet (lang ""): the site default is not confirmed
409 slug_taken a PATCH slug used by another article of the same site and language (X-Slug-Suggestion)
409 slug_conflict a move would put an address that already exists at the destination (X-Conflicting-Articles)
409 story_move_required PATCH site_id on a version whose story holds several versions
409 story_busy the database could not lock the rows in time (lock timeout, deadlock or serialization failure); retry
422 invalid_language the tag is not a language tag
422 unsupported_language a valid tag outside the supported list
422 language_not_enabled the site has no language row for the tag (creation), or a destination site lacks or has disabled a language of the story (move, X-Missing-Languages)
422 language_disabled publishing an offline version whose language is disabled on its site
422 source_other_story the source belongs to another story

Fields added to an article read

Added to the article object of GET /v1/article/{id}, POST, PATCH, publish, unpublish, and for revision also to each item of GET /v1/article/. Every other key keeps its name, value and type.

{
  "id": 10,
  "lang": "en",
  "story_id": 5,
  "revision": 3,
  "capabilities": ["read", "edit", "publish", "delete", "create_translation"],
  "translations": [
    {"id": 10, "lang": "en", "title": "Renovation costs", "is_online": true, "language_enabled": true, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]},
    {"id": 13, "lang": "ar", "title": "Takalif", "is_online": true, "language_enabled": true, "capabilities": ["read", "create_translation"]},
    {"id": 12, "lang": "fr", "title": "Couts", "is_online": false, "language_enabled": false, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]}
  ],
  "source": {"article_id": 10, "revision": 4}
}
  • revision is a positive integer on every read and write, public reads included.
  • capabilities lists what the caller may do with this version, always in this order and always starting with read: edit (content.update covers it), publish (content.publish), delete (content.delete), create_translation (the caller holds content.create). An anonymous caller gets ["read"].
  • translations lists the readable versions of the story, the version being read included, with id, lang, title, is_online, language_enabled and their own capabilities. A version without language (lang "") reports language_enabled: true. The order is the default language of the site first, then the other tags in ascending order (en, ar, de, fr); a disabled language is still listed with language_enabled: false. A single-version article lists itself. An anonymous caller gets [].
  • source is {"article_id", "revision"} when the version was created by copying another version and that source still exists and is readable by the caller, otherwise null (a version created empty, a deleted source, an unreadable source, an anonymous caller). source.revision is the content revision of the source at the moment of the copy.

What raises revision

A PATCH raises revision by exactly 1 when it changes at least one stored value, whatever the number of fields, and not at all when it changes nothing. Publishing and unpublishing never raise it.

Changed field revision internal content revision
title, body, excerpt, slug, cover_image_alt, seo_title, seo_description, social_title, social_description +1 +1
author_ids, cover_image_id, social_image_id, canonical_url, noindex, seo_dismissed, site_id +1 unchanged

Existing rows start at revision 1. A story move raises each moved version by 1.

Editing: PATCH /v1/article/{id}

Verified session or API key with content.update on the version (403 missing_permission otherwise, checked first) and able to read it (404 Article not found otherwise, checked second), so a stale if_revision never reveals anything to a caller who cannot edit or read the version.

  • title: 3 to 200 runes (a 4-byte emoji counts as one). "", 2 and 201 runes answer 400 Article title must be between 3 and 200 characters.; null answers 400 with the body-mismatch message. Editing the title never changes the slug and never changes the publication: an online version shows the new title publicly at once.
  • slug: changed only when sent. "" regenerates it from the current title. There is no redirect, the old address stops resolving. Unique per site and language: the same slug in two languages of a story is allowed. A slug already used by another article of the same site and language answers 409 slug_taken with X-Slug-Suggestion. Sending the current slug changes nothing.
  • if_revision: optional integer >= 1. A value that is 0, negative, a string, a fraction or null answers 400 (body mismatch). if_revision alone is not an update (400). When it differs from the current revision, nothing is stored and the answer is 409 revision_conflict with X-Current-Revision, even when the rest of the request would change nothing. The compare and the write are one atomic step: of two concurrent saves with the same if_revision, one answers 200 and the other 409. Without if_revision the last write wins. To overwrite after a conflict, send if_revision equal to the X-Current-Revision the conflict showed; if somebody saved in between, the answer is 409 again with the newer revision.
  • site_id on a version whose story holds several versions the caller can read answers 409 story_move_required and changes nothing (hidden versions are not counted, so the answer never reveals them). When the caller reads only this version of a larger story, content.update is required on every version of the story, readable or not (403 missing_permission otherwise, naming no version) and the request runs the story move rules: every version moves in one transaction, language and slug refusals roll everything back, and headers name readable versions only. The other fields of the same request (title, author_ids, slug, SEO fields) are part of that transaction: a refusal of any of them, an unknown author or an invalid SEO value included, leaves every version on its original site and every revision unchanged, and the edited version's revision rises by exactly 1 for the whole request. To move a story with several readable versions, use the story move. On a story with one version it moves the version when its language is enabled at the destination (422 language_not_enabled, with X-Missing-Languages, otherwise); a version without language moves without a language check. The slug rule of the destination still applies (409 slug_taken).
  • lang, story_id, source_article_id and copy in the body are ignored.

The 200 body is the full article object with the new revision.

Publishing

PATCH /v1/article/{id}/publish on an offline version whose language is disabled on its site answers 422 language_disabled. Publishing an already online version is idempotent (200, unchanged) even when its language is disabled; a version without language publishes as before. unpublish and PATCH stay available in a disabled language. Publishing or unpublishing one version never touches its siblings.

Creating a translation

Two entry points share one implementation: the same result, errors, headers and stored data.

POST /v1/article/{id}/translations

Verified session or API key with content.create; the version {id} must be readable by the caller.

{"lang": "fr", "title": "Couts de renovation", "copy": true, "source_article_id": 11}
Field Rule
lang required; normalized (FR is stored as fr)
title required, 3 to 200 characters
copy required boolean (400 when absent)
source_article_id optional, only with copy: true (400 with copy: false); default is {id}; must be a readable version of the same story

The new version joins the story of {id}, in the same site, as an offline draft created by the caller, with revision 1. The slug is generated from the new title in (site, language) and gets the next free -N suffix when taken.

  • copy: true copies body, excerpt, seo_title, seo_description, social_title, social_description, cover_image_id, cover_image_alt, social_image_id and the authors from the source, and never slug, canonical_url (empty), noindex (false) or seo_dismissed (empty). No image or file row is duplicated. The source and its content revision are captured in the same transaction as the copy: the read shows "source": {"article_id": <source>, "revision": <content revision then>}.
  • copy: false starts empty: empty text, no images, authors is the creating user's byline, "source": null.

Answers 201 with the created article object. Order of checks: permission, path id, body, version readable, copy rules, language tag, setup_required, source, language configuration. A story deleted in the meantime answers 404 Article not found. Both entry points leave the stored lang normalized.

POST /v1/article/ with story_id

{"title": "Couts", "lang": "fr", "story_id": 5, "copy": true, "source_article_id": 10}

story_id selects the story (404 Article not found when it has no readable version, the same answer as a missing story) and the first readable version, oldest first, plays the role of {id}. lang is then required (400). source_article_id and copy are only valid with story_id (400 otherwise).

POST /v1/article/ without story_id

Unchanged: it creates a new story with one offline version. lang is now optional: absent, the version gets the starting language of the site as before; present, it is normalized and must be an enabled language of the default site (422 invalid_language, unsupported_language, language_not_enabled; 409 language_unavailable when disabled; 409 setup_required before the site default is confirmed).

Deleting

DELETE /v1/article/{id} removes one version (204, empty body) and checks content.delete per version (403 missing_permission). Other versions and their story remain. Deleting the last version of a story removes the story. Nothing in the answer mentions a sibling. A deleted language can be created again in the same story; a language with a deleted version still counts as in use for removing the language from the site.

Moving a story: POST /v1/story/{story_id}/move

{"site": "atelier"}

Verified session or API key. The caller needs content.update on every version of the story, readable or not, and must read at least one (404 Article not found otherwise, also for an unknown story). site is the key of the destination (400 when absent or empty, 404 site_not_found when unknown). Answers 200:

{"story_id": 5, "site": "atelier", "site_id": 2, "article_ids": [10, 12, 11, 13]}

article_ids lists the readable versions only, oldest first. Every version moves in one transaction: site_id changes, slugs, languages, publication, content and authors are unchanged, revision of each moved version rises by 1 and its content revision does not. Moving to the current site answers 200 and changes nothing.

Everything is checked inside the transaction, under the locks of both sites, and nothing moves when something fails: every language of every version (readable or not) must be enabled at the destination (422 language_not_enabled, X-Missing-Languages limited to languages of readable versions, a language disabled at the destination counts as missing, a language disabled at the source does not matter), and no address may exist at the destination in the same language (409 slug_conflict, X-Conflicting-Articles limited to readable versions). A conflict that appears between the check and the write answers 409 slug_conflict without the header.

Editorial list by story: GET /v1/article/?group=story

An opt-in shape of the list for a signed-in, verified caller whose role resolves. Without group the response keeps the flat envelope { items, total, page, size, counts } of the paginated list, whose counts are all, online, draft and suggestions. A caller without a valid session, or with an unverified user, who sends group gets 400 with the body-mismatch message and nothing is read.

Parameter Meaning
group Only story. group=, any other value, or group from a public caller is 400.
lang A supported language tag, normalized like the language of a version (fr, pt-br becomes pt-BR). Empty, malformed or unsupported is 400. Also narrows the flat list (items, total, counts.all, counts.online, counts.draft). Disabled languages can be selected.
tab all (default) or missing. review, an empty value, any other value, and missing without group=story are 400.
author A byline id, an integer of at least 1; anything else is 400. Also narrows the flat list.
page, size, q, status, site, seo, sort Same values, defaults and validation as the flat list.

Only versions the caller can read count: they are titled, credited, listed in coverage, searched and counted. A hidden version never appears, and a story whose versions are all hidden has no row.

Rows. With no lang a row is a story: total counts stories. With lang a row is the version of that language (tab=all), and the whole story's coverage, story_authors and missing_langs still ride on the row. With tab=missing rows are stories again: lang=fr lists the stories on a site that enables fr and that have a readable version but none in fr; without lang it lists the stories that lack at least one enabled language, and a caller who cannot create (no content.create) gets total 0. Articles without language (lang "") are one story each and are never listed as missing a language. A version of the story must satisfy status, author, q and seo together to make the story match; q ignores case and accents.

Item. The article list item of the representative version (the one in lang, otherwise the site default language version, otherwise the first readable one by language tag), plus:

Field Type Meaning
story_updated_at RFC 3339 The latest updated_at among the readable versions of the story
story_authors array of bylines The union of the bylines of the readable versions, without duplicates
coverage array One entry per readable version, in the order of translations (default language, other enabled tags, disabled tags): { id, lang, title, is_online, language_enabled, capabilities }
missing_langs array of tags Enabled languages without a readable version, default first then by tag; [] when the caller cannot create
capabilities array The capabilities of the representative version

Sort. The whitelist is the flat one. updated_at uses the latest readable version of the story, created_at the earliest, title and status the representative; with lang the version itself. Ties end with story_id descending.

Counts. { all, online, draft, suggestions, missing }. They follow lang, q, author, seo and site but never status or tab, and count the unit of the mode (stories, or versions of the selected language). online and draft count the units with a readable online or draft version. missing is the total the not-in view would give under the same filters (0 without lang for a caller who cannot create). suggestions is the number of stories with a readable version whose seo_status is suggestions, ignoring every parameter.

A repository failure at any step, counts included, answers 500 Failed to retrieve articles.

Mock-ready examples

Statuses of the new and changed calls. Bodies are plain text unless shown as JSON; headers listed are the only extra ones.

Call Status Headers Body
GET /v1/article/10 (see above) 200 article object with revision, capabilities, translations, source
POST /v1/article/10/translations {"lang":"fr","title":"Couts","copy":true} 201 article object, lang "fr", "is_online": false, "revision": 1, "source": {"article_id": 10, "revision": 4}
DELETE /v1/article/12 204 empty
POST /v1/story/5/move {"site":"atelier"} 200 {"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}
POST /v1/article/10/translations {"lang":"fr","title":"Couts"} 400 The request cannot be processed due to a mismatch in the format of the body.
PATCH /v1/article/10 {"title":"ab"} 400 Article title must be between 3 and 200 characters.
POST /v1/article/10/translations with a cookie of an anonymous client 401 You must be logged in to access this resource.
POST /v1/story/5/move by a user holding content.update at own 403 X-Missing-Permission: content.update missing_permission
POST /v1/article/9999/translations 404 Article not found
POST /v1/article/10/translations with "source_article_id":9999 404 source_unavailable
POST /v1/story/5/move {"site":"nowhere"} 404 site_not_found
PATCH /v1/article/10 {"body":"x","if_revision":3} after somebody saved 409 X-Current-Revision: 4 revision_conflict
POST /v1/article/10/translations {"lang":"de","title":"Kosten","copy":true} 409 X-Existing-Article-Id: 11 language_exists
POST /v1/article/10/translations {"lang":"fr","title":"Couts","copy":true} with fr disabled, unreadable or raced 409 none language_unavailable
POST /v1/article/30/translations on an article without language 409 setup_required
PATCH /v1/article/10 {"slug":"taken-en"} 409 X-Slug-Suggestion: taken-en-2 slug_taken
POST /v1/story/5/move {"site":"atelier"} with an address taken at the destination 409 X-Conflicting-Articles: 12 slug_conflict
PATCH /v1/article/10 {"site_id":2} on a story with several readable versions 409 story_move_required
POST /v1/article/10/translations {"lang":"pt","title":"Custos","copy":true} with no pt row 422 language_not_enabled
POST /v1/story/5/move {"site":"atelier"} with no ar at the destination 422 X-Missing-Languages: ar language_not_enabled
PATCH /v1/article/15/publish with es disabled 422 language_disabled
POST /v1/article/10/translations {"lang":"xx","title":"Couts","copy":true} 422 unsupported_language
POST /v1/article/10/translations {"lang":"english","title":"Couts","copy":true} 422 invalid_language
POST /v1/article/10/translations with "source_article_id":20 of another story 422 source_other_story

Activity

No new action code. The existing events carry params.lang (the language of the version, omitted for a version without language): article.create, article.edit, article.publish, article.unpublish, article.delete. A translation creation records article.create on the new version with params.lang, params.story_id and params.source_article_id (only when copied). A story move records one article.edit per moved version, with a site change (site names before and after) and params.lang; moving to the current site records nothing. A refused creation or move records one failed event with a plain reason that names no version; refusals answered 404 are not recorded.