strapi-content-sync-pro 1.0.0 → 1.0.2

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.
@@ -1,5 +1,8 @@
1
1
  import { Box, Typography, Tabs, Divider } from '@strapi/design-system';
2
2
 
3
+ const INTRO_VIDEO_URL = 'https://www.youtube.com/watch?v=hr3dD6dLgLQ';
4
+ const INTRO_VIDEO_THUMBNAIL = 'https://img.youtube.com/vi/hr3dD6dLgLQ/hqdefault.jpg';
5
+
3
6
  const HelpSection = ({ title, children }) => (
4
7
  <Box paddingBottom={6}>
5
8
  <Typography variant="delta" tag="h3" paddingBottom={2}>{title}</Typography>
@@ -55,6 +58,61 @@ export const HelpTab = () => {
55
58
  {/* Overview Tab */}
56
59
  <Tabs.Content value="overview">
57
60
  <Box paddingTop={4}>
61
+ <HelpSection title="Video walkthrough">
62
+ <Typography variant="omega" paddingBottom={3}>
63
+ Watch the help video to get started:
64
+ </Typography>
65
+
66
+ <Box
67
+ background="neutral100"
68
+ hasRadius
69
+ padding={3}
70
+ style={{ maxWidth: '960px' }}
71
+ >
72
+ <a href={INTRO_VIDEO_URL} target="_blank" rel="noopener noreferrer" style={{ display: 'block', textDecoration: 'none' }}>
73
+ <Box style={{ position: 'relative', paddingTop: '56.25%', overflow: 'hidden', borderRadius: '4px' }}>
74
+ <img
75
+ src={INTRO_VIDEO_THUMBNAIL}
76
+ alt="Content Sync Pro — plugin intro"
77
+ style={{
78
+ position: 'absolute',
79
+ top: 0,
80
+ left: 0,
81
+ width: '100%',
82
+ height: '100%',
83
+ objectFit: 'cover',
84
+ }}
85
+ />
86
+ <Box
87
+ style={{
88
+ position: 'absolute',
89
+ top: '50%',
90
+ left: '50%',
91
+ transform: 'translate(-50%, -50%)',
92
+ width: '68px',
93
+ height: '48px',
94
+ backgroundColor: 'rgba(255,0,0,0.85)',
95
+ borderRadius: '12px',
96
+ display: 'flex',
97
+ alignItems: 'center',
98
+ justifyContent: 'center',
99
+ }}
100
+ >
101
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="white">
102
+ <polygon points="8,5 20,12 8,19" />
103
+ </svg>
104
+ </Box>
105
+ </Box>
106
+ </a>
107
+ <Box paddingTop={2}>
108
+ <Typography variant="pi" textColor="neutral500">
109
+ ▶ Watch the help video on{' '}
110
+ <DocLink href={INTRO_VIDEO_URL}>YouTube</DocLink>
111
+ </Typography>
112
+ </Box>
113
+ </Box>
114
+ </HelpSection>
115
+
58
116
  <HelpSection title="What is Content Sync Pro?">
59
117
  <Typography variant="omega">
60
118
  This plugin enables data synchronization between two Strapi v5 instances. It provides a complete
@@ -0,0 +1,33 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="320" viewBox="0 0 1200 320" role="img" aria-label="Content Sync Pro logo">
3
+ <defs>
4
+ <linearGradient id="csp-g" x1="40" y1="40" x2="240" y2="240" gradientUnits="userSpaceOnUse">
5
+ <stop offset="0" stop-color="#6C63FF"/>
6
+ <stop offset="1" stop-color="#4945FF"/>
7
+ </linearGradient>
8
+ </defs>
9
+
10
+ <!-- mark -->
11
+ <g transform="translate(24,24)">
12
+ <rect x="0" y="0" width="272" height="272" rx="64" fill="url(#csp-g)"/>
13
+ <circle cx="92" cy="120" r="22" fill="#FFFFFF" opacity="0.92"/>
14
+ <circle cx="180" cy="120" r="22" fill="#FFFFFF" opacity="0.92"/>
15
+ <g fill="none" stroke="#FFFFFF" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" opacity="0.95">
16
+ <path d="M110 104 H172"/>
17
+ <path d="M172 104 L160 92"/>
18
+ <path d="M172 104 L160 116"/>
19
+ <path d="M164 136 H102"/>
20
+ <path d="M102 136 L114 124"/>
21
+ <path d="M102 136 L114 148"/>
22
+ </g>
23
+ <path d="M136 158 C156 158 172 150 182 143 V176 C182 202 164 222 136 232 C108 222 90 202 90 176 V143 C100 150 116 158 136 158 Z" fill="#FFFFFF" opacity="0.92"/>
24
+ <path d="M118 186 L130 198 L156 172" fill="none" stroke="#4945FF" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
25
+ </g>
26
+
27
+ <!-- wordmark -->
28
+ <g transform="translate(340,88)">
29
+ <text x="0" y="0" font-size="54" font-weight="700" font-family="Inter, Segoe UI, Arial, sans-serif" fill="#111827">Content Sync</text>
30
+ <text x="0" y="70" font-size="54" font-weight="700" font-family="Inter, Segoe UI, Arial, sans-serif" fill="#4945FF">Pro</text>
31
+ <text x="140" y="70" font-size="22" font-weight="500" font-family="Inter, Segoe UI, Arial, sans-serif" fill="#6B7280">Strapi v5 plugin</text>
32
+ </g>
33
+ </svg>
@@ -0,0 +1,38 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Content Sync Pro logo mark">
3
+ <defs>
4
+ <linearGradient id="csp-g" x1="96" y1="96" x2="416" y2="416" gradientUnits="userSpaceOnUse">
5
+ <stop offset="0" stop-color="#6C63FF"/>
6
+ <stop offset="1" stop-color="#4945FF"/>
7
+ </linearGradient>
8
+ <filter id="csp-shadow" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
9
+ <feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#000" flood-opacity="0.18"/>
10
+ </filter>
11
+ </defs>
12
+
13
+ <!-- background badge -->
14
+ <rect x="56" y="56" width="400" height="400" rx="92" fill="url(#csp-g)" filter="url(#csp-shadow)"/>
15
+
16
+ <!-- sync nodes -->
17
+ <circle cx="182" cy="236" r="34" fill="#FFFFFF" opacity="0.92"/>
18
+ <circle cx="330" cy="236" r="34" fill="#FFFFFF" opacity="0.92"/>
19
+
20
+ <!-- bi-directional arrows -->
21
+ <g fill="none" stroke="#FFFFFF" stroke-width="18" stroke-linecap="round" stroke-linejoin="round" opacity="0.95">
22
+ <!-- top arrow: left -> right -->
23
+ <path d="M208 210 H320"/>
24
+ <path d="M320 210 L302 192"/>
25
+ <path d="M320 210 L302 228"/>
26
+
27
+ <!-- bottom arrow: right -> left -->
28
+ <path d="M304 262 H192"/>
29
+ <path d="M192 262 L210 244"/>
30
+ <path d="M192 262 L210 280"/>
31
+ </g>
32
+
33
+ <!-- safety shield + check ("pro" / enforcement) -->
34
+ <g transform="translate(0,2)">
35
+ <path d="M256 310 C290 310 316 296 332 284 V338 C332 380 302 412 256 430 C210 412 180 380 180 338 V284 C196 296 222 310 256 310 Z" fill="#FFFFFF" opacity="0.92"/>
36
+ <path d="M226 356 L246 376 L292 330" fill="none" stroke="#4945FF" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"/>
37
+ </g>
38
+ </svg>
@@ -0,0 +1,27 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Content Sync Pro square logo">
3
+ <defs>
4
+ <linearGradient id="csp-g" x1="160" y1="160" x2="864" y2="864" gradientUnits="userSpaceOnUse">
5
+ <stop offset="0" stop-color="#6C63FF"/>
6
+ <stop offset="1" stop-color="#4945FF"/>
7
+ </linearGradient>
8
+ </defs>
9
+
10
+ <rect x="112" y="112" width="800" height="800" rx="180" fill="url(#csp-g)"/>
11
+
12
+ <circle cx="364" cy="470" r="68" fill="#FFFFFF" opacity="0.92"/>
13
+ <circle cx="660" cy="470" r="68" fill="#FFFFFF" opacity="0.92"/>
14
+
15
+ <g fill="none" stroke="#FFFFFF" stroke-width="36" stroke-linecap="round" stroke-linejoin="round" opacity="0.95">
16
+ <path d="M416 418 H640"/>
17
+ <path d="M640 418 L604 382"/>
18
+ <path d="M640 418 L604 454"/>
19
+
20
+ <path d="M608 522 H384"/>
21
+ <path d="M384 522 L420 486"/>
22
+ <path d="M384 522 L420 558"/>
23
+ </g>
24
+
25
+ <path d="M512 620 C580 620 632 592 664 568 V676 C664 760 604 824 512 860 C420 824 360 760 360 676 V568 C392 592 444 620 512 620 Z" fill="#FFFFFF" opacity="0.92"/>
26
+ <path d="M452 712 L492 752 L588 656" fill="none" stroke="#4945FF" stroke-width="36" stroke-linecap="round" stroke-linejoin="round"/>
27
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-content-sync-pro",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Strapi v5 plugin to copy, migrate, and live-sync content, media, and data between multiple Strapi environments with bi-directional sync, field-level policies, scheduling, and alerts.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -59,6 +59,7 @@
59
59
  "files": [
60
60
  "admin/",
61
61
  "server/",
62
+ "docs/",
62
63
  "README.md",
63
64
  "LICENSE"
64
65
  ],
@@ -160,6 +160,147 @@ module.exports = ({ strapi }) => {
160
160
  return { syncedAt: new Date().toISOString(), results };
161
161
  },
162
162
 
163
+ /**
164
+ * Sync a single content type using a given profile.
165
+ * Called by the execution service (on-demand / scheduled / live runs).
166
+ *
167
+ * options:
168
+ * - profile: sync profile { contentType, direction, conflictStrategy, isSimple, fieldPolicies }
169
+ * - syncDependencies: boolean (currently informational; dependency resolution handled upstream)
170
+ * - dependencyDepth: number
171
+ */
172
+ async syncContentType(uid, options = {}) {
173
+ if (!uid) {
174
+ throw new Error('Content type uid is required');
175
+ }
176
+
177
+ const logService = plugin().service('syncLog');
178
+ const configService = plugin().service('config');
179
+ const syncConfigService = plugin().service('syncConfig');
180
+ const syncProfilesService = plugin().service('syncProfiles');
181
+ const executionService = plugin().service('syncExecution');
182
+
183
+ const remoteConfig = await configService.getConfig({ safe: false });
184
+ if (!remoteConfig || !remoteConfig.baseUrl) {
185
+ throw new Error('Remote server not configured');
186
+ }
187
+
188
+ const { profile } = options;
189
+ const syncConfig = await syncConfigService.getSyncConfig();
190
+ const ctConfig = (syncConfig.contentTypes || []).find((ct) => ct.uid === uid) || { uid, fields: [] };
191
+
192
+ const direction = profile?.direction || ctConfig.direction || 'both';
193
+ const conflictStrategy = profile?.conflictStrategy || syncConfig.conflictStrategy || 'latest';
194
+ const fields = ctConfig.fields || [];
195
+
196
+ // Field-level policies: prefer the policies on the provided profile,
197
+ // otherwise fall back to the active profile for the content type.
198
+ let fieldPolicies = null;
199
+ if (profile) {
200
+ if (!profile.isSimple && Array.isArray(profile.fieldPolicies) && profile.fieldPolicies.length > 0) {
201
+ fieldPolicies = {};
202
+ for (const fp of profile.fieldPolicies) {
203
+ fieldPolicies[fp.field] = fp.direction;
204
+ }
205
+ }
206
+ } else {
207
+ fieldPolicies = await syncProfilesService.getFieldPoliciesForContentType(uid);
208
+ }
209
+
210
+ const globalExec = (await executionService.getGlobalSettings?.()) || {};
211
+ const pageSize = Number(globalExec.syncPageSize) || 100;
212
+
213
+ const timestamps = await getLastSyncTimestamps();
214
+ const lastSyncAt = timestamps[uid] || null;
215
+ const syncStartTime = new Date().toISOString();
216
+
217
+ let pushed = 0;
218
+ let pulled = 0;
219
+ let errors = 0;
220
+
221
+ try {
222
+ const localRecords = await fetchLocalRecords(strapi, uid, { fields, lastSyncAt, pageSize });
223
+ const remoteRecords = await fetchRemoteRecords(remoteConfig, uid, { fields, lastSyncAt, pageSize });
224
+
225
+ const diff = compareRecords(localRecords, remoteRecords, { direction, conflictStrategy });
226
+
227
+ for (const { local } of diff.toPush) {
228
+ try {
229
+ const filteredRecord = syncProfilesService.filterFieldsByPolicy(local, fieldPolicies, 'push');
230
+ await applyRemote(remoteConfig, uid, filteredRecord, fields);
231
+ pushed++;
232
+ } catch (err) {
233
+ errors++;
234
+ await logService.log({ action: 'push', contentType: uid, syncId: local.syncId, direction: 'push', status: 'error', message: err.message });
235
+ }
236
+ }
237
+
238
+ for (const { remote } of diff.toPull) {
239
+ try {
240
+ const filteredRecord = syncProfilesService.filterFieldsByPolicy(remote, fieldPolicies, 'pull');
241
+ await applyLocal(strapi, uid, filteredRecord, fields);
242
+ pulled++;
243
+ } catch (err) {
244
+ errors++;
245
+ await logService.log({ action: 'pull', contentType: uid, syncId: remote.syncId, direction: 'pull', status: 'error', message: err.message });
246
+ }
247
+ }
248
+
249
+ for (const record of diff.toCreateRemote) {
250
+ try {
251
+ const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'push');
252
+ await applyRemote(remoteConfig, uid, filteredRecord, fields);
253
+ pushed++;
254
+ } catch (err) {
255
+ errors++;
256
+ await logService.log({ action: 'create_remote', contentType: uid, syncId: record.syncId, direction: 'push', status: 'error', message: err.message });
257
+ }
258
+ }
259
+
260
+ for (const record of diff.toCreateLocal) {
261
+ try {
262
+ const filteredRecord = syncProfilesService.filterFieldsByPolicy(record, fieldPolicies, 'pull');
263
+ await applyLocal(strapi, uid, filteredRecord, fields);
264
+ pulled++;
265
+ } catch (err) {
266
+ errors++;
267
+ await logService.log({ action: 'create_local', contentType: uid, syncId: record.syncId, direction: 'pull', status: 'error', message: err.message });
268
+ }
269
+ }
270
+
271
+ await setLastSyncTimestamp(uid, syncStartTime);
272
+
273
+ const summary = {
274
+ uid,
275
+ pushed,
276
+ pulled,
277
+ errors,
278
+ hasFieldPolicies: !!fieldPolicies,
279
+ profile: profile ? { id: profile.id, name: profile.name } : null,
280
+ };
281
+
282
+ await logService.log({
283
+ action: 'sync_complete',
284
+ contentType: uid,
285
+ direction,
286
+ status: errors > 0 ? 'partial' : 'success',
287
+ message: `Pushed: ${pushed}, Pulled: ${pulled}, Errors: ${errors}${fieldPolicies ? ' (with field policies)' : ''}`,
288
+ details: summary,
289
+ });
290
+
291
+ return { syncedAt: new Date().toISOString(), ...summary };
292
+ } catch (err) {
293
+ await logService.log({
294
+ action: 'sync_error',
295
+ contentType: uid,
296
+ direction,
297
+ status: 'error',
298
+ message: err.message,
299
+ });
300
+ throw err;
301
+ }
302
+ },
303
+
163
304
  /**
164
305
  * Step 8 — Push a single record to the remote (called by lifecycle hooks).
165
306
  * Now supports field-level policies.