Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 68 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,12 @@ For the complete version, please refer to [this example](./examples/12-transform

#### `deriveLinkedEntries(config)`

For each entry of the given content type (source entry), derives a new entry and sets up a reference to it on the source entry. The content of the new entry is generated by the user-provided `deriveEntryForLocale` function.
For each source entry, this function will be called as many times as there are locales in the space. Each time, it will be called with the `from` fields and one of the locales as arguments.
The derive function is expected to return an object with the desired target fields. If it returns `undefined`, the new entry will have no values for the current locale.
For each entry of the given content type (source entry), derives linked entries and sets references on the source entry.

There are two modes:

- Single-entry mode (existing): provide `deriveEntryForLocale` to derive a single entry and write a single link (or a single-element array if the destination is an Array).
- Multi-entry mode (new): provide `deriveEntriesForLocale` to derive 0..N entries per locale and write an array of entry links.

**`config : Object`** – Entry derivation definition, with the following properties:

Expand All @@ -341,14 +344,20 @@ The derive function is expected to return an object with the desired target fiel

- `fields` is an object containing each of the `from` fields. Each field will contain their current localized values (i.e. `fields == {myField: {'en-US': 'my field value'}}`)

- **`deriveEntryForLocale : function (fields, locale, {id}): object`** _(required)_ – Function that generates the field values for the derived entry.
- **`deriveEntryForLocale : function (fields, locale, {id}): object`** _(required for single-entry derivation)_ – Function that generates the field values for the derived entry.

- `fields` is an object containing each of the `from` fields. Each field will contain their current localized values (i.e. `fields == {myField: {'en-US': 'my field value'}}`)
- `locale` one of the locales in the space being transformed
- `id` id of the current entry in scope

The return value must be an object with the same keys as specified in `derivedFields`. Their values will be written to the respective new entry fields for the current locale (i.e. `{nameField: 'myNewValue'}`)

- **`deriveEntriesForLocale : function (fields, locale, {id}): { fields }[]`** _(required for multi-entry derivation)_ – Return an array of child entries to upsert for the current locale. Each array element must be shaped like CMA entries: `{ fields: { fieldId: { [locale]: value } } }`.

- **`derivedEntryId: function ({ sourceId, locale, index, candidate }): string`** _(optional)_ – Custom deterministic ID generator for each derived child in multi-entry mode. Defaults to `${sourceId}-${toReferenceField}-${locale}-${index}` (truncated to 64 chars with a short hash suffix if needed).

- **`publishDerived : 'always' | 'never' | 'preserve'`** _(optional)_ – Publish behavior for derived child entries in multi-entry mode. Defaults to `'preserve'`. Parent publish behavior is still controlled by `shouldPublish`.

- **`shouldPublish : bool|'preserve'`** _(optional)_ – If true, both the source and the derived entries will be published. If false, both will remain in draft state. If preserve, will keep current states of the source entries (default `true`)

##### `deriveLinkedEntries(config)` Example
Expand Down Expand Up @@ -379,6 +388,61 @@ migration.deriveLinkedEntries({

For the complete version of this migration, please refer to [this example](./examples/15-derive-entry-n-to-1.js).

##### Deriving multiple entries (multi-entry mode)

When deriving multiple entries per locale, the destination field `toReferenceField` must be an `Array<Link<Entry>>` and, if it has `linkContentType` validation, it must include `derivedContentType`.

```javascript
migration.deriveLinkedEntries({
contentType: 'post',
from: ['raw'],
toReferenceField: 'items',
derivedContentType: 'jsonItem',
derivedFields: ['kind', 'key', 'index', 'payload'],
shouldPublish: 'preserve',
// Multi-entry derivation: return 0..N child entries for each (source, locale)
deriveEntriesForLocale: (from, locale, { id }) => {
const raw = from.raw?.[locale]
if (raw == null) return []
if (Array.isArray(raw)) {
return raw.map((value, i) => ({
fields: {
kind: { [locale]: 'array' },
index: { [locale]: i },
payload: { [locale]: value }
}
}))
}
if (typeof raw === 'object') {
return Object.entries(raw).map(([k, v]) => ({
fields: {
kind: { [locale]: 'object' },
key: { [locale]: String(k) },
payload: { [locale]: v }
}
}))
}
return [
{
fields: {
kind: { [locale]: 'scalar' },
index: { [locale]: 0 },
payload: { [locale]: raw }
}
}
]
},
// Optional custom child IDs; defaults to `${sourceId}-${toReferenceField}-${locale}-${index}`
derivedEntryId: ({ sourceId, locale, index }) =>
`${sourceId}-items-${locale}-${index}`.slice(0, 63)
})
```

Notes:

- Only locales present in the source `from` fields are written to children.
- Re-runs are idempotent: children are upserted by deterministic IDs and links are set positionally per locale. An empty array result writes `[]` to the destination field.

#### `transformEntriesToType(config)`

For the given (source) content type, transforms all its entries according to the user-provided `transformEntryForLocale` function into a new entry of a specific different (target) content type. For each entry, the CLI will call the function `transformEntryForLocale` once per locale in the space, passing in the `from` fields and the locale as arguments. The transform function is expected to return an object with the desired target fields. If it returns `undefined`, this entry locale will be left untouched.
Expand Down
24 changes: 22 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,13 +598,33 @@ export interface IDeriveLinkedEntriesConfig {
/** (optional) – If true, both the source and the derived entries will be published. If false, both will remain in draft state. If preserve, will keep current states of the source entries (default true) */
shouldPublish?: boolean | 'preserve'
/**
* (required) – Function that generates the field values for the derived entry.
* (required for single-entry derivation) – Function that generates the field values for the derived entry.
* fields is an object containing each of the from fields. Each field will contain their current localized values (i.e. fields == {myField: {'en-US': 'my field value'}})
* locale one of the locales in the space being transformed
*
* The return value must be an object with the same keys as specified in derivedFields. Their values will be written to the respective new entry fields for the current locale (i.e. {nameField: 'myNewValue'})
*/
deriveEntryForLocale: (inputFields: ContentFields, locale: string) => { [field: string]: any }
deriveEntryForLocale?: (inputFields: ContentFields, locale: string) => { [field: string]: any }

/** (required for multi-entry derivation) – Function that generates 0..N child entries for this (source, locale). If provided, overrides deriveEntryForLocale. */
deriveEntriesForLocale?: (
fromFields: ContentFields,
locale: string,
context: { id: string }
) =>
| Array<{ fields: { [field: string]: { [locale: string]: any } } }>
| Promise<Array<{ fields: { [field: string]: { [locale: string]: any } } }>>

/** (optional) – control deterministic IDs per derived child; if omitted, default is `${sourceId}-${toReferenceField}-${locale}-${index}`. */
derivedEntryId?: (params: {
sourceId: string
locale: string
index: number
candidate: { fields: { [field: string]: { [locale: string]: any } } }
}) => string

/** (optional) – publish behavior for derived entries; default 'preserve' */
publishDerived?: 'always' | 'never' | 'preserve'
}

type TagVisibility = 'private' | 'public'
Expand Down
174 changes: 174 additions & 0 deletions src/lib/action/entry-derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,53 @@ import isDefined from '../utils/is-defined'
import Entry from '../entities/entry'
import * as _ from 'lodash'
import shouldPublishLocalChanges from '../utils/should-publish-local-changes'
import { createHash } from 'crypto'

class EntryDeriveAction extends APIAction {
private contentTypeId: string
private fromFields: string[]
private referenceField: string
private derivedContentType: string
private derivedFields: string[]
private deriveEntryForLocale: (
inputFields: any,
locale: string,
{ id }: { id: string }
) => Promise<any>
private deriveEntriesForLocale?: (
inputFields: any,
locale: string,
context: { id: string }
) => Promise<Array<{ fields: Record<string, any> }>> | Array<{ fields: Record<string, any> }>
private identityKey: (fromFields: any) => Promise<string>
private shouldPublish: boolean | 'preserve'
private useLocaleBasedPublishing: boolean
private derivedEntryId?: (params: {
sourceId: string
locale: string
index: number
candidate: { fields: Record<string, any> }
}) => string
private publishDerived?: 'always' | 'never' | 'preserve'

constructor(contentTypeId: string, entryDerivation: EntryDerive) {
super()
this.contentTypeId = contentTypeId
this.fromFields = entryDerivation.from
this.referenceField = entryDerivation.toReferenceField
this.derivedContentType = entryDerivation.derivedContentType
this.derivedFields = entryDerivation.derivedFields
this.deriveEntryForLocale = entryDerivation.deriveEntryForLocale
this.deriveEntriesForLocale = entryDerivation.deriveEntriesForLocale
this.identityKey = entryDerivation.identityKey
this.shouldPublish = isDefined(entryDerivation.shouldPublish)
? entryDerivation.shouldPublish
: true
this.useLocaleBasedPublishing = isDefined(entryDerivation.useLocaleBasedPublishing)
? entryDerivation.useLocaleBasedPublishing
: false
this.derivedEntryId = entryDerivation.derivedEntryId
this.publishDerived = entryDerivation.publishDerived
}

private async publishEntry(api: OfflineAPI, entry: Entry, locales: string[]) {
Expand All @@ -46,6 +64,17 @@ class EntryDeriveAction extends APIAction {
}
}

private isDerivedFieldAllowed(fieldId: string): boolean {
return this.derivedFields?.includes(fieldId)
}

private resolveChildPublishMode(): boolean | 'preserve' {
if (this.publishDerived === 'always') return true
if (this.publishDerived === 'never') return false
if (this.publishDerived === 'preserve') return 'preserve'
return this.shouldPublish
}

async applyTo(api: OfflineAPI) {
const entries: Entry[] = await api.getEntriesForContentType(this.contentTypeId)
const locales: string[] = await api.getLocalesForSpace()
Expand All @@ -56,6 +85,132 @@ class EntryDeriveAction extends APIAction {
const newEntryId = await this.identityKey(inputs)
const hasEntry = await api.hasEntry(newEntryId)

// Multi-entry mode: derive multiple children and set array of links
if (this.deriveEntriesForLocale) {
const field = sourceContentType.fields.getField(this.referenceField)
// Validate destination is Array<Link<Entry>>
if (
!(
field &&
field.type === 'Array' &&
field.items?.type === 'Link' &&
field.items?.linkType === 'Entry'
)
) {
await api.recordRuntimeError(
new Error(
`Invalid destination: field "${this.referenceField}" must be of type Array<Link<Entry>> to use deriveEntriesForLocale.`
)
)
continue
}

// If validations specify linkContentType ensure includes derivedContentType
const linkContentTypeValidation = (field.items?.validations || []).find((v) =>
Array.isArray(v?.linkContentType)
)
if (
linkContentTypeValidation &&
!linkContentTypeValidation.linkContentType.includes(this.derivedContentType)
) {
await api.recordRuntimeError(
new Error(
`Destination field "${this.referenceField}" validations.linkContentType does not include "${this.derivedContentType}".`
)
)
continue
}

// For each locale, derive child entries and link them positionally
for (const locale of locales) {
let derivedList: Array<{ fields: Record<string, any> }> = []
try {
const res = await this.deriveEntriesForLocale(inputs, locale, { id: entry.id })
derivedList = Array.isArray(res) ? res : []
} catch (err) {
await api.recordRuntimeError(err)
continue
}

const childIdsInOrder: string[] = []
for (let index = 0; index < derivedList.length; index++) {
const candidate = derivedList[index]
const targetId = this.derivedEntryId
? this.derivedEntryId({ sourceId: entry.id, locale, index, candidate })
: defaultDerivedChildId(entry.id, this.referenceField, locale, index)

childIdsInOrder.push(targetId)

const childExists = await api.hasEntry(targetId)
if (!childExists) {
const targetEntry = await api.createEntry(this.derivedContentType, targetId)
// Only write declared derivedFields for this locale
for (const [fieldId, localizedField] of _.entries(candidate.fields)) {
if (!this.isDerivedFieldAllowed(fieldId)) continue
if (!targetEntry.fields[fieldId]) {
targetEntry.setField(fieldId, {})
}
const value = _.get(localizedField, locale)
if (value !== undefined) {
targetEntry.setFieldForLocale(fieldId, locale, value)
}
}
await api.saveEntry(targetEntry.id)
// Publish behavior for derived children
const childPublishMode = this.resolveChildPublishMode()
if (
shouldPublishLocalChanges(childPublishMode, entry, this.useLocaleBasedPublishing)
) {
if (this.useLocaleBasedPublishing) {
await api.localeBasedPublishEntry(targetEntry.id, [locale])
} else {
await api.publishEntry(targetEntry.id)
}
}
} else {
// Update existing entry fields for this locale (upsert semantics)
const targetEntry = (
await api.getEntriesForContentType(this.derivedContentType)
).find((e) => e.id === targetId)
for (const [fieldId, localizedField] of _.entries(candidate.fields)) {
if (!this.isDerivedFieldAllowed(fieldId)) continue
if (!targetEntry.fields[fieldId]) {
targetEntry.setField(fieldId, {})
}
const value = _.get(localizedField, locale)
if (value !== undefined) {
targetEntry.setFieldForLocale(fieldId, locale, value)
}
}
await api.saveEntry(targetEntry.id)
const childPublishMode = this.resolveChildPublishMode()
if (
shouldPublishLocalChanges(childPublishMode, entry, this.useLocaleBasedPublishing)
) {
if (this.useLocaleBasedPublishing) {
await api.localeBasedPublishEntry(targetEntry.id, [locale])
} else {
await api.publishEntry(targetEntry.id)
}
}
}
}

// Write array of links for this locale (can be empty)
const links = childIdsInOrder.map((id) => ({
sys: { type: 'Link', linkType: 'Entry', id }
}))
entry.setFieldForLocale(this.referenceField, locale, links)
}

await api.saveEntry(entry.id)
if (shouldPublishLocalChanges(this.shouldPublish, entry, this.useLocaleBasedPublishing)) {
await this.publishEntry(api, entry, locales)
}
continue
}

// Single-entry mode
let skipEntry = true
let fieldsForTargetEntry = {}

Expand Down Expand Up @@ -138,3 +293,22 @@ class EntryDeriveAction extends APIAction {
}

export { EntryDeriveAction }

function defaultDerivedChildId(
sourceId: string,
toReferenceField: string,
locale: string,
index: number
): string {
const base = `${sourceId}-${toReferenceField}-${locale}-${index}`
if (base.length <= 64) return base
const hash = shortHash(base)
const maxBase = 64 - (1 + hash.length)
return `${base.slice(0, Math.max(0, maxBase))}-${hash}`
}

function shortHash(input: string): string {
const hashHex = createHash('sha256').update(input).digest('hex')
const base36 = BigInt('0x' + hashHex).toString(36)
return base36.slice(0, 6)
}
Loading