strapi-plugin-meilisearch 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import * as React from "react";
3
3
  import { useState, useEffect, memo } from "react";
4
4
  import { Routes, Route } from "react-router-dom";
5
5
  import { useNotification, useFetchClient, useRBAC, private_useAutoReloadOverlayBlocker, private_AutoReloadOverlayBlockerProvider, Page, Layouts, BackButton } from "@strapi/strapi/admin";
6
- import { p as pluginId, P as PERMISSIONS } from "./index-DuX8ujje.mjs";
6
+ import { p as pluginId, P as PERMISSIONS } from "./index-CajY83vq.mjs";
7
7
  import { Tr, Td, Checkbox, Typography, Flex, Box, Button, Thead, Th, VisuallyHidden, Table, Tbody, Field, Link, Tabs } from "@strapi/design-system";
8
8
  var __assign = function() {
9
9
  __assign = Object.assign || function __assign2(t) {
@@ -4,7 +4,7 @@ const jsxRuntime = require("react/jsx-runtime");
4
4
  const React = require("react");
5
5
  const reactRouterDom = require("react-router-dom");
6
6
  const admin = require("@strapi/strapi/admin");
7
- const index = require("./index-D_Ao_M3T.js");
7
+ const index = require("./index-KTMW2G6w.js");
8
8
  const designSystem = require("@strapi/design-system");
9
9
  function _interopNamespace(e) {
10
10
  if (e && e.__esModule) return e;
@@ -18,7 +18,7 @@ const __variableDynamicImportRuntimeHelper = (glob, path, segs) => {
18
18
  });
19
19
  };
20
20
  const name$1 = "strapi-plugin-meilisearch";
21
- const version = "0.15.0";
21
+ const version = "0.16.1";
22
22
  const description = "Synchronise and search in your Strapi content-types with Meilisearch";
23
23
  const scripts = {
24
24
  build: "strapi-plugin build",
@@ -82,6 +82,7 @@ const devDependencies = {
82
82
  "@strapi/strapi": "^5.6.0",
83
83
  "@types/jest": "^30.0.0",
84
84
  "babel-jest": "^30.2.0",
85
+ "better-sqlite3": "^12.8.0",
85
86
  concurrently: "^9.2.1",
86
87
  cypress: "^15.11.0",
87
88
  eslint: "^10.0.2",
@@ -214,7 +215,7 @@ const index = {
214
215
  defaultMessage: name
215
216
  },
216
217
  Component: async () => {
217
- const { App } = await import("./App-DEDEzxD1.mjs");
218
+ const { App } = await import("./App-sobdRlxo.mjs");
218
219
  return App;
219
220
  },
220
221
  permissions: PERMISSIONS.main
@@ -19,7 +19,7 @@ const __variableDynamicImportRuntimeHelper = (glob, path, segs) => {
19
19
  });
20
20
  };
21
21
  const name$1 = "strapi-plugin-meilisearch";
22
- const version = "0.15.0";
22
+ const version = "0.16.1";
23
23
  const description = "Synchronise and search in your Strapi content-types with Meilisearch";
24
24
  const scripts = {
25
25
  build: "strapi-plugin build",
@@ -83,6 +83,7 @@ const devDependencies = {
83
83
  "@strapi/strapi": "^5.6.0",
84
84
  "@types/jest": "^30.0.0",
85
85
  "babel-jest": "^30.2.0",
86
+ "better-sqlite3": "^12.8.0",
86
87
  concurrently: "^9.2.1",
87
88
  cypress: "^15.11.0",
88
89
  eslint: "^10.0.2",
@@ -215,7 +216,7 @@ const index = {
215
216
  defaultMessage: name
216
217
  },
217
218
  Component: async () => {
218
- const { App } = await Promise.resolve().then(() => require("./App-DqXRl-ux.js"));
219
+ const { App } = await Promise.resolve().then(() => require("./App-zelJ7Obw.js"));
219
220
  return App;
220
221
  },
221
222
  permissions: PERMISSIONS.main
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-D_Ao_M3T.js");
2
+ const index = require("../_chunks/index-KTMW2G6w.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-DuX8ujje.mjs";
1
+ import { i } from "../_chunks/index-CajY83vq.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -94,6 +94,84 @@ async function registerDocumentMiddleware({ strapi: strapi2 }) {
94
94
  if (!strapi2?.documents || typeof strapi2.documents.use !== "function") {
95
95
  return;
96
96
  }
97
+ const extractEntryCandidates = (result) => {
98
+ if (result == null) return [];
99
+ const candidates = [];
100
+ const appendCandidate = (data, source) => {
101
+ if (data != null && typeof data === "object") {
102
+ candidates.push({ data, source });
103
+ }
104
+ };
105
+ if (Array.isArray(result)) {
106
+ result.forEach((data) => appendCandidate(data, "root"));
107
+ return candidates;
108
+ }
109
+ appendCandidate(result, "root");
110
+ if (Array.isArray(result.versions)) {
111
+ result.versions.forEach((data) => appendCandidate(data, "versions"));
112
+ }
113
+ if (Array.isArray(result.entries)) {
114
+ result.entries.forEach((data) => appendCandidate(data, "entries"));
115
+ }
116
+ if (result.entry != null) {
117
+ appendCandidate(result.entry, "entry");
118
+ }
119
+ return candidates;
120
+ };
121
+ const isPublishedEntry = (entry) => !(entry?.publishedAt === void 0 || entry?.publishedAt === null);
122
+ const rankEntryCandidates = (candidates) => {
123
+ return [...candidates].sort((a, b) => {
124
+ const aHasPrimaryKey = a.data?.id != null;
125
+ const bHasPrimaryKey = b.data?.id != null;
126
+ if (aHasPrimaryKey && !bHasPrimaryKey) return -1;
127
+ if (!aHasPrimaryKey && bHasPrimaryKey) return 1;
128
+ const aIsRoot = a.source === "root";
129
+ const bIsRoot = b.source === "root";
130
+ if (!aIsRoot && bIsRoot) return -1;
131
+ if (aIsRoot && !bIsRoot) return 1;
132
+ return 0;
133
+ });
134
+ };
135
+ const getEntryFromResult = ({
136
+ resultCandidates,
137
+ documentId,
138
+ entriesQuery
139
+ }) => {
140
+ const documentCandidates = (resultCandidates || []).filter(
141
+ (candidate) => candidate?.data?.documentId === documentId
142
+ );
143
+ if (documentCandidates.length === 0) return null;
144
+ const rankedCandidates = rankEntryCandidates(documentCandidates);
145
+ if (entriesQuery?.status === "draft") {
146
+ const draftCandidate = rankedCandidates.find(
147
+ (candidate) => !isPublishedEntry(candidate.data)
148
+ );
149
+ return draftCandidate?.data || rankedCandidates[0]?.data || null;
150
+ }
151
+ const publishedCandidate = rankedCandidates.find(
152
+ (candidate) => isPublishedEntry(candidate.data)
153
+ );
154
+ return publishedCandidate?.data || null;
155
+ };
156
+ const getEntryOutsideTransaction = ({
157
+ contentTypeService: contentTypeService2,
158
+ contentType: contentType2,
159
+ documentId,
160
+ entriesQuery
161
+ }) => new Promise((resolve, reject) => {
162
+ setImmediate(async () => {
163
+ try {
164
+ const entry = await contentTypeService2.getEntry({
165
+ contentType: contentType2,
166
+ documentId,
167
+ entriesQuery: { ...entriesQuery }
168
+ });
169
+ resolve(entry);
170
+ } catch (error2) {
171
+ reject(error2);
172
+ }
173
+ });
174
+ });
97
175
  strapi2.documents.use(async (ctx, next) => {
98
176
  let result;
99
177
  try {
@@ -115,31 +193,72 @@ async function registerDocumentMiddleware({ strapi: strapi2 }) {
115
193
  "unpublish",
116
194
  "discardDraft"
117
195
  ];
196
+ const entriesQuery = meilisearch2.entriesQuery({ contentType: contentType2 });
197
+ const shouldDeleteByLocale = entriesQuery.locale === "*" || entriesQuery.locale === "all";
198
+ const { status } = entriesQuery || {};
199
+ const statusFilter = typeof status === "string" && status.length > 0 ? { status } : {};
118
200
  const preDeleteDocumentId = deleteActions.includes(ctx.action) && ctx?.params?.documentId ? ctx.params.documentId : null;
119
- const preDeleteEntry = preDeleteDocumentId != null ? await contentTypeService2.getEntry({
120
- contentType: contentType2,
121
- documentId: preDeleteDocumentId,
122
- entriesQuery: {}
123
- }) : null;
201
+ let preDeleteEntry = null;
202
+ let preDeleteLocales = [];
203
+ if (preDeleteDocumentId != null) {
204
+ preDeleteEntry = await contentTypeService2.getEntry({
205
+ contentType: contentType2,
206
+ documentId: preDeleteDocumentId,
207
+ entriesQuery: { ...statusFilter }
208
+ });
209
+ if (shouldDeleteByLocale) {
210
+ const localeVariants = await contentTypeService2.getEntries({
211
+ contentType: contentType2,
212
+ fields: ["documentId", "locale"],
213
+ locale: "*",
214
+ ...statusFilter,
215
+ filters: {
216
+ documentId: preDeleteDocumentId
217
+ }
218
+ });
219
+ preDeleteLocales = [
220
+ ...new Set(
221
+ localeVariants.map((entry) => entry?.locale).filter(
222
+ (locale) => typeof locale === "string" && locale.length > 0
223
+ )
224
+ )
225
+ ];
226
+ }
227
+ }
124
228
  result = await next();
125
- const documentId = result?.documentId ?? preDeleteEntry?.documentId ?? preDeleteDocumentId ?? null;
229
+ const contextDocumentId = typeof ctx?.params?.documentId === "string" && ctx.params.documentId.length > 0 ? ctx.params.documentId : null;
230
+ const documentId = contextDocumentId ?? result?.documentId ?? preDeleteEntry?.documentId ?? preDeleteDocumentId ?? null;
126
231
  if (updateActions.includes(ctx.action) && documentId != null) {
127
- const entriesQuery = meilisearch2.entriesQuery({ contentType: contentType2 });
128
- const entry = await contentTypeService2.getEntry({
129
- contentType: contentType2,
232
+ const resultCandidates = extractEntryCandidates(result);
233
+ let entry = getEntryFromResult({
234
+ resultCandidates,
130
235
  documentId,
131
- entriesQuery: { ...entriesQuery }
236
+ entriesQuery
132
237
  });
238
+ if (!entry) {
239
+ entry = await getEntryOutsideTransaction({
240
+ contentTypeService: contentTypeService2,
241
+ contentType: contentType2,
242
+ documentId,
243
+ entriesQuery
244
+ });
245
+ }
133
246
  if (entry) {
247
+ const normalizedEntry = entry.documentId === documentId ? entry : { ...entry, documentId };
134
248
  await meilisearch2.updateEntriesInMeilisearch({
135
249
  contentType: contentType2,
136
- entries: [entry]
250
+ entries: [normalizedEntry]
137
251
  });
138
- } else {
252
+ } else if (ctx.action === "create" || ctx.action === "publish") {
139
253
  await meilisearch2.deleteEntriesFromMeiliSearch({
140
254
  contentType: contentType2,
141
- documentIds: [documentId]
255
+ documentIds: [documentId],
256
+ entriesQuery
142
257
  });
258
+ } else {
259
+ strapi2.log.info(
260
+ `Meilisearch document middleware skipped indexing ${contentType2} documentId=${documentId} for action ${ctx.action}: no indexable entry in result payload`
261
+ );
143
262
  }
144
263
  } else if (deleteActions.includes(ctx.action)) {
145
264
  if (documentId != null) {
@@ -148,7 +267,9 @@ async function registerDocumentMiddleware({ strapi: strapi2 }) {
148
267
  );
149
268
  await meilisearch2.deleteEntriesFromMeiliSearch({
150
269
  contentType: contentType2,
151
- documentIds: [documentId]
270
+ documentIds: [documentId],
271
+ entriesQuery,
272
+ locales: shouldDeleteByLocale && preDeleteLocales.length > 0 ? preDeleteLocales : void 0
152
273
  });
153
274
  } else {
154
275
  strapi2.log.info(
@@ -1233,6 +1354,7 @@ const store = ({ strapi: strapi2 }) => {
1233
1354
  ...createStoreConnector({ strapi: strapi2 })
1234
1355
  };
1235
1356
  };
1357
+ const isWildcardLocale = (locale) => locale === "all" || locale === "*";
1236
1358
  const aborted = ({ contentType: contentType2, action }) => {
1237
1359
  strapi.log.error(
1238
1360
  `Indexing of ${contentType2} aborted as the data could not be ${action}`
@@ -1441,7 +1563,7 @@ const configurationService = ({ strapi: strapi2 }) => {
1441
1563
  const collection = contentTypeService2.getCollectionName({ contentType: contentType2 });
1442
1564
  const contentTypeConfig = meilisearchConfig[collection] || {};
1443
1565
  const entriesQuery = contentTypeConfig.entriesQuery || {};
1444
- if (!entriesQuery.locale || entriesQuery.locale === "all" || entriesQuery.locale === "*") {
1566
+ if (!entriesQuery.locale || isWildcardLocale(entriesQuery.locale)) {
1445
1567
  return entries;
1446
1568
  } else {
1447
1569
  return entries.filter((entry) => entry.locale === entriesQuery.locale);
@@ -1449,7 +1571,7 @@ const configurationService = ({ strapi: strapi2 }) => {
1449
1571
  }
1450
1572
  };
1451
1573
  };
1452
- const version = "0.15.0";
1574
+ const version = "0.16.1";
1453
1575
  const Meilisearch = (config2) => {
1454
1576
  return new meilisearch$1.MeiliSearch({
1455
1577
  ...config2,
@@ -1516,26 +1638,82 @@ const connectorService = ({ strapi: strapi2, adapter, config: config2 }) => {
1516
1638
  * @param {object} options
1517
1639
  * @param {string} options.contentType - ContentType name.
1518
1640
  * @param {string[]} options.documentIds - Entry documentIds.
1641
+ * @param {object} options.entriesQuery - Entries query.
1519
1642
  *
1520
1643
  * @returns { Promise<import("meilisearch").Task>} p - Task body returned by Meilisearch API.
1521
1644
  */
1522
1645
  deleteEntriesFromMeiliSearch: async function({
1523
1646
  contentType: contentType2,
1524
- documentIds
1647
+ documentIds,
1648
+ entriesQuery = {},
1649
+ locales
1525
1650
  }) {
1526
1651
  const { apiKey, host } = await store2.getCredentials();
1527
1652
  const client = Meilisearch({ apiKey, host });
1528
1653
  const indexUids = config2.getIndexNamesOfContentType({ contentType: contentType2 });
1529
- const validDocumentIds = documentIds.filter((id) => id != null);
1654
+ const validDocumentIds = [
1655
+ ...new Set(documentIds.filter((id) => id != null))
1656
+ ];
1530
1657
  if (validDocumentIds.length === 0) return [];
1531
- const documentsIds = validDocumentIds.map(
1532
- (entryDocumentId) => adapter.addCollectionNamePrefixToId({ entryDocumentId, contentType: contentType2 })
1658
+ const resolvedEntriesQuery = Object.keys(entriesQuery).length > 0 ? entriesQuery : config2.entriesQuery({ contentType: contentType2 });
1659
+ const shouldDeleteAllLocaleVariants = isWildcardLocale(
1660
+ resolvedEntriesQuery.locale
1661
+ );
1662
+ const resolvedLocales = Array.isArray(locales) ? [
1663
+ ...new Set(
1664
+ locales.filter(
1665
+ (locale) => typeof locale === "string" && locale.trim().length > 0
1666
+ ).map((locale) => locale.trim())
1667
+ )
1668
+ ] : [];
1669
+ const meilisearchDocumentIds = shouldDeleteAllLocaleVariants && resolvedLocales.length > 0 ? validDocumentIds.flatMap(
1670
+ (entryDocumentId) => resolvedLocales.map(
1671
+ (resolvedLocale) => adapter.addCollectionNamePrefixToId({
1672
+ contentType: contentType2,
1673
+ entryDocumentId,
1674
+ locale: resolvedLocale
1675
+ })
1676
+ )
1677
+ ) : shouldDeleteAllLocaleVariants ? (await Promise.all(
1678
+ validDocumentIds.map(async (entryDocumentId) => {
1679
+ const baseFilters = resolvedEntriesQuery.filters != null ? resolvedEntriesQuery.filters : {};
1680
+ const localizedEntries = await contentTypeService2.getEntries({
1681
+ contentType: contentType2,
1682
+ fields: ["documentId", "locale"],
1683
+ locale: "*",
1684
+ ...resolvedEntriesQuery.status ? { status: resolvedEntriesQuery.status } : {},
1685
+ filters: {
1686
+ ...baseFilters,
1687
+ documentId: entryDocumentId
1688
+ }
1689
+ });
1690
+ return localizedEntries.length > 0 ? localizedEntries.map(
1691
+ (entry) => adapter.addCollectionNamePrefixToId({
1692
+ contentType: contentType2,
1693
+ entryDocumentId,
1694
+ locale: entry.locale
1695
+ })
1696
+ ) : [
1697
+ // Fallback: delete the non-localized document when no localized variants exist.
1698
+ adapter.addCollectionNamePrefixToId({
1699
+ contentType: contentType2,
1700
+ entryDocumentId
1701
+ })
1702
+ ];
1703
+ })
1704
+ )).flat() : validDocumentIds.map(
1705
+ (entryDocumentId) => adapter.addCollectionNamePrefixToId({
1706
+ entryDocumentId,
1707
+ contentType: contentType2,
1708
+ locale: resolvedEntriesQuery.locale
1709
+ })
1533
1710
  );
1711
+ const uniqueDocumentIds = [...new Set(meilisearchDocumentIds)];
1534
1712
  const tasks = await Promise.all(
1535
1713
  indexUids.map(async (indexUid) => {
1536
- const task = await client.index(indexUid).deleteDocuments(documentsIds);
1714
+ const task = await client.index(indexUid).deleteDocuments(uniqueDocumentIds);
1537
1715
  strapi2.log.info(
1538
- `A task to delete ${documentsIds.length} documents of the index "${indexUid}" in Meilisearch has been enqueued (Task uid: ${task.taskUid}).`
1716
+ `A task to delete ${uniqueDocumentIds.length} documents of the index "${indexUid}" in Meilisearch has been enqueued (Task uid: ${task.taskUid}).`
1539
1717
  );
1540
1718
  return task;
1541
1719
  })
@@ -1562,19 +1740,27 @@ const connectorService = ({ strapi: strapi2, adapter, config: config2 }) => {
1562
1740
  config: config2,
1563
1741
  adapter
1564
1742
  });
1565
- const deleteDocuments = entries.filter(
1566
- (entry) => entry.documentId != null && !addDocuments.map((document) => document.documentId).includes(entry.documentId)
1743
+ const addDocumentIds = addDocuments.map(
1744
+ (document) => document._meilisearch_id
1745
+ );
1746
+ const deleteDocuments = entries.map((entry) => {
1747
+ if (entry.documentId == null) return null;
1748
+ return {
1749
+ ...entry,
1750
+ _meilisearch_id: adapter.addCollectionNamePrefixToId({
1751
+ contentType: contentType2,
1752
+ entryDocumentId: entry.documentId,
1753
+ locale: entry.locale
1754
+ })
1755
+ };
1756
+ }).filter(
1757
+ (entry) => entry && entry._meilisearch_id != null && !addDocumentIds.includes(entry._meilisearch_id)
1567
1758
  );
1568
1759
  const deleteTasks = await Promise.all(
1569
1760
  indexUids.map(async (indexUid) => {
1570
1761
  const tasks = await Promise.all(
1571
1762
  deleteDocuments.map(async (document) => {
1572
- const task = await client.index(indexUid).deleteDocument(
1573
- adapter.addCollectionNamePrefixToId({
1574
- contentType: contentType2,
1575
- entryDocumentId: document.documentId
1576
- })
1577
- );
1763
+ const task = await client.index(indexUid).deleteDocument(document._meilisearch_id);
1578
1764
  strapi2.log.info(
1579
1765
  `A task to delete one document from the Meilisearch index "${indexUid}" has been enqueued (Task uid: ${task.taskUid}).`
1580
1766
  );
@@ -1796,7 +1982,8 @@ const connectorService = ({ strapi: strapi2, adapter, config: config2 }) => {
1796
1982
  const deleteEntries = async ({ entries, contentType: contentType3 }) => {
1797
1983
  await this.deleteEntriesFromMeiliSearch({
1798
1984
  contentType: contentType3,
1799
- documentIds: entries.map((entry) => entry.documentId)
1985
+ documentIds: entries.map((entry) => entry.documentId),
1986
+ entriesQuery: config2.entriesQuery({ contentType: contentType3 })
1800
1987
  });
1801
1988
  };
1802
1989
  await contentTypeService2.actionInBatches({
@@ -1839,12 +2026,18 @@ const connectorService = ({ strapi: strapi2, adapter, config: config2 }) => {
1839
2026
  };
1840
2027
  const adapterService = ({ strapi: strapi2 }) => {
1841
2028
  const contentTypeService2 = strapi2.plugin("meilisearch").service("contentType");
2029
+ const buildMeilisearchId = ({ collectionName, entryDocumentId, locale }) => {
2030
+ if (locale) return `${collectionName}-${entryDocumentId}-${locale}`;
2031
+ return `${collectionName}-${entryDocumentId}`;
2032
+ };
1842
2033
  return {
1843
2034
  /**
1844
2035
  * Add the prefix of the contentType in front of the documentId of its entry.
1845
2036
  *
1846
2037
  * We do this to avoid id's conflict in case of composite indexes.
1847
- * It returns the id in the following format: `[collectionName]-[documentId]`
2038
+ * It returns the id in the following format:
2039
+ * - `[collectionName]-[documentId]` for non-localized entries
2040
+ * - `[collectionName]-[documentId]-[locale]` for localized entries
1848
2041
  *
1849
2042
  * Uses documentId (stable across draft/published) instead of the internal
1850
2043
  * database id to prevent duplicate entries in Meilisearch when Draft & Publish
@@ -1853,20 +2046,27 @@ const adapterService = ({ strapi: strapi2 }) => {
1853
2046
  * @param {object} options
1854
2047
  * @param {string} options.contentType - ContentType name.
1855
2048
  * @param {string} options.entryDocumentId - Entry documentId.
2049
+ * @param {string} options.locale - Entry locale.
1856
2050
  *
1857
2051
  * @returns {string} - Formatted id
1858
2052
  */
1859
- addCollectionNamePrefixToId: function({ contentType: contentType2, entryDocumentId }) {
2053
+ addCollectionNamePrefixToId: function({
2054
+ contentType: contentType2,
2055
+ entryDocumentId,
2056
+ locale
2057
+ }) {
1860
2058
  const collectionName = contentTypeService2.getCollectionName({
1861
2059
  contentType: contentType2
1862
2060
  });
1863
- return `${collectionName}-${entryDocumentId}`;
2061
+ return buildMeilisearchId({ collectionName, entryDocumentId, locale });
1864
2062
  },
1865
2063
  /**
1866
2064
  * Add the prefix of the contentType on a list of entries using documentId.
1867
2065
  *
1868
2066
  * We do this to avoid id's conflict in case of composite indexes.
1869
- * The ids are transformed in the following format: `[collectionName]-[documentId]`
2067
+ * The ids are transformed in the following format:
2068
+ * - `[collectionName]-[documentId]` for non-localized entries
2069
+ * - `[collectionName]-[documentId]-[locale]` for localized entries
1870
2070
  *
1871
2071
  * @param {object} options
1872
2072
  * @param {string} options.contentType - ContentType name.
@@ -1886,7 +2086,8 @@ const adapterService = ({ strapi: strapi2 }) => {
1886
2086
  ...entry,
1887
2087
  _meilisearch_id: this.addCollectionNamePrefixToId({
1888
2088
  entryDocumentId: entry.documentId,
1889
- contentType: contentType2
2089
+ contentType: contentType2,
2090
+ locale: entry.locale
1890
2091
  })
1891
2092
  });
1892
2093
  return acc;
@@ -93,6 +93,84 @@ async function registerDocumentMiddleware({ strapi: strapi2 }) {
93
93
  if (!strapi2?.documents || typeof strapi2.documents.use !== "function") {
94
94
  return;
95
95
  }
96
+ const extractEntryCandidates = (result) => {
97
+ if (result == null) return [];
98
+ const candidates = [];
99
+ const appendCandidate = (data, source) => {
100
+ if (data != null && typeof data === "object") {
101
+ candidates.push({ data, source });
102
+ }
103
+ };
104
+ if (Array.isArray(result)) {
105
+ result.forEach((data) => appendCandidate(data, "root"));
106
+ return candidates;
107
+ }
108
+ appendCandidate(result, "root");
109
+ if (Array.isArray(result.versions)) {
110
+ result.versions.forEach((data) => appendCandidate(data, "versions"));
111
+ }
112
+ if (Array.isArray(result.entries)) {
113
+ result.entries.forEach((data) => appendCandidate(data, "entries"));
114
+ }
115
+ if (result.entry != null) {
116
+ appendCandidate(result.entry, "entry");
117
+ }
118
+ return candidates;
119
+ };
120
+ const isPublishedEntry = (entry) => !(entry?.publishedAt === void 0 || entry?.publishedAt === null);
121
+ const rankEntryCandidates = (candidates) => {
122
+ return [...candidates].sort((a, b) => {
123
+ const aHasPrimaryKey = a.data?.id != null;
124
+ const bHasPrimaryKey = b.data?.id != null;
125
+ if (aHasPrimaryKey && !bHasPrimaryKey) return -1;
126
+ if (!aHasPrimaryKey && bHasPrimaryKey) return 1;
127
+ const aIsRoot = a.source === "root";
128
+ const bIsRoot = b.source === "root";
129
+ if (!aIsRoot && bIsRoot) return -1;
130
+ if (aIsRoot && !bIsRoot) return 1;
131
+ return 0;
132
+ });
133
+ };
134
+ const getEntryFromResult = ({
135
+ resultCandidates,
136
+ documentId,
137
+ entriesQuery
138
+ }) => {
139
+ const documentCandidates = (resultCandidates || []).filter(
140
+ (candidate) => candidate?.data?.documentId === documentId
141
+ );
142
+ if (documentCandidates.length === 0) return null;
143
+ const rankedCandidates = rankEntryCandidates(documentCandidates);
144
+ if (entriesQuery?.status === "draft") {
145
+ const draftCandidate = rankedCandidates.find(
146
+ (candidate) => !isPublishedEntry(candidate.data)
147
+ );
148
+ return draftCandidate?.data || rankedCandidates[0]?.data || null;
149
+ }
150
+ const publishedCandidate = rankedCandidates.find(
151
+ (candidate) => isPublishedEntry(candidate.data)
152
+ );
153
+ return publishedCandidate?.data || null;
154
+ };
155
+ const getEntryOutsideTransaction = ({
156
+ contentTypeService: contentTypeService2,
157
+ contentType: contentType2,
158
+ documentId,
159
+ entriesQuery
160
+ }) => new Promise((resolve, reject) => {
161
+ setImmediate(async () => {
162
+ try {
163
+ const entry = await contentTypeService2.getEntry({
164
+ contentType: contentType2,
165
+ documentId,
166
+ entriesQuery: { ...entriesQuery }
167
+ });
168
+ resolve(entry);
169
+ } catch (error2) {
170
+ reject(error2);
171
+ }
172
+ });
173
+ });
96
174
  strapi2.documents.use(async (ctx, next) => {
97
175
  let result;
98
176
  try {
@@ -114,31 +192,72 @@ async function registerDocumentMiddleware({ strapi: strapi2 }) {
114
192
  "unpublish",
115
193
  "discardDraft"
116
194
  ];
195
+ const entriesQuery = meilisearch2.entriesQuery({ contentType: contentType2 });
196
+ const shouldDeleteByLocale = entriesQuery.locale === "*" || entriesQuery.locale === "all";
197
+ const { status } = entriesQuery || {};
198
+ const statusFilter = typeof status === "string" && status.length > 0 ? { status } : {};
117
199
  const preDeleteDocumentId = deleteActions.includes(ctx.action) && ctx?.params?.documentId ? ctx.params.documentId : null;
118
- const preDeleteEntry = preDeleteDocumentId != null ? await contentTypeService2.getEntry({
119
- contentType: contentType2,
120
- documentId: preDeleteDocumentId,
121
- entriesQuery: {}
122
- }) : null;
200
+ let preDeleteEntry = null;
201
+ let preDeleteLocales = [];
202
+ if (preDeleteDocumentId != null) {
203
+ preDeleteEntry = await contentTypeService2.getEntry({
204
+ contentType: contentType2,
205
+ documentId: preDeleteDocumentId,
206
+ entriesQuery: { ...statusFilter }
207
+ });
208
+ if (shouldDeleteByLocale) {
209
+ const localeVariants = await contentTypeService2.getEntries({
210
+ contentType: contentType2,
211
+ fields: ["documentId", "locale"],
212
+ locale: "*",
213
+ ...statusFilter,
214
+ filters: {
215
+ documentId: preDeleteDocumentId
216
+ }
217
+ });
218
+ preDeleteLocales = [
219
+ ...new Set(
220
+ localeVariants.map((entry) => entry?.locale).filter(
221
+ (locale) => typeof locale === "string" && locale.length > 0
222
+ )
223
+ )
224
+ ];
225
+ }
226
+ }
123
227
  result = await next();
124
- const documentId = result?.documentId ?? preDeleteEntry?.documentId ?? preDeleteDocumentId ?? null;
228
+ const contextDocumentId = typeof ctx?.params?.documentId === "string" && ctx.params.documentId.length > 0 ? ctx.params.documentId : null;
229
+ const documentId = contextDocumentId ?? result?.documentId ?? preDeleteEntry?.documentId ?? preDeleteDocumentId ?? null;
125
230
  if (updateActions.includes(ctx.action) && documentId != null) {
126
- const entriesQuery = meilisearch2.entriesQuery({ contentType: contentType2 });
127
- const entry = await contentTypeService2.getEntry({
128
- contentType: contentType2,
231
+ const resultCandidates = extractEntryCandidates(result);
232
+ let entry = getEntryFromResult({
233
+ resultCandidates,
129
234
  documentId,
130
- entriesQuery: { ...entriesQuery }
235
+ entriesQuery
131
236
  });
237
+ if (!entry) {
238
+ entry = await getEntryOutsideTransaction({
239
+ contentTypeService: contentTypeService2,
240
+ contentType: contentType2,
241
+ documentId,
242
+ entriesQuery
243
+ });
244
+ }
132
245
  if (entry) {
246
+ const normalizedEntry = entry.documentId === documentId ? entry : { ...entry, documentId };
133
247
  await meilisearch2.updateEntriesInMeilisearch({
134
248
  contentType: contentType2,
135
- entries: [entry]
249
+ entries: [normalizedEntry]
136
250
  });
137
- } else {
251
+ } else if (ctx.action === "create" || ctx.action === "publish") {
138
252
  await meilisearch2.deleteEntriesFromMeiliSearch({
139
253
  contentType: contentType2,
140
- documentIds: [documentId]
254
+ documentIds: [documentId],
255
+ entriesQuery
141
256
  });
257
+ } else {
258
+ strapi2.log.info(
259
+ `Meilisearch document middleware skipped indexing ${contentType2} documentId=${documentId} for action ${ctx.action}: no indexable entry in result payload`
260
+ );
142
261
  }
143
262
  } else if (deleteActions.includes(ctx.action)) {
144
263
  if (documentId != null) {
@@ -147,7 +266,9 @@ async function registerDocumentMiddleware({ strapi: strapi2 }) {
147
266
  );
148
267
  await meilisearch2.deleteEntriesFromMeiliSearch({
149
268
  contentType: contentType2,
150
- documentIds: [documentId]
269
+ documentIds: [documentId],
270
+ entriesQuery,
271
+ locales: shouldDeleteByLocale && preDeleteLocales.length > 0 ? preDeleteLocales : void 0
151
272
  });
152
273
  } else {
153
274
  strapi2.log.info(
@@ -1232,6 +1353,7 @@ const store = ({ strapi: strapi2 }) => {
1232
1353
  ...createStoreConnector({ strapi: strapi2 })
1233
1354
  };
1234
1355
  };
1356
+ const isWildcardLocale = (locale) => locale === "all" || locale === "*";
1235
1357
  const aborted = ({ contentType: contentType2, action }) => {
1236
1358
  strapi.log.error(
1237
1359
  `Indexing of ${contentType2} aborted as the data could not be ${action}`
@@ -1440,7 +1562,7 @@ const configurationService = ({ strapi: strapi2 }) => {
1440
1562
  const collection = contentTypeService2.getCollectionName({ contentType: contentType2 });
1441
1563
  const contentTypeConfig = meilisearchConfig[collection] || {};
1442
1564
  const entriesQuery = contentTypeConfig.entriesQuery || {};
1443
- if (!entriesQuery.locale || entriesQuery.locale === "all" || entriesQuery.locale === "*") {
1565
+ if (!entriesQuery.locale || isWildcardLocale(entriesQuery.locale)) {
1444
1566
  return entries;
1445
1567
  } else {
1446
1568
  return entries.filter((entry) => entry.locale === entriesQuery.locale);
@@ -1448,7 +1570,7 @@ const configurationService = ({ strapi: strapi2 }) => {
1448
1570
  }
1449
1571
  };
1450
1572
  };
1451
- const version = "0.15.0";
1573
+ const version = "0.16.1";
1452
1574
  const Meilisearch = (config2) => {
1453
1575
  return new MeiliSearch({
1454
1576
  ...config2,
@@ -1515,26 +1637,82 @@ const connectorService = ({ strapi: strapi2, adapter, config: config2 }) => {
1515
1637
  * @param {object} options
1516
1638
  * @param {string} options.contentType - ContentType name.
1517
1639
  * @param {string[]} options.documentIds - Entry documentIds.
1640
+ * @param {object} options.entriesQuery - Entries query.
1518
1641
  *
1519
1642
  * @returns { Promise<import("meilisearch").Task>} p - Task body returned by Meilisearch API.
1520
1643
  */
1521
1644
  deleteEntriesFromMeiliSearch: async function({
1522
1645
  contentType: contentType2,
1523
- documentIds
1646
+ documentIds,
1647
+ entriesQuery = {},
1648
+ locales
1524
1649
  }) {
1525
1650
  const { apiKey, host } = await store2.getCredentials();
1526
1651
  const client = Meilisearch({ apiKey, host });
1527
1652
  const indexUids = config2.getIndexNamesOfContentType({ contentType: contentType2 });
1528
- const validDocumentIds = documentIds.filter((id) => id != null);
1653
+ const validDocumentIds = [
1654
+ ...new Set(documentIds.filter((id) => id != null))
1655
+ ];
1529
1656
  if (validDocumentIds.length === 0) return [];
1530
- const documentsIds = validDocumentIds.map(
1531
- (entryDocumentId) => adapter.addCollectionNamePrefixToId({ entryDocumentId, contentType: contentType2 })
1657
+ const resolvedEntriesQuery = Object.keys(entriesQuery).length > 0 ? entriesQuery : config2.entriesQuery({ contentType: contentType2 });
1658
+ const shouldDeleteAllLocaleVariants = isWildcardLocale(
1659
+ resolvedEntriesQuery.locale
1660
+ );
1661
+ const resolvedLocales = Array.isArray(locales) ? [
1662
+ ...new Set(
1663
+ locales.filter(
1664
+ (locale) => typeof locale === "string" && locale.trim().length > 0
1665
+ ).map((locale) => locale.trim())
1666
+ )
1667
+ ] : [];
1668
+ const meilisearchDocumentIds = shouldDeleteAllLocaleVariants && resolvedLocales.length > 0 ? validDocumentIds.flatMap(
1669
+ (entryDocumentId) => resolvedLocales.map(
1670
+ (resolvedLocale) => adapter.addCollectionNamePrefixToId({
1671
+ contentType: contentType2,
1672
+ entryDocumentId,
1673
+ locale: resolvedLocale
1674
+ })
1675
+ )
1676
+ ) : shouldDeleteAllLocaleVariants ? (await Promise.all(
1677
+ validDocumentIds.map(async (entryDocumentId) => {
1678
+ const baseFilters = resolvedEntriesQuery.filters != null ? resolvedEntriesQuery.filters : {};
1679
+ const localizedEntries = await contentTypeService2.getEntries({
1680
+ contentType: contentType2,
1681
+ fields: ["documentId", "locale"],
1682
+ locale: "*",
1683
+ ...resolvedEntriesQuery.status ? { status: resolvedEntriesQuery.status } : {},
1684
+ filters: {
1685
+ ...baseFilters,
1686
+ documentId: entryDocumentId
1687
+ }
1688
+ });
1689
+ return localizedEntries.length > 0 ? localizedEntries.map(
1690
+ (entry) => adapter.addCollectionNamePrefixToId({
1691
+ contentType: contentType2,
1692
+ entryDocumentId,
1693
+ locale: entry.locale
1694
+ })
1695
+ ) : [
1696
+ // Fallback: delete the non-localized document when no localized variants exist.
1697
+ adapter.addCollectionNamePrefixToId({
1698
+ contentType: contentType2,
1699
+ entryDocumentId
1700
+ })
1701
+ ];
1702
+ })
1703
+ )).flat() : validDocumentIds.map(
1704
+ (entryDocumentId) => adapter.addCollectionNamePrefixToId({
1705
+ entryDocumentId,
1706
+ contentType: contentType2,
1707
+ locale: resolvedEntriesQuery.locale
1708
+ })
1532
1709
  );
1710
+ const uniqueDocumentIds = [...new Set(meilisearchDocumentIds)];
1533
1711
  const tasks = await Promise.all(
1534
1712
  indexUids.map(async (indexUid) => {
1535
- const task = await client.index(indexUid).deleteDocuments(documentsIds);
1713
+ const task = await client.index(indexUid).deleteDocuments(uniqueDocumentIds);
1536
1714
  strapi2.log.info(
1537
- `A task to delete ${documentsIds.length} documents of the index "${indexUid}" in Meilisearch has been enqueued (Task uid: ${task.taskUid}).`
1715
+ `A task to delete ${uniqueDocumentIds.length} documents of the index "${indexUid}" in Meilisearch has been enqueued (Task uid: ${task.taskUid}).`
1538
1716
  );
1539
1717
  return task;
1540
1718
  })
@@ -1561,19 +1739,27 @@ const connectorService = ({ strapi: strapi2, adapter, config: config2 }) => {
1561
1739
  config: config2,
1562
1740
  adapter
1563
1741
  });
1564
- const deleteDocuments = entries.filter(
1565
- (entry) => entry.documentId != null && !addDocuments.map((document) => document.documentId).includes(entry.documentId)
1742
+ const addDocumentIds = addDocuments.map(
1743
+ (document) => document._meilisearch_id
1744
+ );
1745
+ const deleteDocuments = entries.map((entry) => {
1746
+ if (entry.documentId == null) return null;
1747
+ return {
1748
+ ...entry,
1749
+ _meilisearch_id: adapter.addCollectionNamePrefixToId({
1750
+ contentType: contentType2,
1751
+ entryDocumentId: entry.documentId,
1752
+ locale: entry.locale
1753
+ })
1754
+ };
1755
+ }).filter(
1756
+ (entry) => entry && entry._meilisearch_id != null && !addDocumentIds.includes(entry._meilisearch_id)
1566
1757
  );
1567
1758
  const deleteTasks = await Promise.all(
1568
1759
  indexUids.map(async (indexUid) => {
1569
1760
  const tasks = await Promise.all(
1570
1761
  deleteDocuments.map(async (document) => {
1571
- const task = await client.index(indexUid).deleteDocument(
1572
- adapter.addCollectionNamePrefixToId({
1573
- contentType: contentType2,
1574
- entryDocumentId: document.documentId
1575
- })
1576
- );
1762
+ const task = await client.index(indexUid).deleteDocument(document._meilisearch_id);
1577
1763
  strapi2.log.info(
1578
1764
  `A task to delete one document from the Meilisearch index "${indexUid}" has been enqueued (Task uid: ${task.taskUid}).`
1579
1765
  );
@@ -1795,7 +1981,8 @@ const connectorService = ({ strapi: strapi2, adapter, config: config2 }) => {
1795
1981
  const deleteEntries = async ({ entries, contentType: contentType3 }) => {
1796
1982
  await this.deleteEntriesFromMeiliSearch({
1797
1983
  contentType: contentType3,
1798
- documentIds: entries.map((entry) => entry.documentId)
1984
+ documentIds: entries.map((entry) => entry.documentId),
1985
+ entriesQuery: config2.entriesQuery({ contentType: contentType3 })
1799
1986
  });
1800
1987
  };
1801
1988
  await contentTypeService2.actionInBatches({
@@ -1838,12 +2025,18 @@ const connectorService = ({ strapi: strapi2, adapter, config: config2 }) => {
1838
2025
  };
1839
2026
  const adapterService = ({ strapi: strapi2 }) => {
1840
2027
  const contentTypeService2 = strapi2.plugin("meilisearch").service("contentType");
2028
+ const buildMeilisearchId = ({ collectionName, entryDocumentId, locale }) => {
2029
+ if (locale) return `${collectionName}-${entryDocumentId}-${locale}`;
2030
+ return `${collectionName}-${entryDocumentId}`;
2031
+ };
1841
2032
  return {
1842
2033
  /**
1843
2034
  * Add the prefix of the contentType in front of the documentId of its entry.
1844
2035
  *
1845
2036
  * We do this to avoid id's conflict in case of composite indexes.
1846
- * It returns the id in the following format: `[collectionName]-[documentId]`
2037
+ * It returns the id in the following format:
2038
+ * - `[collectionName]-[documentId]` for non-localized entries
2039
+ * - `[collectionName]-[documentId]-[locale]` for localized entries
1847
2040
  *
1848
2041
  * Uses documentId (stable across draft/published) instead of the internal
1849
2042
  * database id to prevent duplicate entries in Meilisearch when Draft & Publish
@@ -1852,20 +2045,27 @@ const adapterService = ({ strapi: strapi2 }) => {
1852
2045
  * @param {object} options
1853
2046
  * @param {string} options.contentType - ContentType name.
1854
2047
  * @param {string} options.entryDocumentId - Entry documentId.
2048
+ * @param {string} options.locale - Entry locale.
1855
2049
  *
1856
2050
  * @returns {string} - Formatted id
1857
2051
  */
1858
- addCollectionNamePrefixToId: function({ contentType: contentType2, entryDocumentId }) {
2052
+ addCollectionNamePrefixToId: function({
2053
+ contentType: contentType2,
2054
+ entryDocumentId,
2055
+ locale
2056
+ }) {
1859
2057
  const collectionName = contentTypeService2.getCollectionName({
1860
2058
  contentType: contentType2
1861
2059
  });
1862
- return `${collectionName}-${entryDocumentId}`;
2060
+ return buildMeilisearchId({ collectionName, entryDocumentId, locale });
1863
2061
  },
1864
2062
  /**
1865
2063
  * Add the prefix of the contentType on a list of entries using documentId.
1866
2064
  *
1867
2065
  * We do this to avoid id's conflict in case of composite indexes.
1868
- * The ids are transformed in the following format: `[collectionName]-[documentId]`
2066
+ * The ids are transformed in the following format:
2067
+ * - `[collectionName]-[documentId]` for non-localized entries
2068
+ * - `[collectionName]-[documentId]-[locale]` for localized entries
1869
2069
  *
1870
2070
  * @param {object} options
1871
2071
  * @param {string} options.contentType - ContentType name.
@@ -1885,7 +2085,8 @@ const adapterService = ({ strapi: strapi2 }) => {
1885
2085
  ...entry,
1886
2086
  _meilisearch_id: this.addCollectionNamePrefixToId({
1887
2087
  entryDocumentId: entry.documentId,
1888
- contentType: contentType2
2088
+ contentType: contentType2,
2089
+ locale: entry.locale
1889
2090
  })
1890
2091
  });
1891
2092
  return acc;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-plugin-meilisearch",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Synchronise and search in your Strapi content-types with Meilisearch",
5
5
  "scripts": {
6
6
  "build": "strapi-plugin build",
@@ -64,6 +64,7 @@
64
64
  "@strapi/strapi": "^5.6.0",
65
65
  "@types/jest": "^30.0.0",
66
66
  "babel-jest": "^30.2.0",
67
+ "better-sqlite3": "^12.8.0",
67
68
  "concurrently": "^9.2.1",
68
69
  "cypress": "^15.11.0",
69
70
  "eslint": "^10.0.2",