import _ from 'lodash'; import { ImageModel, Model } from '@stackbit/sdk'; import { ScheduledAction } from '@stackbit/types'; import { append } from '@stackbit/utils'; import * as ContentStoreTypes from '../types'; import { ReferenceValueType, SearchFilter, SearchFilterItem, SearchFilterItemGrouped } from '../types'; import { getContentSourceId, getDocumentFieldForLocale } from '../content-store-utils'; import { META_FIELDS, MetaFieldMethod } from './meta-fields-filters'; export type SearchSchema = Record>>; export const searchDocuments = (data: { query?: string; filter?: SearchFilter; models: Array<{ srcProjectId: string; srcType: string; modelName: string; }>; documents: ContentStoreTypes.Document[]; schema: SearchSchema; scheduledActions?: ScheduledAction[]; locale?: string; defaultLocales?: Record; }): { total: number; items: ContentStoreTypes.Document[]; } => { const query = data.query?.toLowerCase(); const { documents, schema } = data; let allDocuments = 0; const activeScheduledActionsByDocumentId = groupScheduledActionsByDocumentId(data.scheduledActions ?? []); const matchedDocuments = documents.filter((document) => { const contentSourceId = getContentSourceId(document.srcType, document.srcProjectId); const isIncludedModel = _.find(data.models, { srcType: document.srcType, srcProjectId: document.srcProjectId, modelName: document.srcModelName }); if (!isIncludedModel) { return false; } allDocuments += 1; if (query) { const matches = isDocumentMatchesPattern(document, query, data.locale ?? data.defaultLocales?.[contentSourceId]); if (!matches) { return false; } } if (data.filter) { const matches = isFilterMatches({ filter: data.filter, document, locale: data.locale ?? data.defaultLocales?.[contentSourceId], schema, activeScheduledActionsByDocumentId, nestedLevel: 0 }); if (!matches) { return false; } } return true; }); return { items: matchedDocuments, total: allDocuments }; }; const isDocumentMatchesPattern = (document: ContentStoreTypes.Document, query: string, locale?: string): boolean => { if (document.srcModelLabel.toLowerCase().includes(query)) { return true; } return areFieldsMatchingPattern(Object.values(document.fields), query, locale); }; const areFieldsMatchingPattern = (fields: ContentStoreTypes.DocumentField[], query: string, locale?: string): boolean => { return fields.some((field) => { let value; switch (field.type) { case 'string': case 'slug': case 'url': case 'text': case 'markdown': case 'html': value = getDocumentFieldForLocale(field, locale)?.value; break; case 'list': { const list = getDocumentFieldForLocale(field, locale); const result = list ? areFieldsMatchingPattern(list.items, query, locale) : false; if (result) { return result; } break; } case 'model': case 'object': { const object = getDocumentFieldForLocale(field, locale); if (object && !object.isUnset) { const result = areFieldsMatchingPattern(Object.values(object.fields), query, locale); if (result) { return result; } } break; } } if (value) { return value?.toString().toLowerCase().includes(query); } return false; }); }; const isFilterMatches = ({ filter, document, locale, schema, activeScheduledActionsByDocumentId, nestedLevel }: { filter: SearchFilterItemGrouped; document: ContentStoreTypes.Document; locale?: string; schema: SearchSchema; activeScheduledActionsByDocumentId: Record; nestedLevel: number; }): boolean | null => { if (_.intersection(Object.keys(filter), ['and', 'or', 'field']).length > 1) { throw new Error(`Unsupported filter - 'and', 'or' and 'field' are mutually exclusive.`); } if ('field' in filter) { const filterFieldResult = getFieldForFilter({ document, filter, context: { activeScheduledActionsByDocumentId, locale, schema } }); if ('result' in filterFieldResult) { return filterFieldResult.result; } const { field, model } = filterFieldResult; return isFieldMatchesFilter({ field, filter, document, model, locale }); } else { if (!('and' in filter) && !('or' in filter)) { throw new Error(`Unsupported filter - neither 'and' nor 'or' provided`); } const comparisonMethod = 'and' in filter ? 'every' : 'some'; const root = 'and' in filter ? filter.and : filter.or; if (!root.length) { // for empty search groups - skip them unless it's root group - this should match return nestedLevel === 0 ? true : null; } return root[comparisonMethod]((childFilter) => { const isMatches = isFilterMatches({ filter: childFilter, document, locale, schema, activeScheduledActionsByDocumentId, nestedLevel: nestedLevel + 1 }); if (isMatches === null) { return 'or' in filter ? false : true; } return isMatches; }); } }; const getFieldForFilter: MetaFieldMethod = ({ document, filter, context }) => { if (filter.isMeta) { const filterField = filter.field; if (!(filterField in META_FIELDS)) { throw new Error(`Unsupported meta field ${filter.field}`); } return META_FIELDS[filterField]!({ filter, document, context }); } else { const documentField = document.fields[filter.field]; if (!documentField) { throw new Error(`Field not found ${filter.field}`); } const model = context.schema?.[document.srcType]?.[document.srcProjectId]?.[document.srcModelName]; return { field: documentField, model }; } }; const isFieldMatchesFilter = ({ document, field, filter, locale, model }: { document: ContentStoreTypes.Document; field: ContentStoreTypes.DocumentField; filter: SearchFilterItem; locale?: string; model?: Model | ImageModel; }): boolean => { switch (field?.type) { case 'string': case 'slug': case 'url': case 'text': case 'markdown': case 'html': return isStringFieldMatches({ field, filter, locale }); case 'number': return isNumberFieldMatches({ field, filter, locale }); case 'boolean': return isBooleanFieldMatches({ field, filter, locale }); case 'date': case 'datetime': return isDateFieldMatches({ field, filter, locale }); case 'enum': return isEnumFieldMatches({ field, filter, locale }); case 'list': { if (!model || model.type === 'image') { throw new Error(`Can't find model for the ${filter.field}`); } return isListFieldMatches({ field, filter, model, locale, document }); } case 'cross-reference': case 'reference': return isReferenceFieldMatches({ field, filter, locale, document }); default: throw new Error(`Unsupported filter for field ${filter.field} with type ${field?.type}`); } }; const isStringFieldMatches = ({ field, filter, locale }: { field: ContentStoreTypes.DocumentFieldForType<'string' | 'slug' | 'url' | 'text' | 'markdown' | 'html'>; filter: SearchFilterItem; locale?: string; }) => { // ignoring case const fieldValue = getDocumentFieldForLocale(field, locale)?.value?.toLowerCase(); switch (filter.operator) { case 'is-undefined': return fieldValue === undefined; case 'is-not-undefined': return fieldValue !== undefined; case 'eq': // === case 'neq': // !== case 'includes': case 'not-includes': { if (typeof filter.value !== 'string') { throw new Error(`Filter value should be string for field ${filter.field}`); } const filterValue = filter.value?.toString().toLowerCase(); switch (filter.operator) { case 'eq': return fieldValue === filterValue; case 'neq': return fieldValue !== filterValue; case 'includes': return Boolean(fieldValue?.includes(filterValue)); case 'not-includes': return !fieldValue?.includes(filterValue); } } } throw new Error(`Not supported operator ${filter.operator} for field ${filter.field}`); }; const isNumberFieldMatches = ({ field, filter, locale }: { field: ContentStoreTypes.DocumentFieldForType<'number'>; filter: SearchFilterItem; locale?: string; }) => { const fieldValue = getDocumentFieldForLocale(field, locale)?.value; switch (filter.operator) { case 'is-undefined': return fieldValue === undefined; case 'is-not-undefined': return fieldValue !== undefined; case 'eq': // === case 'neq': // !== case 'gte': // >= case 'lte': { // <= if (typeof filter.value !== 'number') { throw new Error(`Filter value should be number for field ${filter.field}`); } switch (filter.operator) { case 'eq': return fieldValue === filter.value; case 'neq': return fieldValue !== filter.value; case 'gte': return Boolean(fieldValue && fieldValue >= filter.value); case 'lte': return Boolean(fieldValue && fieldValue <= filter.value); } break; } case 'between': { // ..N.. if (typeof filter.startValue !== 'number' || typeof filter.endValue !== 'number') { throw new Error(`Filter startValue and endValue should be number for field ${filter.field}`); } return Boolean(fieldValue && fieldValue >= filter.startValue && fieldValue <= filter.endValue); } } throw new Error(`Not supported operator ${filter.operator} for field ${filter.field}`); }; export const isBooleanFieldMatches = ({ field, filter, locale }: { field: ContentStoreTypes.DocumentFieldForType<'boolean'>; filter: SearchFilterItem; locale?: string; }) => { const fieldValue = getDocumentFieldForLocale(field, locale)?.value; switch (filter.operator) { case 'is-undefined': return fieldValue === undefined; case 'is-not-undefined': return fieldValue !== undefined; case 'eq': case 'neq': { if (typeof filter.value !== 'boolean') { throw new Error(`Filter value should be boolean for field ${filter.field}`); } switch (filter.operator) { case 'eq': return fieldValue === filter.value; case 'neq': return fieldValue !== filter.value; } } } throw new Error(`Not supported operator ${filter.operator} for field ${filter.field}`); }; const parseDateValue = (value?: string): Date | undefined => { let dateValue; if (value?.endsWith('Z')) { dateValue = new Date(value); } else if (value?.match(/\d{1,4}-\d{1,2}-\d{1,2}$/)) { // try to parse it as a date // when use '-' in js dates constructor, it make the date with TZ offset // 2022-10-04 => 03 Oct 2022 20:00 GMT-4 // 2022/10/04 => 04 Oct 2022 00:00 GMT-4 dateValue = new Date(value.replace(/-/g, '/')); } if (dateValue && !Number.isNaN(dateValue)) { return dateValue; } }; export const isDateFieldMatches = ({ field, filter, locale }: { field: ContentStoreTypes.DocumentFieldForType<'date' | 'datetime'>; filter: SearchFilterItem; locale?: string; }) => { const origValue = getDocumentFieldForLocale(field, locale)?.value; const fieldValue = parseDateValue(origValue); if (origValue && !fieldValue) { throw new Error(`Can't parse value ${origValue} for field ${filter.field}`); } switch (filter.operator) { case 'is-undefined': return fieldValue === undefined; case 'is-not-undefined': return fieldValue !== undefined; case 'eq': // === case 'neq': // !== case 'gte': // >= case 'lte': { // <= if (typeof filter.value !== 'string') { throw new Error(`Filter value should be in string date format for field ${filter.field}`); } const filterValue = parseDateValue(filter.value); if (!filterValue) { throw new Error(`Filter value should be in date format for field ${filter.field}`); } switch (filter.operator) { case 'eq': case 'neq': { // check if day is the same const result = fieldValue?.getUTCFullYear() === filterValue.getUTCFullYear() && fieldValue?.getUTCMonth() === filterValue.getUTCMonth() && fieldValue?.getUTCDate() === filterValue.getUTCDate(); return filter.operator === 'eq' ? result : !result; } case 'gte': return (fieldValue?.getTime() ?? 0) >= filterValue.getTime(); case 'lte': return (fieldValue?.getTime() ?? 0) <= filterValue.getTime(); } break; } case 'between': { // ..N.. if (typeof filter.startValue !== 'string' || typeof filter.endValue !== 'string') { throw new Error(`Filter startValue and endValue should be in string date format for field ${filter.field}`); } const startDate = parseDateValue(filter.startValue); const endDate = parseDateValue(filter.endValue); if (!startDate || !endDate) { throw new Error(`Filter startValue and endValue should be in date format for field ${filter.field}`); } return (fieldValue?.getTime() ?? 0) >= startDate.getTime() && (fieldValue?.getTime() ?? 0) <= endDate.getTime(); } } throw new Error(`Not supported operator ${filter.operator} for field ${filter.field}`); }; const isEnumFieldMatches = ({ field, filter, locale }: { field: ContentStoreTypes.DocumentFieldForType<'enum'>; filter: SearchFilterItem; locale?: string; }) => { const fieldValue = getDocumentFieldForLocale(field, locale)?.value; switch (filter.operator) { case 'is-undefined': return fieldValue === undefined; case 'is-not-undefined': return fieldValue !== undefined; case 'in': // one of case 'nin': { // none of const filterValues = filter.values as any[]; switch (filter.operator) { case 'in': return filterValues.includes(fieldValue); case 'nin': return !filterValues.includes(fieldValue); } } } throw new Error(`Not supported operator ${filter.operator} for field ${filter.field}`); }; export const isListFieldMatches = ({ field, filter, locale, model, document }: { field: ContentStoreTypes.DocumentFieldForType<'list'>; filter: SearchFilterItem; model?: Model; document: ContentStoreTypes.Document; locale?: string; }) => { const fieldModel = model?.fields?.find((field) => field.name === filter.field); const listItemsType = fieldModel?.type === 'list' && fieldModel.items?.type; const isPrimitiveList = listItemsType && ['string', 'slug', 'url', 'text', 'markdown', 'boolean', 'date', 'datetime', 'number', 'enum', 'reference', 'cross-reference'].includes(listItemsType); if (!isPrimitiveList) { throw new Error(`Unsupported filter for list field ${filter.field} with children ${listItemsType}`); } const localizedField = getDocumentFieldForLocale(field, locale); switch (filter.operator) { case 'is-empty': return (localizedField?.items.length ?? 0) === 0; case 'is-not-empty': return (localizedField?.items.length ?? 0) > 0; case 'in': // one of case 'nin': // none of case 'all': { // all of const filterValues = filter.values as any[]; switch (filter.operator) { case 'in': case 'nin': { const result = localizedField?.items.some((item) => { switch (item.type) { case 'string': case 'slug': case 'url': case 'text': case 'markdown': case 'boolean': case 'date': case 'datetime': case 'number': case 'enum': { return filterValues.includes(item.value); } case 'reference': case 'cross-reference': { if (item.isUnset) { // include unset items for nin operator and not for in return false; } let { srcType, srcProjectId } = document; if (item.type === 'cross-reference') { srcType = item.refSrcType; srcProjectId = item.refProjectId; } return filterValues.find( (filterObject) => filterObject.srcType === srcType && filterObject.srcProjectId === srcProjectId && filterObject.srcDocumentId === item.refId ); } default: return false; } }) ?? false; return filter.operator === 'in' ? result : !result; } case 'all': return filterValues.every((filterValue) => { return localizedField?.items.find((item) => { switch (item.type) { case 'string': case 'slug': case 'url': case 'text': case 'markdown': case 'boolean': case 'date': case 'datetime': case 'number': case 'enum': { return item.value === filterValue; } case 'reference': case 'cross-reference': { if (item.isUnset) { return false; } let { srcType, srcProjectId } = document; if (item?.type === 'cross-reference') { srcType = item.refSrcType; srcProjectId = item.refProjectId; } return ( filterValue.srcType === srcType && filterValue.srcProjectId === srcProjectId && filterValue.srcDocumentId === item.refId ); } default: return false; } }); }); } } } throw new Error(`Not supported operator ${filter.operator} for field ${filter.field}`); }; const isReferenceFieldMatches = ({ field, filter, document, locale }: { field: ContentStoreTypes.DocumentFieldForType<'reference' | 'cross-reference'>; filter: SearchFilterItem; document: ContentStoreTypes.Document; locale?: string; }) => { // ignoring case const fieldValue = getDocumentFieldForLocale(field, locale); switch (filter.operator) { case 'is-undefined': return fieldValue?.isUnset ?? false; case 'is-not-undefined': return !fieldValue?.isUnset; case 'in': case 'nin': { if (typeof filter.values[0] !== 'object' || filter.values[0] instanceof Date) { throw new Error(`Filter value should be array of objects for field ${filter.field}`); } const filterValues = filter.values as ReferenceValueType[]; if (fieldValue?.isUnset) { // include unset items in nin operator return filter.operator === 'nin'; } let { srcType, srcProjectId } = document; if (fieldValue?.type === 'cross-reference') { srcType = fieldValue.refSrcType; srcProjectId = fieldValue.refProjectId; } switch (filter.operator) { case 'in': return filterValues.some( (filterValue) => filterValue.srcType === srcType && filterValue.srcProjectId === srcProjectId && filterValue.srcDocumentId === fieldValue?.refId ); case 'nin': return filterValues.every( (filterValue) => filterValue.srcType !== srcType || filterValue.srcProjectId !== srcProjectId || filterValue.srcDocumentId !== fieldValue?.refId ); } } } throw new Error(`Not supported operator ${filter.operator} for field ${filter.field}`); }; function groupScheduledActionsByDocumentId(scheduledActions: ScheduledAction[]) { return _.reduce( scheduledActions?.filter((scheduledAction) => scheduledAction.state === 'scheduled'), (res: Record, scheduledAction) => { for (const docId of scheduledAction.documentIds) { append(res, [docId], scheduledAction); } return res; }, {} ); }