strapi-content-sync-pro 1.0.4 → 1.0.6
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.
- package/LICENSE +1 -1
- package/README.md +32 -14
- package/admin/src/components/BulkTransferTab.jsx +185 -20
- package/admin/src/components/ConfigTab.jsx +81 -3
- package/admin/src/components/ContentTypesTab.jsx +28 -1
- package/admin/src/components/HelpTab.jsx +34 -0
- package/admin/src/components/LogsTab.jsx +66 -8
- package/admin/src/components/MediaTab.jsx +253 -36
- package/admin/src/components/SyncProfilesTab.jsx +140 -4
- package/admin/src/components/SyncTab.jsx +161 -35
- package/docs/Screenshot 2026-04-22 183540.png +0 -0
- package/docs/Screenshot 2026-04-22 183552.png +0 -0
- package/docs/Screenshot 2026-04-23 114332.png +0 -0
- package/docs/Screenshot 2026-04-23 114644.png +0 -0
- package/docs/Screenshot 2026-04-23 114651.png +0 -0
- package/docs/Screenshot 2026-04-23 114737.png +0 -0
- package/docs/Screenshot 2026-04-23 114904.png +0 -0
- package/docs/Screenshot 2026-04-23 114940.png +0 -0
- package/docs/Screenshot 2026-04-23 115003.png +0 -0
- package/docs/Screenshot 2026-04-23 115024.png +0 -0
- package/docs/Screenshot 2026-04-23 115116.png +0 -0
- package/docs/Screenshot 2026-04-23 115141.png +0 -0
- package/docs/Screenshot 2026-04-23 115252.png +0 -0
- package/docs/Screenshot 2026-04-23 115448.png +0 -0
- package/docs/Screenshot 2026-04-23 120534.png +0 -0
- package/docs/Screenshot 2026-04-23 122544.png +0 -0
- package/docs/Screenshot 2026-04-23 122712.png +0 -0
- package/docs/Screenshot 2026-04-23 122730.png +0 -0
- package/docs/Screenshot 2026-04-23 122858.png +0 -0
- package/docs/Screenshot 2026-04-23 122924.png +0 -0
- package/docs/Screenshot 2026-04-23 122937.png +0 -0
- package/docs/sync-strategy-approach-review.md +127 -0
- package/package.json +1 -1
- package/server/src/controllers/config.js +76 -3
- package/server/src/controllers/sync-media.js +24 -0
- package/server/src/routes/index.js +3 -0
- package/server/src/services/bulk-transfer.js +45 -1
- package/server/src/services/dependency-resolver.js +37 -0
- package/server/src/services/sync-execution.js +21 -9
- package/server/src/services/sync-media.js +168 -32
- package/server/src/services/sync-profiles.js +36 -15
- package/server/src/services/sync.js +234 -134
- package/server/src/utils/fetcher.js +7 -0
- package/docs/Screenshot 2026-04-20 160506.png +0 -0
- package/docs/Screenshot 2026-04-20 160558.png +0 -0
- package/docs/Screenshot 2026-04-20 175903.png +0 -0
- package/docs/Screenshot 2026-04-20 175931.png +0 -0
- package/docs/Screenshot 2026-04-20 180001.png +0 -0
- package/docs/Screenshot 2026-04-20 180041.png +0 -0
- package/docs/Screenshot 2026-04-20 180116.png +0 -0
- package/docs/Screenshot 2026-04-20 180135.png +0 -0
- package/docs/Screenshot 2026-04-20 180202.png +0 -0
- package/docs/Screenshot 2026-04-20 180228.png +0 -0
- package/docs/Screenshot 2026-04-20 180251.png +0 -0
- package/docs/Screenshot 2026-04-20 180301.png +0 -0
- package/docs/clipchamp-screen-recording-script.md +0 -0
- package/docs/production-readiness-status.md +0 -34
- package/docs/production-readiness-test-matrix.md +0 -151
- package/docs/test-environments-setup-legacy.txt +0 -60
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2024 Ejaz
|
|
3
|
+
Copyright (c) 2024–2025 Ejaz Hussain Arain. All rights reserved.
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ A powerful Strapi v5 plugin to copy, migrate, and live-sync content, media, and
|
|
|
14
14
|
Plugin intro: https://youtu.be/hr3dD6dLgLQ
|
|
15
15
|
|
|
16
16
|
<a href="https://youtu.be/hr3dD6dLgLQ" target="_blank" rel="noopener noreferrer">
|
|
17
|
-
<img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
17
|
+
<img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122937.png" alt="Content Sync Pro — watch the intro video" width="100%" />
|
|
18
18
|
</a>
|
|
19
19
|
|
|
20
20
|
## Screenshots
|
|
@@ -26,24 +26,39 @@ Plugin intro: https://youtu.be/hr3dD6dLgLQ
|
|
|
26
26
|
|
|
27
27
|
<table>
|
|
28
28
|
<tr>
|
|
29
|
-
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
30
|
-
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
31
|
-
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
29
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20114332.png" alt="Content Sync Pro - screenshot 1" width="100%" /></td>
|
|
30
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20114644.png" alt="Content Sync Pro - screenshot 2" width="100%" /></td>
|
|
31
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20114651.png" alt="Content Sync Pro - screenshot 3" width="100%" /></td>
|
|
32
32
|
</tr>
|
|
33
33
|
<tr>
|
|
34
|
-
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
35
|
-
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
36
|
-
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
34
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20114737.png" alt="Content Sync Pro - screenshot 4" width="100%" /></td>
|
|
35
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20114904.png" alt="Content Sync Pro - screenshot 5" width="100%" /></td>
|
|
36
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20114940.png" alt="Content Sync Pro - screenshot 6" width="100%" /></td>
|
|
37
37
|
</tr>
|
|
38
38
|
<tr>
|
|
39
|
-
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
40
|
-
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
41
|
-
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
39
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20115003.png" alt="Content Sync Pro - screenshot 7" width="100%" /></td>
|
|
40
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20115024.png" alt="Content Sync Pro - screenshot 8" width="100%" /></td>
|
|
41
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20115116.png" alt="Content Sync Pro - screenshot 9" width="100%" /></td>
|
|
42
42
|
</tr>
|
|
43
43
|
<tr>
|
|
44
|
-
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
45
|
-
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
46
|
-
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-
|
|
44
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20115141.png" alt="Content Sync Pro - screenshot 10" width="100%" /></td>
|
|
45
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20115252.png" alt="Content Sync Pro - screenshot 11" width="100%" /></td>
|
|
46
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20115448.png" alt="Content Sync Pro - screenshot 12" width="100%" /></td>
|
|
47
|
+
</tr>
|
|
48
|
+
<tr>
|
|
49
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20120534.png" alt="Content Sync Pro - screenshot 13" width="100%" /></td>
|
|
50
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122544.png" alt="Content Sync Pro - screenshot 14" width="100%" /></td>
|
|
51
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122712.png" alt="Content Sync Pro - screenshot 15" width="100%" /></td>
|
|
52
|
+
</tr>
|
|
53
|
+
<tr>
|
|
54
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122730.png" alt="Content Sync Pro - screenshot 16" width="100%" /></td>
|
|
55
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122858.png" alt="Content Sync Pro - screenshot 17" width="100%" /></td>
|
|
56
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122924.png" alt="Content Sync Pro - screenshot 18" width="100%" /></td>
|
|
57
|
+
</tr>
|
|
58
|
+
<tr>
|
|
59
|
+
<td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122937.png" alt="Content Sync Pro - screenshot 19" width="100%" /></td>
|
|
60
|
+
<td width="33%"></td>
|
|
61
|
+
<td width="33%"></td>
|
|
47
62
|
</tr>
|
|
48
63
|
</table>
|
|
49
64
|
</details>
|
|
@@ -191,6 +206,7 @@ Full media synchronization between Strapi instances:
|
|
|
191
206
|
- **Profile-based** — Create media sync profiles with direction, conflict strategy, MIME filters, filename patterns, and execution settings.
|
|
192
207
|
- **DB + File Sync** — Syncs both the `plugin::upload.file` database rows and the actual file bytes.
|
|
193
208
|
- **Morph Link Remapping** — Syncs `files_related_morphs` links by mapping file + related entities through documentId, then remapping to local numeric ids before insert.
|
|
209
|
+
- **Live Status + Pause/Resume/Stop** — The Media tab polls status every 2 s while a profile is running or paused and shows live phase and counters (`pushed`, `pulled`, `skipped`, `errors`). Long runs can be paused, resumed, or stopped cooperatively from the UI or via `POST /api/strapi-content-sync-pro/media-sync/profiles/:id/pause|resume|cancel` (URL strategy; rsync runs cannot be paused mid-process).
|
|
194
210
|
|
|
195
211
|
## Enforcement
|
|
196
212
|
|
|
@@ -293,7 +309,8 @@ Check the **Logs** tab for detailed sync history including:
|
|
|
293
309
|
|
|
294
310
|
## Contributing
|
|
295
311
|
|
|
296
|
-
Contributions are welcome! Please open an issue or submit a pull request.
|
|
312
|
+
Contributions are welcome! Please open an issue or submit a pull request.
|
|
313
|
+
|
|
297
314
|
|
|
298
315
|
## License
|
|
299
316
|
|
|
@@ -303,4 +320,5 @@ MIT License - see [LICENSE](LICENSE) for details.
|
|
|
303
320
|
|
|
304
321
|
**Ejaz Husain Arain**
|
|
305
322
|
- GitHub: [@eharain](https://github.com/eharain)
|
|
323
|
+
- LinkedIn: [Ejaz Husain Arain](https://www.linkedin.com/in/eharain/)
|
|
306
324
|
- Email: eharain@yahoo.com
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
Checkbox,
|
|
9
9
|
SingleSelect,
|
|
10
10
|
SingleSelectOption,
|
|
11
|
+
TextInput,
|
|
11
12
|
Field,
|
|
12
13
|
Table,
|
|
13
14
|
Thead,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
Badge,
|
|
19
20
|
Loader,
|
|
20
21
|
} from '@strapi/design-system';
|
|
22
|
+
import { CaretUp, CaretDown } from '@strapi/icons';
|
|
21
23
|
import { useFetchClient } from '@strapi/strapi/admin';
|
|
22
24
|
|
|
23
25
|
const PLUGIN_ID = 'strapi-content-sync-pro';
|
|
@@ -66,6 +68,18 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
66
68
|
// 'history' shows the persisted previous-runs table.
|
|
67
69
|
const [subTab, setSubTab] = useState('run');
|
|
68
70
|
|
|
71
|
+
// Chunk table filters + sort
|
|
72
|
+
const [chunkSearch, setChunkSearch] = useState('');
|
|
73
|
+
const [chunkKindFilter, setChunkKindFilter] = useState('');
|
|
74
|
+
const [chunkStatusFilter, setChunkStatusFilter] = useState('');
|
|
75
|
+
const [chunkSortField, setChunkSortField] = useState('');
|
|
76
|
+
const [chunkSortDir, setChunkSortDir] = useState('asc');
|
|
77
|
+
|
|
78
|
+
// History table filters + sort
|
|
79
|
+
const [historySearch, setHistorySearch] = useState('');
|
|
80
|
+
const [historySortField, setHistorySortField] = useState('');
|
|
81
|
+
const [historySortDir, setHistorySortDir] = useState('asc');
|
|
82
|
+
|
|
69
83
|
const pollRef = useRef(null);
|
|
70
84
|
|
|
71
85
|
const scopeCount = Object.values(scopes).filter(Boolean).length;
|
|
@@ -354,6 +368,77 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
354
368
|
const isActive = isRunning || isPaused;
|
|
355
369
|
const isTerminal = job && ['success', 'partial', 'cancelled', 'error'].includes(job.status);
|
|
356
370
|
|
|
371
|
+
// Derived chunk list (filter + sort)
|
|
372
|
+
const displayedChunks = useMemo(() => {
|
|
373
|
+
let result = [...chunkRows];
|
|
374
|
+
if (chunkSearch.trim()) {
|
|
375
|
+
const q = chunkSearch.trim().toLowerCase();
|
|
376
|
+
result = result.filter(
|
|
377
|
+
(c) => (c.label || '').toLowerCase().includes(q) || (c.kind || '').toLowerCase().includes(q)
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (chunkKindFilter) {
|
|
381
|
+
result = result.filter((c) => (c.kind || '') === chunkKindFilter);
|
|
382
|
+
}
|
|
383
|
+
if (chunkStatusFilter) {
|
|
384
|
+
result = result.filter((c) => (c.status || '') === chunkStatusFilter);
|
|
385
|
+
}
|
|
386
|
+
if (chunkSortField) {
|
|
387
|
+
result.sort((a, b) => {
|
|
388
|
+
const aVal = a[chunkSortField] ?? '';
|
|
389
|
+
const bVal = b[chunkSortField] ?? '';
|
|
390
|
+
if (typeof aVal === 'string') {
|
|
391
|
+
return chunkSortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
392
|
+
}
|
|
393
|
+
return chunkSortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return result;
|
|
397
|
+
}, [chunkRows, chunkSearch, chunkKindFilter, chunkStatusFilter, chunkSortField, chunkSortDir]);
|
|
398
|
+
|
|
399
|
+
// Derived history list (filter + sort)
|
|
400
|
+
const displayedHistory = useMemo(() => {
|
|
401
|
+
let result = [...history];
|
|
402
|
+
if (historySearch.trim()) {
|
|
403
|
+
const q = historySearch.trim().toLowerCase();
|
|
404
|
+
result = result.filter((h) => (h.direction || '').toLowerCase().includes(q) || (h.status || '').toLowerCase().includes(q));
|
|
405
|
+
}
|
|
406
|
+
if (historySortField) {
|
|
407
|
+
result.sort((a, b) => {
|
|
408
|
+
const aVal = a[historySortField] ?? '';
|
|
409
|
+
const bVal = b[historySortField] ?? '';
|
|
410
|
+
if (typeof aVal === 'string') {
|
|
411
|
+
return historySortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
|
|
412
|
+
}
|
|
413
|
+
return historySortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return result;
|
|
417
|
+
}, [history, historySearch, historySortField, historySortDir]);
|
|
418
|
+
|
|
419
|
+
const handleChunkSort = (field) => {
|
|
420
|
+
if (chunkSortField === field) setChunkSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
421
|
+
else { setChunkSortField(field); setChunkSortDir('asc'); }
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const handleHistorySort = (field) => {
|
|
425
|
+
if (historySortField === field) setHistorySortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
426
|
+
else { setHistorySortField(field); setHistorySortDir('asc'); }
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const SortableTh = ({ field, onSort, sortF, sortD, style, children }) => (
|
|
430
|
+
<Th onClick={() => onSort(field)} style={{ cursor: 'pointer', userSelect: 'none', ...style }}>
|
|
431
|
+
<Flex alignItems="center" gap={1}>
|
|
432
|
+
<Typography variant="sigma">{children}</Typography>
|
|
433
|
+
{sortF === field && (sortD === 'asc' ? <CaretUp /> : <CaretDown />)}
|
|
434
|
+
</Flex>
|
|
435
|
+
</Th>
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
// Chunk kind options from current rows
|
|
439
|
+
const chunkKindOptions = useMemo(() => [...new Set(chunkRows.map((c) => c.kind).filter(Boolean))], [chunkRows]);
|
|
440
|
+
const chunkStatusOptions = useMemo(() => [...new Set(chunkRows.map((c) => c.status).filter(Boolean))], [chunkRows]);
|
|
441
|
+
|
|
357
442
|
const jobStats = useMemo(() => {
|
|
358
443
|
if (!job) return null;
|
|
359
444
|
const totals = (job.chunks || []).reduce(
|
|
@@ -605,6 +690,56 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
605
690
|
{job ? `Chunks (${job.cursor}/${job.total})` : `Planned Chunks (${preview?.total || chunkRows.length})`}
|
|
606
691
|
</Typography>
|
|
607
692
|
<Box paddingTop={2}>
|
|
693
|
+
{/* Chunk filter bar */}
|
|
694
|
+
<Flex gap={2} wrap="wrap" marginBottom={2} alignItems="flex-end">
|
|
695
|
+
<Box style={{ flex: '1 1 180px', minWidth: 150 }}>
|
|
696
|
+
<TextInput
|
|
697
|
+
placeholder="Search target or kind…"
|
|
698
|
+
value={chunkSearch}
|
|
699
|
+
onChange={(e) => setChunkSearch(e.target.value)}
|
|
700
|
+
label="Search"
|
|
701
|
+
size="S"
|
|
702
|
+
/>
|
|
703
|
+
</Box>
|
|
704
|
+
{chunkKindOptions.length > 1 && (
|
|
705
|
+
<Box style={{ minWidth: 130 }}>
|
|
706
|
+
<SingleSelect
|
|
707
|
+
placeholder="All kinds"
|
|
708
|
+
value={chunkKindFilter}
|
|
709
|
+
onChange={setChunkKindFilter}
|
|
710
|
+
onClear={() => setChunkKindFilter('')}
|
|
711
|
+
size="S"
|
|
712
|
+
label="Kind"
|
|
713
|
+
>
|
|
714
|
+
{chunkKindOptions.map((k) => (
|
|
715
|
+
<SingleSelectOption key={k} value={k}>{k}</SingleSelectOption>
|
|
716
|
+
))}
|
|
717
|
+
</SingleSelect>
|
|
718
|
+
</Box>
|
|
719
|
+
)}
|
|
720
|
+
{job && chunkStatusOptions.length > 1 && (
|
|
721
|
+
<Box style={{ minWidth: 130 }}>
|
|
722
|
+
<SingleSelect
|
|
723
|
+
placeholder="All statuses"
|
|
724
|
+
value={chunkStatusFilter}
|
|
725
|
+
onChange={setChunkStatusFilter}
|
|
726
|
+
onClear={() => setChunkStatusFilter('')}
|
|
727
|
+
size="S"
|
|
728
|
+
label="Status"
|
|
729
|
+
>
|
|
730
|
+
{chunkStatusOptions.map((s) => (
|
|
731
|
+
<SingleSelectOption key={s} value={s}>{s}</SingleSelectOption>
|
|
732
|
+
))}
|
|
733
|
+
</SingleSelect>
|
|
734
|
+
</Box>
|
|
735
|
+
)}
|
|
736
|
+
{(chunkSearch || chunkKindFilter || chunkStatusFilter) && (
|
|
737
|
+
<Button variant="tertiary" size="S" onClick={() => { setChunkSearch(''); setChunkKindFilter(''); setChunkStatusFilter(''); }}>
|
|
738
|
+
Clear
|
|
739
|
+
</Button>
|
|
740
|
+
)}
|
|
741
|
+
</Flex>
|
|
742
|
+
|
|
608
743
|
<Table>
|
|
609
744
|
<Thead>
|
|
610
745
|
<Tr>
|
|
@@ -618,17 +753,23 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
618
753
|
<Typography variant="sigma">Run</Typography>
|
|
619
754
|
)}
|
|
620
755
|
</Th>
|
|
621
|
-
<
|
|
622
|
-
<
|
|
623
|
-
<
|
|
624
|
-
<
|
|
756
|
+
<SortableTh field="index" onSort={handleChunkSort} sortF={chunkSortField} sortD={chunkSortDir} style={{ width: 60 }}>#</SortableTh>
|
|
757
|
+
<SortableTh field="kind" onSort={handleChunkSort} sortF={chunkSortField} sortD={chunkSortDir}>Kind</SortableTh>
|
|
758
|
+
<SortableTh field="label" onSort={handleChunkSort} sortF={chunkSortField} sortD={chunkSortDir}>Target</SortableTh>
|
|
759
|
+
<SortableTh field="status" onSort={handleChunkSort} sortF={chunkSortField} sortD={chunkSortDir}>Status</SortableTh>
|
|
625
760
|
<Th><Typography variant="sigma">Page</Typography></Th>
|
|
626
761
|
<Th><Typography variant="sigma">Pushed / Pulled</Typography></Th>
|
|
627
762
|
<Th><Typography variant="sigma">Notes</Typography></Th>
|
|
628
763
|
</Tr>
|
|
629
764
|
</Thead>
|
|
630
765
|
<Tbody>
|
|
631
|
-
{
|
|
766
|
+
{displayedChunks.length === 0 ? (
|
|
767
|
+
<Tr>
|
|
768
|
+
<Td colSpan={8}>
|
|
769
|
+
<Typography textColor="neutral500">No chunks match the current filters.</Typography>
|
|
770
|
+
</Td>
|
|
771
|
+
</Tr>
|
|
772
|
+
) : displayedChunks.map((c) => {
|
|
632
773
|
const pageLabel = c.pagesTotal
|
|
633
774
|
? `${c.page || 0}/${c.pagesTotal}`
|
|
634
775
|
: c.page
|
|
@@ -666,17 +807,17 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
666
807
|
<Td><Typography variant="pi">{pageLabel}</Typography></Td>
|
|
667
808
|
<Td><Typography variant="pi">{pushPullLabel}</Typography></Td>
|
|
668
809
|
<Td>
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
810
|
+
{c.error && <Typography textColor="danger600" variant="pi">{c.error}</Typography>}
|
|
811
|
+
{!c.error && c.warning && <Typography textColor="warning600" variant="pi">{c.warning}</Typography>}
|
|
812
|
+
</Td>
|
|
813
|
+
</Tr>
|
|
814
|
+
);
|
|
815
|
+
})}
|
|
816
|
+
</Tbody>
|
|
817
|
+
</Table>
|
|
818
|
+
</Box>
|
|
819
|
+
</Box>
|
|
820
|
+
)}
|
|
680
821
|
|
|
681
822
|
{job && job.status && job.status !== 'running' && (
|
|
682
823
|
<Box paddingTop={4}>
|
|
@@ -707,6 +848,24 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
707
848
|
Paused, cancelled, and completed runs are preserved here. Restart a run from scratch
|
|
708
849
|
using the same chunk selection, or load its selection into the Run Transfer tab to tweak.
|
|
709
850
|
</Typography>
|
|
851
|
+
|
|
852
|
+
{history.length > 0 && (
|
|
853
|
+
<Flex gap={2} wrap="wrap" marginTop={3} marginBottom={2} alignItems="flex-end">
|
|
854
|
+
<Box style={{ flex: '1 1 180px', minWidth: 150 }}>
|
|
855
|
+
<TextInput
|
|
856
|
+
placeholder="Search direction or status…"
|
|
857
|
+
value={historySearch}
|
|
858
|
+
onChange={(e) => setHistorySearch(e.target.value)}
|
|
859
|
+
label="Search"
|
|
860
|
+
size="S"
|
|
861
|
+
/>
|
|
862
|
+
</Box>
|
|
863
|
+
{historySearch && (
|
|
864
|
+
<Button variant="tertiary" size="S" onClick={() => setHistorySearch('')}>Clear</Button>
|
|
865
|
+
)}
|
|
866
|
+
</Flex>
|
|
867
|
+
)}
|
|
868
|
+
|
|
710
869
|
<Box paddingTop={2}>
|
|
711
870
|
{history.length === 0 ? (
|
|
712
871
|
<Typography variant="pi" textColor="neutral500">No previous runs yet.</Typography>
|
|
@@ -714,15 +873,21 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
|
|
|
714
873
|
<Table>
|
|
715
874
|
<Thead>
|
|
716
875
|
<Tr>
|
|
717
|
-
<
|
|
718
|
-
<
|
|
719
|
-
<
|
|
876
|
+
<SortableTh field="startedAt" onSort={handleHistorySort} sortF={historySortField} sortD={historySortDir}>When</SortableTh>
|
|
877
|
+
<SortableTh field="direction" onSort={handleHistorySort} sortF={historySortField} sortD={historySortDir}>Direction</SortableTh>
|
|
878
|
+
<SortableTh field="status" onSort={handleHistorySort} sortF={historySortField} sortD={historySortDir}>Status</SortableTh>
|
|
720
879
|
<Th><Typography variant="sigma">Chunks</Typography></Th>
|
|
721
880
|
<Th><Typography variant="sigma">Actions</Typography></Th>
|
|
722
881
|
</Tr>
|
|
723
882
|
</Thead>
|
|
724
883
|
<Tbody>
|
|
725
|
-
{
|
|
884
|
+
{displayedHistory.length === 0 ? (
|
|
885
|
+
<Tr>
|
|
886
|
+
<Td colSpan={5}>
|
|
887
|
+
<Typography textColor="neutral500">No runs match the search.</Typography>
|
|
888
|
+
</Td>
|
|
889
|
+
</Tr>
|
|
890
|
+
) : displayedHistory.map((h) => {
|
|
726
891
|
const selCount = (h.chunks || []).filter((c) => c.selected !== false).length;
|
|
727
892
|
const doneCount = (h.chunks || []).filter(
|
|
728
893
|
(c) => c.status === 'success' || c.status === 'partial'
|
|
@@ -143,6 +143,38 @@ const ConfigTab = () => {
|
|
|
143
143
|
}
|
|
144
144
|
};
|
|
145
145
|
|
|
146
|
+
// Validate + normalize a Strapi Server URL entered by the user.
|
|
147
|
+
// Returns { ok: true, normalized } or { ok: false, error }.
|
|
148
|
+
const validateBaseUrl = (raw) => {
|
|
149
|
+
if (!raw || !raw.trim()) {
|
|
150
|
+
return { ok: false, error: 'Server URL is required' };
|
|
151
|
+
}
|
|
152
|
+
let url = raw.trim();
|
|
153
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: 'Server URL must start with http:// or https:// (e.g. http://localhost:4010)',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// Strip trailing slashes and common mistakes like /admin or /api
|
|
160
|
+
url = url.replace(/\/+$/, '').replace(/\/admin$/i, '').replace(/\/api$/i, '');
|
|
161
|
+
try {
|
|
162
|
+
const parsed = new URL(url);
|
|
163
|
+
if (!parsed.hostname) {
|
|
164
|
+
return { ok: false, error: 'Server URL is missing a hostname' };
|
|
165
|
+
}
|
|
166
|
+
if (parsed.pathname && parsed.pathname !== '/' && parsed.pathname !== '') {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: `Server URL should be the Strapi root (e.g. http://localhost:4010), not include a path like "${parsed.pathname}"`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
return { ok: false, error: 'Server URL is not a valid URL' };
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, normalized: url };
|
|
176
|
+
};
|
|
177
|
+
|
|
146
178
|
// Login with credentials to remote server and get/create API token
|
|
147
179
|
const handleLoginWithCredentials = async () => {
|
|
148
180
|
if (!config.baseUrl || !credentials.email || !credentials.password) {
|
|
@@ -150,12 +182,22 @@ const ConfigTab = () => {
|
|
|
150
182
|
return;
|
|
151
183
|
}
|
|
152
184
|
|
|
185
|
+
const urlCheck = validateBaseUrl(config.baseUrl);
|
|
186
|
+
if (!urlCheck.ok) {
|
|
187
|
+
setLoginState({ loading: false, success: false, error: urlCheck.error });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// Auto-correct the stored value if we had to normalize it (e.g. trim slash or /admin)
|
|
191
|
+
if (urlCheck.normalized !== config.baseUrl) {
|
|
192
|
+
setConfig((prev) => ({ ...prev, baseUrl: urlCheck.normalized }));
|
|
193
|
+
}
|
|
194
|
+
|
|
153
195
|
setLoginState({ loading: true, success: false, error: null });
|
|
154
196
|
|
|
155
197
|
try {
|
|
156
198
|
// Call our backend to proxy the login request
|
|
157
199
|
const response = await post(`/${PLUGIN_ID}/config/remote-login`, {
|
|
158
|
-
baseUrl:
|
|
200
|
+
baseUrl: urlCheck.normalized,
|
|
159
201
|
email: credentials.email,
|
|
160
202
|
password: credentials.password,
|
|
161
203
|
});
|
|
@@ -368,10 +410,46 @@ const ConfigTab = () => {
|
|
|
368
410
|
placeholder="https://my-other-strapi.com"
|
|
369
411
|
value={config.baseUrl}
|
|
370
412
|
onChange={(e) => setConfig((p) => ({ ...p, baseUrl: e.target.value }))}
|
|
413
|
+
onBlur={(e) => {
|
|
414
|
+
const v = validateBaseUrl(e.target.value);
|
|
415
|
+
if (v.ok && v.normalized !== e.target.value) {
|
|
416
|
+
setConfig((p) => ({ ...p, baseUrl: v.normalized }));
|
|
417
|
+
}
|
|
418
|
+
}}
|
|
371
419
|
/>
|
|
372
|
-
<Field.Hint>URL of the remote Strapi server where this plugin is also installed
|
|
420
|
+
<Field.Hint>URL of the remote Strapi server where this plugin is also installed. Use the root URL only (e.g. http://localhost:4010) — do not append /admin or /api.</Field.Hint>
|
|
421
|
+
{config.baseUrl && (() => {
|
|
422
|
+
const v = validateBaseUrl(config.baseUrl);
|
|
423
|
+
return v.ok ? null : (
|
|
424
|
+
<Box paddingTop={1}>
|
|
425
|
+
<Typography variant="pi" textColor="danger600">
|
|
426
|
+
{v.error}
|
|
427
|
+
</Typography>
|
|
428
|
+
</Box>
|
|
429
|
+
);
|
|
430
|
+
})()}
|
|
373
431
|
</Field.Root>
|
|
374
432
|
|
|
433
|
+
<Box>
|
|
434
|
+
<Alert variant="default" title="Before you continue">
|
|
435
|
+
<Box>
|
|
436
|
+
<Typography variant="pi">
|
|
437
|
+
• <strong>Server URL</strong> must be the Strapi root (e.g. <code>http://localhost:4010</code>) — not <code>/admin</code>, <code>/api</code>, or an internal IP the server can't see.
|
|
438
|
+
</Typography>
|
|
439
|
+
</Box>
|
|
440
|
+
<Box paddingTop={1}>
|
|
441
|
+
<Typography variant="pi">
|
|
442
|
+
• Use an <strong>existing admin user's</strong> email and password for the remote Strapi panel. This is not an API token and not a local DB user.
|
|
443
|
+
</Typography>
|
|
444
|
+
</Box>
|
|
445
|
+
<Box paddingTop={1}>
|
|
446
|
+
<Typography variant="pi">
|
|
447
|
+
• If you see "Missing or invalid credentials", verify the URL first, then the email/password. A wrong URL often surfaces as an auth error.
|
|
448
|
+
</Typography>
|
|
449
|
+
</Box>
|
|
450
|
+
</Alert>
|
|
451
|
+
</Box>
|
|
452
|
+
|
|
375
453
|
<Field.Root>
|
|
376
454
|
<Field.Label>API Token</Field.Label>
|
|
377
455
|
<Flex gap={2}>
|
|
@@ -391,7 +469,7 @@ const ConfigTab = () => {
|
|
|
391
469
|
<Button
|
|
392
470
|
variant="secondary"
|
|
393
471
|
onClick={() => setShowLoginModal(true)}
|
|
394
|
-
disabled={!config.baseUrl}
|
|
472
|
+
disabled={!config.baseUrl || !validateBaseUrl(config.baseUrl).ok}
|
|
395
473
|
>
|
|
396
474
|
{config.apiToken ? 'Regenerate' : 'Generate'}
|
|
397
475
|
</Button>
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
Alert,
|
|
8
8
|
Switch,
|
|
9
9
|
Badge,
|
|
10
|
+
TextInput,
|
|
10
11
|
} from '@strapi/design-system';
|
|
11
12
|
import { useFetchClient } from '@strapi/strapi/admin';
|
|
12
13
|
|
|
@@ -20,6 +21,7 @@ const ContentTypesTab = () => {
|
|
|
20
21
|
const [profiles, setProfiles] = useState([]);
|
|
21
22
|
const [loading, setLoading] = useState(true);
|
|
22
23
|
const [message, setMessage] = useState(null);
|
|
24
|
+
const [search, setSearch] = useState('');
|
|
23
25
|
|
|
24
26
|
useEffect(() => {
|
|
25
27
|
loadData();
|
|
@@ -110,8 +112,25 @@ const ContentTypesTab = () => {
|
|
|
110
112
|
</Box>
|
|
111
113
|
)}
|
|
112
114
|
|
|
115
|
+
<Box paddingBottom={4}>
|
|
116
|
+
<TextInput
|
|
117
|
+
placeholder="Search by name or UID…"
|
|
118
|
+
value={search}
|
|
119
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
120
|
+
label="Search"
|
|
121
|
+
size="S"
|
|
122
|
+
style={{ maxWidth: 320 }}
|
|
123
|
+
/>
|
|
124
|
+
</Box>
|
|
125
|
+
|
|
113
126
|
<Box>
|
|
114
|
-
{contentTypes
|
|
127
|
+
{contentTypes
|
|
128
|
+
.filter((ct) => {
|
|
129
|
+
if (!search.trim()) return true;
|
|
130
|
+
const q = search.trim().toLowerCase();
|
|
131
|
+
return (ct.displayName || '').toLowerCase().includes(q) || ct.uid.toLowerCase().includes(q);
|
|
132
|
+
})
|
|
133
|
+
.map((ct) => {
|
|
115
134
|
const enabled = isEnabled(ct.uid);
|
|
116
135
|
const activeProfile = getActiveProfile(ct.uid);
|
|
117
136
|
const profileCount = getProfileCount(ct.uid);
|
|
@@ -151,6 +170,14 @@ const ContentTypesTab = () => {
|
|
|
151
170
|
</Box>
|
|
152
171
|
);
|
|
153
172
|
})}
|
|
173
|
+
{contentTypes.length > 0 && search.trim() && contentTypes.filter((ct) => {
|
|
174
|
+
const q = search.trim().toLowerCase();
|
|
175
|
+
return (ct.displayName || '').toLowerCase().includes(q) || ct.uid.toLowerCase().includes(q);
|
|
176
|
+
}).length === 0 && (
|
|
177
|
+
<Box padding={4} background="neutral0" hasRadius>
|
|
178
|
+
<Typography textColor="neutral500">No content types match the search.</Typography>
|
|
179
|
+
</Box>
|
|
180
|
+
)}
|
|
154
181
|
</Box>
|
|
155
182
|
</Box>
|
|
156
183
|
);
|
|
@@ -840,6 +840,40 @@ http://localhost:1337</CodeBlock>
|
|
|
840
840
|
</Typography>
|
|
841
841
|
</HelpSection>
|
|
842
842
|
|
|
843
|
+
<HelpSection title="Live status, Pause, Resume, and Stop">
|
|
844
|
+
<Typography variant="omega">
|
|
845
|
+
Media sync runs can take a long time. The Media tab shows live state for any profile
|
|
846
|
+
that is currently running or paused and automatically polls status every 2 seconds
|
|
847
|
+
while work is in progress, so you can navigate away and come back without losing
|
|
848
|
+
visibility. The Status sub-tab shows the active phase (for example
|
|
849
|
+
<code> listing</code>, <code>pushing</code>, <code>pulling</code>) and live counters
|
|
850
|
+
for <code>pushed</code>, <code>pulled</code>, <code>skipped</code>, and
|
|
851
|
+
<code> errors</code>.
|
|
852
|
+
</Typography>
|
|
853
|
+
<ul style={{ paddingLeft: '20px', marginTop: '8px', lineHeight: '1.8' }}>
|
|
854
|
+
<li><Typography variant="omega"><strong>Run</strong> - starts the profile as a background job; the UI flips to Running immediately and keeps polling.</Typography></li>
|
|
855
|
+
<li><Typography variant="omega"><strong>Pause</strong> - requests a cooperative halt at the next checkpoint (between pages/batches). Already in-flight file transfers finish first.</Typography></li>
|
|
856
|
+
<li><Typography variant="omega"><strong>Resume</strong> - continues a paused run from where it stopped without redoing completed work.</Typography></li>
|
|
857
|
+
<li><Typography variant="omega"><strong>Stop</strong> - cancels the run at the next checkpoint. Work already synced is kept; remaining items are skipped.</Typography></li>
|
|
858
|
+
</ul>
|
|
859
|
+
<Typography variant="omega" paddingTop={2}>
|
|
860
|
+
Corresponding endpoints:
|
|
861
|
+
</Typography>
|
|
862
|
+
<ul style={{ paddingLeft: '20px', marginTop: '8px', lineHeight: '1.8' }}>
|
|
863
|
+
<li><code>POST /api/strapi-content-sync-pro/media-sync/profiles/:id/pause</code></li>
|
|
864
|
+
<li><code>POST /api/strapi-content-sync-pro/media-sync/profiles/:id/resume</code></li>
|
|
865
|
+
<li><code>POST /api/strapi-content-sync-pro/media-sync/profiles/:id/cancel</code></li>
|
|
866
|
+
<li><code>GET /api/strapi-content-sync-pro/media-sync/status</code> (returns <code>running</code>, <code>paused</code>, and per-profile <code>progress</code>)</li>
|
|
867
|
+
</ul>
|
|
868
|
+
<Typography variant="pi" textColor="warning600" paddingTop={2}>
|
|
869
|
+
<strong>Note:</strong> Pause/Resume/Stop are implemented for the <strong>URL</strong>
|
|
870
|
+
strategy, which is the default and most common choice. The <strong>rsync</strong>
|
|
871
|
+
strategy runs as a single child process and cannot be paused mid-flight; for rsync
|
|
872
|
+
profiles these controls are a no-op until the current rsync invocation finishes on
|
|
873
|
+
its own.
|
|
874
|
+
</Typography>
|
|
875
|
+
</HelpSection>
|
|
876
|
+
|
|
843
877
|
<HelpSection title="Dry run & testing">
|
|
844
878
|
<Typography variant="omega">
|
|
845
879
|
Toggle <strong>Dry run</strong> on a profile to list what would change without transferring any
|