Skip to content

Commit

Permalink
feat(content type): New Action to transform entries to another conten…
Browse files Browse the repository at this point in the history
…t type (#143)

## Summary
This PR extends the available migrations with a new "transformEntriesToType"-transform that allows migrations from one content type to another as discussed in #113

## Description
A new action was added to support the transformation. Various enhancements to the OfflineAPI as well as the Fetcher were needed to implement the features.

## Motivation and Context
See #113

## Todos

-   [x] Basic functionality working
-   [x] Option to remove the source item after transformation
-   [x] Option to publish/not publish or keep the state of the original entry
-   [x] Option to change all references to the original entry so that they point to the new entry instead
-   [x] Additional tests
  • Loading branch information
aKzenT authored and Khaledgarbaya committed Jan 2, 2019
1 parent b4fb608 commit f5b3595
Show file tree
Hide file tree
Showing 23 changed files with 998 additions and 12 deletions.
36 changes: 36 additions & 0 deletions examples/22-transform-entries-to-type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const MurmurHash3 = require('imurmurhash');

// Basic example: create new content type.
module.exports = function (migration) {
// please run 01-angry-dog first, add some entries
// create new content type
const copycat = migration.createContentType('copycat').name('copy of dog').description('super friendly copy dog');

// add field
copycat.createField('woofs', {
name: 'woof woof woof',
type: 'Symbol',
required: true
});

// add entry title
copycat.displayField('woofs');

migration.transformEntriesToType({
sourceContentType: 'dog',
targetContentType: 'copycat',
from: ['woofs'],
shouldPublish: false,
updateReferences: true,
removeOldEntries: true,
identityKey: function (fields) {
const value = fields.woofs['en-US'].toString();
return MurmurHash3(value).result().toString();
},
transformEntryForLocale: function (fromFields, currentLocale) {
return {
woofs: `copy - ${fromFields.woofs[currentLocale]}`
};
}
});
};
32 changes: 30 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,30 @@ export interface ITransformEntriesConfig {
* The return value must be an object with the same keys as specified in to. Their values will be written to the respective entry fields for the current locale (i.e. {nameField: 'myNewValue'}). If it returns undefined, this the values for this locale on the entry will be left untouched.
*/
transformEntryForLocale: (fromFields: ContentFields, currentLocale: string) => any,
/** (optional) – If true, the transformed entries will be published. If false, both will remain in draft state (default true) */
shouldPublish?: boolean
/** (optional) – If true, the transformed entries will be published. If false, they will remain in draft state. When the value is set to "preserve" items will be published only if the original entry was published as well (default true) */
shouldPublish?: boolean|"preserve"
}

export interface ITransformEntriesToTypeConfig {
/** (required) – Content type ID */
contentType: string,
/** (required) – Array of the source field IDs */
from: string[],
/**
* (required) – Transformation function to be applied.
*
* fields is an object containing each of the from fields. Each field will contain their current localized values (i.e. from == {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 to. Their values will be written to the respective entry fields for the current locale (i.e. {nameField: 'myNewValue'}). If it returns undefined, this the values for this locale on the entry will be left untouched.
*/
transformEntryForLocale: (fromFields: ContentFields, currentLocale: string) => any,
/** (optional) – If true, the transformed entries will be published. If false, they will remain in draft state. When the value is set to "preserve" items will be published only if the original entry was published as well (default true) */
shouldPublish?: boolean|"preserve",
/** (optional) – If true, references to the old entry are replaced with references to the new entry (default true) */
updateReferences?: boolean,
/** (optional) – If true, the original entry is removed after the new entry was created (default true) */
removeOldEntries?: boolean
}

export interface IDeriveLinkedEntriesConfig {
Expand Down Expand Up @@ -281,6 +303,12 @@ export default interface Migration {
*/
transformEntries (transformation: ITransformEntriesConfig): void

/**
* For the given content type, transforms all its entries according to the user-provided transformEntryForLocale function into a new content type. For each entry, the CLI will call this function 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
* @param transformation
*/
transformEntriesToType (transformation: ITransformEntriesToTypeConfig): void

/**
* 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.
* @param transformation
Expand Down
126 changes: 126 additions & 0 deletions src/lib/action/entry-transform-to-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import EntryTransformToType from '../interfaces/entry-transform-to-type'
import { APIAction } from './action'
import { OfflineAPI } from '../offline-api'
import Entry from '../entities/entry'
import * as _ from 'lodash'

class EntryTransformToTypeAction extends APIAction {
private fromFields?: string[]
private sourceContentTypeId: string
private targetContentTypeId: string
private transformEntryForLocale: (inputFields: any, locale: string) => Promise<any>
private identityKey: (fromFields: any) => Promise<string>
private shouldPublish: boolean|'preserve'
private removeOldEntries: boolean
private updateReferences: boolean

constructor (entryTransformation: EntryTransformToType) {
super()
this.fromFields = entryTransformation.from
this.sourceContentTypeId = entryTransformation.sourceContentType
this.targetContentTypeId = entryTransformation.targetContentType
this.identityKey = entryTransformation.identityKey
this.shouldPublish = entryTransformation.shouldPublish || false
this.removeOldEntries = entryTransformation.removeOldEntries || false
this.updateReferences = entryTransformation.updateReferences || false
this.transformEntryForLocale = entryTransformation.transformEntryForLocale
}

async applyTo (api: OfflineAPI) {
const entries: Entry[] = await api.getEntriesForContentType(this.sourceContentTypeId)
const locales: string[] = await api.getLocalesForSpace()

for (const entry of entries) {
const inputs = this.fromFields ? _.pick(entry.fields, this.fromFields) : entry.fields
const newEntryId = await this.identityKey(inputs)
const hasEntry = await api.hasEntry(newEntryId)

if (hasEntry) {
await api.recordRuntimeError(new Error(`Entry with id '${newEntryId}' already exists`))
continue
}

let skipEntry = true
let fieldsForTargetEntry = {}

for (const locale of locales) {
let outputsForCurrentLocale
try {
outputsForCurrentLocale = await this.transformEntryForLocale(inputs, locale)
} catch (err) {
await api.recordRuntimeError(err)
continue
}

if (outputsForCurrentLocale === undefined) {
continue
}

skipEntry = false

// we collect all the values for the target entry before writing it to the
// offline API because we don't yet know if the entry might be skipped
// TODO: verify that the derivedFields actually get written to
// and to no other field
for (const [fieldId, localizedValue] of _.entries(outputsForCurrentLocale)) {
if (!fieldsForTargetEntry[fieldId]) {
fieldsForTargetEntry[fieldId] = {}
}
fieldsForTargetEntry[fieldId][locale] = localizedValue
}
}

// if derive returned undefined for all locales of this entry, there are no changes
// to be made, neither on the source nor the target entry, so we move on to the next
if (skipEntry) {
continue
}

const targetEntry = await api.createEntry(this.targetContentTypeId, newEntryId)

// we are not skipping this source entry and the target entry does not yet exist,
// so now is the time to write the collected target entry values to the offline API
for (const [fieldId, localizedField] of _.entries(fieldsForTargetEntry)) {
if (!targetEntry.fields[fieldId]) {
targetEntry.setField(fieldId, {})
}

for (const [locale, localizedValue] of _.entries(localizedField)) {
targetEntry.setFieldForLocale(fieldId, locale, localizedValue)
}

}
await api.saveEntry(targetEntry.id)
if (this.shouldPublish === true || (this.shouldPublish === 'preserve' && entry.isPublished) ) {
await api.publishEntry(targetEntry.id)
}

// look for entries linking to the old entry and replace them with references to the new entry
if (this.updateReferences) {
const links = await api.getLinks(entry.id, locales)
for (const link of links) {
if (!link.isInArray()) {
link.element.setFieldForLocale(link.field, link.locale,{ sys: { id: newEntryId, type: 'Link', linkType: 'Entry'}})
} else {
link.element.replaceArrayLinkForLocale(link.field, link.locale, link.index, newEntryId)
}

await api.saveEntry(link.element.id)
if (this.shouldPublish === true || (this.shouldPublish === 'preserve' && link.element.isPublished) ) {
await api.publishEntry(link.element.id)
}
}
}

// remove the original item
if (this.removeOldEntries) {
if (entry.isPublished) {
await api.unpublishEntry(entry.id)
}
await api.deleteEntry(entry.id)
}
}
}
}

export { EntryTransformToTypeAction }
6 changes: 3 additions & 3 deletions src/lib/action/entry-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ class EntryTransformAction extends APIAction {
private contentTypeId: string
private fromFields: string[]
private transformEntryForLocale: Function
private shouldPublish: boolean
private shouldPublish: boolean|'preserve'

constructor (contentTypeId: string, fromFields: string[], transformation: Function, shouldPublish: boolean = true) {
constructor (contentTypeId: string, fromFields: string[], transformation: Function, shouldPublish: boolean|'preserve' = true) {
super()
this.contentTypeId = contentTypeId
this.fromFields = fromFields
Expand Down Expand Up @@ -50,7 +50,7 @@ class EntryTransformAction extends APIAction {
}
if (changesForThisEntry) {
await api.saveEntry(entry.id)
if (this.shouldPublish) {
if (this.shouldPublish === true || (this.shouldPublish === 'preserve' && entry.isPublished) ) {
await api.publishEntry(entry.id)
}
}
Expand Down
28 changes: 28 additions & 0 deletions src/lib/entities/entry.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { cloneDeep } from 'lodash'
import APIEntry from '../interfaces/api-entry'
import { isNullOrUndefined } from 'util'

class Entry {
private _id: string
private _contentTypeId: string
private _version: number
private _fields: object
private _publishedVersion?: number

constructor (entry: APIEntry) {
this._id = entry.sys.id
this._fields = entry.fields
this._version = entry.sys.version
this._contentTypeId = entry.sys.contentType.sys.id
this._publishedVersion = entry.sys.publishedVersion
}

get id () {
Expand Down Expand Up @@ -40,6 +43,18 @@ class Entry {
this._fields[id] = field
}

replaceArrayLinkForLocale (id: string, locale: string, index: number, linkId: string) {
const link = { sys: { id: linkId, type: 'Link', linkType: 'Entry'}}
const field = this._fields[id] || {}
const fieldArray: any[] = field[locale]

if (fieldArray.length < index + 1) {
fieldArray.push(link)
} else {
fieldArray[index] = link
}
}

get version () {
return this._version
}
Expand All @@ -48,10 +63,23 @@ class Entry {
this._version = version
}

get isPublished () {
return !isNullOrUndefined(this._publishedVersion)
}

get publishedVersion () {
return this._publishedVersion
}

set publishedVersion (version: number|null) {
this._publishedVersion = version
}

toApiEntry (): APIEntry {
const sys = {
id: this.id,
version: this.version,
publishedVersion: this.publishedVersion,
contentType: {
sys: {
type: 'Link',
Expand Down
44 changes: 44 additions & 0 deletions src/lib/entities/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Entry from './entry'

class Link {
private _field: string
private _locale: string
private _index: number
private _element: Entry

constructor (element: Entry, field: string, locale: string, index: number = -1) {
this._field = field
this._locale = locale
this._index = index
this._element = element
}

get field (): string {
return this._field
}

get locale (): string {
return this._locale
}

get index (): number {
return this._index
}

get element (): Entry {
return this._element
}

isValid (): boolean {
return this._field.length > 0
}

isInArray (): boolean {
return this._index !== -1
}
}

export {
Link as default,
Link
}
10 changes: 7 additions & 3 deletions src/lib/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,27 @@ export default class Fetcher implements APIFetcher {
}

async getEntriesInIntents (intentList: IntentList): Promise<APIEntry[]> {
const loadAllEntries = intentList.getIntents().some((intent) => intent.requiresAllEntries())

const ids: string[] = _.uniq(
intentList.getIntents()
.filter((intent) => intent.isContentTransform() || intent.isEntryDerive())
.filter((intent) => intent.isContentTransform() || intent.isEntryDerive() || intent.isEntryTransformToType())
.map((intent) => intent.getContentTypeId())
)

if (ids.length === 0) {
if (!loadAllEntries && ids.length === 0) {
return []
}

let entries: APIEntry[] = []
let skip: number = 0

const filterSpecification = loadAllEntries ? '' : `sys.contentType.sys.id[in]=${ids.join(',')}&`

while (true) {
const response = await this.makeRequest({
method: 'GET',
url: `/entries?sys.contentType.sys.id[in]=${ids.join(',')}&skip=${skip}`
url: `/entries?${filterSpecification}skip=${skip}`
})

entries = entries.concat(response.items)
Expand Down
Loading

0 comments on commit f5b3595

Please sign in to comment.