@stackbit/cms-contentful
Version:
Stackbit Contentful CMS Interface
988 lines (987 loc) • 56.2 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ContentfulContentSource = void 0;
const path = __importStar(require("path"));
const lodash_1 = __importDefault(require("lodash"));
const stackbitUtils = __importStar(require("@stackbit/types"));
const utils_1 = require("@stackbit/utils");
const contentful_api_client_1 = require("./contentful-api-client");
const contentful_schema_converter_1 = require("./contentful-schema-converter");
const contentful_entries_converter_1 = require("./contentful-entries-converter");
const contentful_scheduled_actions_converter_1 = require("./contentful-scheduled-actions-converter");
const content_poller_1 = require("./content-poller");
const stream_1 = require("stream");
const contentful_consts_1 = require("./contentful-consts");
const utils_2 = require("./utils");
class ContentfulContentSource {
constructor(options) {
this.contentPoller = null;
this.locales = [];
this.userMap = {};
this.spaceId = options.spaceId ?? process.env.CONTENTFUL_SPACE_ID;
this.environment = options.environment ?? process.env.CONTENTFUL_ENVIRONMENT ?? 'master';
this.accessToken = options.accessToken ?? process.env.CONTENTFUL_ACCESS_TOKEN;
this.previewToken = options.previewToken ?? process.env.CONTENTFUL_PREVIEW_TOKEN;
this.useWebhookForContentUpdates = options.useWebhookForContentUpdates ?? false;
this.useAccessTokenForUpdates = options.useAccessTokenForUpdates ?? false;
this.useEURegion = options.useEURegion ?? false;
this.previewHost = options.previewHost ?? (this.useEURegion ? 'preview.eu.contentful.com' : undefined);
this.managementHost = options.managementHost ?? (this.useEURegion ? 'api.eu.contentful.com' : undefined);
this.uploadHost = options.uploadHost ?? (this.useEURegion ? 'upload.eu.contentful.com' : undefined);
// Max active bulk actions per space is limited to 5.
// Max of 200 items per bulk action.
this.taskQueue = new utils_1.TaskQueue({ limit: 5 });
}
async getVersion() {
return stackbitUtils.getVersion({ packageJsonPath: path.join(__dirname, '../package.json') });
}
getContentSourceType() {
return this.useEURegion ? 'contentfuleu' : 'contentful';
}
getProjectId() {
return this.spaceId;
}
getProjectEnvironment() {
return this.environment;
}
getProjectManageUrl() {
return `${this.getProjectUrl()}/home`;
}
getProjectUrl() {
return `https://app.${this.useEURegion ? 'eu.' : ''}contentful.com/spaces/${this.spaceId}`;
}
async init({ logger, userLogger, localDev, webhookUrl, devAppRestartNeeded, cache }) {
this.logger = logger.createLogger({ label: 'cms-contentful' });
this.userLogger = userLogger.createLogger({ label: 'cms-contentful' });
this.localDev = localDev;
this.webhookUrl = webhookUrl;
this.devAppRestartNeeded = devAppRestartNeeded;
this.cache = cache;
// If running locally, use ContentPoller instead of webhook unless explicitly debugging webhooks
if (localDev && !webhookUrl) {
this.useWebhookForContentUpdates = false;
}
if (this.useWebhookForContentUpdates) {
this.userLogger.info('Using webhook for content updates');
}
if (this.useEURegion) {
this.userLogger.info('Using EU data region');
}
this.plainClient = (0, contentful_api_client_1.createPlainApiClient)({
spaceId: this.spaceId,
accessToken: this.accessToken,
environment: this.environment,
managementHost: this.managementHost,
uploadHost: this.uploadHost
});
await this.validateConfig();
await this.createWebhookIfNeeded();
await this.reset();
}
async reset() {
const locales = await (0, contentful_api_client_1.fetchAllLocales)(this.plainClient);
const appInstallations = await (0, contentful_api_client_1.fetchAllAppInstallations)(this.plainClient);
await this.fetchUsers();
// replace all data at once in atomic action
this.locales = locales.filter((locale) => {
// filter out disabled locales
return locale.contentManagementApi;
});
this.defaultLocale = this.locales.find((locale) => locale.default);
// Fetch up installations, and find the cloudinary app installation.
// If cloudinary app is installed, use its 'maxFiles' parameter to decide
// if cloudinary images should be presented as a single 'image' field or
// as an array of 'images'.
const cloudinaryAppInstallation = appInstallations.find((appInstallation) => appInstallation.sys.appDefinition.sys.id === contentful_consts_1.CONTENTFUL_CLOUDINARY_APP);
const maxFiles = lodash_1.default.get(cloudinaryAppInstallation, 'parameters.maxFiles', 1);
this.cloudinaryImagesAsList = maxFiles > 1;
if (cloudinaryAppInstallation) {
this.logger.debug(`found cloudinary app installation, maxFiles=${maxFiles}, treating cloudinary images as ${this.cloudinaryImagesAsList ? 'list of images' : 'a single image field'}`);
}
// same story with bynder as with cloudinary images - either array or one
const bynderAppInstallation = appInstallations.find((appInstallation) => appInstallation.sys.appDefinition.sys.id === contentful_consts_1.CONTENTFUL_BYNDER_APP);
const viewMode = lodash_1.default.get(bynderAppInstallation, 'parameters.compactViewMode', 'MultiSelect');
this.bynderImagesAsList = viewMode === 'MultiSelect';
if (bynderAppInstallation) {
this.logger.debug(`found bynder app installation, viewMode=${viewMode}, treating bynder images as ${this.bynderImagesAsList ? 'list of images' : 'a single image field'}`);
}
}
async destroy() { }
async validateConfig() {
this.logger.debug('Validating config...');
let previewTokens;
let environments;
try {
previewTokens = await this.plainClient.previewApiKey.getMany({});
environments = await this.plainClient.environment.getMany({});
}
catch (err) {
if (err?.message?.includes('The resource could not be found')) {
throw new Error(`Can't find Contentful space '${this.spaceId}'. Verify that the space exists and accessible with the provided access token.`);
}
else {
throw new Error(`Can't access Contentful. Verify that the access token you provided is valid.`);
}
}
const foundPreviewToken = previewTokens?.items.find((item) => item.accessToken === this.previewToken);
if (!foundPreviewToken) {
throw new Error(`Can't find Contentful preview token. Verify that the preview token is valid and associated with the provided Contentful space-id.`);
}
const foundEnvironment = environments?.items.find((item) => item.name === this.environment || item.sys?.aliases?.map((alias) => alias?.sys?.id).includes(this.environment));
if (!foundEnvironment) {
throw new Error(`Can't find Contentful environment '${this.environment}'. Verify that the environment exists in the provided Contentful space-id.`);
}
}
async createWebhookIfNeeded() {
if (!this.webhookUrl) {
return;
}
try {
this.logger.debug('Creating webhook...');
const webhooks = await (0, contentful_api_client_1.fetchAllWebhooks)(this.plainClient);
if (!webhooks.find((webhook) => webhook.url === this.webhookUrl)) {
await this.createWebhook(this.webhookUrl);
}
}
catch (err) {
this.userLogger.error('Error fetching or creating a contentful webhook', {
srcProjectId: this.getProjectId(),
error: err
});
if (this.useWebhookForContentUpdates) {
this.userLogger.error('Falling back to using content poller', { srcProjectId: this.getProjectId() });
this.useWebhookForContentUpdates = false;
}
}
}
async createWebhook(webhookURL) {
return (0, contentful_api_client_1.createWebhook)(this.plainClient, {
url: webhookURL,
name: 'stackbit-content-source-webhook',
topics: ['*.*'],
transformation: {
contentType: 'application/json'
}
});
}
startWatchingContentUpdates() {
this.logger.debug('startWatchingContentUpdates');
if (this.useWebhookForContentUpdates) {
return;
}
if (this.contentPoller) {
this.stopWatchingContentUpdates();
}
this.contentPoller = new content_poller_1.ContentPoller({
spaceId: this.spaceId,
environment: this.environment,
previewToken: this.previewToken,
managementToken: this.accessToken,
previewHost: this.previewHost,
managementHost: this.managementHost,
uploadHost: this.uploadHost,
pollType: 'date',
syncContext: this.getSyncContextForContentPollerFromCache(),
notificationCallback: async (syncResult) => {
if (syncResult.contentTypes.length) {
this.logger.debug('content type was changed, invalidate schema');
await this.cache.invalidateSchema();
}
else {
const result = await this.convertSyncResult(syncResult);
await this.cache.updateContent(result);
}
},
logger: this.logger
});
this.contentPoller.start();
}
stopWatchingContentUpdates() {
this.logger.debug('stopWatchingContentUpdates');
if (this.contentPoller) {
this.contentPoller.stop();
this.contentPoller = null;
}
}
async fetchUsers() {
const users = await (0, contentful_api_client_1.fetchAllUsers)(this.plainClient);
this.userMap = lodash_1.default.keyBy(users, 'sys.id');
}
async fetchUsersIfNeeded(entities) {
const entityHasNoCachedAuthor = entities.some((entity) => !this.userMap[entity.sys.updatedBy?.sys.id ?? '']);
if (entityHasNoCachedAuthor) {
await this.fetchUsers();
}
}
updateContentPollerSyncContext(syncContext) {
if (this.contentPoller && this.contentPoller.pollType === 'date') {
this.contentPoller.setSyncContext({
...this.getSyncContextForContentPollerFromCache(),
...syncContext
});
}
}
getSyncContextForContentPollerFromCache() {
const cacheSyncContext = this.cache.getSyncContext();
return {
lastUpdatedEntryDate: cacheSyncContext.documentsSyncContext,
lastUpdatedAssetDate: cacheSyncContext.assetsSyncContext,
lastUpdatedContentTypeDate: this.cache.getSchema().context?.lastUpdatedContentTypeDate
};
}
async convertSyncResult(syncResult) {
// remove deleted entries and assets from fieldData
// generally, the "sync" method of the preview API never notifies of deleted objects, therefore we rely on
// the deleteObject method to notify the user that restart is needed. Then, once user restarts the SSG, it
// will re-fetch the data effectively removing deleted objects
// https://www.notion.so/stackbit/Contentful-Sync-API-preview-issue-6b4816ebceef4ab181cdf1603058d324
this.logger.debug('received sync data', {
entries: syncResult.entries.length,
assets: syncResult.assets.length,
deletedEntries: syncResult.deletedEntries.length,
deletedAssets: syncResult.deletedAssets.length
});
await this.fetchUsersIfNeeded([...syncResult.entries, ...syncResult.assets]);
// convert updated/created entries and assets
const documents = this.convertEntries(syncResult.entries, this.cache.getModelByName);
const assets = this.convertAssets(syncResult.assets);
return {
documents,
assets,
deletedDocumentIds: syncResult.deletedEntries.map((entry) => entry.sys.id),
deletedAssetIds: syncResult.deletedAssets.map((asset) => asset.sys.id)
};
}
async getSchema() {
this.logger.debug('getSchema');
const contentTypes = await (0, contentful_api_client_1.fetchAllContentTypes)(this.plainClient);
const editorInterfaces = await (0, contentful_api_client_1.fetchAllEditorInterfaces)(this.plainClient);
const defaultLocaleCode = this.localeOrDefaultOrThrow();
const { models } = (0, contentful_schema_converter_1.convertSchema)({
contentTypes: contentTypes,
editorInterfaces: editorInterfaces,
defaultLocaleCode: defaultLocaleCode,
cloudinaryImagesAsList: this.cloudinaryImagesAsList,
bynderImagesAsList: this.bynderImagesAsList
});
const prevLastUpdatedContentTypeDate = this.cache.getSchema().context?.lastUpdatedContentTypeDate;
// Check if one of the content types was changed from the last time the
// content types were fetched, in which case remove all cached content.
const lastUpdatedContentTypeDate = (0, utils_2.getLastUpdatedEntityDate)(contentTypes);
if (prevLastUpdatedContentTypeDate !== lastUpdatedContentTypeDate) {
this.logger.debug(`last updated content type date '${lastUpdatedContentTypeDate}' is different ` +
`from the cached date '${prevLastUpdatedContentTypeDate}', clearing cache`);
await this.cache.clearSyncContext({
clearDocumentsSyncContext: true,
clearAssetsSyncContext: false
});
}
this.updateContentPollerSyncContext({ lastUpdatedContentTypeDate });
return {
models,
locales: this.locales.map((locale) => ({
code: locale.code,
default: locale.default
})),
context: {
lastUpdatedContentTypeDate: lastUpdatedContentTypeDate
}
};
}
async getDocuments(options) {
this.logger.debug('getDocuments');
let lastUpdatedEntryDate = options?.syncContext;
let entries;
try {
if (lastUpdatedEntryDate) {
this.logger.debug(`fetching entries updated after ${lastUpdatedEntryDate}`);
entries = await (0, contentful_api_client_1.fetchEntriesUpdatedAfter)(this.plainClient, lastUpdatedEntryDate, this.userLogger);
this.logger.debug(`got ${entries.length} updated/created entries after ${lastUpdatedEntryDate}`);
if (entries.length) {
lastUpdatedEntryDate = (0, utils_2.getLastUpdatedEntityDate)(entries);
}
}
else {
entries = await (0, contentful_api_client_1.fetchAllEntries)(this.plainClient, this.userLogger);
lastUpdatedEntryDate = (0, utils_2.getLastUpdatedEntityDate)(entries);
}
}
catch (error) {
// Stackbit won't be able to work properly even if one of the entries was not fetched.
// All fetch methods use Contentful's API client that handles errors and retries automatically.
this.logger.error(`Failed fetching documents from Contentful, error: ${error.message}`);
// By returning the original syncContext we are ensuring that next
// time the getDocuments is called, it will try to get the documents
// using the same syncContext
return { documents: [], syncContext: lastUpdatedEntryDate };
}
this.updateContentPollerSyncContext({ lastUpdatedEntryDate });
this.logger.debug(`got ${entries.length} entries from space ${this.spaceId}, environment ${this.environment}, lastUpdatedEntryDate: ${lastUpdatedEntryDate}`);
await this.fetchUsersIfNeeded(entries);
const documents = this.convertEntries(entries, this.cache.getModelByName);
return { documents, syncContext: lastUpdatedEntryDate };
}
async getAssets(options) {
this.logger.debug('getAssets');
let lastUpdatedAssetDate = options?.syncContext;
let ctflAssets;
try {
if (lastUpdatedAssetDate) {
this.logger.debug(`fetching assets updated after ${lastUpdatedAssetDate}`);
ctflAssets = await (0, contentful_api_client_1.fetchAssetsUpdatedAfter)(this.plainClient, lastUpdatedAssetDate, this.userLogger);
this.logger.debug(`got ${ctflAssets.length} updated/created assets after ${lastUpdatedAssetDate}`);
if (ctflAssets.length) {
lastUpdatedAssetDate = (0, utils_2.getLastUpdatedEntityDate)(ctflAssets);
}
}
else {
ctflAssets = await (0, contentful_api_client_1.fetchAllAssets)(this.plainClient, this.userLogger);
lastUpdatedAssetDate = (0, utils_2.getLastUpdatedEntityDate)(ctflAssets);
}
}
catch (error) {
// Stackbit won't be able to work properly even if one of the entries or the assets was not fetched.
// All fetch methods use Contentful's API client that handles errors and retries automatically.
this.logger.error(`Failed fetching assets from Contentful, error: ${error.message}`);
return { assets: [], syncContext: lastUpdatedAssetDate };
}
this.updateContentPollerSyncContext({ lastUpdatedAssetDate });
this.logger.debug(`got ${ctflAssets.length} assets from space ${this.spaceId}, environment ${this.environment}, lastUpdatedAssetDate: ${lastUpdatedAssetDate}`);
await this.fetchUsersIfNeeded(ctflAssets);
const assets = this.convertAssets(ctflAssets);
return { assets, syncContext: lastUpdatedAssetDate };
}
async hasAccess({ userContext }) {
if (!this.localDev && !this.useAccessTokenForUpdates && !userContext?.accessToken) {
return {
hasConnection: false,
hasPermissions: false
};
}
try {
const apiClient = this.getPlainApiClientForUser({ userContext });
await apiClient.entry.getMany({ query: { limit: 1 } });
return {
hasConnection: true,
hasPermissions: true
};
}
catch (error) {
this.logger.debug('Contentful: failed to access space', { error });
return {
hasConnection: true,
hasPermissions: false
};
}
}
async createDocument({ updateOperationFields, model, locale, userContext }) {
this.logger.debug('createDocument');
const entry = { fields: {} };
lodash_1.default.forEach(updateOperationFields, (operationField, fieldName) => {
const modelField = model.fields?.find((field) => field.name === fieldName);
if (!modelField) {
return;
}
const value = mapOperationFieldToContentfulValue(operationField, modelField, false);
const localeOrDefault = this.localeOrDefaultOrThrow(locale);
setEntryField(entry, value, [fieldName], modelField.localized ? localeOrDefault : this.defaultLocale.code);
});
const apiClient = this.getPlainApiClientForUser({ userContext });
const entryResult = await (0, contentful_api_client_1.createEntry)(apiClient, model.name, entry);
return { documentId: entryResult.sys.id };
}
async updateDocument({ document, operations, userContext }) {
this.logger.debug('updateDocument');
const documentId = document.id;
const entry = await (0, contentful_api_client_1.fetchEntryById)(this.plainClient, documentId);
const modelName = entry.sys.contentType.sys.id;
const model = this.cache.getModelByName(modelName);
if (!model) {
throw new Error(`Error updating document: could not find document model '${modelName}'.`);
}
const defaultLocale = this.defaultLocale?.code ?? 'en-US';
for (const operation of operations) {
const locale = 'localized' in operation.modelField && operation.modelField.localized ? operation.locale ?? defaultLocale : defaultLocale;
const operationWithLocale = {
...operation,
locale
};
const opFunc = Operations[operationWithLocale.opType];
await opFunc({ entry, operation: operationWithLocale });
}
const apiClient = this.getPlainApiClientForUser({ userContext });
await (0, contentful_api_client_1.updateEntry)(apiClient, documentId, entry);
}
async deleteDocument({ document, userContext }) {
this.logger.debug('deleteDocument');
const documentId = document.id;
try {
const entry = await (0, contentful_api_client_1.fetchEntryById)(this.plainClient, documentId);
const apiClient = this.getPlainApiClientForUser({ userContext });
// Contentful won't let us delete a published entry, so if the entry is
// published we must unpublish it first.
if (lodash_1.default.get(entry, 'sys.publishedVersion')) {
await (0, contentful_api_client_1.unpublishEntry)(apiClient, documentId);
}
await (0, contentful_api_client_1.deleteEntry)(apiClient, documentId);
}
catch (err) {
this.logger.error('Contentful: Failed to delete object', {
documentId: documentId,
error: err
});
throw err;
}
}
async uploadAsset({ url, base64, fileName, mimeType, locale, userContext }) {
this.logger.debug('uploadAsset');
locale = this.localeOrDefaultOrThrow(locale);
const apiClient = this.getPlainApiClientForUser({ userContext });
let asset;
if (url) {
asset = await (0, contentful_api_client_1.createAsset)(apiClient, {
fields: {
title: {
[locale]: fileName
},
file: {
[locale]: {
fileName: fileName,
contentType: mimeType,
upload: url
}
}
}
});
}
else {
const imgBuffer = Buffer.from(base64, 'base64');
const readable = new stream_1.Readable();
readable.push(imgBuffer);
readable.push(null);
asset = await (0, contentful_api_client_1.createAssetFromFile)(apiClient, {
fields: {
title: {
[locale]: fileName
},
// the description is marked as required in Contentful's Typescript
description: {
[locale]: ''
},
file: {
[locale]: {
fileName: fileName,
contentType: mimeType,
file: readable
}
}
}
});
}
const processedAsset = await (0, contentful_api_client_1.processAssetForAllLocales)(apiClient, asset);
const publishedAsset = await (0, contentful_api_client_1.publishAsset)(apiClient, processedAsset);
const assetDocument = this.convertAssets([publishedAsset]);
return assetDocument[0];
}
async updateAsset({ asset, operations, userContext }) {
this.logger.debug('updateAsset');
const assetId = asset.id;
const assetEntry = await (0, contentful_api_client_1.fetchAssetById)(this.plainClient, assetId);
const defaultLocale = this.defaultLocale?.code ?? 'en-US';
for (const operation of operations) {
const locale = 'localized' in operation.modelField && operation.modelField.localized ? operation.locale ?? defaultLocale : defaultLocale;
const operationWithLocale = {
...operation,
locale
};
const opFunc = Operations[operationWithLocale.opType];
await opFunc({ entry: assetEntry, operation: operationWithLocale });
}
const apiClient = this.getPlainApiClientForUser({ userContext });
await (0, contentful_api_client_1.updateAsset)(apiClient, assetId, assetEntry);
}
async validateDocuments({ documents, assets, locale, userContext }) {
const linkChunks = lodash_1.default.chunk([
...documents.map((document) => ({ sys: { type: 'Link', id: document.id, linkType: 'Entry' } })),
...assets.map((asset) => ({ sys: { type: 'Link', id: asset.id, linkType: 'Asset' } }))
], 200);
const apiClient = this.getPlainApiClientForUser({ userContext });
locale = this.localeOrDefaultOrThrow(locale);
const result = await Promise.all(linkChunks.map((link) => {
return this.taskQueue.addTask(async () => {
const bulkActionOptions = {
spaceId: this.spaceId,
environmentId: this.environment
};
const bulkActionCreateResult = await (0, contentful_api_client_1.createValidateBulkAction)(apiClient, bulkActionOptions, {
entities: {
sys: { type: 'Array' },
items: link
}
});
const bulkAction = await waitBulkActionToComplete(bulkActionCreateResult, apiClient, bulkActionOptions);
if (bulkAction.sys.status === 'failed') {
const errors = bulkAction.error?.details?.errors ?? [];
return errors.reduce((result, { error, entity }) => {
const entityErrors = error?.details?.errors ?? [];
return entityErrors.reduce((result, entityError) => {
// error.path is:
// ['fields', 'title'] => ['title']
// ['fields', 'title', 'en-US'] => ['title']
// ['fields', 'action', 'en-US', 1] => ['title']
const fieldPath = entityError.path.slice(1);
const fieldLocale = fieldPath[1];
if (typeof fieldLocale === 'string' && ['en-US', locale].includes(fieldLocale)) {
// remove locale from fieldPath
fieldPath.splice(1, 1);
}
result.push({
message: entityError?.customMessage ?? entityError.details,
objectType: entity.sys.linkType === 'Entry' ? 'document' : 'asset',
objectId: entity.sys.id,
fieldPath: fieldPath,
isUniqueValidation: entityError.name === 'unique'
});
return result;
}, result);
}, []);
}
return [];
});
}));
return { errors: lodash_1.default.flatten(result) };
}
async publishDocuments({ documents, assets, userContext }) {
const apiClient = this.getPlainApiClientForUser({ userContext });
// to publish, we need the most recent entity version, fetch the entities
// by id to get their latest version and also ensure they were not publish
// by someone else.
const entries = await (0, contentful_api_client_1.fetchEntriesByIds)(apiClient, documents.map((document) => document.id), {
'sys.archivedVersion[exists]': false,
select: 'sys.id,sys.type,sys.version,sys.publishedVersion,sys.archivedVersion'
});
const contentfulAssets = await (0, contentful_api_client_1.fetchAssetsByIds)(apiClient, assets.map((asset) => asset.id), {
'sys.archivedVersion[exists]': false,
select: 'sys.id,sys.type,sys.version,sys.publishedVersion,sys.archivedVersion'
});
const entities = [...entries, ...contentfulAssets];
const entitiesToPublish = entities.filter((entity) => {
const isDraft = !entity.sys.publishedVersion && !entity.sys.archivedVersion;
const isChanged = entity.sys.publishedVersion && entity.sys.version >= entity.sys.publishedVersion + 2;
return isDraft || isChanged;
});
const versionedLinkChunks = lodash_1.default.chunk(entitiesToPublish.map((entity) => ({
sys: {
type: 'Link',
id: entity.sys.id,
linkType: entity.sys.type,
version: entity.sys.version
}
})), 200);
await Promise.all(versionedLinkChunks.map((versionedLinks) => {
return this.taskQueue.addTask(async () => {
const bulkActionOptions = {
spaceId: this.spaceId,
environmentId: this.environment
};
const bulkActionCreateResult = await (0, contentful_api_client_1.createPublishBulkAction)(apiClient, bulkActionOptions, {
entities: {
sys: { type: 'Array' },
items: versionedLinks
}
});
await waitBulkActionToComplete(bulkActionCreateResult, apiClient, bulkActionOptions);
});
}));
}
async unpublishDocuments({ documents, assets, userContext }) {
const apiClient = this.getPlainApiClientForUser({ userContext });
const entities = [...documents, ...assets];
const linkChunks = lodash_1.default.chunk(entities.map((entity) => ({
sys: {
type: 'Link',
id: entity.id,
linkType: entity.type === 'document' ? 'Entry' : 'Asset'
}
})), 200);
await Promise.all(linkChunks.map((links) => {
return this.taskQueue.addTask(async () => {
const bulkActionOptions = {
spaceId: this.spaceId,
environmentId: this.environment
};
const bulkActionCreateResult = await (0, contentful_api_client_1.createUnpublishBulkAction)(apiClient, bulkActionOptions, {
entities: {
sys: { type: 'Array' },
items: links
}
});
await waitBulkActionToComplete(bulkActionCreateResult, apiClient, bulkActionOptions);
});
}));
}
async archiveDocument({ document, userContext }) {
this.logger.debug('archiveDocument');
const documentId = document.id;
try {
const entry = await (0, contentful_api_client_1.fetchEntryById)(this.plainClient, documentId);
const apiClient = this.getPlainApiClientForUser({ userContext });
// Contentful won't let us archive a published entry, so if the entry is
// published we must unpublish it first.
if (lodash_1.default.get(entry, 'sys.publishedVersion')) {
await (0, contentful_api_client_1.unpublishEntry)(apiClient, documentId);
}
await (0, contentful_api_client_1.archiveEntry)(apiClient, documentId);
}
catch (err) {
this.logger.error('Contentful: Failed to archive object', {
documentId: documentId,
error: err
});
throw err;
}
}
async unarchiveDocument({ document, userContext }) {
this.logger.debug('unarchiveDocument');
const documentId = document.id;
try {
const apiClient = this.getPlainApiClientForUser({ userContext });
await (0, contentful_api_client_1.unarchiveEntry)(apiClient, documentId);
}
catch (err) {
this.logger.error('Contentful: Failed to archive object', {
documentId: documentId,
error: err
});
throw err;
}
}
async onWebhook({ data, headers }) {
const topic = headers['x-contentful-topic'] || '';
const didContentTypeChange = topic.startsWith('ContentManagement.ContentType.');
const didDocumentDelete = topic === 'ContentManagement.Entry.delete';
const didAssetDelete = topic === 'ContentManagement.Asset.delete';
const didScheduledActionDelete = topic === 'ContentManagement.ScheduledAction.delete' || topic === 'ContentManagement.Release.delete';
const environment = lodash_1.default.get(data, 'sys.environment.sys.id');
// skip updates on other environments
if (environment !== this.environment) {
return;
}
// skip updates on BulkAction event type
if (topic.includes('BulkAction')) {
return;
}
// if contentPoller.pollType === 'date', then the invalidateSchema will
// be called by contentPoller when it identifies change in content types
if (didContentTypeChange && this.contentPoller?.pollType !== 'date') {
this.cache.invalidateSchema();
}
if (didDocumentDelete) {
const documentId = lodash_1.default.get(data, 'sys.id');
if (documentId) {
this.cache.updateContent({
documents: [],
assets: [],
deletedDocumentIds: [documentId],
deletedAssetIds: []
});
}
}
if (didAssetDelete) {
const assetId = lodash_1.default.get(data, 'sys.id');
if (assetId) {
this.cache.updateContent({
documents: [],
assets: [],
deletedDocumentIds: [],
deletedAssetIds: [assetId]
});
}
}
if (didScheduledActionDelete) {
let scheduledActionId;
const scheduleId = lodash_1.default.get(data, 'sys.id');
if (scheduleId && lodash_1.default.get(data, 'sys.type') === 'ScheduledAction') {
// refetch schedule because we're not getting the entity.sys.id in the webhook data
const schedule = await (0, contentful_api_client_1.fetchScheduleById)(this.plainClient, { environment: this.environment, scheduleId: scheduleId });
scheduledActionId = schedule.entity.sys.id;
}
else {
scheduledActionId = scheduleId;
}
if (scheduledActionId) {
this.cache.updateContent({
deletedScheduledActionIds: [scheduledActionId]
});
}
}
// ScheduledActions are handled outside of this.useWebhookForContentUpdates block below because Releases and Schedules are not handled by sync.
if ((topic.startsWith('ContentManagement.ScheduledAction') || topic.startsWith('ContentManagement.Release')) && !didScheduledActionDelete) {
const scheduledActionId = lodash_1.default.get(data, 'sys.type') === 'ScheduledAction' && lodash_1.default.get(data, 'entity.sys.linkType') === 'Release'
? lodash_1.default.get(data, 'entity.sys.id')
: lodash_1.default.get(data, 'sys.type') === 'Release'
? lodash_1.default.get(data, 'sys.id')
: null;
this.logger.debug(`onWebhook ScheduledAction/Release - ${scheduledActionId}`);
if (scheduledActionId) {
const scheduledAction = await (0, contentful_api_client_1.getScheduledAction)(this.plainClient, scheduledActionId, { environment: this.environment });
if (scheduledAction) {
// If a schedule is updated from the studio, both the ScheduledAction and the Release will post a webhook update,
// So this diff change makes sure we send only one update.
const cachedScheduledAction = this.cache.getScheduledActions().find((schedule) => schedule.id === scheduledActionId);
const updatedActions = lodash_1.default.differenceWith([scheduledAction], [cachedScheduledAction], lodash_1.default.isEqual);
if (updatedActions.length) {
this.cache.updateContent({ scheduledActions: updatedActions });
}
}
}
}
// if sync poller is used, content updates are handled in the poller and we don't need to do anything here
if (this.useWebhookForContentUpdates) {
const userId = lodash_1.default.get(data, 'sys.updatedBy.sys.id');
if (userId && !this.userMap[userId]) {
await this.fetchUsers();
}
if (topic.startsWith('ContentManagement.Entry') && !didDocumentDelete) {
const documentId = lodash_1.default.get(data, 'sys.id');
this.logger.debug(`onWebhook ${topic} ${documentId}`);
if (documentId) {
const entry = await (0, contentful_api_client_1.fetchEntryById)(this.plainClient, documentId);
this.cache.updateContent({
documents: this.convertEntries([entry], this.cache.getModelByName),
assets: [],
deletedDocumentIds: [],
deletedAssetIds: []
});
}
}
else if (topic.startsWith('ContentManagement.Asset') && !didAssetDelete) {
const assetId = lodash_1.default.get(data, 'sys.id');
this.logger.debug(`onWebhook ${topic} ${assetId}`);
if (assetId) {
const asset = await (0, contentful_api_client_1.fetchAssetById)(this.plainClient, assetId);
this.cache.updateContent({
documents: [],
assets: this.convertAssets([asset]),
deletedDocumentIds: [],
deletedAssetIds: []
});
}
}
}
}
getPlainApiClientForUser({ userContext }) {
if (this.localDev || this.useAccessTokenForUpdates) {
return this.plainClient;
}
const userAccessToken = userContext?.accessToken;
if (!userAccessToken) {
throw new Error(`User does not have an access token for space '${this.spaceId}'.`);
}
return (0, contentful_api_client_1.createPlainApiClient)({
spaceId: this.spaceId,
accessToken: userAccessToken,
environment: this.environment,
managementHost: this.managementHost,
uploadHost: this.uploadHost
});
}
convertEntries(entries, getModelByName) {
return (0, contentful_entries_converter_1.convertEntities)({
entries: entries,
getModelByName: getModelByName,
userMap: this.userMap,
defaultLocale: this.localeOrDefaultOrThrow(),
projectUrl: this.getProjectUrl()
});
}
convertAssets(assets) {
return (0, contentful_entries_converter_1.convertAssets)({
assets: assets,
userMap: this.userMap,
defaultLocale: this.localeOrDefaultOrThrow(),
projectUrl: this.getProjectUrl()
});
}
convertVersions(entries) {
return (0, contentful_entries_converter_1.convertDocumentVersions)({
entries,
getModelByName: this.cache.getModelByName,
userMap: this.userMap,
defaultLocale: this.localeOrDefaultOrThrow(),
projectUrl: this.getProjectUrl()
});
}
localeOrDefaultOrThrow(locale) {
const result = locale ?? this.defaultLocale?.code;
if (!result) {
throw new Error('Localization error: default locale is not set.');
}
return result;
}
async cancelScheduledAction({ scheduledActionId, userContext }) {
try {
this.logger.debug('cancelScheduledAction', { scheduledActionId });
const apiClient = this.getPlainApiClientForUser({ userContext });
const schedule = await (0, contentful_api_client_1.cancelScheduledAction)(apiClient, scheduledActionId, {
environment: this.environment
});
this.logger.debug('cancelScheduledAction - success', { schedule });
return { cancelledScheduledActionId: schedule.id };
}
catch (err) {
this.logger.debug('Failed to update scheduled actions');
throw err;
}
}
async createScheduledAction({ documentIds, name, action, executeAt, userContext }) {
this.logger.debug('createScheduledAction', { action, executeAt });
const apiClient = this.getPlainApiClientForUser({ userContext });
const { schedule, contentfulSchedule, contentfulRelease } = await (0, contentful_api_client_1.createScheduledAction)(apiClient, {
name,
documentIds,
executeAt,
action,
environment: this.environment
});
this.logger.debug('createScheduledAction - Create success', { schedule, contentfulSchedule, contentfulRelease });
return { newScheduledActionId: schedule.id };
}
async getScheduledActions() {
this.logger.debug('getScheduledActions');
const contentfulReleases = await (0, contentful_api_client_1.fetchAllReleases)(this.plainClient, this.logger);
const contentfulSchedules = await (0, contentful_api_client_1.fetchAllSchedules)(this.plainClient, { environment: this.environment }, this.logger);
return (0, contentful_scheduled_actions_converter_1.convertAndFilterScheduledActions)(contentfulReleases, contentfulSchedules);
}
async updateScheduledAction({ scheduledActionId, documentIds, name, executeAt, userContext }) {
try {
this.logger.debug('updateScheduledAction', { scheduledActionId, documentIds, name, executeAt });
const apiClient = this.getPlainApiClientForUser({ userContext });
const schedule = await (0, contentful_api_client_1.updateScheduledAction)(apiClient, scheduledActionId, {
name,
documentIds,
executeAt,
environment: this.environment
});
this.logger.debug('updateScheduledAction - Update success', { schedule });
return { updatedScheduledActionId: schedule.id };
}
catch (err) {
this.logger.debug('Failed to update scheduled actions');
throw err;
}
}
async getDocumentVersions({ documentId }) {
this.logger.debug('getDocumentVersions', { documentId });
const versions = await (0, contentful_api_client_1.fetchEntityVersions)(this.plainClient, { environment: this.environment, entityId: documentId });
return { versions: this.convertVersions(versions.items) };
}
async getDocumentForVersion({ documentId, versionId }) {
this.logger.debug('getDocumentForVersion', { documentId, versionId });
const version = await (0, contentful_api_client_1.fetchEntityVersion)(this.plainClient, { environment: this.environment, entityId: documentId, versionId });
const parsedVersion = this.convertVersions([version])[0];
if (!parsedVersion) {
throw new Error(`Could not parse version ${versionId} for entity ${documentId}`);
}
return { version: parsedVersion };
}
}
exports.ContentfulContentSource = ContentfulContentSource;
const Operations = {
set: async function ({ entry, operation }) {
const { field, fieldPath, locale, modelField } = operation;
// bynder & cloudinary always stored as array in cms; depends on the app setting - it might be in csi either single image or array of images
// if we call set on the entire list it should be an array, otherwise if we calling on the item of the list - not
const isListOp = typeof fieldPath[fieldPath.length - 1] === 'number';
const value = mapOperationFieldToContentfulValue(field, modelField, isListOp);
setEntryField(entry, value, fieldPath, locale);
return entry;
},
unset: async ({ entry, operation }) => {
const { fieldPath, locale } = operation;
const localizedFieldPath = getLocalizedFieldPath(fieldPath, locale);
lodash_1.default.unset(entry, localizedFieldPath);
return entry;
},
insert: async function ({ entry, operation }) {
const { item, fieldPath, locale, index, modelField } = operation;
const array = getEntryField(entry, fieldPath, locale) ?? [];
const listItemModelField = modelField.items ?? { type: 'string' };
const value = mapOperationFieldToContentfulValue(item, listItemModelField, true);
array.splice(index ?? array.length, 0, value);
setEntryField(entry, array, fieldPath, locale);
return entry;
},
remove: async ({ entry, operation }) => {
const { fieldPath, locale, index } = operation;
const array = getEntryField(entry, fieldPath, locale) ?? [];
array.splice(index, 1);
setEntryField(entry, array, fieldPath, locale);
return entry;
},
reorder: async ({ entry, operation }) => {
const { fieldPath, locale, order } = operation;
const array = getEntryField(entry, fieldPath, locale) ?? [];
const newEntryArr = order.map((newIndex) => array[newIndex]);
setEntryField(entry, newEntryArr, fieldPath, locale);
return entry;
}
};
function mapOperationFieldToContentfulValue(documentField, modelField, inArray) {
if (documentField.type === 'object') {
throw new Error('Nested object fields not supported in Contentful.');
}
else if (documentField.type === 'model') {
throw new Error('Nested model fields not supported in Contentful.');
}
else if (documentField.type === 'image') {
if (modelField.type === 'image' && modelField.source && contentful_consts_1.CONTENTFUL_BUILT_IN_IMAGE_SOURCES.includes(modelField.source)) {
// The Cloudinary and Bynder apps in Contentful always stores images as array,
// but if we are already adding items to array, return don't wrap in array
let value = inArray ? documentField.value : [documentField.value];
if (modelField.source === 'bynder') {
// contentful saves data in different format - align the incoming data from studio (which is in full format) to the one
// contentful bynder app uses internally
if (documentField.value?.__typename) {
// full object - transform to bynder contentful app format
const thumbnails = {
webimage: documentField.value.files.webImage?.url,
thul: documentField.value.files.thumbnail?.url
};
lodash_1.default.forEach(documentField.value.files, (value, key) => {
if (key === 'webImage' || key === 'thumbnail') {
return;
}
thumbnails[key] = value.url;
});
const imageObject = lodash_1.default.omitBy({
id: documentField.value.databaseId,
orientation: documentField.value.orientation.toLowerCase(),
archive: documentField.value.isArchived ? 1 : 0,
type: documentField.value.type.toLowerCase(),