Logo VitNode

Duplicate content

Let editors copy a VitNode Content Engine record into a new draft, with its translations, relations and repeatable rows, from the AdminCP or from your own code.

Duplication copies one record into a new draft: every field, every translation, relations in their order and repeatable rows. The blog uses it so an editor can start a new article from a similar one, or prepare a rewrite of a published article without touching the live version.

Turn on duplication

Add duplication: true to the definition. The blog's article content type does exactly that:

plugins/blog/src/content/post.ts
export const blogPostContentType = defineContentType({
  id: "blog.post",
  tableName: "blog_posts",
  publication: true,
  duplication: true, 
  // ...
});

Duplication adds no column, so there is nothing to migrate, and no new permission. Duplicating needs can_create and can_view, because it creates a record by reading another. For the blog those are Create articles and View articles list. Run pnpm build:plugins and restart pnpm dev.

Duplicate an article in the AdminCP

Open AdminCP → Blog → Articles, open the More actions menu of an article and choose Duplicate. Confirm with Yes, duplicate it:

Duplicate Article dialog asking to make a draft copy of Hello world, with Cancel and Yes, duplicate it buttons

The list refreshes, a toast says the article was duplicated as a draft, and the copy opens on its edit page (or in the edit dialog, for a content type whose admin.edit.mode is "dialog"). If some of the source's languages are switched off on this site, a second toast lists the ones that were left out.

Duplicating the blog's "Hello world", published in English and Polish with its categories and authors, creates a draft with:

LanguageTitleSlug
EnglishHello world (Copy)hello-world-copy
PolishWitaj świecie (Copy)witaj-swiecie-copy

The copy has the same categories and the same authors in the same order, and each language starts its own revision history.

What the copy contains

CopiedNot copied
Every declared field, including rich text, groups and blocksRevisions, schedules and preview links
Repeatable rows, as new rows in the same orderSlug history and redirects
Relation, user and file references, in their order. Files are reused, not uploaded againPublished state and publishedAt: the copy is a draft
Every translation, each as a draft with its own first revisionHidden state: the copy is never hidden

The copy gets a new id, new timestamps and version 1. Everything happens in one database transaction, so a failure leaves nothing behind. The source is only read, so its history gets no new revision.

Titles and slugs

When admin.titleField is a field.text(), the copy's title gets a suffix in each language, such as "Hello world (Copy)". The source title is shortened when the suffix would push it past the field's maxLength. The suffix is the core API message core.content.duplicate.title_suffix, resolved in each copied language and falling back to "(Copy)".

Turn the suffix off with duplication: { titleSuffix: false }. titleSuffix: true with a title field that is not a text field throws when the content type is defined.

Each slug is regenerated from the copy's title, so hello-world becomes hello-world-copy. A slug without a source gets -copy appended. When that slug is taken, by a live record or by another record's old URL, the engine tries -2, -3 and so on, up to 20 candidates.

Unique fields

A field.text({ unique: true }) cannot keep the source's value. Duplicating such a record without a new value fails before anything is written, with 422 and the code CONTENT_DUPLICATE_UNIQUE_REQUIRED. Pass the new value as an override from the API, as shown below.

Duplicate from the API

The admin module of a content type with duplication has POST /{id}/duplicate. For the blog, the module is named after its permission module, posts:

POST /api/@vitnode/blog/admin/content/posts/701/duplicate
Content-Type: application/json

{
  "translations": {
    "en": { "title": "Hello again", "friendlyUrl": "hello-again" },
    "pl": { "title": "Witaj ponownie" }
  }
}

The body is optional:

NameTypeDescription
overridesPartial shared field valuesValues the copy takes instead of the source's. A group is merged leaf by leaf. Validated like a create
translations{ [locale]: partial localized values }Localized values per copied language. A language the source has no translation in is a 400

A slug you pass is used as it is, without -copy, and a title you pass is used without the "(Copy)" suffix. In the example above, the English copy is "Hello again" at hello-again, and the Polish copy is "Witaj ponownie" with the slug witaj-ponownie generated from that title.

The response is 201 with { id, sourceId, row, locales, skippedLocales }: the copy's id, the source id, the new row, the copied languages and the languages left out because they are switched off.

StatusWhen
400An invalid id, override or translation
403Missing can_create or can_view
404The source does not exist
409No free slug was found (CONTENT_DUPLICATE_SLUG_CONFLICT, with field, locale and slug), or an override collides with another record
422A unique field needs an override (CONTENT_DUPLICATE_UNIQUE_REQUIRED, with fields)

Duplicate from your own code

service(c).duplicate(sourceId, options) and editorialService(c, { pluginId }).duplicate(sourceId, { actor, ...options }) take the same overrides and translations, plus tx. Both exist only when duplication is on, and both return null when the source does not exist.

The services do not emit events or index the copy. Run contentDuplicateEffects from @vitnode/core/content/server after the transaction commits, as the generated route does:

import { contentDuplicateEffects, resolveContentActor } from "@vitnode/core/content/server";

const { editorialService } = postContent;
if (!editorialService) throw new Error("Articles have no editorial features.");

const pluginId = CONFIG_PLUGIN.pluginId;
const actor = resolveContentActor(c);
const outcome = await editorialService(c, { pluginId }).duplicate(sourceId, {
  actor,
});

if (outcome) {
  await contentDuplicateEffects(
    c,
    postContent,
    { kind: "editorial", outcome },
    { actorUserId: actor.userId, pluginId },
  );
}

The effects emit content.blog.post.created with duplicatedFromId, one translation_created per copied language, and then content.blog.post.duplicated. They also add the copy to the search index as a private draft. See Events.

Check the result

Duplicate "Hello world" and go back to the list. "Hello world (Copy)" is a Draft, and /blog/hello-world-copy answers 404 until you publish it. Open the original's History and you'll find no new revision. As far as the original knows, nothing happened.