strapi-plugin-meilisearch
Version:
Synchronise and search in your Strapi content-types with Meilisearch
1,516 lines (1,515 loc) • 68.4 kB
JavaScript
"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