UNPKG

strapi-plugin-meilisearch

Version:

Synchronise and search in your Strapi content-types with Meilisearch

1,516 lines (1,515 loc) 68.4 kB
"use strict"; const meilisearch$1 = require("meilisearch"); async function subscribeToLifecycles({ lifecycle: lifecycle2, store: store2 }) { const contentTypes = await store2.getIndexedContentTypes(); await store2.emptyListenedContentTypes(); let lifecycles; for (const contentType2 of contentTypes) { lifecycles = await lifecycle2.subscribeContentType({ contentType: contentType2 }); } return lifecycles; } async function syncIndexedCollections({ store: store2, contentTypeService: contentTypeService2, meilisearch: meilisearch2 }) { const indexUids = await meilisearch2.getIndexUids(); const indexedContentTypes2 = await store2.getIndexedContentTypes(); const contentTypes = contentTypeService2.getContentTypesUid(); for (const contentType2 of contentTypes) { const contentTypeIndexUids = await meilisearch2.getIndexNamesOfContentType({ contentType: contentType2 }); const indexesInMeiliSearch = contentTypeIndexUids.some( (indexUid) => indexUids.includes(indexUid) ); const contentTypeInIndexStore = indexedContentTypes2.includes(contentType2); if (!indexesInMeiliSearch && contentTypeInIndexStore) { await store2.removeIndexedContentType({ contentType: contentType2 }); } } } const registerPermissionActions = async () => { const RBAC_ACTIONS = [ { section: "plugins", displayName: "Access the Meilisearch", uid: "read", pluginName: "meilisearch" }, { section: "plugins", displayName: "Create", uid: "collections.create", subCategory: "collections", pluginName: "meilisearch" }, { section: "plugins", displayName: "Update", uid: "collections.update", subCategory: "collections", pluginName: "meilisearch" }, { section: "plugins", displayName: "Delete", uid: "collections.delete", subCategory: "collections", pluginName: "meilisearch" }, { section: "plugins", displayName: "Edit", uid: "settings.edit", subCategory: "settings", pluginName: "meilisearch" } ]; await strapi.admin.services.permission.actionProvider.registerMany( RBAC_ACTIONS ); }; const bootstrap = async ({ strapi: strapi2 }) => { const store2 = strapi2.plugin("meilisearch").service("store"); const lifecycle2 = strapi2.plugin("meilisearch").service("lifecycle"); const meilisearch2 = strapi2.plugin("meilisearch").service("meilisearch"); const contentTypeService2 = strapi2.plugin("meilisearch").service("contentType"); await store2.syncCredentials(); await syncIndexedCollections({ store: store2, contentTypeService: contentTypeService2, meilisearch: meilisearch2 }); await subscribeToLifecycles({ lifecycle: lifecycle2, store: store2 }); await registerPermissionActions(); }; const destroy = () => { }; const register = () => { }; function isObject(elem) { return typeof elem === "object" && !Array.isArray(elem) && elem !== null; } function EntriesQuery({ configuration, collectionName }) { const log = strapi.log; const { fields, filters, start, limit, sort, populate, status, locale, ...unknownKeys } = configuration; const options = {}; return { validateFields() { if (fields !== void 0 && !Array.isArray(fields)) { log.error( `The "fields" option in "queryOptions" of "${collectionName}" should be an array of strings.` ); } else if (fields !== void 0) { options.fields = fields; } return this; }, validateFilters() { if (filters !== void 0 && !isObject(filters)) { log.error( `The "filters" option in "queryOptions" of "${collectionName}" should be an object.` ); } else if (filters !== void 0) { options.filters = filters; } return this; }, validateStart() { if (start !== void 0) { log.error( `The "start" option in "queryOptions" of "${collectionName}" is forbidden.` ); } return this; }, validateLimit() { if (limit !== void 0 && (isNaN(limit) || limit < 1)) { log.error( `The "limit" option in "queryOptions" of "${collectionName}" should be a number higher than 0.` ); } else if (limit !== void 0) { options.limit = limit; } return this; }, validateSort() { if (sort !== void 0 && !isObject(sort) && !Array.isArray(sort) && typeof sort !== "string") { log.error( `The "sort" option in "queryOptions" of "${collectionName}" should be an object/array/string.` ); } else if (sort !== void 0) { options.sort = sort; } return this; }, validatePopulate() { if (populate !== void 0 && !isObject(populate) && !Array.isArray(populate) && typeof populate !== "string") { log.error( `The "populate" option in "queryOptions" of "restaurant" should be an object/array/string.` ); } else if (populate !== void 0) { options.populate = populate; } return this; }, validateStatus() { if (status !== void 0 && status !== "draft" && status !== "published") { log.error( `The "status" option in "queryOptions" of "${collectionName}" should be either "draft" or "published".` ); } else if (status !== void 0) { options.status = status; } return this; }, validateLocale() { if (locale !== void 0 && typeof locale !== "string" || locale === "") { log.error( `The "locale" option in "queryOptions" of "${collectionName}" should be a non-empty string.` ); } else if (locale !== void 0) { options.locale = locale; } return this; }, addUnknownKeys() { Object.keys(unknownKeys).map((key) => { log.error( `The "${key}" option in "queryOptions" of "${collectionName}" is not a known option. Check the "findMany" API references in the Strapi Documentation.` ); }); return this; }, get() { return options; } }; } function CollectionConfig({ collectionName, configuration }) { const log = strapi.log; const { indexName, transformEntry, filterEntry, settings, entriesQuery, noSanitizePrivateFields, ...unknownFields } = configuration; const options = {}; return { validateIndexName() { const isStringAndNotEmpty = typeof indexName === "string" && indexName !== ""; const isArrayWithNonEmptyStrings = Array.isArray(indexName) && indexName.length > 0 && indexName.every((name) => typeof name === "string" && name !== ""); if (indexName !== void 0 && !(isStringAndNotEmpty || isArrayWithNonEmptyStrings)) { log.error( `The "indexName" option of "${collectionName}" should be a non-empty string or an array of non-empty strings` ); } else if (indexName !== void 0) { options.indexName = indexName; } return this; }, validateTransformEntry() { if (transformEntry !== void 0 && typeof transformEntry !== "function") { log.error( `The "transformEntry" option of "${collectionName}" should be a function` ); } else if (transformEntry !== void 0) { options.transformEntry = transformEntry; } return this; }, validateFilterEntry() { if (filterEntry !== void 0 && typeof filterEntry !== "function") { log.error( `The "filterEntry" option of "${collectionName}" should be a function` ); } else if (filterEntry !== void 0) { options.filterEntry = filterEntry; } return this; }, validateMeilisearchSettings() { if (settings !== void 0 && !isObject(settings)) { log.error( `The "settings" option of "${collectionName}" should be an object` ); } else if (settings !== void 0) { options.settings = settings; } return this; }, validateEntriesQuery() { if (entriesQuery !== void 0 && !isObject(entriesQuery)) { log.error( `The "entriesQuery" option of "${collectionName}" should be an object` ); } else if (entriesQuery !== void 0) { options.entriesQuery = EntriesQuery({ configuration: entriesQuery, collectionName }).validateFields().validateFilters().validateStart().validateLimit().validateSort().validatePopulate().validateStatus().validateLocale().addUnknownKeys().get(); } return this; }, validateNoSanitizePrivateFields() { if (noSanitizePrivateFields !== void 0 && !Array.isArray(noSanitizePrivateFields)) { log.error( `The "noSanitizePrivateFields" option of "${collectionName}" should be an array of strings.` ); } else if (noSanitizePrivateFields !== void 0) { options.noSanitizePrivateFields = noSanitizePrivateFields; } return this; }, validateNoInvalidKeys() { Object.keys(unknownFields).map((key) => { log.warn( `The "${key}" option of "${collectionName}" is not a known option` ); }); return this; }, get() { return options; } }; } function PluginConfig({ configuration }) { const log = strapi.log; const { apiKey, host, ...collections } = configuration; const options = {}; return { validateApiKey() { if (apiKey !== void 0 && typeof apiKey !== "string") { log.error('The "apiKey" option should be a string'); } else if (apiKey !== void 0) { options.apiKey = apiKey; } return this; }, validateHost() { if (host !== void 0 && typeof host !== "string" || host === "") { log.error('The "host" option should be a non-empty string'); } else if (host !== void 0) { options.host = host; } return this; }, validateCollections() { for (const collection in collections) { if (!isObject(collections[collection])) { log.error( `The collection "${collection}" configuration should be of type object` ); options[collection] = {}; } else { options[collection] = CollectionConfig({ collectionName: collection, configuration: collections[collection] }).validateIndexName().validateFilterEntry().validateTransformEntry().validateMeilisearchSettings().validateEntriesQuery().validateNoSanitizePrivateFields().validateNoInvalidKeys().get(); } } return this; }, get() { return options; } }; } function validatePluginConfig(configuration) { const log = strapi.log; if (configuration === void 0) { return; } else if (configuration !== void 0 && !isObject(configuration)) { log.error( 'The "config" field in the Meilisearch plugin configuration should be an object' ); return; } const options = PluginConfig({ configuration }).validateApiKey().validateHost().validateCollections().get(); Object.assign(configuration, options); return configuration; } const config = { default: {}, validator: validatePluginConfig }; const credentialController = ({ strapi: strapi2 }) => { const store2 = strapi2.plugin("meilisearch").service("store"); return { /** * Get Client Credentials from the Store. * */ async getCredentials(ctx) { await store2.getCredentials().then((credentials) => { ctx.body = { data: credentials }; }).catch((e) => { const message = e.message; ctx.body = { error: { message } }; }); }, /** * Add Meilisearch Credentials to the Store. * * @param {object} ctx - Http request object. */ async addCredentials(ctx) { const { host, apiKey } = ctx.request.body; await store2.addCredentials({ host, apiKey }).then((credentials) => { ctx.body = { data: credentials }; }).catch((e) => { const message = e.message; ctx.body = { error: { message } }; }); } }; }; const contentTypeController = ({ strapi: strapi2 }) => { const meilisearch2 = strapi2.plugin("meilisearch").service("meilisearch"); const error2 = strapi2.plugin("meilisearch").service("error"); return { /** * Get extended information about contentTypes. * * @param {object} ctx - Http request object. * */ async getContentTypes(ctx) { await meilisearch2.getContentTypesReport().then((contentTypes) => { ctx.body = { data: contentTypes }; }).catch(async (e) => { ctx.body = await error2.createError(e); }); }, /** * Add a contentType to Meilisearch. * * @param {object} ctx - Http request object. * */ async addContentType(ctx) { const { contentType: contentType2 } = ctx.request.body; await meilisearch2.addContentTypeInMeiliSearch({ contentType: contentType2 }).then((taskUids) => { ctx.body = { data: taskUids }; }).catch(async (e) => { ctx.body = await error2.createError(e); }); }, /** * Remove and re-index a contentType in Meilisearch. * * @param {object} ctx - Http request object. * */ async updateContentType(ctx) { const { contentType: contentType2 } = ctx.request.body; await meilisearch2.updateContentTypeInMeiliSearch({ contentType: contentType2 }).then((taskUids) => { ctx.body = { data: taskUids }; }).catch(async (e) => { ctx.body = await error2.createError(e); }); }, /** * Remove or empty a contentType from Meilisearch * * @param {object} ctx - Http request object. * */ async removeContentType(ctx) { const { contentType: contentType2 } = ctx.request.params; await meilisearch2.emptyOrDeleteIndex({ contentType: contentType2 }).then(() => { ctx.body = { data: "ok" }; }).catch(async (e) => { ctx.body = await error2.createError(e); }); } }; }; async function reloadServer({ strapi: strapi2 }) { const { config: { autoReload } } = strapi2; if (!autoReload) { return { message: "Reload is only possible in develop mode. Please reload server manually.", title: "Reload failed", error: true, link: "https://strapi.io/documentation/developer-docs/latest/developer-resources/cli/CLI.html#strapi-start" }; } else { strapi2.reload.isWatching = false; strapi2.reload(); return { message: "ok" }; } } const reloadController = ({ strapi: strapi2 }) => { return { /** * Reloads the server. Only works in development mode. * * @param {object} ctx - Http request object. */ reload(ctx) { ctx.send({ message: "ok" }); return reloadServer({ strapi: strapi2 }); } }; }; const controllers = { credentialController, contentTypeController, reloadController }; const isAdmin = (policyContext) => { const isAdmin2 = policyContext?.state?.user?.roles.find( (role) => role.code === "strapi-super-admin" ); if (isAdmin2) return true; return false; }; const policies = { isAdmin }; const ACTIONS = { read: "plugin::meilisearch.read", settings: "plugin::meilisearch.settings.edit", create: "plugin::meilisearch.collections.create", update: "plugin::meilisearch.collections.update", delete: "plugin::meilisearch.collections.delete" }; const routes = [ { method: "GET", path: "/credential", handler: "credentialController.getCredentials", config: { policies: ["admin::isAuthenticatedAdmin"] } }, { method: "POST", path: "/credential", handler: "credentialController.addCredentials", config: { policies: [ "admin::isAuthenticatedAdmin", { name: "admin::hasPermissions", config: { actions: [ACTIONS.settings] } } ] } }, { method: "GET", path: "/content-type", handler: "contentTypeController.getContentTypes", config: { policies: ["admin::isAuthenticatedAdmin"] } }, { method: "POST", path: "/content-type", handler: "contentTypeController.addContentType", config: { policies: [ "admin::isAuthenticatedAdmin", { name: "admin::hasPermissions", config: { actions: [ACTIONS.create] } } ] } }, { method: "PUT", path: "/content-type", handler: "contentTypeController.updateContentType", config: { policies: [ "admin::isAuthenticatedAdmin", { name: "admin::hasPermissions", config: { actions: [ACTIONS.update] } } ] } }, { method: "DELETE", path: "/content-type/:contentType", handler: "contentTypeController.removeContentType", config: { policies: [ "admin::isAuthenticatedAdmin", { name: "admin::hasPermissions", config: { actions: [ACTIONS.delete] } } ] } }, { method: "GET", path: "/reload", handler: "reloadController.reload", config: { policies: ["admin::isAuthenticatedAdmin"] } } ]; const IGNORED_PLUGINS = [ "admin", "upload", "i18n", "review-workflows", "content-releases" ]; const IGNORED_CONTENT_TYPES = [ "plugin::users-permissions.permission", "plugin::users-permissions.role" ]; const removeIgnoredAPIs = ({ contentTypes }) => { const contentTypeUids = Object.keys(contentTypes); return contentTypeUids.reduce((sanitized, contentType2) => { if (!(IGNORED_PLUGINS.includes(contentTypes[contentType2].plugin) || IGNORED_CONTENT_TYPES.includes(contentType2))) { sanitized[contentType2] = contentTypes[contentType2]; } return sanitized; }, {}); }; const contentTypeService = ({ strapi: strapi2 }) => ({ /** * Get all content types name being plugins or API's existing in Strapi instance. * * Content Types are formated like this: `type::apiName.contentType`. * * @returns {string[]} - list of all content types name. */ getContentTypesUid() { const contentTypes = removeIgnoredAPIs({ contentTypes: strapi2.contentTypes }); return Object.keys(contentTypes); }, /** * Get the content type uid in this format: "type::service.contentType". * * If it is already an uid it returns it. If not it searches for it * * @param {object} options * @param {string} options.contentType - Name of the contentType. * * @returns {string | undefined} Returns the contentType uid */ getContentTypeUid({ contentType: contentType2 }) { const contentTypes = strapi2.contentTypes; const contentTypeUids = Object.keys(contentTypes); if (contentTypeUids.includes(contentType2)) return contentType2; const contentTypeUid = contentTypeUids.find((uid) => { return contentTypes[uid].modelName === contentType2; }); return contentTypeUid; }, /** * Get the content type uid in this format: "type::service.contentType". * * If it is already an uid it returns it. If not it searches for it * * @param {object} options * @param {string} options.contentType - Name of the contentType. * * @returns {string | undefined} Returns the contentType uid */ getCollectionName({ contentType: contentType2 }) { const contentTypes = strapi2.contentTypes; const contentTypeUids = Object.keys(contentTypes); if (contentTypeUids.includes(contentType2)) return contentTypes[contentType2].modelName; return contentType2; }, /** * Number of entries in a content type. * * @param {object} options * @param {string} options.contentType - Name of the contentType. * @param {object} [options.where] - Filter condition * * @returns {Promise<number>} number of entries in the content type. */ numberOfEntries: async function({ contentType: contentType2, filters = {}, status = "published" }) { const contentTypeUid = this.getContentTypeUid({ contentType: contentType2 }); if (contentTypeUid === void 0) return 0; try { const count = await strapi2.documents(contentTypeUid).count({ filters, status }); return count; } catch (e) { strapi2.log.warn(e); return 0; } }, /** * Returns the total number of entries of the content types. * * @param {object} options * @param {string[]} options.contentTypes - Names of the contentType. * @param {object} [options.where] - Filter condition * * @returns {Promise<number>} Total entries number of the content types. */ totalNumberOfEntries: async function({ contentTypes, filters = {}, status = "published" }) { let numberOfEntries = await Promise.all( contentTypes.map( async (contentType2) => this.numberOfEntries({ contentType: contentType2, filters, status }) ) ); const entriesSum = numberOfEntries.reduce((acc, curr) => acc += curr, 0); return entriesSum; }, /** * Find an entry of a given content type. * More information: https://docs.strapi.io/developer-docs/latest/developer-resources/database-apis-reference/entity-service/crud.html#findone * * @param {object} options * @param {string | number} [options.documentId] - DocumentId of the entry. * @param {object} [options.entriesQuery={}] - Options to apply when fetching entries from the database. * @param {string | string[]} [options.entriesQuery.fields] - Fields present in the returned entry. * @param {object} [options.entriesQuery.populate] - Relations, components and dynamic zones to populate. * @param {object} [options.entriesQuery.locale] - When using internalization, the language to fetch. * @param {string} options.contentType - Content type. * * @returns {Promise<object>} - Entries. */ async getEntry({ contentType: contentType2, documentId, entriesQuery = {} }) { const { populate = "*", fields = "*" } = entriesQuery; const contentTypeUid = this.getContentTypeUid({ contentType: contentType2 }); if (contentTypeUid === void 0) return {}; const entry = await strapi2.documents(contentTypeUid).findOne({ documentId, fields, populate }); if (entry == null) { strapi2.log.error( `Could not find entry with id ${documentId} in ${contentType2}` ); } return entry || { documentId }; }, /** * Returns a batch of entries of a given content type. * More information: https://docs.strapi.io/developer-docs/latest/developer-resources/database-apis-reference/entity-service/crud.html#findmany * * @param {object} options * @param {string | string[]} [options.fields] - Fields present in the returned entry. * @param {number} [options.start] - Pagination start. * @param {number} [options.limit] - Number of entries to return. * @param {object} [options.filters] - Filters to use. * @param {object|string} [options.sort] - Order definition. * @param {object} [options.populate] - Relations, components and dynamic zones to populate. * @param {object} [options.status] - Publication state: draft or published. * @param {string} options.contentType - Content type. * @param {string} [options.locale] - When using internalization, the language to fetch. * * @returns {Promise<object[]>} - Entries. */ async getEntries({ contentType: contentType2, fields = "*", start = 0, limit = 500, filters = {}, sort = "id", populate = "*", status = "published", locale }) { const contentTypeUid = this.getContentTypeUid({ contentType: contentType2 }); if (contentTypeUid === void 0) return []; const queryOptions = { fields: fields || "*", start, limit, filters, sort, populate, status }; if (locale) { queryOptions.locale = locale; } const entries = await strapi2.documents(contentTypeUid).findMany(queryOptions); if (entries && !Array.isArray(entries)) return [entries]; return entries || []; }, /** * Apply an action on all the entries of the provided content type. * * @param {object} options * @param {string} options.contentType - Name of the content type. * @param {object} [options.entriesQuery] - Options to apply when fetching entries from the database. * @param {function} options.callback - Function applied on each entry of the contentType. * * @returns {Promise<any[]>} - List of all the returned elements from the callback. */ actionInBatches: async function({ contentType: contentType2, callback = () => { }, entriesQuery = {} }) { const batchSize = entriesQuery.limit || 500; const entries_count = await this.numberOfEntries({ contentType: contentType2, ...entriesQuery }); const cbResponse = []; for (let index2 = 0; index2 < entries_count; index2 += batchSize) { const entries = await this.getEntries({ start: index2, contentType: contentType2, ...entriesQuery }) || []; if (entries.length > 0) { const info = await callback({ entries, contentType: contentType2 }); if (Array.isArray(info)) cbResponse.push(...info); else if (info) cbResponse.push(info); } } return cbResponse; } }); const contentType = ({ strapi: strapi2 }) => { return { ...contentTypeService({ strapi: strapi2 }) }; }; const createStoreConnector = ({ strapi: strapi2 }) => { const strapiStore = strapi2.store({ type: "plugin", name: "meilisearch" }); return { /** * Get value of a given key from the store. * * @param {object} options * @param {string} options.key */ getStoreKey: async function({ key }) { return strapiStore.get({ key }); }, /** * Set value of a given key to the store. * * @param {object} options * @param {string} options.key * @param {any} options.value */ setStoreKey: async function({ key, value }) { return strapiStore.set({ key, value }); }, /** * Delete a store * * @param {object} options * @param {string} options.key * @param {any} options.value */ deleteStore: async function({ key }) { return strapiStore.delete({ key }); } }; }; const credential = ({ store: store2, strapi: strapi2 }) => ({ /** * Get the API key of Meilisearch from the store. * * @returns {Promise<string>} API key of Meilisearch instance. */ getApiKey: async function() { return store2.getStoreKey({ key: "meilisearch_api_key" }); }, /** * Set the API key of Meilisearch to the store. * * @param {string} apiKey - API key of Meilisearch instance. */ setApiKey: async function(apiKey) { return store2.setStoreKey({ key: "meilisearch_api_key", value: apiKey || "" }); }, /** * Get host of Meilisearch from the store. * * @returns {Promise<string>} Host of Meilisearch instance. */ getHost: async function() { return store2.getStoreKey({ key: "meilisearch_host" }); }, /** * Set the host of Meilisearch to the store. * * @param {string} value */ setHost: async function(value) { return store2.setStoreKey({ key: "meilisearch_host", value: value || "" }); }, /** * Add Clients credentials to the store * * @param {Object} credentials * @param {string} credentials.host - Host of the searchClient. * @param {string} credentials.apiKey - ApiKey of the searchClient. * * @return {Promise<{ * host: string, * apiKey: string, * ApiKeyIsFromConfigFile: boolean, * HostIsFromConfigFile: boolean * }>} Extended Credentials information */ addCredentials: async function({ host, apiKey }) { const { ApiKeyIsFromConfigFile, HostIsFromConfigFile } = await this.getCredentials(); if (!ApiKeyIsFromConfigFile) { await this.setApiKey(apiKey || ""); } if (!HostIsFromConfigFile) { await this.setHost(host || ""); } return this.getCredentials(); }, /** * Get credentials from the store and from the config file. * * @return {Promise<{ * host: string, * apiKey: string, * ApiKeyIsFromConfigFile: boolean, * HostIsFromConfigFile: boolean * }>} Extended Credentials information */ getCredentials: async function() { const apiKey = await this.getApiKey(); const host = await this.getHost(); const ApiKeyIsFromConfigFile = !!await this.getApiKeyIsFromConfigFile() || false; const HostIsFromConfigFile = !!await this.getHostIsFromConfigFile() || false; return { apiKey, host, ApiKeyIsFromConfigFile, HostIsFromConfigFile }; }, /** * Update clients credentials in the store * * @param {Object} config - Credentials * * @return {Promise<{ * host: string, * apiKey: string, * ApiKeyIsFromConfigFile: boolean, * HostIsFromConfigFile: boolean * }>} Extended Credentials information * */ syncCredentials: async function(config2) { let apiKey = ""; let host = ""; config2 = strapi2.config.get("plugin::meilisearch"); if (config2 && config2) { apiKey = config2.apiKey; host = config2.host; } if (apiKey) { await this.setApiKey(apiKey); } const ApiKeyIsFromConfigFile = await this.setApiKeyIsFromConfigFile(!!apiKey); if (host) { await this.setHost(host); } const HostIsFromConfigFile = await this.setHostIsFromConfigFile(!!host); return { apiKey, host, ApiKeyIsFromConfigFile, HostIsFromConfigFile }; }, /** * True if the host is defined in the configuration file of the plugin. * False otherwise * * @returns {Promise<boolean>} APIKeyCameFromConfigFile */ getApiKeyIsFromConfigFile: async function() { return store2.getStoreKey({ key: "meilisearch_api_key_config" }); }, /** * Set to true if the API key is defined in the configuration file of the plugin. * False otherwise * * @param {boolean} value - Whether the API key came from the configuration file * * @returns {Promise<boolean>} APIKeyCameFromConfigFile */ setApiKeyIsFromConfigFile: async function(value) { return store2.setStoreKey({ key: "meilisearch_api_key_config", value }); }, /** * True if the host is defined in the configuration file of the plugin. * False otherwise * * @returns {Promise<boolean>} HostCameFromConfigFile */ getHostIsFromConfigFile: async function() { return store2.getStoreKey({ key: "meilisearch_host_config" }); }, /** * Set to true if the host is defined in the configuration file of the plugin. * False otherwise * * @param {string} value - Whether the host came from the configuration file * * @returns {Promise<boolean>} HostCameFromConfigFile */ setHostIsFromConfigFile: async function(value) { return store2.setStoreKey({ key: "meilisearch_host_config", value }); } }); const indexedContentTypes = ({ store: store2 }) => ({ /** * Get listened contentTypes from the store. * * @returns {Promise<string[]>} List of contentTypes indexed in Meilisearch. */ getIndexedContentTypes: async function() { const contentTypes = await store2.getStoreKey({ key: "meilisearch_indexed_content_types" }); return contentTypes || []; }, /** * Set indexed contentTypes to the store. * * @param {object} options * @param {string[]} options.contentTypes * * @returns {Promise<string[]>} List of contentTypes indexed in Meilisearch. */ setIndexedContentTypes: async function({ contentTypes }) { return store2.setStoreKey({ key: "meilisearch_indexed_content_types", value: contentTypes }); }, /** * Add a contentType to the indexed contentType list if it is not already present. * * @param {object} options * @param {string} options.contentType * * @returns {Promise<string[]>} List of contentTypes indexed in Meilisearch. */ addIndexedContentType: async function({ contentType: contentType2 }) { const indexedContentTypes2 = await this.getIndexedContentTypes(); const newSet = new Set(indexedContentTypes2); newSet.add(contentType2); return this.setIndexedContentTypes({ contentTypes: [...newSet] }); }, /** * Remove a contentType from the indexed contentType list if it exists. * * @param {object} options * @param {string} options.contentType * * @returns {Promise<string[]>} List of contentTypes indexed in Meilisearch. */ removeIndexedContentType: async function({ contentType: contentType2 }) { const indexedContentTypes2 = await this.getIndexedContentTypes(); const newSet = new Set(indexedContentTypes2); newSet.delete(contentType2); return this.setIndexedContentTypes({ contentTypes: [...newSet] }); } }); const listenedContentTypes = ({ store: store2 }) => ({ /** * Get listened contentTypes from the store. * * @returns {Promise<string[]>} - ContentType names. */ getListenedContentTypes: async function() { const contentTypes = await store2.getStoreKey({ key: "meilisearch_listened_content_types" }); return contentTypes || []; }, /** * Set listened contentTypes to the store. * @param {object} options * @param {string[]} [options.contentTypes] * * @returns {Promise<string[]>} - ContentType names. */ setListenedContentTypes: async function({ contentTypes = [] }) { return store2.setStoreKey({ key: "meilisearch_listened_content_types", value: contentTypes }); }, /** * Add a contentType to the listened contentTypes list. * * @param {object} options * @param {string} options.contentType * * @returns {Promise<string[]>} - ContentType names. */ addListenedContentType: async function({ contentType: contentType2 }) { const listenedContentTypes2 = await this.getListenedContentTypes(); const newSet = new Set(listenedContentTypes2); newSet.add(contentType2); return this.setListenedContentTypes({ contentTypes: [...newSet] }); }, /** * Add multiple contentTypes to the listened contentTypes list. * * @param {object} options * @param {string[]} options.contentTypes * * @returns {Promise<string[]>} - ContentType names. */ addListenedContentTypes: async function({ contentTypes }) { for (const contentType2 of contentTypes) { await this.addListenedContentType({ contentType: contentType2 }); } return this.getListenedContentTypes(); }, /** * Add multiple contentTypes to the listened contentTypes list. * * @returns {Promise<string[]>} - ContentType names. */ emptyListenedContentTypes: async function() { await this.setListenedContentTypes({}); return this.getListenedContentTypes(); } }); const store = ({ strapi: strapi2 }) => { const store2 = createStoreConnector({ strapi: strapi2 }); return { ...credential({ store: store2, strapi: strapi2 }), ...listenedContentTypes({ store: store2 }), ...indexedContentTypes({ store: store2 }), ...createStoreConnector({ strapi: strapi2 }) }; }; const aborted = ({ contentType: contentType2, action }) => { strapi.log.error( `Indexing of ${contentType2} aborted as the data could not be ${action}` ); return []; }; const configurationService = ({ strapi: strapi2 }) => { const meilisearchConfig = strapi2.config.get("plugin::meilisearch") || {}; const contentTypeService2 = strapi2.plugin("meilisearch").service("contentType"); return { /** * Get the names of the indexes from Meilisearch in which the contentType content is added. * * @param {object} options * @param {string} options.contentType - ContentType name. * * @return {String[]} - Index names */ getIndexNamesOfContentType: function({ contentType: contentType2 }) { const collection = contentTypeService2.getCollectionName({ contentType: contentType2 }); const contentTypeConfig = meilisearchConfig[collection] || {}; let indexNames = [contentTypeConfig.indexName].flat(2).filter((index2) => index2); return indexNames.length > 0 ? indexNames : [collection]; }, /** * Get the entries query rule of a content-type that are applied when fetching entries in the Strapi database. * * @param {object} options * @param {string} options.contentType - ContentType name. * * @return {String} - EntriesQuery rules. */ entriesQuery: function({ contentType: contentType2 }) { const collection = contentTypeService2.getCollectionName({ contentType: contentType2 }); const contentTypeConfig = meilisearchConfig[collection] || {}; return contentTypeConfig.entriesQuery || {}; }, /** * Transform contentTypes entries before indexation in Meilisearch. * * @param {object} options * @param {string} options.contentType - ContentType name. * @param {Array<Object>} options.entries - The data to convert. Conversion will use * the static method `toSearchIndex` defined in the model definition * * @return {Promise<Array<Object>>} - Converted or mapped data */ transformEntries: async function({ contentType: contentType2, entries = [] }) { const collection = contentTypeService2.getCollectionName({ contentType: contentType2 }); const contentTypeConfig = meilisearchConfig[collection] || {}; try { if (Array.isArray(entries) && typeof contentTypeConfig?.transformEntry === "function") { const transformed = await Promise.all( entries.map( async (entry) => await contentTypeConfig.transformEntry({ entry, contentType: contentType2 }) ) ); if (transformed.length > 0 && !isObject(transformed[0])) { return aborted({ contentType: contentType2, action: "transformed" }); } return transformed; } } catch (e) { strapi2.log.error(e); return aborted({ contentType: contentType2, action: "transformed" }); } return entries; }, /** * Filter contentTypes entries before indexation in Meilisearch. * * @param {object} options * @param {string} options.contentType - ContentType name. * @param {Array<Object>} options.entries - The data to convert. Conversion will use * the static method `toSearchIndex` defined in the model definition * * @return {Promise<Array<Object>>} - Converted or mapped data */ filterEntries: async function({ contentType: contentType2, entries = [] }) { const collection = contentTypeService2.getCollectionName({ contentType: contentType2 }); const contentTypeConfig = meilisearchConfig[collection] || {}; try { if (Array.isArray(entries) && typeof contentTypeConfig?.filterEntry === "function") { const filtered = await entries.reduce( async (filteredEntries, entry) => { const isValid = await contentTypeConfig.filterEntry({ entry, contentType: contentType2 }); if (!isValid) return filteredEntries; const syncFilteredEntries = await filteredEntries; return [...syncFilteredEntries, entry]; }, [] ); return filtered; } } catch (e) { strapi2.log.error(e); return aborted({ contentType: contentType2, action: "filtered" }); } return entries; }, /** * Returns Meilisearch index settings from model definition. * * @param {object} options * @param {string} options.contentType - ContentType name. * @param {Array<Object>} [options.entries] - The data to convert. Conversion will use * @typedef Settings * @type {import('meilisearch').Settings} * @return {Settings} - Meilisearch index settings */ getSettings: function({ contentType: contentType2 }) { const collection = contentTypeService2.getCollectionName({ contentType: contentType2 }); const contentTypeConfig = meilisearchConfig[collection] || {}; const settings = contentTypeConfig.settings || {}; return settings; }, /** * Return all contentTypes having the provided indexName setting. * * @param {object} options * @param {string} options.indexName - Index in Meilisearch. * * @returns {string[]} List of contentTypes storing its data in the provided indexName */ listContentTypesWithCustomIndexName: function({ indexName }) { const contentTypes = strapi2.plugin("meilisearch").service("contentType").getContentTypesUid() || []; const collectionNames = contentTypes.map( (contentType2) => contentTypeService2.getCollectionName({ contentType: contentType2 }) ); const contentTypeWithIndexName = collectionNames.filter((contentType2) => { const names = this.getIndexNamesOfContentType({ contentType: contentType2 }); return names.includes(indexName); }); return contentTypeWithIndexName; }, /** * Remove sensitive fields (password, author, etc, ..) from entry. * * @param {object} options * @param {Array<Object>} options.entries - The entries to sanitize * * * @return {Array<Object>} - Entries */ removeSensitiveFields: function({ contentType: contentType2, entries }) { const collection = contentTypeService2.getCollectionName({ contentType: contentType2 }); const contentTypeConfig = meilisearchConfig[collection] || {}; const noSanitizePrivateFields = contentTypeConfig.noSanitizePrivateFields || []; if (noSanitizePrivateFields.includes("*")) { return entries; } const attrs = strapi2.contentTypes[contentType2].attributes; const privateFields = Object.entries(attrs).map( ([field, schema]) => schema.private && !noSanitizePrivateFields.includes(field) ? field : false ); return entries.map((entry) => { privateFields.forEach((attr) => delete entry[attr]); return entry; }); }, /** * Remove unpublished entries from array of entries * unless `status` is set to 'draft'. * * @param {object} options * @param {Array<Object>} options.entries - The entries to filter. * @param {string} options.contentType - ContentType name. * * @return {Array<Object>} - Published entries. */ removeUnpublishedArticles: function({ entries, contentType: contentType2 }) { const collection = contentTypeService2.getCollectionName({ contentType: contentType2 }); const contentTypeConfig = meilisearchConfig[collection] || {}; const entriesQuery = contentTypeConfig.entriesQuery || {}; if (entriesQuery.status === "draft") { return entries; } else { return entries.filter((entry) => !(entry?.publishedAt === null)); } }, /** * Remove language entries. * In the plugin entriesQuery, if `locale` is set and not equal to * `all` (used with the i18n plugin for Strapi) or `*` (used with Strapi 5 * native localization), all entries that do not have the specified * language are removed. * * @param {object} options * @param {Array<Object>} options.entries - The entries to filter. * @param {string} options.contentType - ContentType name. * * @return {Array<Object>} - Published entries. */ removeLocaleEntries: function({ entries, contentType: contentType2 }) { const collection = contentTypeService2.getCollectionName({ contentType: contentType2 }); const contentTypeConfig = meilisearchConfig[collection] || {}; const entriesQuery = contentTypeConfig.entriesQuery || {}; if (!entriesQuery.locale || entriesQuery.locale === "all" || entriesQuery.locale === "*") { return entries; } else { return entries.filter((entry) => entry.locale === entriesQuery.locale); } } }; }; const version = "0.13.2"; const Meilisearch = (config2) => { return new meilisearch$1.MeiliSearch({ ...config2, clientAgents: [`Meilisearch Strapi (v${version})`] }); }; const sanitizeEntries = async function({ contentType: contentType2, entries, config: config2, adapter }) { if (!Array.isArray(entries)) entries = [entries]; entries = await config2.removeUnpublishedArticles({ contentType: contentType2, entries }); entries = await config2.removeLocaleEntries({ contentType: contentType2, entries }); entries = await config2.filterEntries({ contentType: contentType2, entries }); entries = await config2.removeSensitiveFields({ contentType: contentType2, entries }); entries = await config2.transformEntries({ contentType: contentType2, entries }); entries = await adapter.addCollectionNamePrefix({ contentType: contentType2, entries }); return entries; }; const connectorService = ({ strapi: strapi2, adapter, config: config2 }) => { const store2 = strapi2.plugin("meilisearch").service("store"); const contentTypeService2 = strapi2.plugin("meilisearch").service("contentType"); const lifecycle2 = strapi2.plugin("meilisearch").service("lifecycle"); return { /** * Get index uids with a safe guard in case of error. * * @returns { Promise<import("meilisearch").Index[]> } */ getIndexUids: async function() { try { const { apiKey, host } = await store2.getCredentials(); const client = Meilisearch({ apiKey, host }); const { indexes } = await client.getStats(); return Object.keys(indexes); } catch (e) { strapi2.log.error(`meilisearch: ${e.message}`); return []; } }, /** * Delete multiples entries from the contentType in all its indexes in Meilisearch. * * @param {object} options * @param {string} options.contentType - ContentType name. * @param {number[]} options.entriesId - Entries id. * * @returns { Promise<import("meilisearch").Task>} p - Task body returned by Meilisearch API. */ deleteEntriesFromMeiliSearch: async function({ contentType: contentType2, entriesId }) { const { apiKey, host } = await store2.getCredentials(); const client = Meilisearch({ apiKey, host }); const indexUids = config2.getIndexNamesOfContentType({ contentType: contentType2 }); const documentsIds = entriesId.map( (entryId) => adapter.addCollectionNamePrefixToId({ entryId, contentType: contentType2 }) ); const tasks = await Promise.all( indexUids.map(async (indexUid) => { const task = await client.index(indexUid).deleteDocuments(documentsIds); strapi2.log.info( `A task to delete ${documentsIds.length} documents of the index "${indexUid}" in Meilisearch has been enqueued (Task uid: ${task.taskUid}).` ); return task; }) ); return tasks.flat(); }, /** * Update entries from the contentType in all its indexes in Meilisearch. * * @param {object} options * @param {string} options.contentType - ContentType name. * @param {object[]} options.entries - Entries to update. * * @returns { Promise<void> } */ updateEntriesInMeilisearch: async function({ contentType: contentType2, entries }) { const { apiKey, host } = await store2.getCredentials(); const client = Meilisearch({ apiKey, host }); if (!Array.isArray(entries)) entries = [entries]; const indexUids = config2.getIndexNamesOfContentType({ contentType: contentType2 }); const addDocuments = await sanitizeEntries({ contentType: contentType2, entries, config: config2, adapter }); const deleteDocuments = entries.filter( (entry) => !addDocuments.map((document) => document.id).includes(entry.id) ); const deleteTasks = await Promise.all( indexUids.map(async (indexUid) => { const tasks = await Promise.all( deleteDocuments.map(async (document) => { const task = await client.index(indexUid).deleteDocument( adapter.addCollectionNamePrefixToId({ contentType: contentType2, entryId: document.id }) ); strapi2.log.info( `A task to delete one document from the Meilisearch index "${indexUid}" has been enqueued (Task uid: ${task.taskUid}).` ); return task; }) ); return tasks; }) ); const updateTasks = await Promise.all( indexUids.map(async (indexUid) => { const task = client.index(indexUid).updateDocuments(addDocuments, { primaryKey: "_meilisearch_id" }); strapi2.log.info( `A task to update ${addDocuments.length} documents to the Meilisearch index "${indexUid}" has been enqueued.` ); return task; }) ); return [...deleteTasks.flat(), ...updateTasks]; }, /** * Get stats of an index with a safe guard in case of er