plugin-migration-manager 2.0.0
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/INSTALL.md +100 -0
- package/LICENSE +201 -0
- package/Migration_Manager_Documentation_1.0.pdf +0 -0
- package/README.md +30 -0
- package/client.d.ts +2 -0
- package/client.js +1 -0
- package/dist/client/bf902aacdcc9b8b8.js +10 -0
- package/dist/client/index.js +10 -0
- package/dist/externalVersion.js +18 -0
- package/dist/index.js +48 -0
- package/dist/locale/en-US.json +8 -0
- package/dist/locale/id-ID.json +8 -0
- package/dist/server/controllers/migration.js +1161 -0
- package/dist/server/index.js +70 -0
- package/package.json +23 -0
- package/plugin-migration-manager-2.0.0.tgz +0 -0
- package/server.d.ts +2 -0
- package/server.js +1 -0
- package/src/client/index.tsx +18 -0
- package/src/client/pages/MigrationPage.tsx +565 -0
- package/src/index.ts +2 -0
- package/src/locale/en-US.json +8 -0
- package/src/locale/id-ID.json +8 -0
- package/src/server/controllers/migration.ts +1217 -0
- package/src/server/index.ts +34 -0
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
import { Context } from '@nocobase/actions';
|
|
2
|
+
|
|
3
|
+
type Id = string | number;
|
|
4
|
+
|
|
5
|
+
export interface ExportBody {
|
|
6
|
+
collections?: string[];
|
|
7
|
+
workflows?: Id[];
|
|
8
|
+
uiSchemas?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FieldRow {
|
|
12
|
+
name: string;
|
|
13
|
+
type?: string;
|
|
14
|
+
interface?: string;
|
|
15
|
+
options?: any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CollectionBundle {
|
|
19
|
+
name: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
primaryKey?: string;
|
|
22
|
+
fields?: FieldRow[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function asArray<T = any>(v: unknown): T[] {
|
|
26
|
+
return Array.isArray(v) ? (v as T[]) : [];
|
|
27
|
+
}
|
|
28
|
+
function isPlainObject(v: any) {
|
|
29
|
+
return v && typeof v === 'object' && v.constructor === Object;
|
|
30
|
+
}
|
|
31
|
+
function isEmptyObject(v: any) {
|
|
32
|
+
return isPlainObject(v) && Object.keys(v).length === 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type AnyObj = Record<string, any>;
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
type RouteType = 'page' | 'tabs' | 'menu' | 'group' | 'link' | string
|
|
40
|
+
|
|
41
|
+
interface RouteBundle {
|
|
42
|
+
title?: string | null
|
|
43
|
+
tooltip?: string | null
|
|
44
|
+
icon?: string | null
|
|
45
|
+
schemaUid: string | null
|
|
46
|
+
menuSchemaUid?: string | null
|
|
47
|
+
tabSchemaName?: string | null
|
|
48
|
+
type?: RouteType | null
|
|
49
|
+
options?: unknown
|
|
50
|
+
sort?: number | null
|
|
51
|
+
hideInMenu?: boolean | null
|
|
52
|
+
enableTabs?: boolean | null
|
|
53
|
+
enableHeader?: boolean | null
|
|
54
|
+
displayTitle?: string | null
|
|
55
|
+
hidden?: boolean | null
|
|
56
|
+
routeRef?: number | string | null
|
|
57
|
+
parentRef?: number | string | null
|
|
58
|
+
children?: RouteBundle[]
|
|
59
|
+
[k: string]: unknown
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type AnyRow = Record<string, any>;
|
|
63
|
+
|
|
64
|
+
function pickRouteFields(r: AnyRow) {
|
|
65
|
+
return {
|
|
66
|
+
title: r.title ?? null,
|
|
67
|
+
tooltip: r.tooltip ?? null,
|
|
68
|
+
icon: r.icon ?? null,
|
|
69
|
+
schemaUid: r.schemaUid ?? null,
|
|
70
|
+
menuSchemaUid: r.menuSchemaUid ?? null,
|
|
71
|
+
tabSchemaName: r.tabSchemaName ?? null,
|
|
72
|
+
type: r.type ?? null,
|
|
73
|
+
options: r.options ?? null,
|
|
74
|
+
sort: r.sort ?? null,
|
|
75
|
+
hideInMenu: r.hideInMenu ?? null,
|
|
76
|
+
enableTabs: r.enableTabs ?? null,
|
|
77
|
+
enableHeader: r.enableHeader ?? null,
|
|
78
|
+
displayTitle: r.displayTitle ?? null,
|
|
79
|
+
hidden: r.hidden ?? null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class MigrationController {
|
|
84
|
+
static async export(ctx: Context) {
|
|
85
|
+
const actionParams: any = ctx.action?.params || {};
|
|
86
|
+
const reqBody: any = ctx.request.body || {};
|
|
87
|
+
const requestData: any =
|
|
88
|
+
(actionParams.data && Object.keys(actionParams.data).length ? actionParams.data : undefined) ||
|
|
89
|
+
(reqBody.data && Object.keys(reqBody.data).length ? reqBody.data : undefined) ||
|
|
90
|
+
(actionParams.values && Object.keys(actionParams.values).length ? actionParams.values : undefined) ||
|
|
91
|
+
(Object.keys(reqBody).length ? reqBody : undefined) ||
|
|
92
|
+
{};
|
|
93
|
+
|
|
94
|
+
const collections = asArray<string>(requestData.collections);
|
|
95
|
+
const workflows = asArray<Id>(requestData.workflows);
|
|
96
|
+
const uiSchemasInput = asArray<any>(requestData.uiSchemas);
|
|
97
|
+
const routesInput = asArray<any>(requestData.desktopRoutes || requestData.routes);
|
|
98
|
+
|
|
99
|
+
const db: any = ctx.db;
|
|
100
|
+
const app: any = ctx.app;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const collectionsRepo = db.getRepository('collections');
|
|
104
|
+
const fieldsRepo = db.getRepository('fields');
|
|
105
|
+
const workflowRepo = db.getRepository('workflows');
|
|
106
|
+
const routeRepo = db.getRepository('desktopRoutes');
|
|
107
|
+
const uiSchemaRepo = db.getRepository('uiSchemas');
|
|
108
|
+
|
|
109
|
+
const payload = {
|
|
110
|
+
version: typeof app?.version === 'string' ? app.version : 'unknown',
|
|
111
|
+
exportDate: new Date().toISOString(),
|
|
112
|
+
collections: [] as any[],
|
|
113
|
+
workflows: [] as any[],
|
|
114
|
+
uiSchemas: [] as any[],
|
|
115
|
+
desktopRoutes: [] as any[],
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
for (const name of collections) {
|
|
119
|
+
try {
|
|
120
|
+
const colMeta = await collectionsRepo.findOne({ filter: { name } });
|
|
121
|
+
if (!colMeta) continue;
|
|
122
|
+
const primaryKey = colMeta.get('primaryKey') || 'id';
|
|
123
|
+
const title = colMeta.get('title') || name;
|
|
124
|
+
const template = colMeta.get('template') || 'general';
|
|
125
|
+
const runtimeCol = (() => { try { return db.getCollection(name); } catch { return null; } })();
|
|
126
|
+
const fieldRows = await fieldsRepo.find({ filter: { collectionName: name } });
|
|
127
|
+
const fields: FieldRow[] = (fieldRows || []).map((r: any) => {
|
|
128
|
+
const row = r.get ? r.get() : r;
|
|
129
|
+
let options = row.options ?? {};
|
|
130
|
+
if (isEmptyObject(options) && runtimeCol) {
|
|
131
|
+
try {
|
|
132
|
+
const rf = runtimeCol.getField(row.name);
|
|
133
|
+
if (rf?.options && !isEmptyObject(rf.options)) options = rf.options;
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
return { name: row.name, type: row.type, interface: row.interface, options };
|
|
137
|
+
});
|
|
138
|
+
payload.collections.push({ name, title, primaryKey, template, fields } as CollectionBundle);
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const id of workflows) {
|
|
143
|
+
try {
|
|
144
|
+
const wRow = await workflowRepo.findOne({ filter: { id }, appends: ['nodes'] });
|
|
145
|
+
if (!wRow) continue;
|
|
146
|
+
const w = wRow.get ? wRow.get() : wRow;
|
|
147
|
+
const idToKey = new Map<number, string>();
|
|
148
|
+
for (const n of (w.nodes || [])) idToKey.set(n.id, n.key);
|
|
149
|
+
const nodes = (w.nodes || []).map((n: any) => ({
|
|
150
|
+
key: n.key,
|
|
151
|
+
type: n.type,
|
|
152
|
+
title: n.title ?? null,
|
|
153
|
+
upstreamKey: n.upstreamId ? idToKey.get(n.upstreamId) ?? null : null,
|
|
154
|
+
downstreamKey: n.downstreamId ? idToKey.get(n.downstreamId) ?? null : null,
|
|
155
|
+
branchIndex: n.branchIndex ?? null,
|
|
156
|
+
config: n.config ?? {},
|
|
157
|
+
}));
|
|
158
|
+
payload.workflows.push({
|
|
159
|
+
key: w.key,
|
|
160
|
+
title: w.title ?? null,
|
|
161
|
+
description: w.description ?? null,
|
|
162
|
+
type: w.type,
|
|
163
|
+
sync: !!w.sync,
|
|
164
|
+
current: true,
|
|
165
|
+
triggerTitle: w.triggerTitle ?? null,
|
|
166
|
+
options: w.options ?? {},
|
|
167
|
+
config: w.config ?? {},
|
|
168
|
+
nodes,
|
|
169
|
+
});
|
|
170
|
+
} catch {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const getCompleteSchemaTree = async (uid: string) => {
|
|
174
|
+
try {
|
|
175
|
+
const schema = await uiSchemaRepo.getJsonSchema(uid);
|
|
176
|
+
return schema || null;
|
|
177
|
+
} catch { return null; }
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const pushedUids = new Set<string>();
|
|
181
|
+
const pushSchemaPayload = async (uid: string, sourceType: string) => {
|
|
182
|
+
if (!uid || pushedUids.has(uid)) return;
|
|
183
|
+
const schema = await getCompleteSchemaTree(uid);
|
|
184
|
+
if (schema) {
|
|
185
|
+
payload.uiSchemas.push({ mode: 'complete', rootUid: uid, sourceType, data: schema });
|
|
186
|
+
pushedUids.add(uid);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
for (const item of uiSchemasInput) {
|
|
191
|
+
try {
|
|
192
|
+
if (typeof item === 'string') { await pushSchemaPayload(item, 'direct'); continue; }
|
|
193
|
+
if (isPlainObject(item) && item.uid) { await pushSchemaPayload(String(item.uid), 'direct'); continue; }
|
|
194
|
+
} catch {}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const allRoutesRaw = await routeRepo.find();
|
|
198
|
+
const allRoutes = (allRoutesRaw || []).map((x: any) => (x.get ? x.get() : x));
|
|
199
|
+
|
|
200
|
+
const byId = new Map<number, any>();
|
|
201
|
+
const childrenMap = new Map<number | null, any[]>();
|
|
202
|
+
for (const r of allRoutes) {
|
|
203
|
+
byId.set(r.id, r);
|
|
204
|
+
const pid = r.parentId ?? null;
|
|
205
|
+
if (!childrenMap.has(pid)) childrenMap.set(pid, []);
|
|
206
|
+
childrenMap.get(pid)!.push(r);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const selectedRoots: any[] = [];
|
|
210
|
+
for (const r of routesInput) {
|
|
211
|
+
let row: any = null;
|
|
212
|
+
if (typeof r === 'number' || /^\d+$/.test(String(r))) {
|
|
213
|
+
row = await routeRepo.findOne({ filter: { id: Number(r) } });
|
|
214
|
+
row = row ? (row.get ? row.get() : row) : null;
|
|
215
|
+
} else if (isPlainObject(r) && (r.id || r.routeId)) {
|
|
216
|
+
const rid = Number(r.id ?? r.routeId);
|
|
217
|
+
row = byId.get(rid) || null;
|
|
218
|
+
} else if (typeof r === 'string') {
|
|
219
|
+
row = allRoutes.find((x: any) => x.schemaUid === r) || null;
|
|
220
|
+
}
|
|
221
|
+
if (row) selectedRoots.push(row);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const buildTree = async (node: any, parentType: string | null): Promise<any | null> => {
|
|
225
|
+
const t = String(node.type || '').toLowerCase();
|
|
226
|
+
|
|
227
|
+
if (t === 'tabs') {
|
|
228
|
+
if (parentType === 'page') {
|
|
229
|
+
if (node.schemaUid) await pushSchemaPayload(String(node.schemaUid), 'tabs');
|
|
230
|
+
return {
|
|
231
|
+
type: 'tabs',
|
|
232
|
+
schemaUid: node.schemaUid ?? null,
|
|
233
|
+
tabSchemaName: node.tabSchemaName ?? null,
|
|
234
|
+
hidden: !!node.hidden,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const entry: any = pickRouteFields(node);
|
|
241
|
+
if (node.schemaUid) {
|
|
242
|
+
await pushSchemaPayload(String(node.schemaUid), String(node.type || 'route'));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const kids = childrenMap.get(node.id) || [];
|
|
246
|
+
const tabKids = kids.filter((k: any) => String(k.type || '').toLowerCase() === 'tabs');
|
|
247
|
+
const otherKids = kids.filter((k: any) => String(k.type || '').toLowerCase() !== 'tabs');
|
|
248
|
+
|
|
249
|
+
entry.children = [];
|
|
250
|
+
if (t === 'page' && tabKids.length) {
|
|
251
|
+
for (const tk of tabKids) {
|
|
252
|
+
const packed = await buildTree(tk, 'page');
|
|
253
|
+
if (packed) entry.children.push(packed);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const ok of otherKids) {
|
|
257
|
+
const childEntry = await buildTree(ok, t);
|
|
258
|
+
if (childEntry) entry.children.push(childEntry);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return entry;
|
|
262
|
+
};
|
|
263
|
+
for (const root of selectedRoots) {
|
|
264
|
+
const built = await buildTree(root, null);
|
|
265
|
+
if (built) payload.desktopRoutes.push(built);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
ctx.status = 200;
|
|
270
|
+
ctx.type = 'application/json';
|
|
271
|
+
ctx.body = { success: true, data: payload };
|
|
272
|
+
} catch (err: any) {
|
|
273
|
+
ctx.status = 500;
|
|
274
|
+
ctx.type = 'application/json';
|
|
275
|
+
ctx.body = { success: false, message: String(err.message || 'Export failed'), stack: err.stack };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
static async import(ctx: Context) {
|
|
280
|
+
const actionParams: any = ctx.action?.params || {};
|
|
281
|
+
const app: any = (ctx as any).app;
|
|
282
|
+
const reqBody: any = ctx.request.body || {};
|
|
283
|
+
let importData: any =
|
|
284
|
+
(actionParams.data && Object.keys(actionParams.data).length ? actionParams.data : undefined) ||
|
|
285
|
+
(reqBody.data && Object.keys(reqBody.data).length ? reqBody.data : undefined) ||
|
|
286
|
+
(actionParams.values && Object.keys(actionParams.values).length ? actionParams.values : undefined) ||
|
|
287
|
+
reqBody;
|
|
288
|
+
|
|
289
|
+
const collections = asArray(importData?.collections);
|
|
290
|
+
const workflows = asArray(importData?.workflows);
|
|
291
|
+
const uiSchemas = asArray(importData?.uiSchemas);
|
|
292
|
+
const desktopRoutes = asArray(importData?.desktopRoutes || importData?.routes);
|
|
293
|
+
const options = isPlainObject(importData?.options) ? importData.options : {};
|
|
294
|
+
const preview = !!options.preview;
|
|
295
|
+
const overwrite = !!options.overwrite;
|
|
296
|
+
|
|
297
|
+
const db: any = ctx.db;
|
|
298
|
+
|
|
299
|
+
const results: any = {
|
|
300
|
+
collections: { success: 0, failed: 0, skipped: 0, updated: 0, errors: [] as any[], pendingCreates: [] as any[] },
|
|
301
|
+
workflows: { success: 0, failed: 0, skipped: 0, updated: 0, errors: [] as any[], pendingCreates: [] as any[] },
|
|
302
|
+
uiSchemas: { success: 0, failed: 0, skipped: 0, updated: 0, errors: [] as any[], pendingCreates: [] as any[] },
|
|
303
|
+
desktopRoutes: { success: 0, failed: 0, skipped: 0, updated: 0, errors: [] as any[], pendingCreates: [] as any[] },
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const collectionsRepo = db.getRepository('collections');
|
|
308
|
+
const fieldsRepo = db.getRepository('fields');
|
|
309
|
+
|
|
310
|
+
for (const colConfig of collections) {
|
|
311
|
+
const collectionName = colConfig.name;
|
|
312
|
+
try {
|
|
313
|
+
const existingColMeta = await collectionsRepo.findOne({ filter: { name: collectionName } });
|
|
314
|
+
if (!existingColMeta) {
|
|
315
|
+
if (preview) {
|
|
316
|
+
results.collections.pendingCreates.push({
|
|
317
|
+
collection: collectionName,
|
|
318
|
+
title: colConfig.title || collectionName,
|
|
319
|
+
fieldsCount: (colConfig.fields || []).length,
|
|
320
|
+
});
|
|
321
|
+
} else {
|
|
322
|
+
await collectionsRepo.create({
|
|
323
|
+
values: {
|
|
324
|
+
name: collectionName,
|
|
325
|
+
title: colConfig.title || collectionName,
|
|
326
|
+
template: colConfig.template || 'general',
|
|
327
|
+
logging: true,
|
|
328
|
+
autoGenId: true,
|
|
329
|
+
createdBy: true,
|
|
330
|
+
updatedBy: true,
|
|
331
|
+
createdAt: true,
|
|
332
|
+
updatedAt: true,
|
|
333
|
+
sortable: true,
|
|
334
|
+
primaryKey: colConfig.primaryKey || 'id',
|
|
335
|
+
dataSource: 'main',
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
for (const f of colConfig.fields || []) {
|
|
339
|
+
await fieldsRepo.create({
|
|
340
|
+
values: {
|
|
341
|
+
collectionName,
|
|
342
|
+
name: f.name,
|
|
343
|
+
type: f.type || 'string',
|
|
344
|
+
interface: f.interface || 'input',
|
|
345
|
+
dataSource: 'main',
|
|
346
|
+
...(f.options || {}),
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
results.collections.success++;
|
|
352
|
+
} else {
|
|
353
|
+
for (const fieldCfg of colConfig.fields || []) {
|
|
354
|
+
const existingField = await fieldsRepo.findOne({ filter: { name: fieldCfg.name, collectionName } });
|
|
355
|
+
if (!existingField && !preview) {
|
|
356
|
+
await fieldsRepo.create({
|
|
357
|
+
values: {
|
|
358
|
+
name: fieldCfg.name,
|
|
359
|
+
type: fieldCfg.type || 'string',
|
|
360
|
+
interface: fieldCfg.interface || 'input',
|
|
361
|
+
collectionName,
|
|
362
|
+
dataSource: 'main',
|
|
363
|
+
...(fieldCfg.options || {}),
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
} else if (existingField && overwrite && !preview) {
|
|
367
|
+
await fieldsRepo.update({
|
|
368
|
+
filterByTk: existingField.get('id'),
|
|
369
|
+
values: {
|
|
370
|
+
type: fieldCfg.type || existingField.get('type'),
|
|
371
|
+
interface: fieldCfg.interface || existingField.get('interface'),
|
|
372
|
+
...(fieldCfg.options || {}),
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
results.collections.updated++;
|
|
376
|
+
} else if (!existingField && preview) {
|
|
377
|
+
results.collections.pendingCreates.push({ collection: collectionName, field: fieldCfg.name });
|
|
378
|
+
} else {
|
|
379
|
+
results.collections.skipped++;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
results.collections.success++;
|
|
383
|
+
}
|
|
384
|
+
} catch (err: any) {
|
|
385
|
+
results.collections.failed++;
|
|
386
|
+
results.collections.errors.push({ collection: collectionName, error: String(err.message || err) });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const workflowRepo = db.getRepository('workflows');
|
|
391
|
+
let flowNodeRepo: any = null;
|
|
392
|
+
try { flowNodeRepo = db.getRepository('flowNodes'); } catch {}
|
|
393
|
+
if (!flowNodeRepo) { try { flowNodeRepo = db.getRepository('flow_nodes'); } catch {} }
|
|
394
|
+
if (!flowNodeRepo) { try { flowNodeRepo = db.getRepository('nodes'); } catch {} }
|
|
395
|
+
if (!flowNodeRepo) throw new Error('flowNodes repository not found');
|
|
396
|
+
|
|
397
|
+
for (const wf of workflows) {
|
|
398
|
+
try {
|
|
399
|
+
if (!wf || !wf.title || !wf.type) {
|
|
400
|
+
results.workflows.skipped++;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let existingWorkflow = null;
|
|
405
|
+
|
|
406
|
+
if (wf.key) {
|
|
407
|
+
existingWorkflow = await workflowRepo.findOne({
|
|
408
|
+
filter: { key: wf.key },
|
|
409
|
+
appends: ['nodes']
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!existingWorkflow) {
|
|
414
|
+
const allWorkflows = await workflowRepo.find({
|
|
415
|
+
filter: {
|
|
416
|
+
title: wf.title,
|
|
417
|
+
type: wf.type
|
|
418
|
+
},
|
|
419
|
+
appends: ['nodes']
|
|
420
|
+
});
|
|
421
|
+
if (allWorkflows && allWorkflows.length > 0) {
|
|
422
|
+
existingWorkflow = allWorkflows[0];
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (existingWorkflow && !overwrite) {
|
|
427
|
+
results.workflows.skipped++;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (preview) {
|
|
432
|
+
results.workflows.pendingCreates.push({
|
|
433
|
+
key: wf.key || '(auto)',
|
|
434
|
+
title: wf.title,
|
|
435
|
+
action: existingWorkflow ? 'update' : 'create'
|
|
436
|
+
});
|
|
437
|
+
results.workflows.success++;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
let workflowId: number;
|
|
442
|
+
|
|
443
|
+
if (existingWorkflow && overwrite) {
|
|
444
|
+
await workflowRepo.update({
|
|
445
|
+
filterByTk: existingWorkflow.get('id'),
|
|
446
|
+
values: {
|
|
447
|
+
title: wf.title,
|
|
448
|
+
type: wf.type,
|
|
449
|
+
sync: !!wf.sync,
|
|
450
|
+
description: wf.description ?? null,
|
|
451
|
+
triggerTitle: wf.triggerTitle ?? null,
|
|
452
|
+
options: wf.options ?? {},
|
|
453
|
+
config: wf.config ?? {},
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
workflowId = existingWorkflow.get('id');
|
|
457
|
+
|
|
458
|
+
const oldNodes = existingWorkflow.get('nodes') || [];
|
|
459
|
+
for (const oldNode of oldNodes) {
|
|
460
|
+
await flowNodeRepo.destroy({ filterByTk: oldNode.id });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
results.workflows.updated++;
|
|
464
|
+
} else {
|
|
465
|
+
const createdW = await workflowRepo.create({
|
|
466
|
+
values: {
|
|
467
|
+
current: true,
|
|
468
|
+
title: wf.title,
|
|
469
|
+
type: wf.type,
|
|
470
|
+
sync: !!wf.sync,
|
|
471
|
+
description: wf.description ?? null,
|
|
472
|
+
triggerTitle: wf.triggerTitle ?? null,
|
|
473
|
+
options: wf.options ?? {},
|
|
474
|
+
config: wf.config ?? {},
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
workflowId = createdW?.get ? createdW.get('id') : createdW?.id;
|
|
478
|
+
results.workflows.success++;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const nodesInput: any[] = Array.isArray(wf.nodes) ? wf.nodes : [];
|
|
482
|
+
const keyToId = new Map<string, number>();
|
|
483
|
+
const existingByKey = new Map<string, any>();
|
|
484
|
+
try {
|
|
485
|
+
const existingNodes = await flowNodeRepo.find({ filter: { workflowId } });
|
|
486
|
+
for (const en of existingNodes || []) {
|
|
487
|
+
const row = en.get ? en.get() : en;
|
|
488
|
+
if (row.key) existingByKey.set(row.key, row);
|
|
489
|
+
}
|
|
490
|
+
} catch {}
|
|
491
|
+
|
|
492
|
+
for (const n of nodesInput) {
|
|
493
|
+
if (!n || !n.key) continue;
|
|
494
|
+
const existing = existingByKey.get(n.key);
|
|
495
|
+
if (existing) {
|
|
496
|
+
const id = existing.id ?? existing.get?.('id');
|
|
497
|
+
await flowNodeRepo.update({
|
|
498
|
+
filterByTk: id,
|
|
499
|
+
values: {
|
|
500
|
+
type: n.type ?? existing.type ?? null,
|
|
501
|
+
title: n.title ?? existing.title ?? null,
|
|
502
|
+
config: n.config ?? existing.config ?? {},
|
|
503
|
+
branchIndex: n.branchIndex ?? null,
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
keyToId.set(n.key, Number(id));
|
|
507
|
+
} else {
|
|
508
|
+
const created = await flowNodeRepo.create({
|
|
509
|
+
values: {
|
|
510
|
+
workflowId,
|
|
511
|
+
key: n.key,
|
|
512
|
+
type: n.type,
|
|
513
|
+
title: n.title ?? null,
|
|
514
|
+
config: n.config ?? {},
|
|
515
|
+
upstreamId: null,
|
|
516
|
+
downstreamId: null,
|
|
517
|
+
branchIndex: n.branchIndex ?? null,
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
const id = created?.get ? created.get('id') : created?.id;
|
|
521
|
+
if (n.key) keyToId.set(n.key, Number(id));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (overwrite) {
|
|
526
|
+
try {
|
|
527
|
+
const existingNodes = await flowNodeRepo.find({ filter: { workflowId } });
|
|
528
|
+
const inputKeys = new Set(nodesInput.filter(x => x && x.key).map(x => x.key));
|
|
529
|
+
for (const en of existingNodes || []) {
|
|
530
|
+
const row = en.get ? en.get() : en;
|
|
531
|
+
if (row.key && !inputKeys.has(row.key)) {
|
|
532
|
+
await flowNodeRepo.destroy({ filterByTk: row.id });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
} catch {}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
for (const n of nodesInput) {
|
|
539
|
+
if (!n || !n.key) continue;
|
|
540
|
+
const id = keyToId.get(n.key);
|
|
541
|
+
if (!id) continue;
|
|
542
|
+
const upstreamId = n.upstreamKey ? (keyToId.get(n.upstreamKey) ?? null) : null;
|
|
543
|
+
const downstreamId = n.downstreamKey ? (keyToId.get(n.downstreamKey) ?? null) : null;
|
|
544
|
+
await flowNodeRepo.update({
|
|
545
|
+
filterByTk: id,
|
|
546
|
+
values: {
|
|
547
|
+
upstreamId,
|
|
548
|
+
downstreamId,
|
|
549
|
+
branchIndex: n.branchIndex ?? null,
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
} catch (e: any) {
|
|
554
|
+
results.workflows.failed++;
|
|
555
|
+
results.workflows.errors.push({
|
|
556
|
+
workflow: wf?.title || wf?.key || 'unknown',
|
|
557
|
+
error: String(e?.message || e),
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const routeRepo = db.getRepository('desktopRoutes');
|
|
563
|
+
const uiSchemaRepo = db.getRepository('uiSchemas');
|
|
564
|
+
const treeRepo = db.getRepository('uiSchemaTreePath');
|
|
565
|
+
|
|
566
|
+
const createRouteRecursive = async (node: any, parentId: number | null) => {
|
|
567
|
+
try {
|
|
568
|
+
const nodeType = String(node.type || '').toLowerCase();
|
|
569
|
+
|
|
570
|
+
if (nodeType === 'tabs') {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (node?.schemaUid && nodeType !== 'link') {
|
|
575
|
+
const existBySchema = await routeRepo.findOne({ filter: { schemaUid: node.schemaUid } });
|
|
576
|
+
if (existBySchema) {
|
|
577
|
+
results.desktopRoutes.skipped++;
|
|
578
|
+
return existBySchema.get ? existBySchema.get('id') : existBySchema.id;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const values: any = {
|
|
583
|
+
parentId,
|
|
584
|
+
title: node.title ?? null,
|
|
585
|
+
tooltip: node.tooltip ?? null,
|
|
586
|
+
icon: node.icon ?? null,
|
|
587
|
+
schemaUid: node.schemaUid ?? null,
|
|
588
|
+
menuSchemaUid: node.menuSchemaUid ?? null,
|
|
589
|
+
tabSchemaName: node.tabSchemaName ?? null,
|
|
590
|
+
type: node.type ?? null,
|
|
591
|
+
options: node.options ?? null,
|
|
592
|
+
sort: node.sort ?? null,
|
|
593
|
+
hideInMenu: node.hideInMenu ?? null,
|
|
594
|
+
enableTabs: node.enableTabs ?? null,
|
|
595
|
+
enableHeader: node.enableHeader ?? null,
|
|
596
|
+
displayTitle: node.displayTitle ?? null,
|
|
597
|
+
hidden: node.hidden ?? null,
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const tabsChildren = asArray(node.children).filter((c: any) => String(c?.type).toLowerCase() === 'tabs');
|
|
601
|
+
if (nodeType === 'page' && tabsChildren.length > 0) {
|
|
602
|
+
values.children = tabsChildren.map((c: any) => ({
|
|
603
|
+
type: 'tabs',
|
|
604
|
+
schemaUid: c.schemaUid || null,
|
|
605
|
+
tabSchemaName: c.tabSchemaName || null,
|
|
606
|
+
hidden: !!c.hidden,
|
|
607
|
+
}));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (preview) {
|
|
611
|
+
results.desktopRoutes.success++;
|
|
612
|
+
let fakeId = 0;
|
|
613
|
+
for (const child of asArray(node.children)) {
|
|
614
|
+
const ct = String(child?.type || '').toLowerCase();
|
|
615
|
+
if (ct === 'tabs' && nodeType === 'page') continue;
|
|
616
|
+
await createRouteRecursive(child, fakeId);
|
|
617
|
+
}
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const created = await routeRepo.create({ values });
|
|
622
|
+
const newId = created?.get ? created.get('id') : created?.id;
|
|
623
|
+
results.desktopRoutes.success++;
|
|
624
|
+
|
|
625
|
+
for (const child of asArray(node.children)) {
|
|
626
|
+
const ct = String(child?.type || '').toLowerCase();
|
|
627
|
+
if (ct === 'tabs' && nodeType === 'page') continue;
|
|
628
|
+
await createRouteRecursive(child, newId);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return newId;
|
|
632
|
+
} catch (e: any) {
|
|
633
|
+
results.desktopRoutes.failed++;
|
|
634
|
+
results.desktopRoutes.errors.push({ route: node?.title || node?.schemaUid || 'unknown', error: String(e.message || e) });
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
for (const root of desktopRoutes) {
|
|
640
|
+
await createRouteRecursive(root, null);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
for (const bundle of uiSchemas) {
|
|
644
|
+
try {
|
|
645
|
+
const uid = bundle?.rootUid;
|
|
646
|
+
const data = bundle?.data;
|
|
647
|
+
if (!uid || !data) { results.uiSchemas.failed++; continue; }
|
|
648
|
+
|
|
649
|
+
if (preview) {
|
|
650
|
+
results.uiSchemas.pendingCreates.push({ uid, action: 'replace' });
|
|
651
|
+
results.uiSchemas.success++;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const exist = await uiSchemaRepo.findOne({ filter: { 'x-uid': uid } });
|
|
656
|
+
|
|
657
|
+
if (exist) {
|
|
658
|
+
try { if (app.schemaManager?.removeSchema) await app.schemaManager.removeSchema(uid); } catch {}
|
|
659
|
+
try { await treeRepo.destroy({ filter: { ancestor: uid }, force: true }); } catch {}
|
|
660
|
+
try { await treeRepo.destroy({ filter: { descendant: uid }, force: true }); } catch {}
|
|
661
|
+
try { await uiSchemaRepo.destroy({ filter: { 'x-uid': uid }, force: true }); } catch {}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (typeof (uiSchemaRepo as any).insert === 'function') {
|
|
665
|
+
await (uiSchemaRepo as any).insert(data);
|
|
666
|
+
} else {
|
|
667
|
+
await uiSchemaRepo.create({ values: { 'x-uid': uid, name: data?.name || '', schema: data } });
|
|
668
|
+
const ex0 = await treeRepo.findOne({ filter: { ancestor: uid, descendant: uid, depth: 0 } });
|
|
669
|
+
if (!ex0) {
|
|
670
|
+
await treeRepo.create({ values: { ancestor: uid, descendant: uid, depth: 0, async: false, type: null, sort: null } });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (exist) results.uiSchemas.updated++; else results.uiSchemas.success++;
|
|
675
|
+
} catch (e: any) {
|
|
676
|
+
results.uiSchemas.failed++;
|
|
677
|
+
results.uiSchemas.errors.push({ schema: bundle?.rootUid || 'unknown', error: String(e.message || e) });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const totalProcessed = collections.length + workflows.length + uiSchemas.length + desktopRoutes.length;
|
|
682
|
+
const totalSuccess = results.collections.success + results.workflows.success + results.uiSchemas.success + results.desktopRoutes.success;
|
|
683
|
+
const totalUpdated = results.collections.updated + results.workflows.updated + results.uiSchemas.updated + results.desktopRoutes.updated;
|
|
684
|
+
|
|
685
|
+
ctx.status = 200;
|
|
686
|
+
ctx.type = 'application/json';
|
|
687
|
+
ctx.body = {
|
|
688
|
+
success: totalSuccess > 0 || totalUpdated > 0,
|
|
689
|
+
preview,
|
|
690
|
+
overwrite,
|
|
691
|
+
results,
|
|
692
|
+
summary: {
|
|
693
|
+
totalProcessed,
|
|
694
|
+
totalSuccess,
|
|
695
|
+
totalUpdated,
|
|
696
|
+
totalFailed: results.collections.failed + results.workflows.failed + results.uiSchemas.failed + results.desktopRoutes.failed,
|
|
697
|
+
totalSkipped: results.collections.skipped + results.workflows.skipped + results.uiSchemas.skipped + results.desktopRoutes.skipped,
|
|
698
|
+
},
|
|
699
|
+
message: preview
|
|
700
|
+
? 'Preview completed. Set preview:false to apply changes.'
|
|
701
|
+
: (totalSuccess + totalUpdated) > 0
|
|
702
|
+
? `Import completed. ${totalSuccess} created, ${totalUpdated} updated.`
|
|
703
|
+
: 'No changes applied.',
|
|
704
|
+
};
|
|
705
|
+
} catch (err: any) {
|
|
706
|
+
ctx.status = 500;
|
|
707
|
+
ctx.type = 'application/json';
|
|
708
|
+
ctx.body = { success: false, message: String(err.message || 'Import failed'), stack: err.stack };
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
static async list(ctx: Context) {
|
|
714
|
+
const db: any = ctx.db;
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
const collectionsRepo = db.getRepository('collections');
|
|
718
|
+
const fieldsRepo = db.getRepository('fields');
|
|
719
|
+
const workflowRepo = db.getRepository('workflows');
|
|
720
|
+
const routeRepo = db.getRepository('desktopRoutes');
|
|
721
|
+
|
|
722
|
+
const colRows = await collectionsRepo.find();
|
|
723
|
+
const cols = (colRows || []).map((r: any) => (r.get ? r.get() : r));
|
|
724
|
+
|
|
725
|
+
const nonSystem = cols.filter((c: any) => !(c?.options && c.options.origin));
|
|
726
|
+
const names = nonSystem.map((c: any) => c.name);
|
|
727
|
+
|
|
728
|
+
const fldRows = await fieldsRepo.find({ filter: { collectionName: { $in: names } } });
|
|
729
|
+
const fieldsByCollection = new Map<string, number>();
|
|
730
|
+
for (const fr of fldRows || []) {
|
|
731
|
+
const row = fr.get ? fr.get() : fr;
|
|
732
|
+
const k = String(row.collectionName || '');
|
|
733
|
+
fieldsByCollection.set(k, (fieldsByCollection.get(k) || 0) + 1);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const collectionsArray = nonSystem
|
|
737
|
+
.map((c: any) => ({
|
|
738
|
+
name: String(c.name || ''),
|
|
739
|
+
title: String((c.title || c.name || '')),
|
|
740
|
+
fields: Number(fieldsByCollection.get(String(c.name || '')) || 0),
|
|
741
|
+
}))
|
|
742
|
+
.filter((c) => !!c.name && !c.name.startsWith('_'))
|
|
743
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
744
|
+
|
|
745
|
+
let workflows: any[] = [];
|
|
746
|
+
try {
|
|
747
|
+
const workflowsData = await workflowRepo.find();
|
|
748
|
+
workflows = (workflowsData || []).map((w: any) => ({
|
|
749
|
+
id: w.get('id'),
|
|
750
|
+
title: String(w.get('title') || ''),
|
|
751
|
+
key: String(w.get('key') || ''),
|
|
752
|
+
enabled: Boolean(w.get('enabled')),
|
|
753
|
+
}));
|
|
754
|
+
} catch {}
|
|
755
|
+
|
|
756
|
+
let uiSchemas: any[] = [];
|
|
757
|
+
try {
|
|
758
|
+
const routes = await routeRepo.find();
|
|
759
|
+
const routeRows = (routes || []).map((r: any) => (r.get ? r.get() : r));
|
|
760
|
+
|
|
761
|
+
const schemaTypes = new Set<string>(['page', 'menu', 'group']);
|
|
762
|
+
const linkTypes = new Set<string>(['link']);
|
|
763
|
+
const roots = routeRows.filter((r) => r.parentId == null);
|
|
764
|
+
|
|
765
|
+
const schemaEntries = roots
|
|
766
|
+
.filter((r) => !!r.schemaUid && schemaTypes.has(String(r.type || '').toLowerCase()))
|
|
767
|
+
.map((r) => ({
|
|
768
|
+
routeId: r.id,
|
|
769
|
+
displayTitle: r.title || r.displayTitle || 'Untitled',
|
|
770
|
+
type: r.type || '',
|
|
771
|
+
uid: r.schemaUid || null,
|
|
772
|
+
schemaUid: r.schemaUid || null,
|
|
773
|
+
menuSchemaUid: r.menuSchemaUid || null,
|
|
774
|
+
isLink: false,
|
|
775
|
+
linkTarget: null,
|
|
776
|
+
}));
|
|
777
|
+
|
|
778
|
+
const linkEntries = roots
|
|
779
|
+
.filter((r) => linkTypes.has(String(r.type || '').toLowerCase()))
|
|
780
|
+
.map((r) => ({
|
|
781
|
+
routeId: r.id,
|
|
782
|
+
displayTitle: r.title || r.displayTitle || 'Untitled',
|
|
783
|
+
type: r.type || 'link',
|
|
784
|
+
uid: r.schemaUid || null,
|
|
785
|
+
schemaUid: r.schemaUid || null,
|
|
786
|
+
menuSchemaUid: r.menuSchemaUid || null,
|
|
787
|
+
isLink: true,
|
|
788
|
+
linkTarget: r.options?.to ?? r.options?.url ?? r.options?.path ?? null,
|
|
789
|
+
}));
|
|
790
|
+
|
|
791
|
+
uiSchemas = [...schemaEntries, ...linkEntries];
|
|
792
|
+
} catch {}
|
|
793
|
+
|
|
794
|
+
ctx.status = 200;
|
|
795
|
+
ctx.type = 'application/json';
|
|
796
|
+
ctx.body = {
|
|
797
|
+
success: true,
|
|
798
|
+
data: {
|
|
799
|
+
collections: collectionsArray,
|
|
800
|
+
workflows,
|
|
801
|
+
uiSchemas,
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
} catch (err: any) {
|
|
805
|
+
ctx.status = 500;
|
|
806
|
+
ctx.type = 'application/json';
|
|
807
|
+
ctx.body = { success: false, message: String(err.message || 'List failed') };
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
static async validate(ctx: Context) {
|
|
812
|
+
const importData = ctx.action?.params?.values || ctx.request.body || {};
|
|
813
|
+
const collections = asArray(importData?.collections);
|
|
814
|
+
const workflows = asArray(importData?.workflows);
|
|
815
|
+
const uiSchemas = asArray(importData?.uiSchemas);
|
|
816
|
+
const db: any = ctx.db;
|
|
817
|
+
|
|
818
|
+
try {
|
|
819
|
+
const validation = { valid: true, warnings: [] as any[], errors: [] as any[] };
|
|
820
|
+
|
|
821
|
+
const collectionsRepo = db.getRepository('collections');
|
|
822
|
+
const fieldsRepo = db.getRepository('fields');
|
|
823
|
+
|
|
824
|
+
for (const col of collections) {
|
|
825
|
+
try {
|
|
826
|
+
const existing = await collectionsRepo.findOne({ filter: { name: col.name } });
|
|
827
|
+
if (existing) {
|
|
828
|
+
let newFieldsCount = 0;
|
|
829
|
+
let existingFieldsCount = 0;
|
|
830
|
+
for (const field of col.fields || []) {
|
|
831
|
+
const existingField = await fieldsRepo.findOne({ filter: { name: field.name, collectionName: col.name } });
|
|
832
|
+
if (existingField) existingFieldsCount++;
|
|
833
|
+
else newFieldsCount++;
|
|
834
|
+
}
|
|
835
|
+
validation.warnings.push({
|
|
836
|
+
type: 'collection',
|
|
837
|
+
name: String(col.name),
|
|
838
|
+
message: `Collection exists. ${newFieldsCount} new field(s) will be added. ${existingFieldsCount} existing field(s) will be preserved.`,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
} catch {}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (workflows.length > 0) {
|
|
845
|
+
try {
|
|
846
|
+
const workflowRepo = db.getRepository('workflows');
|
|
847
|
+
for (const wf of workflows) {
|
|
848
|
+
if (!wf.key && !wf.title) continue;
|
|
849
|
+
|
|
850
|
+
// Check by key or title+type
|
|
851
|
+
let existing = null;
|
|
852
|
+
if (wf.key) {
|
|
853
|
+
existing = await workflowRepo.findOne({ filter: { key: wf.key } });
|
|
854
|
+
}
|
|
855
|
+
if (!existing && wf.title && wf.type) {
|
|
856
|
+
const matches = await workflowRepo.find({
|
|
857
|
+
filter: { title: wf.title, type: wf.type }
|
|
858
|
+
});
|
|
859
|
+
if (matches && matches.length > 0) existing = matches[0];
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (existing) {
|
|
863
|
+
validation.warnings.push({
|
|
864
|
+
type: 'workflow',
|
|
865
|
+
name: String(wf.title || wf.key),
|
|
866
|
+
message: 'Workflow already exists (will be updated if overwrite=true).'
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} catch {}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (uiSchemas.length > 0) {
|
|
874
|
+
try {
|
|
875
|
+
const uiSchemaRepo = db.getRepository('uiSchemas');
|
|
876
|
+
for (const schema of uiSchemas) {
|
|
877
|
+
const uid = schema?.rootUid;
|
|
878
|
+
if (!uid) continue;
|
|
879
|
+
const existing = await uiSchemaRepo.findOne({ filter: { 'x-uid': uid } });
|
|
880
|
+
if (existing) {
|
|
881
|
+
validation.warnings.push({
|
|
882
|
+
type: 'uiSchema',
|
|
883
|
+
name: String(uid),
|
|
884
|
+
message: 'UI Schema already exists (will be replaced if overwrite=true).'
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
} catch {}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
ctx.status = 200;
|
|
892
|
+
ctx.type = 'application/json';
|
|
893
|
+
ctx.body = { success: true, validation };
|
|
894
|
+
} catch (err: any) {
|
|
895
|
+
ctx.status = 500;
|
|
896
|
+
ctx.type = 'application/json';
|
|
897
|
+
ctx.body = { success: false, message: String(err.message || 'Validation failed') };
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
static async apply(ctx: Context) {
|
|
902
|
+
const actionParams: any = (ctx as any).action?.params || {};
|
|
903
|
+
const reqBody: any = (ctx.request as any).body || {};
|
|
904
|
+
const importData: any =
|
|
905
|
+
(actionParams.data && Object.keys(actionParams.data).length ? actionParams.data : undefined) ||
|
|
906
|
+
(reqBody.data && Object.keys(reqBody.data).length ? reqBody.data : undefined) ||
|
|
907
|
+
(Object.keys(reqBody).length ? reqBody : undefined) ||
|
|
908
|
+
{};
|
|
909
|
+
|
|
910
|
+
const db: any = (ctx as any).db;
|
|
911
|
+
const app: any = (ctx as any).app;
|
|
912
|
+
|
|
913
|
+
const overwrite: boolean = !!(importData?.options?.overwrite);
|
|
914
|
+
|
|
915
|
+
const uiSchemasInput: any[] = Array.isArray(importData?.uiSchemas) ? importData.uiSchemas : [];
|
|
916
|
+
const workflowsInput: any[] = Array.isArray(importData?.workflows) ? importData.workflows : [];
|
|
917
|
+
const collectionsInput: any[] = Array.isArray(importData?.collections) ? importData.collections : [];
|
|
918
|
+
|
|
919
|
+
const results: any = {
|
|
920
|
+
collections: { synced: 0, rebuilt: 0, skipped: 0, errors: [] as any[] },
|
|
921
|
+
workflows: { updated: 0, created: 0, nodesUpserted: 0, nodesDeleted: 0, errors: [] as any[] },
|
|
922
|
+
uiSchemas: { updated: 0, created: 0, errors: [] as any[] },
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
try {
|
|
926
|
+
try { if (app?.collectionManager?.reload) await app.collectionManager.reload(); } catch {}
|
|
927
|
+
|
|
928
|
+
// Collections: light sync using runtime collection.sync (no dropping fields)
|
|
929
|
+
try {
|
|
930
|
+
const collectionsRepo = db.getRepository('collections');
|
|
931
|
+
const fieldsRepo = db.getRepository('fields');
|
|
932
|
+
for (const col of (collectionsInput || [])) {
|
|
933
|
+
const name = typeof col === 'string' ? col : col?.name;
|
|
934
|
+
if (!name) continue;
|
|
935
|
+
let runtimeCol: any = null;
|
|
936
|
+
try { runtimeCol = db.getCollection(name); } catch {}
|
|
937
|
+
if (!runtimeCol) {
|
|
938
|
+
try { if (app?.collectionManager?.reload) await app.collectionManager.reload(); } catch {}
|
|
939
|
+
try { runtimeCol = db.getCollection(name); } catch {}
|
|
940
|
+
}
|
|
941
|
+
if (!runtimeCol) {
|
|
942
|
+
const meta = await collectionsRepo.findOne({ filter: { name } }).catch(() => null);
|
|
943
|
+
if (!meta) { results.collections.skipped++; continue; }
|
|
944
|
+
const title = meta.get ? meta.get('title') || name : (meta.title || name);
|
|
945
|
+
const primaryKey = meta.get ? meta.get('primaryKey') || 'id' : (meta.primaryKey || 'id');
|
|
946
|
+
const fieldRows = await fieldsRepo.find({ filter: { collectionName: name } }).catch(() => []);
|
|
947
|
+
const fields = (fieldRows || []).map((r: any) => {
|
|
948
|
+
const row = r.get ? r.get() : r;
|
|
949
|
+
const f: any = { name: row.name, type: row.type || 'string', interface: row.interface || 'input' };
|
|
950
|
+
for (const k of Object.keys(row)) {
|
|
951
|
+
if (['name','type','interface','collectionName'].includes(k)) continue;
|
|
952
|
+
f[k] = row[k];
|
|
953
|
+
}
|
|
954
|
+
return f;
|
|
955
|
+
});
|
|
956
|
+
await db.import({ collections: [{ name, title, logging: true, autoGenId: true, createdBy: true, updatedBy: true, createdAt: true, updatedAt: true, sortable: true, primaryKey, fields }] });
|
|
957
|
+
try { runtimeCol = db.getCollection(name); } catch {}
|
|
958
|
+
if (!runtimeCol) continue;
|
|
959
|
+
results.collections.rebuilt++;
|
|
960
|
+
}
|
|
961
|
+
await runtimeCol.sync({ force: false, alter: true });
|
|
962
|
+
results.collections.synced++;
|
|
963
|
+
}
|
|
964
|
+
} catch (e: any) {
|
|
965
|
+
results.collections.errors.push({ error: String(e?.message || e) });
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Workflows: update/create + nodes via flow_nodes:update/create and prune on overwrite
|
|
969
|
+
if (workflowsInput.length) {
|
|
970
|
+
try {
|
|
971
|
+
const workflowRepo = db.getRepository('workflows');
|
|
972
|
+
const flowNodeRepo = db.getRepository('flow_nodes');
|
|
973
|
+
|
|
974
|
+
for (const wf of workflowsInput) {
|
|
975
|
+
const wfKey = wf?.key || wf?.slug || wf?.id || wf?.name;
|
|
976
|
+
if (!wfKey) continue;
|
|
977
|
+
|
|
978
|
+
const existing = await workflowRepo.findOne({
|
|
979
|
+
filter: { key: wfKey },
|
|
980
|
+
appends: ['nodes'],
|
|
981
|
+
}).catch(() => null);
|
|
982
|
+
|
|
983
|
+
let workflowId: number;
|
|
984
|
+
|
|
985
|
+
if (existing) {
|
|
986
|
+
await workflowRepo.update({
|
|
987
|
+
filterByTk: existing.get('id'),
|
|
988
|
+
values: {
|
|
989
|
+
title: wf.title,
|
|
990
|
+
type: wf.type,
|
|
991
|
+
sync: !!wf.sync,
|
|
992
|
+
description: wf.description ?? null,
|
|
993
|
+
triggerTitle: wf.triggerTitle ?? null,
|
|
994
|
+
options: wf.options ?? {},
|
|
995
|
+
config: wf.config ?? {},
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
workflowId = existing.get('id');
|
|
999
|
+
} else {
|
|
1000
|
+
const created = await workflowRepo.create({
|
|
1001
|
+
values: {
|
|
1002
|
+
title: wf.title,
|
|
1003
|
+
type: wf.type,
|
|
1004
|
+
key: wfKey,
|
|
1005
|
+
sync: !!wf.sync,
|
|
1006
|
+
description: wf.description ?? null,
|
|
1007
|
+
triggerTitle: wf.triggerTitle ?? null,
|
|
1008
|
+
options: wf.options ?? {},
|
|
1009
|
+
config: wf.config ?? {},
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
1012
|
+
workflowId = created?.get ? created.get('id') : created.id;
|
|
1013
|
+
results.workflows.created++;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const nodesInput: any[] = Array.isArray(wf.nodes) ? wf.nodes : [];
|
|
1017
|
+
const keyToId = new Map<string, number>();
|
|
1018
|
+
const existingByKey = new Map<string, any>();
|
|
1019
|
+
|
|
1020
|
+
try {
|
|
1021
|
+
const existingNodes = await flowNodeRepo.find({ filter: { workflowId } });
|
|
1022
|
+
for (const en of existingNodes || []) {
|
|
1023
|
+
const row = en.get ? en.get() : en;
|
|
1024
|
+
if (row.key) existingByKey.set(row.key, row);
|
|
1025
|
+
}
|
|
1026
|
+
} catch {}
|
|
1027
|
+
|
|
1028
|
+
for (const n of nodesInput) {
|
|
1029
|
+
if (!n || !n.key) continue;
|
|
1030
|
+
const ex = existingByKey.get(n.key);
|
|
1031
|
+
if (ex) {
|
|
1032
|
+
await flowNodeRepo.update({
|
|
1033
|
+
filterByTk: ex.id ?? ex.get?.('id'),
|
|
1034
|
+
values: {
|
|
1035
|
+
type: n.type ?? ex.type ?? null,
|
|
1036
|
+
title: n.title ?? ex.title ?? null,
|
|
1037
|
+
config: n.config ?? ex.config ?? {},
|
|
1038
|
+
branchIndex: n.branchIndex ?? null,
|
|
1039
|
+
},
|
|
1040
|
+
});
|
|
1041
|
+
keyToId.set(n.key, Number(ex.id ?? ex.get?.('id')));
|
|
1042
|
+
} else {
|
|
1043
|
+
const createdNode = await flowNodeRepo.create({
|
|
1044
|
+
values: {
|
|
1045
|
+
workflowId,
|
|
1046
|
+
key: n.key,
|
|
1047
|
+
type: n.type,
|
|
1048
|
+
title: n.title ?? null,
|
|
1049
|
+
config: n.config ?? {},
|
|
1050
|
+
upstreamId: null,
|
|
1051
|
+
downstreamId: null,
|
|
1052
|
+
branchIndex: n.branchIndex ?? null,
|
|
1053
|
+
},
|
|
1054
|
+
});
|
|
1055
|
+
const id = createdNode?.get ? createdNode.get('id') : createdNode.id;
|
|
1056
|
+
keyToId.set(n.key, Number(id));
|
|
1057
|
+
}
|
|
1058
|
+
results.workflows.nodesUpserted++;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (overwrite) {
|
|
1062
|
+
try {
|
|
1063
|
+
const existingNodes = await flowNodeRepo.find({ filter: { workflowId } });
|
|
1064
|
+
const inputKeys = new Set(nodesInput.filter(x => x && x.key).map(x => x.key));
|
|
1065
|
+
for (const en of existingNodes || []) {
|
|
1066
|
+
const row = en.get ? en.get() : en;
|
|
1067
|
+
if (row.key && !inputKeys.has(row.key)) {
|
|
1068
|
+
await flowNodeRepo.destroy({ filterByTk: row.id });
|
|
1069
|
+
results.workflows.nodesDeleted++;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
} catch {}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
for (const n of nodesInput) {
|
|
1076
|
+
if (!n || !n.key) continue;
|
|
1077
|
+
const id = keyToId.get(n.key);
|
|
1078
|
+
if (!id) continue;
|
|
1079
|
+
const upstreamId = n.upstreamKey ? (keyToId.get(n.upstreamKey) ?? null) : null;
|
|
1080
|
+
const downstreamId = n.downstreamKey ? (keyToId.get(n.downstreamKey) ?? null) : null;
|
|
1081
|
+
await flowNodeRepo.update({
|
|
1082
|
+
filterByTk: id,
|
|
1083
|
+
values: {
|
|
1084
|
+
upstreamId,
|
|
1085
|
+
downstreamId,
|
|
1086
|
+
branchIndex: n.branchIndex ?? null,
|
|
1087
|
+
},
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
results.workflows.updated++;
|
|
1092
|
+
}
|
|
1093
|
+
} catch (e: any) {
|
|
1094
|
+
results.workflows.errors.push({ error: String(e?.message || e) });
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// uiSchema
|
|
1099
|
+
const uiBundles: any[] = uiSchemasInput;
|
|
1100
|
+
|
|
1101
|
+
function pickChildren(schema: any): AnyObj[] {
|
|
1102
|
+
const props = (schema && schema.properties) || {};
|
|
1103
|
+
return Object.keys(props).map(k => props[k]).filter(Boolean);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const uiRepo = db.getRepository('ui_schemas');
|
|
1107
|
+
const treeRepo = db.getRepository('ui_schema_tree');
|
|
1108
|
+
async function insertAdjacent(targetUid: string, node: AnyObj): Promise<string> {
|
|
1109
|
+
const base: AnyObj = { ...node };
|
|
1110
|
+
delete base.properties;
|
|
1111
|
+
if (typeof (uiRepo as any).insertAdjacent === 'function') {
|
|
1112
|
+
const r = await (uiRepo as any).insertAdjacent({ target: targetUid, position: (node.position || 'beforeEnd'), schema: base, wrap: null });
|
|
1113
|
+
const ins = r?.get ? r.get() : r;
|
|
1114
|
+
const inserted = ins?.data || ins;
|
|
1115
|
+
const uid = inserted?.['x-uid'] || inserted?.xUid || inserted?.uid || base?.['x-uid'];
|
|
1116
|
+
const children = pickChildren(node);
|
|
1117
|
+
for (const child of children) {
|
|
1118
|
+
await insertAdjacent(uid, child);
|
|
1119
|
+
}
|
|
1120
|
+
return uid;
|
|
1121
|
+
} else {
|
|
1122
|
+
const created = await uiRepo.create({ values: { 'x-uid': base?.['x-uid'] || base?.uid || base?.name, name: base?.name || '', schema: base } });
|
|
1123
|
+
const uid = created?.get ? created.get('x-uid') : (created?.['x-uid'] || created?.uid || base?.['x-uid']);
|
|
1124
|
+
const ex0 = await treeRepo.findOne({ filter: { ancestor: uid, descendant: uid, depth: 0 } });
|
|
1125
|
+
if (!ex0) {
|
|
1126
|
+
await treeRepo.create({ values: { ancestor: uid, descendant: uid, depth: 0, async: false, type: null, sort: null } });
|
|
1127
|
+
}
|
|
1128
|
+
const children = pickChildren(node);
|
|
1129
|
+
for (const child of children) {
|
|
1130
|
+
await insertAdjacent(uid, child);
|
|
1131
|
+
}
|
|
1132
|
+
return uid;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
async function removeDescendants(rootUid: string) {
|
|
1137
|
+
const rows = await treeRepo.find({ filter: { ancestor: rootUid } }).catch(() => []);
|
|
1138
|
+
const list = (rows || []).map((r: any) => (r.get ? r.get() : r)).filter((x: AnyObj) => x.depth > 0);
|
|
1139
|
+
for (const row of list) {
|
|
1140
|
+
const uid = row.descendant;
|
|
1141
|
+
if (app?.schemaManager?.removeSchema) {
|
|
1142
|
+
try { await app.schemaManager.removeSchema(uid); } catch {}
|
|
1143
|
+
} else {
|
|
1144
|
+
try { await uiRepo.destroy({ filter: { 'x-uid': uid }, force: true }); } catch {}
|
|
1145
|
+
try { await treeRepo.destroy({ filter: { ancestor: uid }, force: true }); } catch {}
|
|
1146
|
+
try { await treeRepo.destroy({ filter: { descendant: uid }, force: true }); } catch {}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
for (const bundle of uiBundles) {
|
|
1152
|
+
const incoming = bundle?.data;
|
|
1153
|
+
if (!incoming) continue;
|
|
1154
|
+
const name = incoming?.name;
|
|
1155
|
+
const type = incoming?.type;
|
|
1156
|
+
if (!name || !type) continue;
|
|
1157
|
+
|
|
1158
|
+
let target = await uiRepo.findOne({ filter: { name, type } }).catch(() => null);
|
|
1159
|
+
if (!target) {
|
|
1160
|
+
const rootUid = incoming?.['x-uid'] || incoming?.uid || incoming?.schemaUid || incoming?.xUid || incoming?.name;
|
|
1161
|
+
const base: AnyObj = { ...incoming };
|
|
1162
|
+
delete base.properties;
|
|
1163
|
+
if (typeof (uiRepo as any).insert === 'function') {
|
|
1164
|
+
const r = await (uiRepo as any).insert(base);
|
|
1165
|
+
const ins = r?.get ? r.get() : r;
|
|
1166
|
+
const inserted = ins?.data || ins;
|
|
1167
|
+
const uid = inserted?.['x-uid'] || inserted?.xUid || inserted?.uid || base?.['x-uid'] || rootUid;
|
|
1168
|
+
const children = pickChildren(incoming);
|
|
1169
|
+
for (const child of children) {
|
|
1170
|
+
await insertAdjacent(uid, child);
|
|
1171
|
+
}
|
|
1172
|
+
} else {
|
|
1173
|
+
const created = await uiRepo.create({ values: { 'x-uid': rootUid, name: base?.name || '', schema: base } });
|
|
1174
|
+
const uid = created?.get ? created.get('x-uid') : (created?.['x-uid'] || created?.uid || rootUid);
|
|
1175
|
+
const ex0 = await treeRepo.findOne({ filter: { ancestor: uid, descendant: uid, depth: 0 } });
|
|
1176
|
+
if (!ex0) { await treeRepo.create({ values: { ancestor: uid, descendant: uid, depth: 0, async: false, type: null, sort: null } }); }
|
|
1177
|
+
const children = pickChildren(incoming);
|
|
1178
|
+
for (const child of children) {
|
|
1179
|
+
await insertAdjacent(uid, child);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
results.uiSchemas.created++;
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const rootUid = target.get ? target.get('x-uid') : (target['x-uid'] || target.uid);
|
|
1187
|
+
const base: AnyObj = { ...incoming };
|
|
1188
|
+
delete base.properties;
|
|
1189
|
+
|
|
1190
|
+
await (uiRepo as any).update({ filterByTk: target.get ? target.get('id') : target.id, values: { schema: base, name } });
|
|
1191
|
+
|
|
1192
|
+
await removeDescendants(rootUid);
|
|
1193
|
+
|
|
1194
|
+
const children = pickChildren(incoming);
|
|
1195
|
+
for (const child of children) {
|
|
1196
|
+
await insertAdjacent(rootUid, child);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
results.uiSchemas.merged++;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
try { if (app?.collectionManager?.reload) await app.collectionManager.reload(); } catch {}
|
|
1203
|
+
try { if (app?.schemaManager?.reload) await app.schemaManager.reload(); } catch {}
|
|
1204
|
+
try { const wf = app?.pm?.get?.('workflow') || app?.workflow || app?.plugins?.workflow; if (wf?.engine?.reload) await wf.engine.reload(); if (wf?.reload) await wf.reload(); } catch {}
|
|
1205
|
+
|
|
1206
|
+
(ctx as any).set && (ctx as any).set('X-Noco-Refresh', 'schema');
|
|
1207
|
+
|
|
1208
|
+
ctx.status = 200;
|
|
1209
|
+
ctx.type = 'application/json';
|
|
1210
|
+
ctx.body = { success: true, results, message: 'Apply completed.', refresh: { schema: true, collections: true, workflows: true } };
|
|
1211
|
+
} catch (err: any) {
|
|
1212
|
+
ctx.status = 500;
|
|
1213
|
+
ctx.type = 'application/json';
|
|
1214
|
+
ctx.body = { success: false, message: String(err?.message || 'Apply failed') };
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|