import _ from 'lodash'; import { SanityDocument } from '@sanity/client'; import * as StackbitTypes from '@stackbit/types'; import { DocumentHistory, DocumentHistoryMap } from './sanity-api-client'; import { SchemaContext, ModelWithContext, ModelContext } from './sanity-schema-converter'; import { getItemTypeForListItem, isLocalizedModelField, resolvedFieldType } from './utils'; export interface SanitySchedule { author: string; action: ScheduleAction; createdAt: string; dataset: string; description: string; documents: { documentId: string; documentType?: string; }[]; executeAt: string | null; executedAt?: string; id: string; name: string; projectId: string; state: ScheduleState; stateReason: string; } export type ScheduleAction = 'publish' | 'unpublish'; export type ScheduleState = 'cancelled' | 'scheduled' | 'succeeded'; export type DocumentContext = { publishedDocument?: SanityDocument; draftDocument?: SanityDocument; }; export type AssetContext = DocumentContext; export type ContextualDocument = StackbitTypes.Document; export type ContextualAsset = StackbitTypes.Asset; export const DRAFT_ID_PREFIX = 'drafts.'; export type GetModelByName = StackbitTypes.Cache['getModelByName']; export type ConvertDocumentsOptions = { documents: SanityDocument[]; getModelByName: GetModelByName; documentsHistory?: DocumentHistoryMap; studioUrl?: string; }; export function getPureObjectId(objectId: string) { return objectId.replace(/^drafts\./, ''); } export function getDraftObjectId(objectId: string) { return isDraftId(objectId) ? objectId : `${DRAFT_ID_PREFIX}${objectId}`; } export function isDraftId(objectId: string) { return objectId && objectId.startsWith(DRAFT_ID_PREFIX); } export function convertAndFilterScheduledActions(sanitySchedules: SanitySchedule[]): StackbitTypes.ScheduledAction[] { const allScheduledActions = sanitySchedules.map((schedule: SanitySchedule) => convertScheduledAction(schedule)); return filterScheduledActions(allScheduledActions); } export function convertScheduledAction(sanitySchedule: SanitySchedule): StackbitTypes.ScheduledAction { return { id: sanitySchedule.id, name: sanitySchedule.name, state: getScheduledActionState(sanitySchedule), action: sanitySchedule.action, createdAt: sanitySchedule.createdAt, createdBy: sanitySchedule.author, executeAt: sanitySchedule.executeAt || sanitySchedule.executedAt || '', documentIds: sanitySchedule.documents.map((doc: { documentId: string }) => doc.documentId) }; } export function filterScheduledActions(scheduledActions: StackbitTypes.ScheduledAction[]) { const cutoffDate = new Date(); cutoffDate.setMonth(cutoffDate.getMonth() - 1); const cutoffDateStr = cutoffDate.toISOString(); return scheduledActions.filter( (scheduledAction: StackbitTypes.ScheduledAction) => scheduledAction.state !== 'cancelled' && scheduledAction.executeAt.localeCompare(cutoffDateStr) > 0 ); } export function convertDocuments({ documents, getModelByName, documentsHistory, studioUrl }: ConvertDocumentsOptions): ContextualDocument[] { const publishedDocuments = _.keyBy( documents.filter((document) => !isDraftId(document._id)), '_id' ); const draftDocuments = _.keyBy( documents.filter((document) => isDraftId(document._id)), (document) => getPureObjectId(document._id) ); return _.uniq([..._.keys(publishedDocuments), ..._.keys(draftDocuments)]) .map((documentId) => { const docId = draftDocuments[documentId]?._id; const draftDocumentHistory = docId ? documentsHistory?.[docId] : undefined; return convertDocument({ publishedDocument: publishedDocuments[documentId], draftDocument: draftDocuments[documentId], getModelByName, documentHistory: draftDocumentHistory, studioUrl }); }) .filter((document): document is ContextualDocument => !!document); } export type ConvertDocumentOptions = { publishedDocument?: SanityDocument; draftDocument?: SanityDocument; getModelByName: GetModelByName; documentHistory?: DocumentHistory[]; studioUrl?: string; }; function convertDocument({ publishedDocument, draftDocument, getModelByName, documentHistory, studioUrl }: ConvertDocumentOptions): ContextualDocument | undefined { const document = draftDocument || publishedDocument; if (!document) { return; } const model = getModelByName(document._type); if (!model) { return; } const pureObjectId = getPureObjectId(document._id); const manageUrl = studioUrl ? `${studioUrl}/desk/${model.name};${pureObjectId}` : ''; return { type: 'document', id: pureObjectId, manageUrl, modelName: model.name, ...commonFields({ publishedDocument, draftDocument, documentHistory }), fields: convertFields({ object: document, modelFields: model.fields, rootModel: model, modelFieldPath: [], getModelByName }), context: { draftDocument, publishedDocument } }; } export type ConvertAssetsOptions = { assets: SanityDocument[]; documentsHistory: DocumentHistoryMap; }; export function convertAssets({ assets, documentsHistory }: ConvertAssetsOptions): ContextualAsset[] { const publishedDocuments = _.keyBy( assets.filter((document) => !isDraftId(document._id)), '_id' ); const draftDocuments = _.keyBy( assets.filter((document) => isDraftId(document._id)), (document) => getPureObjectId(document._id) ); return _.uniq([..._.keys(publishedDocuments), ..._.keys(draftDocuments)]).map((documentId): ContextualAsset => { const publishedDocument = publishedDocuments[documentId]; const draftDocument = draftDocuments[documentId]; const document = (draftDocument || publishedDocument)!; const pureObjectId = getPureObjectId(document._id); const draftDocId = draftDocument?._id; const draftDocumentHistory = draftDocId ? documentsHistory[draftDocId] : undefined; return { type: 'asset', id: pureObjectId, manageUrl: document.url, ...commonFields({ draftDocument, publishedDocument, documentHistory: draftDocumentHistory }), fields: { title: { type: 'string', value: document.originalFilename }, file: { type: 'assetFile', url: document.url, fileName: document.originalFilename, contentType: document.mimeType, dimensions: document.metadata?.dimensions } }, context: { publishedDocument, draftDocument } }; }); } export type ConvertFieldsOptions = { object: Record; modelFields?: StackbitTypes.Field[]; rootModel: ModelWithContext; modelFieldPath: string[]; getModelByName: GetModelByName; }; function convertFields({ object, modelFields, rootModel, modelFieldPath, getModelByName }: ConvertFieldsOptions): Record { const fieldsByName = _.keyBy(modelFields, 'name'); const result: Record = {}; for (const fieldName in fieldsByName) { const value = object[fieldName]; const modelField = fieldsByName[fieldName]; if (!modelField) { continue; } const field = convertFieldType({ value, modelField, rootModel, modelFieldPath: modelFieldPath.concat(fieldName), getModelByName }); if (field) { result[fieldName] = field; } } return result; } /** * Converts Sanity's document or object field value, or list item value, to * Stackbit's DocumentField or DocumentListFieldItems. * * The convertFieldType has two overloads: * When modelField is Field, it returns DocumentField or undefined. * When modelField is FieldListItems it returns DocumentListFieldItems or undefined. */ function convertFieldType(options: { value: any; modelField: StackbitTypes.Field; rootModel: ModelWithContext; modelFieldPath: string[]; getModelByName: GetModelByName; }): StackbitTypes.DocumentField | undefined; function convertFieldType(options: { value: any; modelField: StackbitTypes.FieldListItems; rootModel: ModelWithContext; modelFieldPath: string[]; getModelByName: GetModelByName; }): StackbitTypes.DocumentListFieldItems | undefined; function convertFieldType({ value, modelField, rootModel, modelFieldPath, getModelByName }: { value: any; modelField: StackbitTypes.Field | StackbitTypes.FieldListItems; rootModel: ModelWithContext; modelFieldPath: string[]; getModelByName: GetModelByName; }): StackbitTypes.DocumentField | undefined { switch (modelField.type) { case 'string': case 'text': case 'html': case 'url': case 'boolean': case 'number': case 'date': case 'datetime': case 'enum': case 'json': case 'style': case 'markdown': { return createDocumentField({ fieldValue: value, modelField: modelField, documentFieldBaseProps: { type: modelField.type }, documentFieldSpecificProps: (value: any) => { if (_.isUndefined(value)) { return undefined; } return { value }; } }); } case 'cross-reference': { return undefined; } case 'list': { return createDocumentField({ fieldValue: value, modelField: modelField, documentFieldBaseProps: { type: 'list' }, documentFieldSpecificProps: (value: any) => { return { items: _.reduce( value, (accum: StackbitTypes.DocumentListFieldItems[], item) => { const itemModel = getItemTypeForListItem(item, modelField); if (!itemModel) { return accum; } const documentField = convertFieldType({ value: item, modelField: itemModel, rootModel, modelFieldPath: modelFieldPath.concat('items'), getModelByName }); if (!documentField) { return accum; } return accum.concat(documentField); }, [] ) }; } }); } case 'object': { return createDocumentField({ fieldValue: value, modelField: modelField, documentFieldBaseProps: { type: 'object' }, documentFieldSpecificProps: (value: any) => { if (!value) { return undefined; } return { fields: convertFields({ object: value, modelFields: modelField.fields, rootModel, modelFieldPath, getModelByName }) }; } }); } case 'model': { return createDocumentField({ fieldValue: value, modelField: modelField, documentFieldBaseProps: { type: 'model' }, documentFieldSpecificProps: (value: any) => { if (!value) { return undefined; } const modelName = resolvedFieldType({ sanityFieldType: value._type, model: rootModel, modelFieldPath }); const model = getModelByName(modelName); if (!model) { return undefined; } return { modelName: model.name, fields: convertFields({ object: value, modelFields: model.fields, rootModel: model, modelFieldPath: [], getModelByName }) }; } }); } case 'reference': { return createDocumentField({ fieldValue: value, modelField: modelField, documentFieldBaseProps: { type: 'reference', refType: 'document' }, documentFieldSpecificProps: (value: any) => { if (!value) { return undefined; } let refId = _.get(value, '_ref', null); if (refId) { refId = refId.replace(/^drafts\./, ''); } return { refId }; } }); } case 'color': { return createDocumentField({ fieldValue: value, modelField: modelField, documentFieldBaseProps: { type: 'color' }, documentFieldSpecificProps: (value: any) => { if (!value) { return undefined; } return { value: _.get(value, 'hex') }; } }); } case 'richText': { return createDocumentField({ fieldValue: value, modelField: modelField, documentFieldBaseProps: { type: 'richText' }, documentFieldSpecificProps: (value: any) => { if (!value) { return undefined; } return { value: value, hint: flattenRichText(value).substring(0, 200) }; } }); } case 'image': { if (modelField.source && ['cloudinary', 'aprimo', 'bynder'].includes(modelField.source)) { return createDocumentField({ fieldValue: value, modelField: modelField, documentFieldBaseProps: { type: 'image', source: modelField.source }, documentFieldSpecificProps: (value: any) => { if (!value) { return undefined; } return { sourceData: value, ...(modelField.source === 'bynder' ? { fields: { title: { type: 'string', value: value?.name }, url: { type: 'string', value: value?.previewImg } } } : null) }; } }); } return createDocumentField({ fieldValue: value, // The modelField.type and documentField.type should match. // However, in the case of the modelField.type === 'image', // it is OK to have the documentField.type === 'reference' // and documentField.type === 'asset. modelField: modelField as unknown as StackbitTypes.FieldReference, documentFieldBaseProps: { type: 'reference', refType: 'asset' }, documentFieldSpecificProps: (value: any) => { if (!value) { return undefined; } return { refId: _.get(value, 'asset._ref') }; } }); } case 'file': { return createDocumentField({ fieldValue: value, // The modelField.type and documentField.type should match. // However, in the case of the modelField.type === 'file', // it is OK to have the documentField.type === 'reference' // and documentField.type === 'asset. modelField: modelField as unknown as StackbitTypes.FieldReference, documentFieldBaseProps: { type: 'reference', refType: 'asset' }, documentFieldSpecificProps: (value: any) => { if (!value) { return undefined; } return { refId: _.get(value, 'asset._ref') }; } }); } case 'slug': { return createDocumentField({ fieldValue: value, modelField: modelField, documentFieldBaseProps: { type: 'slug' }, documentFieldSpecificProps: (value: any) => { if (!value) { return undefined; } return { value: _.get(value, 'current') }; } }); } default: { const _exhaustiveCheck: never = modelField; return _exhaustiveCheck; } } } type CommonFieldsType = { documentHistory?: DocumentHistory[]; draftDocument?: SanityDocument; publishedDocument?: SanityDocument; }; function commonFields({ draftDocument, publishedDocument, documentHistory }: CommonFieldsType): { status: StackbitTypes.Document['status']; createdAt: string; updatedAt: string; updatedBy: string[]; } { const document = draftDocument ?? publishedDocument; if (!document) { throw new Error('No draft or published document found'); } const isDraft = isDraftId(document._id); let status: StackbitTypes.Document['status']; if (isDraft && !publishedDocument) { status = 'added'; } else { status = isDraft ? 'modified' : 'published'; } const updatedByList = documentHistory?.map(({ author }: DocumentHistory) => author); return { status, createdAt: document._createdAt, // createdBy: createdByEmail, updatedAt: document._updatedAt, updatedBy: _.uniq(updatedByList) }; } function createDocumentField< Type extends StackbitTypes.FieldType, BaseProps extends StackbitTypes.DocumentFieldBasePropsForType, SpecificProps extends StackbitTypes.DocumentFieldSpecificPropsForType >({ fieldValue, modelField, documentFieldBaseProps, documentFieldSpecificProps }: { fieldValue: any; modelField: StackbitTypes.FieldForType | StackbitTypes.FieldListItemsForType; documentFieldBaseProps: BaseProps; documentFieldSpecificProps: (value: any) => SpecificProps | undefined; }): (BaseProps & (SpecificProps | { localized: true; locales: Record })) | undefined { if (isLocalizedModelField(modelField)) { if (!Array.isArray(fieldValue)) { return undefined; } return { ...documentFieldBaseProps, localized: true, locales: _.reduce( fieldValue, (accum: Record, localizedItem: { _key: string; value: any }) => { if (localizedItem === null || typeof localizedItem === 'undefined') { return accum; } const fieldSpecificProps = documentFieldSpecificProps(localizedItem.value); if (!fieldSpecificProps) { return accum; } accum[localizedItem._key] = { locale: localizedItem._key, ...fieldSpecificProps }; return accum; }, {} ) }; } const fieldSpecificProps = documentFieldSpecificProps(fieldValue); if (!fieldSpecificProps) { return undefined; } return { ...documentFieldBaseProps, ...fieldSpecificProps }; } function flattenRichText(richTextArray: any) { return _.reduce( richTextArray, (accum, node) => { if (_.get(node, '_type') !== 'block') { return accum; } const children: any = _.get(node, 'children', []); return ( accum + _.reduce( children, (accum, node) => { if (_.get(node, '_type') !== 'span') { return accum; } return accum + _.get(node, 'text', ''); }, '' ) ); }, '' ); } function getScheduledActionState(sanitySchedule: SanitySchedule): StackbitTypes.ScheduledActionState { if (sanitySchedule.state === 'cancelled' && sanitySchedule.stateReason !== 'cancelled by user') { return 'failed'; } return sanitySchedule.state; }