UNPKG

@stackbit/cms-contentful

Version:

Stackbit Contentful CMS Interface

988 lines (987 loc) 56.2 kB
"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(),