@pintawebware/strapi-sync 1.0.4

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.
@@ -0,0 +1,379 @@
1
+ const { mapWithConcurrency } = require('./async');
2
+ const { contentTypeDisplayName } = require('./format');
3
+ const {
4
+ getContentTypeId,
5
+ getEntryLocale,
6
+ getSchemaAttrsFromSnapshot,
7
+ getComparableSchemaAttrs,
8
+ entryDataDiff,
9
+ simplifyAttributeType,
10
+ hasSnapshotEntries,
11
+ stripIgnoredFields
12
+ } = require('./snapshot-utils');
13
+ const { buildToAddToUpdateOnlyInStrapi } = require('./preview');
14
+ const { buildStrapiSchema, buildStrapiComponentSchema, applyLocalizationToSchema } = require('./schema-writer');
15
+
16
+ async function fetchStrapiState(client, groupedObjects, concurrency = 4) {
17
+ const apiTypes = await client.getAllContentTypes();
18
+ const contentId = getContentTypeId;
19
+ const pluralByType = new Map(apiTypes.map((t) => [contentId(t), t.pluralName]).filter(([, p]) => p));
20
+ const schemaByType = new Map(apiTypes.map((t) => [contentId(t), t.schema]).filter(([, s]) => s));
21
+ const allTypeNames = Array.from(new Set([...Object.keys(groupedObjects), ...apiTypes.map(contentId)])).filter(Boolean);
22
+
23
+ const existingSchemas = {};
24
+ const existingEntriesByType = {};
25
+ const apiIdByType = new Map();
26
+
27
+ await mapWithConcurrency(allTypeNames, concurrency, async (contentType) => {
28
+ const listSchema = schemaByType.get(contentType);
29
+ if (listSchema) {
30
+ existingSchemas[contentType] = { data: { contentType: listSchema } };
31
+ } else {
32
+ try {
33
+ existingSchemas[contentType] = await client.getContentTypeSchema(contentType);
34
+ } catch (err) {
35
+ if (err.status === 401 || err.status === 403) throw err;
36
+ existingSchemas[contentType] = null;
37
+ }
38
+ }
39
+
40
+ const schema = existingSchemas[contentType];
41
+ const schemaData = schema?.data?.contentType ?? schema?.data?.schema ?? schema?.data;
42
+ const kind = schemaData?.kind ?? schema?.data?.contentType?.kind ?? schema?.data?.kind;
43
+ const singleType = kind === 'singleType';
44
+ const localized = !!(schemaData?.pluginOptions?.i18n?.localized);
45
+ const apiId =
46
+ pluralByType.get(contentType) ??
47
+ (schemaData?.pluralName && String(schemaData.pluralName).toLowerCase()) ??
48
+ schema?.data?.contentType?.pluralName ??
49
+ schema?.data?.schema?.pluralName ??
50
+ schema?.data?.apiID ??
51
+ schema?.apiID;
52
+
53
+ if (apiId) apiIdByType.set(contentType, apiId);
54
+
55
+ try {
56
+ existingEntriesByType[contentType] = await client.getEntries(contentType, {}, {
57
+ singleType,
58
+ pageSize: singleType ? undefined : 500,
59
+ apiId,
60
+ locale: 'all',
61
+ localized
62
+ });
63
+ } catch (err) {
64
+ if (err.status === 401 || err.status === 403) throw err;
65
+ existingEntriesByType[contentType] = [];
66
+ }
67
+ });
68
+
69
+ return {
70
+ apiTypes,
71
+ existingSchemas,
72
+ existingEntriesByType,
73
+ apiIdByType
74
+ };
75
+ }
76
+
77
+ function diffComponentSchemas(snapshotAttrs, strapiSimpleAttrs) {
78
+ const snapshotKeys = Object.keys(snapshotAttrs);
79
+ const strapiKeys = Object.keys(strapiSimpleAttrs);
80
+ return (
81
+ snapshotKeys.length !== strapiKeys.length ||
82
+ snapshotKeys.some((key) => JSON.stringify(strapiSimpleAttrs[key]) !== JSON.stringify(snapshotAttrs[key])) ||
83
+ strapiKeys.some((key) => JSON.stringify(snapshotAttrs[key]) !== JSON.stringify(strapiSimpleAttrs[key]))
84
+ );
85
+ }
86
+
87
+ async function computeComponentChanges(client, snapshotComponents, includeStrapiOnlyRemovals) {
88
+ const apiComponents = await client.getAllComponents();
89
+ const apiComponentUids = apiComponents.map((component) => component.uid);
90
+ const existingSet = new Set(apiComponentUids);
91
+ const onlyInSnapshotComponents = Object.keys(snapshotComponents).filter((uid) => !existingSet.has(uid));
92
+ const onlyInStrapiComponents = includeStrapiOnlyRemovals
93
+ ? apiComponentUids.filter((uid) => !(uid in snapshotComponents))
94
+ : [];
95
+ const componentSchemaChangesByUid = new Map();
96
+
97
+ for (const component of apiComponents) {
98
+ if (!(component.uid in snapshotComponents)) continue;
99
+ const snapshotAttrs = snapshotComponents[component.uid]?.attributes ?? {};
100
+ const strapiRawAttrs = component.schema?.attributes ?? {};
101
+ const strapiSimpleAttrs = Object.fromEntries(
102
+ Object.entries(strapiRawAttrs).map(([key, value]) => [key, simplifyAttributeType(value)])
103
+ );
104
+ if (diffComponentSchemas(snapshotAttrs, strapiSimpleAttrs)) {
105
+ componentSchemaChangesByUid.set(component.uid, { strapiSimpleAttrs, snapshotAttrs });
106
+ }
107
+ }
108
+
109
+ return {
110
+ onlyInStrapiComponents,
111
+ onlyInSnapshotComponents,
112
+ componentSchemaChangesByUid
113
+ };
114
+ }
115
+
116
+ function getPreviewCounts(groupedObjects, schemaChangesByType, contentChangesByType, onlyInStrapiContentTypes, componentChanges, schemaUpdatesEnabled) {
117
+ let detected = 0;
118
+ let actionable = 0;
119
+
120
+ for (const contentType of Object.keys(groupedObjects)) {
121
+ const hasContentChange = contentChangesByType.get(contentType) === true;
122
+ const hasSchemaChange = schemaChangesByType.get(contentType) === true;
123
+ if (hasContentChange || hasSchemaChange) detected += 1;
124
+ if (hasContentChange || (schemaUpdatesEnabled && hasSchemaChange)) actionable += 1;
125
+ }
126
+
127
+ detected +=
128
+ onlyInStrapiContentTypes.length +
129
+ componentChanges.onlyInStrapiComponents.length +
130
+ componentChanges.onlyInSnapshotComponents.length +
131
+ componentChanges.componentSchemaChangesByUid.size;
132
+
133
+ if (schemaUpdatesEnabled) {
134
+ actionable +=
135
+ onlyInStrapiContentTypes.length +
136
+ componentChanges.onlyInStrapiComponents.length +
137
+ componentChanges.onlyInSnapshotComponents.length +
138
+ componentChanges.componentSchemaChangesByUid.size;
139
+ }
140
+
141
+ return { detected, actionable };
142
+ }
143
+
144
+ function getNonActionableNotes(groupedObjects, schemaChangesByType, onlyInStrapiContentTypes, componentChanges, schemaUpdatesEnabled) {
145
+ if (schemaUpdatesEnabled) return [];
146
+
147
+ const notes = [];
148
+ const schemaOnlyTypes = Object.keys(groupedObjects).filter(
149
+ (contentType) => schemaChangesByType.get(contentType) === true
150
+ );
151
+
152
+ if (schemaOnlyTypes.length > 0) {
153
+ notes.push('Schema changes were detected but schema updates are disabled.');
154
+ }
155
+ if (onlyInStrapiContentTypes.length > 0 || componentChanges.onlyInStrapiComponents.length > 0) {
156
+ notes.push('Strapi-only schemas and components will not be removed while schema updates are disabled.');
157
+ }
158
+ if (componentChanges.onlyInSnapshotComponents.length > 0 || componentChanges.componentSchemaChangesByUid.size > 0) {
159
+ notes.push('Snapshot component changes require schema updates to be enabled.');
160
+ }
161
+
162
+ return notes;
163
+ }
164
+
165
+ async function applySchemaChanges(options) {
166
+ const {
167
+ schemaUpdater,
168
+ snapshotComponents,
169
+ snapshotContentTypes,
170
+ onlyInSnapshotComponents,
171
+ componentSchemaChangesByUid,
172
+ schemaChangesByType,
173
+ localizationChangesByType,
174
+ singleTypeChangesByType,
175
+ existingSchemas,
176
+ onlyInStrapiContentTypes,
177
+ onlyInStrapiComponents
178
+ } = options;
179
+
180
+ let schemaWasUpdated = false;
181
+
182
+ if (!schemaUpdater) return schemaWasUpdated;
183
+
184
+ console.log('📝 Updating Strapi schemas...');
185
+
186
+ for (const [componentUid, block] of Object.entries(snapshotComponents)) {
187
+ const isNew = onlyInSnapshotComponents.includes(componentUid);
188
+ const hasSchemaChange = componentSchemaChangesByUid.has(componentUid);
189
+ if (!isNew && !hasSchemaChange) continue;
190
+ schemaWasUpdated = true;
191
+ const schemaObj = buildStrapiComponentSchema(componentUid, block);
192
+ await schemaUpdater.writeComponent(componentUid, schemaObj);
193
+ console.log(` ✓ component ${componentUid}`);
194
+ }
195
+
196
+ for (const [contentType, block] of Object.entries(snapshotContentTypes)) {
197
+ const schemaChanged = schemaChangesByType.get(contentType);
198
+ const isNewType = !existingSchemas[contentType] || existingSchemas[contentType] === null;
199
+ if (!schemaChanged && !isNewType) continue;
200
+ const locChange = localizationChangesByType.get(contentType);
201
+ const singleTypeChange = singleTypeChangesByType.get(contentType);
202
+ const locSuffix = locChange ? ` (localization: ${locChange.to ? 'enabled' : 'disabled'})` : '';
203
+ const localizationOnly = locChange && !singleTypeChange && !isNewType && (() => {
204
+ const raw = existingSchemas[contentType]?.data?.contentType?.attributes ??
205
+ existingSchemas[contentType]?.data?.schema?.attributes ?? {};
206
+ const strapiSimpleAttrs = Object.fromEntries(
207
+ Object.entries(raw).map(([key, value]) => [key, simplifyAttributeType(value)])
208
+ );
209
+ const snapshotAttrs = block.attributes ?? {};
210
+ const snapshotKeys = Object.keys(snapshotAttrs);
211
+ const strapiKeys = Object.keys(strapiSimpleAttrs);
212
+ return (
213
+ snapshotKeys.length === strapiKeys.length &&
214
+ snapshotKeys.every((key) => JSON.stringify(strapiSimpleAttrs[key]) === JSON.stringify(snapshotAttrs[key]))
215
+ );
216
+ })();
217
+
218
+ if (localizationOnly) {
219
+ const rawExisting = existingSchemas[contentType]?.data?.contentType ??
220
+ existingSchemas[contentType]?.data?.schema ??
221
+ existingSchemas[contentType]?.data;
222
+ const localizedSchema = applyLocalizationToSchema(rawExisting, locChange.to);
223
+ schemaWasUpdated = true;
224
+ await schemaUpdater.writeSchema(contentType, localizedSchema);
225
+ console.log(` ✓ ${contentType}${locSuffix}`);
226
+ continue;
227
+ }
228
+
229
+ schemaWasUpdated = true;
230
+ let schemaObj = buildStrapiSchema(contentType, block);
231
+ if (!isNewType && !hasSnapshotEntries(block)) {
232
+ const currentLocalized = !!(
233
+ existingSchemas[contentType]?.data?.contentType?.pluginOptions?.i18n?.localized ??
234
+ existingSchemas[contentType]?.data?.schema?.pluginOptions?.i18n?.localized ??
235
+ existingSchemas[contentType]?.data?.pluginOptions?.i18n?.localized
236
+ );
237
+ schemaObj = applyLocalizationToSchema(schemaObj, currentLocalized);
238
+ }
239
+ await schemaUpdater.writeSchema(contentType, schemaObj);
240
+ await schemaUpdater.writeCoreFiles(contentType);
241
+ console.log(` ✓ ${contentType}${locSuffix}`);
242
+ }
243
+
244
+ for (const contentType of onlyInStrapiContentTypes) {
245
+ schemaWasUpdated = true;
246
+ await schemaUpdater.deleteContentType(contentType);
247
+ console.log(` 🗑 ${contentType}`);
248
+ }
249
+
250
+ for (const componentUid of onlyInStrapiComponents) {
251
+ schemaWasUpdated = true;
252
+ await schemaUpdater.deleteComponent(componentUid);
253
+ console.log(` 🗑 component ${componentUid}`);
254
+ }
255
+
256
+ return schemaWasUpdated;
257
+ }
258
+
259
+ async function waitForStrapiReload(client, options = {}) {
260
+ const interval = options.reloadPollInterval ?? 1500;
261
+ const timeoutMs = options.reloadTimeoutMs ?? 60000;
262
+
263
+ console.log(' ⏳ Pausing 3s for Strapi to start reloading...');
264
+ await new Promise((resolve) => setTimeout(resolve, 3000));
265
+
266
+ const frames = ['⏳', '⌛'];
267
+ let frameIndex = 0;
268
+ const animation = setInterval(() => {
269
+ const line = ` ${frames[frameIndex % frames.length]} Waiting for Strapi to reload...`;
270
+ process.stdout.write('\r\x1b[K' + line);
271
+ frameIndex += 1;
272
+ }, 400);
273
+
274
+ const deadline = Date.now() + timeoutMs;
275
+ let ready = false;
276
+ while (Date.now() < deadline) {
277
+ if (await client.ping()) {
278
+ ready = true;
279
+ break;
280
+ }
281
+ await new Promise((resolve) => setTimeout(resolve, interval));
282
+ }
283
+
284
+ clearInterval(animation);
285
+ process.stdout.write('\r\x1b[K');
286
+
287
+ if (ready) {
288
+ console.log(' ✓ Strapi is ready');
289
+ return true;
290
+ }
291
+
292
+ console.warn(` ⚠️ Strapi did not respond within ${timeoutMs / 1000}s. Content sync skipped.`);
293
+ return false;
294
+ }
295
+
296
+ async function applyContentChanges(client, groupedObjects, snapshotContentTypes, existingSchemas, existingEntriesByType, apiIdByType, contentChangesByType, schemaChangesByType = new Map()) {
297
+ let changesApplied = false;
298
+
299
+ const sortedTypes = Object.keys(groupedObjects).sort((left, right) => {
300
+ const leftSchema = schemaChangesByType.get(left) ? 1 : 0;
301
+ const rightSchema = schemaChangesByType.get(right) ? 1 : 0;
302
+ return rightSchema - leftSchema;
303
+ });
304
+
305
+ for (const contentType of sortedTypes) {
306
+ if (!contentChangesByType.get(contentType)) continue;
307
+
308
+ const typeObjects = groupedObjects[contentType];
309
+ const singleType = typeObjects.some((entry) => entry.singleType);
310
+ const existingEntries = existingEntriesByType[contentType] || [];
311
+ const apiId =
312
+ apiIdByType.get(contentType) ??
313
+ existingSchemas[contentType]?.data?.contentType?.pluralName ??
314
+ existingSchemas[contentType]?.data?.apiID;
315
+ const snapshotAttrs = getSchemaAttrsFromSnapshot(snapshotContentTypes, contentType);
316
+ const comparableAttrs =
317
+ Object.keys(snapshotAttrs).length > 0
318
+ ? getComparableSchemaAttrs(snapshotAttrs)
319
+ : {};
320
+ const { toAdd, toUpdate, onlyInStrapi } = buildToAddToUpdateOnlyInStrapi(
321
+ contentType,
322
+ typeObjects,
323
+ existingEntries,
324
+ snapshotContentTypes,
325
+ singleType
326
+ );
327
+
328
+ if (onlyInStrapi.length > 0) {
329
+ for (const entry of onlyInStrapi) {
330
+ const deleteId = entry.documentId ?? entry.id;
331
+ console.log(` 🗑 Deleting: ${deleteId}${getEntryLocale(entry) ? ` [${getEntryLocale(entry)}]` : ''}`);
332
+ await client.deleteEntry(contentType, deleteId, { apiId, locale: getEntryLocale(entry) || undefined });
333
+ }
334
+ changesApplied = true;
335
+ console.log(`✓ Deleted ${onlyInStrapi.length} entr${onlyInStrapi.length === 1 ? 'y' : 'ies'}`);
336
+ }
337
+
338
+ const updates = [...toAdd.map((item) => ({ ...item, isCreate: true })), ...toUpdate.map((item) => ({ ...item, isCreate: false }))];
339
+ if (updates.length === 0) continue;
340
+
341
+ console.log(`📋 ${contentTypeDisplayName(contentType)}`);
342
+ console.log('💾 Updating content in Strapi...');
343
+
344
+ const skippedFields = Object.keys(snapshotAttrs).filter((key) => !(key in comparableAttrs));
345
+ if (skippedFields.length > 0) {
346
+ console.log(` ⚠️ Skipping unsupported fields: ${skippedFields.join(', ')}`);
347
+ }
348
+
349
+ for (const entry of updates) {
350
+ const locale = entry.itemLocale ?? entry.locale ?? '';
351
+ const sourceData = entry.isCreate ? entry.data : entryDataDiff(entry.data, entry.existing, comparableAttrs);
352
+ const payload = stripIgnoredFields(sourceData, snapshotAttrs);
353
+ if (!entry.isCreate && (!payload || Object.keys(payload).filter((key) => key !== 'id' && key !== 'documentId').length === 0)) {
354
+ continue;
355
+ }
356
+ if (entry.isCreate && !singleType) {
357
+ await client.createEntry(contentType, payload, { singleType, apiId, locale });
358
+ continue;
359
+ }
360
+ const targetId = singleType ? null : (entry.existing?.documentId ?? entry.existing?.id ?? entry.data.documentId ?? entry.data.id);
361
+ await client.updateEntry(contentType, targetId, payload, { singleType, apiId, locale });
362
+ }
363
+
364
+ changesApplied = true;
365
+ console.log(`✓ Applied ${updates.length} entr${updates.length === 1 ? 'y' : 'ies'}`);
366
+ }
367
+
368
+ return changesApplied;
369
+ }
370
+
371
+ module.exports = {
372
+ fetchStrapiState,
373
+ computeComponentChanges,
374
+ getPreviewCounts,
375
+ getNonActionableNotes,
376
+ applySchemaChanges,
377
+ waitForStrapiReload,
378
+ applyContentChanges
379
+ };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@pintawebware/strapi-sync",
3
+ "version": "1.0.4",
4
+ "description": "Export Strapi content to snapshot JSON and apply snapshot content to Strapi",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "strapi-sync": "bin/cli.js"
8
+ },
9
+ "scripts": {
10
+ "test": "echo \"Error: no test specified\" && exit 1"
11
+ },
12
+ "keywords": [
13
+ "strapi",
14
+ "sync",
15
+ "cli",
16
+ "cms",
17
+ "frontend"
18
+ ],
19
+ "author": "",
20
+ "license": "MIT",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "dependencies": {
25
+ "axios": "^1.6.0",
26
+ "dotenv": "^17.3.1",
27
+ "ssh2": "^1.15.0"
28
+ },
29
+ "files": [
30
+ "bin",
31
+ "lib",
32
+ "index.js"
33
+ ]
34
+ }