Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Action to transform entries to another content type #143

Merged
merged 42 commits into from
Jan 2, 2019
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
8b25a7d
added first version of method to migrate entries from one to another …
Oct 17, 2018
21e3313
added hash function to example
Oct 18, 2018
da9c229
Delete migrated entries
Oct 18, 2018
5a30ab7
Unpublish before delete
Oct 18, 2018
07cbe78
added published information to entry
Oct 18, 2018
f048ed2
Load all entries if references need to be updated
Oct 18, 2018
670b60b
added retrieve parents method, fixed boolean assignment issues, remov…
Oct 19, 2018
3794af8
further development of update references
Oct 19, 2018
54d8f98
parameters added to definition
Oct 19, 2018
17e18c4
Merge branch 'master' of https://github.com/GordonApplepie/contentful…
Oct 19, 2018
9abd18a
changed getparententries method
Oct 19, 2018
70b0541
Merge branch 'master' of https://github.com/GordonApplepie/contentful…
Oct 19, 2018
820138d
"preserve"-Funktion um aktuellen publish-status beizubehalten
Oct 19, 2018
7776d8f
Merged
Oct 19, 2018
4ce5b91
renaming, updating get references method
Oct 19, 2018
7c1a9a8
merge
Oct 19, 2018
b7877d2
merge
Oct 19, 2018
97a0cc1
added link in array logic, renaming
Oct 19, 2018
fd5c0cd
fixes in identity key, array based links
Oct 19, 2018
aafbde4
refactoring link replacement
Oct 19, 2018
c05053b
Comments
Oct 19, 2018
7c898df
renaming example
Oct 19, 2018
5c25591
fixed lint errors
Oct 19, 2018
d43b47b
Merged
Oct 19, 2018
8c399ef
First unit tests
Oct 19, 2018
a7f9cff
Unit test for deletion of source entries after transformation
Oct 22, 2018
b39f967
added unit tests for publishing
Oct 22, 2018
05bab4f
unit test to check if undefined skips the actual change of entries
Oct 22, 2018
14b3997
Remove unused "to"-Parameter
Oct 22, 2018
3290f56
Fixed linter errors
Oct 22, 2018
51208e6
from-fields optional
Oct 22, 2018
4920225
Merge branch 'master' into master
aKzenT Nov 14, 2018
ee8501b
Removed unecessary casting from entry
Dec 20, 2018
0f1e172
Merge branch 'master' into master
Khaledgarbaya Dec 20, 2018
0805dd8
Throw error if target entry exists
Dec 20, 2018
4898485
Separate Interface for transformEntriesToType
Dec 20, 2018
e823d93
Added 'preserve' to standard transform
Dec 21, 2018
a0bb24b
Store published version in entry
Dec 21, 2018
d8ca0ae
Code style foreach=>for...of
Dec 21, 2018
4a20d29
Replace splice with simple assignment
Dec 21, 2018
19f4b0c
Remove unused field
Dec 21, 2018
d3a9ff3
fixed linter errors
Jan 2, 2019
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
37 changes: 37 additions & 0 deletions examples/22-transform-entries-to-type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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'],
to: ['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]}`
};
}
});
};
14 changes: 12 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,12 @@ 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",
/** (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
aKzenT marked this conversation as resolved.
Show resolved Hide resolved
}

export interface IDeriveLinkedEntriesConfig {
Expand Down Expand Up @@ -281,6 +285,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: ITransformEntriesConfig): 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
123 changes: 123 additions & 0 deletions src/lib/action/entry-transform-to-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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)
aKzenT marked this conversation as resolved.
Show resolved Hide resolved

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
}

if (!hasEntry) {
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) ) {
aKzenT marked this conversation as resolved.
Show resolved Hide resolved
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 }
23 changes: 23 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 _isPublished: boolean

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._isPublished = !isNullOrUndefined(entry.sys.publishedVersion)
aKzenT marked this conversation as resolved.
Show resolved Hide resolved
}

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 = field[locale] as Array<any> || new Array<any>()
aKzenT marked this conversation as resolved.
Show resolved Hide resolved

if (fieldArray.length < index + 1) {
aKzenT marked this conversation as resolved.
Show resolved Hide resolved
fieldArray.push(link)
} else {
fieldArray.splice(index, 1, link)
}
}

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

get isPublished () {
return this._isPublished
}

set isPublished (published: boolean) {
this._isPublished = published
}

toApiEntry (): APIEntry {
const sys = {
id: this.id,
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
33 changes: 33 additions & 0 deletions src/lib/intent-validator/entry-transform-to-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Intent from '../intent/base-intent'
import SchemaValidator from './schema-validator'
import * as Joi from 'joi'

class EntryTransformToTypeIntentValidator extends SchemaValidator {
protected article = 'an'
protected displayName = 'entry derivation'

appliesTo (step: Intent) {
return step.isEntryDerive()
}

// NOTE: this could be change to return the object to
// validate against the schema instead of returning
// just the prop name
get propertyNameToValidate () {
return 'derivation'
}

get schema () {
return {
sourceContentType: Joi.string().required(),
targetContentType: Joi.string().required(),
from: Joi.array().items(Joi.string()).required(),
to: Joi.array().items(Joi.string()).required(),
identityKey: Joi.func().required(),
shouldPublish: Joi.boolean(),
deriveEntryForLocale: Joi.func().required()
}
}
}

export default EntryTransformToTypeIntentValidator
8 changes: 8 additions & 0 deletions src/lib/intent/base-intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export default abstract class Intent implements IntentInterface {
return this.payload.fieldId
}

requiresAllEntries () {
return false
}

isContentTypeUpdate () {
return false
}
Expand Down Expand Up @@ -66,6 +70,10 @@ export default abstract class Intent implements IntentInterface {
return false
}

isEntryTransformToType () {
return false
}

isEditorInterfaceUpdate () {
return false
}
Expand Down
8 changes: 8 additions & 0 deletions src/lib/intent/composed-intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ export default class ComposedIntent implements Intent {
return false
}

isEntryTransformToType (): boolean {
return false
}

getContentTypeId (): string {
return this.contentTypeId
}
Expand All @@ -97,6 +101,10 @@ export default class ComposedIntent implements Intent {
return [this.getContentTypeId()]
}

requiresAllEntries (): boolean {
return false
}

groupsWith (): boolean {
return false
}
Expand Down
Loading