@stackbit/cms-core 0.0.15 → 0.0.18-alpha.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.
Files changed (59) hide show
  1. package/dist/annotator/html.d.ts +1 -0
  2. package/dist/annotator/html.d.ts.map +1 -0
  3. package/dist/annotator/index.d.ts +3 -2
  4. package/dist/annotator/index.d.ts.map +1 -0
  5. package/dist/annotator/react.d.ts +1 -0
  6. package/dist/annotator/react.d.ts.map +1 -0
  7. package/dist/common/common-schema.d.ts +3 -10
  8. package/dist/common/common-schema.d.ts.map +1 -0
  9. package/dist/common/common-schema.js +3 -4
  10. package/dist/common/common-schema.js.map +1 -1
  11. package/dist/consts.d.ts +1 -0
  12. package/dist/consts.d.ts.map +1 -0
  13. package/dist/content-source-interface.d.ts +292 -0
  14. package/dist/content-source-interface.d.ts.map +1 -0
  15. package/dist/content-source-interface.js +28 -0
  16. package/dist/content-source-interface.js.map +1 -0
  17. package/dist/content-store-types.d.ts +324 -0
  18. package/dist/content-store-types.d.ts.map +1 -0
  19. package/dist/content-store-types.js +3 -0
  20. package/dist/content-store-types.js.map +1 -0
  21. package/dist/content-store.d.ts +207 -0
  22. package/dist/content-store.d.ts.map +1 -0
  23. package/dist/content-store.js +1643 -0
  24. package/dist/content-store.js.map +1 -0
  25. package/dist/encoder.d.ts +36 -7
  26. package/dist/encoder.d.ts.map +1 -0
  27. package/dist/encoder.js +63 -40
  28. package/dist/encoder.js.map +1 -1
  29. package/dist/index.d.ts +11 -6
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +37 -11
  32. package/dist/index.js.map +1 -1
  33. package/dist/stackbit/index.d.ts +7 -3
  34. package/dist/stackbit/index.d.ts.map +1 -0
  35. package/dist/stackbit/index.js +13 -10
  36. package/dist/stackbit/index.js.map +1 -1
  37. package/dist/utils/index.d.ts +8 -7
  38. package/dist/utils/index.d.ts.map +1 -0
  39. package/dist/utils/schema-utils.d.ts +3 -2
  40. package/dist/utils/schema-utils.d.ts.map +1 -0
  41. package/dist/utils/timer.d.ts +18 -0
  42. package/dist/utils/timer.d.ts.map +1 -0
  43. package/dist/utils/timer.js +36 -0
  44. package/dist/utils/timer.js.map +1 -0
  45. package/package.json +8 -4
  46. package/src/common/common-schema.ts +12 -0
  47. package/src/content-source-interface.ts +417 -0
  48. package/src/content-store-types.ts +406 -0
  49. package/src/content-store.ts +2112 -0
  50. package/src/{encoder.js → encoder.ts} +55 -17
  51. package/src/index.ts +10 -0
  52. package/src/stackbit/{index.js → index.ts} +5 -9
  53. package/src/utils/timer.ts +42 -0
  54. package/dist/utils/lazy-poller.d.ts +0 -21
  55. package/dist/utils/lazy-poller.js +0 -56
  56. package/dist/utils/lazy-poller.js.map +0 -1
  57. package/src/common/common-schema.js +0 -14
  58. package/src/index.js +0 -13
  59. package/src/utils/lazy-poller.ts +0 -74
@@ -0,0 +1,2112 @@
1
+ import _ from 'lodash';
2
+ import slugify from 'slugify';
3
+ import path from 'path';
4
+ import sanitizeFilename from 'sanitize-filename';
5
+ import {
6
+ Config,
7
+ extendConfig,
8
+ Field,
9
+ FieldList,
10
+ FieldListItems,
11
+ FieldListProps,
12
+ FieldModelProps,
13
+ FieldObjectProps,
14
+ FieldSpecificProps,
15
+ loadConfigFromDir,
16
+ Model
17
+ } from '@stackbit/sdk';
18
+ import { mapPromise, omitByNil } from '@stackbit/utils';
19
+ import * as ContentSourceTypes from './content-source-interface';
20
+ import { isLocalizedField } from './content-source-interface';
21
+ import * as ContentStoreTypes from './content-store-types';
22
+ import { IMAGE_MODEL } from './common/common-schema';
23
+ import { Timer } from './utils/timer';
24
+
25
+ export interface ContentSourceOptions {
26
+ logger: ContentStoreTypes.Logger;
27
+ userLogger: ContentStoreTypes.Logger;
28
+ localDev: boolean;
29
+ stackbitYamlDir: string;
30
+ contentSources: ContentSourceTypes.ContentSourceInterface[];
31
+ onSchemaChangeCallback: () => void;
32
+ onContentChangeCallback: (contentChanges: ContentStoreTypes.ContentChangeResult) => void;
33
+ handleConfigAssets: (config: Config) => Promise<Config>;
34
+ }
35
+
36
+ interface ContentSourceData {
37
+ id: string;
38
+ instance: ContentSourceTypes.ContentSourceInterface;
39
+ type: string;
40
+ projectId: string;
41
+ models: Model[];
42
+ modelMap: Record<string, Model>;
43
+ locales?: ContentSourceTypes.Locale[];
44
+ defaultLocaleCode?: string;
45
+ documents: ContentStoreTypes.Document[];
46
+ documentMap: Record<string, ContentStoreTypes.Document>;
47
+ assets: ContentStoreTypes.Asset[];
48
+ assetMap: Record<string, ContentStoreTypes.Asset>;
49
+ }
50
+
51
+ export class ContentStore {
52
+ private readonly contentSources: ContentSourceTypes.ContentSourceInterface[];
53
+ private readonly logger: ContentStoreTypes.Logger;
54
+ private readonly userLogger: ContentStoreTypes.Logger;
55
+ private readonly localDev: boolean;
56
+ private readonly stackbitYamlDir: string; // TODO: remove stackbitYamlDir, ContentStore should not be aware of filesystem, instead pass autoreloading Config object
57
+ private readonly onSchemaChangeCallback: () => void;
58
+ private readonly onContentChangeCallback: (contentChanges: ContentStoreTypes.ContentChangeResult) => void;
59
+ private readonly handleConfigAssets: (config: Config) => Promise<Config>;
60
+ private contentSourceDataById: Record<string, ContentSourceData> = {};
61
+ private contentUpdatesWatchTimer: Timer;
62
+ private rawStackbitConfig: any;
63
+ private presets?: Record<string, any>;
64
+
65
+ constructor(options: ContentSourceOptions) {
66
+ this.logger = options.logger.createLogger({ label: 'content-store' });
67
+ this.userLogger = options.userLogger.createLogger({ label: 'content-store' });
68
+ this.localDev = options.localDev;
69
+ this.stackbitYamlDir = options.stackbitYamlDir; // TODO: remove stackbitYamlDir, ContentStore should not be aware of filesystem, instead pass autoreloading Config object
70
+ this.contentSources = options.contentSources;
71
+ this.onSchemaChangeCallback = options.onSchemaChangeCallback;
72
+ this.onContentChangeCallback = options.onContentChangeCallback;
73
+ this.handleConfigAssets = options.handleConfigAssets;
74
+ this.contentUpdatesWatchTimer = new Timer({ timerCallback: () => this.handleTimerTimeout(), logger: this.logger });
75
+ }
76
+
77
+ async init(): Promise<void> {
78
+ this.logger.debug('init');
79
+ this.rawStackbitConfig = await this.loadStackbitConfig();
80
+
81
+ this.logger.debug('init => load content source data');
82
+ for (const contentSourceInstance of this.contentSources) {
83
+ await this.loadContentSourceData({ contentSourceInstance, init: true });
84
+ }
85
+
86
+ this.contentUpdatesWatchTimer.startTimer();
87
+ }
88
+
89
+ async reset() {
90
+ this.logger.debug('reset');
91
+
92
+ this.contentUpdatesWatchTimer.stopTimer();
93
+ this.rawStackbitConfig = await this.loadStackbitConfig();
94
+
95
+ this.logger.debug('reset => load content source data');
96
+ for (const contentSourceInstance of this.contentSources) {
97
+ await this.loadContentSourceData({ contentSourceInstance, init: false });
98
+ }
99
+
100
+ this.contentUpdatesWatchTimer.startTimer();
101
+ }
102
+
103
+ /**
104
+ * This method is called when contentUpdatesWatchTimer receives timeout.
105
+ * It then notifies all content sources to stop watching for content changes.
106
+ */
107
+ handleTimerTimeout() {
108
+ for (const contentSourceInstance of this.contentSources) {
109
+ contentSourceInstance.stopWatchingContentUpdates();
110
+ }
111
+ }
112
+
113
+ /**
114
+ * This method is called when user interacts with Stackbit application.
115
+ * It is used to reset contentUpdatesWatchTimer. When the timer is over
116
+ * all content sources are notified to stop watching for content updates.
117
+ */
118
+ async keepAlive() {
119
+ if (!this.contentUpdatesWatchTimer.isRunning()) {
120
+ this.logger.debug('keepAlive => contentUpdatesWatchTimer is not running => load content source data');
121
+ for (const contentSourceInstance of this.contentSources) {
122
+ await this.loadContentSourceData({ contentSourceInstance, init: false });
123
+ }
124
+ }
125
+ this.contentUpdatesWatchTimer.resetTimer();
126
+ }
127
+
128
+ /**
129
+ * This method is called when a content source notifies Stackbit of models
130
+ * changes via webhook. When this happens, all content source data
131
+ *
132
+ * For example, Contentful notifies Stackbit of any content-type changes via
133
+ * special webhook.
134
+ *
135
+ * @param contentSourceId
136
+ */
137
+ async onContentSourceSchemaChange({ contentSourceId }: { contentSourceId: string }) {
138
+ this.logger.debug('onContentSourceSchemaChange', { contentSourceId });
139
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
140
+ await this.loadContentSourceData({
141
+ contentSourceInstance: contentSourceData.instance,
142
+ init: false
143
+ });
144
+ this.onSchemaChangeCallback();
145
+ }
146
+
147
+ async onFilesChange(
148
+ updatedFiles: string[]
149
+ ): Promise<{ stackbitConfigUpdated?: boolean; schemaChanged?: boolean; contentChanges: ContentStoreTypes.ContentChangeResult }> {
150
+ this.logger.debug('onFilesChange');
151
+
152
+ const stackbitConfigUpdated = _.some(updatedFiles, (filePath) => isStackbitConfigFile(filePath));
153
+ this.logger.debug(`stackbitConfigUpdated: ${stackbitConfigUpdated}`);
154
+ if (stackbitConfigUpdated) {
155
+ this.rawStackbitConfig = await this.loadStackbitConfig();
156
+ }
157
+
158
+ let someContentSourceSchemaUpdated = false;
159
+ const contentChanges: ContentStoreTypes.ContentChangeResult = {
160
+ updatedDocuments: [],
161
+ updatedAssets: [],
162
+ deletedDocuments: [],
163
+ deletedAssets: []
164
+ };
165
+
166
+ for (const contentSourceInstance of this.contentSources) {
167
+ const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance);
168
+ this.logger.debug(`call onFilesChange for contentSource: ${contentSourceId}`);
169
+ const { schemaChanged, contentChangeEvent } = contentSourceInstance.onFilesChange?.({ updatedFiles: updatedFiles }) ?? {};
170
+ this.logger.debug(`schemaChanged: ${schemaChanged}, has contentChangeEvent: ${!!contentChangeEvent}`);
171
+ // if schema is changed, there is no need to return contentChanges
172
+ // because schema changes reloads everything and implies content changes
173
+ if (stackbitConfigUpdated || schemaChanged) {
174
+ if (schemaChanged) {
175
+ someContentSourceSchemaUpdated = true;
176
+ }
177
+ await this.loadContentSourceData({ contentSourceInstance, init: false });
178
+ } else if (contentChangeEvent) {
179
+ const contentChangeResult = this.onContentChange(contentSourceId, contentChangeEvent);
180
+ contentChanges.updatedDocuments = contentChanges.updatedDocuments.concat(contentChangeResult.updatedDocuments);
181
+ contentChanges.updatedAssets = contentChanges.updatedAssets.concat(contentChangeResult.updatedAssets);
182
+ contentChanges.deletedDocuments = contentChanges.deletedDocuments.concat(contentChangeResult.deletedDocuments);
183
+ contentChanges.deletedAssets = contentChanges.deletedAssets.concat(contentChangeResult.deletedAssets);
184
+ }
185
+ }
186
+
187
+ return {
188
+ stackbitConfigUpdated,
189
+ schemaChanged: someContentSourceSchemaUpdated || stackbitConfigUpdated,
190
+ contentChanges: contentChanges
191
+ };
192
+ }
193
+
194
+ async loadStackbitConfig() {
195
+ this.logger.debug('loadStackbitConfig');
196
+ // TODO: use esbuild to watch for stackbit.config.js changes and notify
197
+ // the content-store via onStackbitConfigChange with updated rawConfig
198
+ const { config, errors } = await loadConfigFromDir({ dirPath: this.stackbitYamlDir });
199
+ for (const error of errors) {
200
+ this.userLogger.warn(error.message);
201
+ }
202
+ if (!config) {
203
+ this.userLogger.error(`could not load stackbit config`);
204
+ }
205
+ return config;
206
+ }
207
+
208
+ async loadContentSourceData({ contentSourceInstance, init }: { contentSourceInstance: ContentSourceTypes.ContentSourceInterface; init: boolean }) {
209
+ // TODO: defer loading content if content is already loading for a specific content source
210
+
211
+ // TODO: optimize: cache raw responses from contentSource
212
+ // e.g.: getModels(), getDocuments(), getAssets()
213
+ // ana use the cached response to remap documents when only stackbitConfig changes
214
+
215
+ const contentSourceId = getContentSourceIdForContentSource(contentSourceInstance);
216
+ this.logger.debug('loadContentSourceData', { contentSourceId, init });
217
+
218
+ if (init) {
219
+ await contentSourceInstance.init({
220
+ logger: this.logger,
221
+ userLogger: this.userLogger,
222
+ localDev: this.localDev
223
+ });
224
+ } else {
225
+ contentSourceInstance.stopWatchingContentUpdates();
226
+ await contentSourceInstance.reset();
227
+ }
228
+
229
+ const csModels = await contentSourceInstance.getModels();
230
+ const locales = await contentSourceInstance?.getLocales();
231
+ const result = await extendConfig({ dirPath: this.stackbitYamlDir, config: this.rawStackbitConfig, externalModels: csModels });
232
+ const config = await this.handleConfigAssets(result.config);
233
+ const models = config.models;
234
+ const modelMap = _.keyBy(models, 'name');
235
+ const defaultLocaleCode = locales?.find((locale) => locale.default)?.code;
236
+
237
+ for (const error of result.errors) {
238
+ this.userLogger.warn(error.message);
239
+ }
240
+
241
+ // TODO: load presets externally from config, and create additional map
242
+ // that maps presetIds by model name instead of storing that map inside every model
243
+ this.presets = config.presets;
244
+
245
+ const documents = await contentSourceInstance.getDocuments({ modelMap });
246
+ const assets = await contentSourceInstance.getAssets();
247
+ const contentStoreDocuments = mapSourceDocumentsToStoreDocuments({
248
+ documents,
249
+ contentSourceInstance,
250
+ modelMap,
251
+ defaultLocaleCode
252
+ });
253
+ const contentStoreAssets = mapSourceAssetsToStoreAssets({
254
+ assets,
255
+ contentSourceInstance,
256
+ defaultLocaleCode
257
+ });
258
+
259
+ this.logger.debug('loaded content source data', {
260
+ contentSourceId,
261
+ locales,
262
+ defaultLocaleCode,
263
+ modelCount: models.length,
264
+ documentCount: contentStoreDocuments.length,
265
+ assetCount: contentStoreAssets.length
266
+ });
267
+
268
+ this.contentSourceDataById[contentSourceId] = {
269
+ id: contentSourceId,
270
+ type: contentSourceInstance.getContentSourceType(),
271
+ projectId: contentSourceInstance.getProjectId(),
272
+ instance: contentSourceInstance,
273
+ locales: locales,
274
+ defaultLocaleCode: defaultLocaleCode,
275
+ models: models,
276
+ modelMap: modelMap,
277
+ documents: contentStoreDocuments,
278
+ documentMap: _.keyBy(contentStoreDocuments, 'srcObjectId'),
279
+ assets: contentStoreAssets,
280
+ assetMap: _.keyBy(contentStoreAssets, 'srcObjectId')
281
+ };
282
+
283
+ contentSourceInstance.startWatchingContentUpdates({
284
+ getModelMap: () => {
285
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
286
+ return contentSourceData.modelMap;
287
+ },
288
+ onContentChange: (contentChangeEvent: ContentSourceTypes.ContentChangeEvent) => {
289
+ this.logger.debug('content source called onContentChange', { contentSourceId });
290
+ const result = this.onContentChange(contentSourceId, contentChangeEvent);
291
+ this.onContentChangeCallback(result);
292
+ },
293
+ onSchemaChange: async () => {
294
+ this.logger.debug('content source called onSchemaChange', { contentSourceId });
295
+ await this.loadContentSourceData({
296
+ contentSourceInstance: contentSourceInstance,
297
+ init: false
298
+ });
299
+ this.onSchemaChangeCallback();
300
+ }
301
+ });
302
+ }
303
+
304
+ onContentChange(contentSourceId: string, contentChangeEvent: ContentSourceTypes.ContentChangeEvent): ContentStoreTypes.ContentChangeResult {
305
+ // TODO: prevent content change process for contentSourceId if loading content is in progress
306
+
307
+ this.logger.debug('onContentChange', {
308
+ contentSourceId,
309
+ documentCount: contentChangeEvent.documents.length,
310
+ assetCount: contentChangeEvent.assets.length,
311
+ deletedDocumentCount: contentChangeEvent.deletedDocumentIds.length,
312
+ deletedAssetCount: contentChangeEvent.deletedAssetIds.length
313
+ });
314
+
315
+ const result: ContentStoreTypes.ContentChangeResult = {
316
+ updatedDocuments: [],
317
+ updatedAssets: [],
318
+ deletedDocuments: [],
319
+ deletedAssets: []
320
+ };
321
+
322
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
323
+ contentChangeEvent.deletedDocumentIds.forEach((docId) => {
324
+ delete contentSourceData.documentMap[docId];
325
+ const index = contentSourceData.documents.findIndex((document) => document.srcObjectId === docId);
326
+ if (index !== -1) {
327
+ contentSourceData.documents.splice(index, 1);
328
+ }
329
+ result.deletedDocuments.push({
330
+ srcType: contentSourceData.type,
331
+ srcProjectId: contentSourceData.projectId,
332
+ srcObjectId: docId
333
+ });
334
+ });
335
+ contentChangeEvent.deletedAssetIds.forEach((assetId) => {
336
+ delete contentSourceData.assetMap[assetId];
337
+ const index = contentSourceData.assets.findIndex((asset) => asset.srcObjectId === assetId);
338
+ if (index !== -1) {
339
+ contentSourceData.assets.splice(index, 1);
340
+ }
341
+ result.deletedAssets.push({
342
+ srcType: contentSourceData.type,
343
+ srcProjectId: contentSourceData.projectId,
344
+ srcObjectId: assetId
345
+ });
346
+ });
347
+ const documents = mapSourceDocumentsToStoreDocuments({
348
+ documents: contentChangeEvent.documents,
349
+ contentSourceInstance: contentSourceData.instance,
350
+ modelMap: contentSourceData.modelMap,
351
+ defaultLocaleCode: contentSourceData.defaultLocaleCode
352
+ });
353
+ const assets = mapSourceAssetsToStoreAssets({
354
+ assets: contentChangeEvent.assets,
355
+ contentSourceInstance: contentSourceData.instance,
356
+ defaultLocaleCode: contentSourceData.defaultLocaleCode
357
+ });
358
+
359
+ Object.assign(contentSourceData.documentMap, _.keyBy(documents, 'srcObjectId'));
360
+ Object.assign(contentSourceData.assetMap, _.keyBy(assets, 'srcObjectId'));
361
+ for (const document of documents) {
362
+ const index = contentSourceData.documents.findIndex((existingDoc) => existingDoc.srcObjectId === document.srcObjectId);
363
+ if (index === -1) {
364
+ contentSourceData.documents.push(document);
365
+ } else {
366
+ contentSourceData.documents.splice(index, 1, document);
367
+ }
368
+ result.updatedDocuments.push({
369
+ srcType: contentSourceData.type,
370
+ srcProjectId: contentSourceData.projectId,
371
+ srcObjectId: document.srcObjectId
372
+ });
373
+ }
374
+ for (const asset of assets) {
375
+ const index = contentSourceData.assets.findIndex((existingAsset) => existingAsset.srcObjectId === asset.srcObjectId);
376
+ if (index === -1) {
377
+ contentSourceData.assets.push(asset);
378
+ } else {
379
+ contentSourceData.assets.splice(index, 1, asset);
380
+ }
381
+ result.updatedAssets.push({
382
+ srcType: contentSourceData.type,
383
+ srcProjectId: contentSourceData.projectId,
384
+ srcObjectId: asset.srcObjectId
385
+ });
386
+ }
387
+
388
+ return result;
389
+ }
390
+
391
+ getModels(): Record<string, Record<string, Record<string, Model>>> {
392
+ return _.reduce(
393
+ this.contentSourceDataById,
394
+ (result: Record<string, Record<string, Record<string, Model>>>, contentSourceData) => {
395
+ const contentSourceType = contentSourceData.instance.getContentSourceType();
396
+ const srcProjectId = contentSourceData.instance.getProjectId();
397
+ _.set(result, [contentSourceType, srcProjectId], contentSourceData.modelMap);
398
+ return result;
399
+ },
400
+ {}
401
+ );
402
+ }
403
+
404
+ getLocales(): string[] {
405
+ return _.reduce(
406
+ this.contentSourceDataById,
407
+ (result: string[], contentSourceData) => {
408
+ const locales = (contentSourceData.locales ?? []).map((locale) => locale.code);
409
+ return result.concat(locales);
410
+ },
411
+ []
412
+ );
413
+ }
414
+
415
+ getPresets(): Record<string, any> {
416
+ return this.presets ?? {};
417
+ }
418
+
419
+ getContentSourceEnvironment({ srcProjectId, srcType }: { srcProjectId: string; srcType: string }): string {
420
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
421
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
422
+ return contentSourceData.instance.getProjectEnvironment();
423
+ }
424
+
425
+ hasAccess({ srcType, srcProjectId, user }: { srcType: string; srcProjectId: string; user?: ContentStoreTypes.User }): Promise<boolean> {
426
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
427
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
428
+ const userContext = getUserContextForSrcType(srcType, user);
429
+ return contentSourceData.instance.hasAccess({ userContext });
430
+ }
431
+
432
+ hasChanges({
433
+ srcType,
434
+ srcProjectId,
435
+ documents
436
+ }: {
437
+ srcType?: string;
438
+ srcProjectId?: string;
439
+ documents?: { srcType: string; srcProjectId: string; srcObjectId: string }[];
440
+ }): {
441
+ hasChanges: boolean;
442
+ changedObjects: {
443
+ srcType: string;
444
+ srcProjectId: string;
445
+ srcObjectId: string;
446
+ }[];
447
+ } {
448
+ let result: (ContentStoreTypes.Document | ContentStoreTypes.Asset)[];
449
+ if (srcType && srcProjectId) {
450
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
451
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
452
+ result = [...contentSourceData.documents, ...contentSourceData.assets];
453
+ } else if (documents && documents.length > 0) {
454
+ const documentsBySourceId = _.groupBy(documents, (document) => getContentSourceId(document.srcType, document.srcProjectId));
455
+ result = _.reduce(
456
+ documentsBySourceId,
457
+ (result: (ContentStoreTypes.Document | ContentStoreTypes.Asset)[], documents, contentSourceId) => {
458
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
459
+ for (const document of documents) {
460
+ if (document.srcObjectId in contentSourceData.documentMap) {
461
+ result.push(contentSourceData.documentMap[document.srcObjectId]!);
462
+ } else if (document.srcObjectId in contentSourceData.assetMap) {
463
+ result.push(contentSourceData.assetMap[document.srcObjectId]!);
464
+ }
465
+ }
466
+ return result;
467
+ },
468
+ []
469
+ );
470
+ } else {
471
+ result = _.reduce(
472
+ this.contentSourceDataById,
473
+ (result: (ContentStoreTypes.Document | ContentStoreTypes.Asset)[], contentSourceData) => {
474
+ return result.concat(contentSourceData.documents, contentSourceData.assets);
475
+ },
476
+ []
477
+ );
478
+ }
479
+ const changedDocuments = result.filter((document) => document.status === 'added' || document.status === 'modified');
480
+ return {
481
+ hasChanges: !_.isEmpty(changedDocuments),
482
+ changedObjects: changedDocuments.map((item) => ({
483
+ srcType: item.srcType,
484
+ srcProjectId: item.srcProjectId,
485
+ srcObjectId: item.srcObjectId
486
+ }))
487
+ };
488
+ }
489
+
490
+ getDocument({
491
+ srcDocumentId,
492
+ srcProjectId,
493
+ srcType
494
+ }: {
495
+ srcDocumentId: string;
496
+ srcProjectId: string;
497
+ srcType: string;
498
+ }): ContentStoreTypes.Document | undefined {
499
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
500
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
501
+ return contentSourceData.documentMap[srcDocumentId];
502
+ }
503
+
504
+ getDocuments(): ContentStoreTypes.Document[] {
505
+ return _.reduce(
506
+ this.contentSourceDataById,
507
+ (documents: ContentStoreTypes.Document[], contentSourceData) => {
508
+ return documents.concat(contentSourceData.documents);
509
+ },
510
+ []
511
+ );
512
+ }
513
+
514
+ getAsset({ srcAssetId, srcProjectId, srcType }: { srcAssetId: string; srcProjectId: string; srcType: string }): ContentStoreTypes.Asset | undefined {
515
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
516
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
517
+ return contentSourceData.assetMap[srcAssetId];
518
+ }
519
+
520
+ getAssets(): ContentStoreTypes.Asset[] {
521
+ return _.reduce(
522
+ this.contentSourceDataById,
523
+ (assets: ContentStoreTypes.Asset[], contentSourceData) => {
524
+ return assets.concat(contentSourceData.assets);
525
+ },
526
+ []
527
+ );
528
+ }
529
+
530
+ getLocalizedApiObjects({ locale }: { locale?: string }): ContentStoreTypes.APIObject[] {
531
+ return _.reduce(
532
+ this.contentSourceDataById,
533
+ (objects: ContentStoreTypes.APIObject[], contentSourceData) => {
534
+ locale = locale ?? contentSourceData.defaultLocaleCode;
535
+ const documentObjects = mapDocumentsToLocalizedApiObjects(contentSourceData.documents, locale);
536
+ const imageObjects = mapAssetsToLocalizedApiImages(contentSourceData.assets, locale);
537
+ return objects.concat(documentObjects, imageObjects);
538
+ },
539
+ []
540
+ );
541
+ }
542
+
543
+ getApiAssets({
544
+ srcType,
545
+ srcProjectId,
546
+ pageSize = 20,
547
+ pageNum = 1,
548
+ searchQuery
549
+ }: { srcType?: string; srcProjectId?: string; pageSize?: number; pageNum?: number; searchQuery?: string } = {}): {
550
+ assets: ContentStoreTypes.APIAsset[];
551
+ pageSize: number;
552
+ pageNum: number;
553
+ totalPages: number;
554
+ } {
555
+ let assets: ContentStoreTypes.APIAsset[];
556
+ if (srcProjectId && srcType) {
557
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
558
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
559
+ assets = mapStoreAssetsToAPIAssets(contentSourceData.assets, contentSourceData.defaultLocaleCode);
560
+ } else {
561
+ assets = _.reduce(
562
+ this.contentSourceDataById,
563
+ (result: ContentStoreTypes.APIAsset[], contentSourceData) => {
564
+ const assets = mapStoreAssetsToAPIAssets(contentSourceData.assets, contentSourceData.defaultLocaleCode);
565
+ return result.concat(assets);
566
+ },
567
+ []
568
+ );
569
+ }
570
+
571
+ let filteredFiles = assets;
572
+ if (searchQuery) {
573
+ const sanitizedSearchQuery = sanitizeFilename(searchQuery).toLowerCase();
574
+ filteredFiles = assets.filter((asset) => asset.fileName && path.basename(asset.fileName).toLowerCase().includes(sanitizedSearchQuery));
575
+ }
576
+ const sortedAssets = _.orderBy(filteredFiles, ['fileName'], ['asc']);
577
+ const skip = (pageNum - 1) * pageSize;
578
+ const totalPages = Math.ceil(filteredFiles.length / pageSize);
579
+ const pagesAssets = sortedAssets.slice(skip, skip + pageSize);
580
+
581
+ return {
582
+ assets: pagesAssets,
583
+ pageSize: pageSize,
584
+ pageNum: pageNum,
585
+ totalPages: totalPages
586
+ };
587
+ }
588
+
589
+ async createAndLinkDocument({
590
+ srcType,
591
+ srcProjectId,
592
+ srcDocumentId,
593
+ fieldPath,
594
+ modelName,
595
+ object,
596
+ index,
597
+ locale,
598
+ user
599
+ }: {
600
+ srcType: string;
601
+ srcProjectId: string;
602
+ srcDocumentId: string;
603
+ fieldPath: (string | number)[];
604
+ modelName?: string;
605
+ object?: Record<string, any>;
606
+ index?: number;
607
+ locale?: string;
608
+ user?: ContentStoreTypes.User;
609
+ }): Promise<{ srcDocumentId: string }> {
610
+ this.logger.debug('createAndLinkDocument', { srcType, srcProjectId, srcDocumentId, fieldPath, modelName, index, locale });
611
+
612
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
613
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
614
+
615
+ // get the document that is being updated
616
+ const document = contentSourceData.documentMap[srcDocumentId];
617
+ if (!document) {
618
+ throw new Error(`no document with id '${srcDocumentId}' was found in ${contentSourceData.id}`);
619
+ }
620
+
621
+ // get the document model
622
+ const documentModelName = document.srcModelName;
623
+ const modelMap = contentSourceData.modelMap;
624
+ const model = modelMap[documentModelName];
625
+ if (!model) {
626
+ throw new Error(`error updating document, could not find document model: '${documentModelName}'`);
627
+ }
628
+
629
+ // get the 'reference' model field in the updated document that will be used to link the new document
630
+ locale = locale ?? contentSourceData.defaultLocaleCode;
631
+ const modelField = getModelFieldForFieldAtPath(document, model, fieldPath, modelMap, locale);
632
+ if (!modelField) {
633
+ throw Error(`the "fieldPath" points to non existing model field: ${fieldPath.join('.')}`);
634
+ }
635
+ const fieldProps = modelField.type === 'list' ? modelField.items! : modelField;
636
+ if (fieldProps.type !== 'reference') {
637
+ throw Error(`error in "createAndLinkDocument", this operation can only be used on reference field: ${fieldPath.join('.')}`);
638
+ }
639
+
640
+ // get the model name for the new document
641
+ if (!modelName && fieldProps.models.length === 1) {
642
+ modelName = fieldProps.models[0];
643
+ }
644
+ if (!modelName) {
645
+ throw Error(`error in "createAndLinkDocument", missing "modelName": ${fieldPath.join('.')}`);
646
+ }
647
+
648
+ // create the new document
649
+ const result = await this.createDocument({
650
+ object: object,
651
+ srcProjectId: srcProjectId,
652
+ srcType: srcType,
653
+ modelName: modelName,
654
+ locale: locale,
655
+ user: user
656
+ });
657
+
658
+ // update the document by linking the field to the created document
659
+ const userContext = getUserContextForSrcType(srcType, user);
660
+ const field = {
661
+ type: 'reference',
662
+ refType: 'document',
663
+ refId: result.srcDocumentId
664
+ } as const;
665
+ const updatedDocument = await contentSourceData.instance.updateDocument({
666
+ documentId: srcDocumentId,
667
+ modelMap: modelMap,
668
+ userContext: userContext,
669
+ operations: [
670
+ modelField.type === 'list'
671
+ ? {
672
+ opType: 'insert',
673
+ fieldPath: fieldPath,
674
+ modelField: modelField,
675
+ locale: locale,
676
+ index: index,
677
+ item: field
678
+ }
679
+ : {
680
+ opType: 'set',
681
+ fieldPath: fieldPath,
682
+ modelField: modelField,
683
+ locale: locale,
684
+ field: field
685
+ }
686
+ ]
687
+ });
688
+ return { srcDocumentId: updatedDocument.id };
689
+ }
690
+
691
+ async uploadAndLinkAsset({
692
+ srcType,
693
+ srcProjectId,
694
+ srcDocumentId,
695
+ fieldPath,
696
+ asset,
697
+ index,
698
+ locale,
699
+ user
700
+ }: {
701
+ srcType: string;
702
+ srcProjectId: string;
703
+ srcDocumentId: string;
704
+ fieldPath: (string | number)[];
705
+ asset: ContentStoreTypes.UploadAssetData;
706
+ index?: number;
707
+ locale?: string;
708
+ user?: ContentStoreTypes.User;
709
+ }): Promise<{ srcDocumentId: string }> {
710
+ this.logger.debug('uploadAndLinkAsset', { srcType, srcProjectId, srcDocumentId, fieldPath, index, locale });
711
+
712
+ // get the document that is being updated
713
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
714
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
715
+ const document = contentSourceData.documentMap[srcDocumentId];
716
+ if (!document) {
717
+ throw new Error(`no document with id '${srcDocumentId}' was found in ${contentSourceData.id}`);
718
+ }
719
+
720
+ // get the document model
721
+ const documentModelName = document.srcModelName;
722
+ const modelMap = contentSourceData.modelMap;
723
+ const model = modelMap[documentModelName];
724
+ if (!model) {
725
+ throw new Error(`error updating document, could not find document model: '${documentModelName}'`);
726
+ }
727
+
728
+ // get the 'reference' model field in the updated document that will be used to link the new asset
729
+ locale = locale ?? contentSourceData.defaultLocaleCode;
730
+ const modelField = getModelFieldForFieldAtPath(document, model, fieldPath, modelMap, locale);
731
+ if (!modelField) {
732
+ throw Error(`the "fieldPath" points to non existing model field: ${fieldPath.join('.')}`);
733
+ }
734
+ const fieldProps = modelField.type === 'list' ? modelField.items! : modelField;
735
+ if (fieldProps.type !== 'reference' && fieldProps.type !== 'image') {
736
+ throw Error(`error in "uploadAndLinkAsset", this operation can only be used on reference and image fields: ${fieldPath.join('.')}`);
737
+ }
738
+
739
+ // upload the new asset
740
+ const userContext = getUserContextForSrcType(srcType, user);
741
+ const result = await contentSourceData.instance.uploadAsset({
742
+ url: asset.url,
743
+ fileName: asset.metadata.name,
744
+ mimeType: asset.metadata.type,
745
+ locale: locale,
746
+ userContext: userContext
747
+ });
748
+
749
+ // update the document by linking the field to the created asset
750
+ const field = {
751
+ type: 'reference',
752
+ refType: 'asset',
753
+ refId: result.id
754
+ } as const;
755
+ const updatedDocument = await contentSourceData.instance.updateDocument({
756
+ documentId: srcDocumentId,
757
+ modelMap: modelMap,
758
+ userContext: userContext,
759
+ operations: [
760
+ modelField.type === 'list'
761
+ ? {
762
+ opType: 'insert',
763
+ fieldPath: fieldPath,
764
+ modelField: modelField,
765
+ locale: locale,
766
+ index: index,
767
+ item: field
768
+ }
769
+ : {
770
+ opType: 'set',
771
+ fieldPath: fieldPath,
772
+ modelField: modelField,
773
+ locale: locale,
774
+ field: field
775
+ }
776
+ ]
777
+ });
778
+ return { srcDocumentId: updatedDocument.id };
779
+ }
780
+
781
+ async createDocument({
782
+ srcType,
783
+ srcProjectId,
784
+ modelName,
785
+ object,
786
+ locale,
787
+ user
788
+ }: {
789
+ srcType: string;
790
+ srcProjectId: string;
791
+ modelName: string;
792
+ object?: Record<string, any>;
793
+ locale?: string;
794
+ user?: ContentStoreTypes.User;
795
+ }): Promise<{ srcDocumentId: string }> {
796
+ this.logger.debug('createDocument', { srcType, srcProjectId, modelName, locale });
797
+
798
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
799
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
800
+ const modelMap = contentSourceData.modelMap;
801
+ const model = modelMap[modelName];
802
+ if (!model) {
803
+ throw new Error(`no model with name '${modelName}' was found`);
804
+ }
805
+
806
+ locale = locale ?? contentSourceData.defaultLocaleCode;
807
+ const userContext = getUserContextForSrcType(srcType, user);
808
+ const result = await createDocumentRecursively({
809
+ object,
810
+ model,
811
+ modelMap,
812
+ locale,
813
+ userContext,
814
+ contentSourceInstance: contentSourceData.instance
815
+ });
816
+ this.logger.debug('created document', { srcType, srcProjectId, srcDocumentId: result.document.id, modelName });
817
+
818
+ // do not update cache in contentSourceData.documents and documentMap,
819
+ // instead wait for contentSource to call onContentChange(contentChangeEvent)
820
+ // and use data from contentChangeEvent to update the cache
821
+ // const newDocuments = [result.document, ...result.referencedDocuments];
822
+ // contentSourceData.documentMap = Object.assign(contentSourceData.documentMap, _.keyBy(newDocuments, 'srcObjectId'));
823
+
824
+ return { srcDocumentId: result.document.id };
825
+ }
826
+
827
+ async updateDocument({
828
+ srcType,
829
+ srcProjectId,
830
+ srcDocumentId,
831
+ updateOperations,
832
+ user
833
+ }: {
834
+ srcType: string;
835
+ srcProjectId: string;
836
+ srcDocumentId: string;
837
+ updateOperations: ContentStoreTypes.UpdateOperation[];
838
+ user?: ContentStoreTypes.User;
839
+ }): Promise<{ srcDocumentId: string }> {
840
+ this.logger.debug('updateDocument');
841
+
842
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
843
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
844
+ const modelMap = contentSourceData.modelMap;
845
+ const userContext = getUserContextForSrcType(srcType, user);
846
+
847
+ const operations = await mapPromise(
848
+ updateOperations,
849
+ async (updateOperation): Promise<ContentSourceTypes.UpdateOperation> => {
850
+ const document = contentSourceData.documentMap[srcDocumentId];
851
+ if (!document) {
852
+ throw new Error(`no document with id '${srcDocumentId}' was found in ${contentSourceData.id}`);
853
+ }
854
+ const documentModelName = document.srcModelName;
855
+ const model = modelMap[documentModelName];
856
+ if (!model) {
857
+ throw new Error(`error updating document, could not find document model: '${documentModelName}'`);
858
+ }
859
+ const locale = updateOperation.locale ?? contentSourceData.defaultLocaleCode;
860
+ const modelField = getModelFieldForFieldAtPath(document, model, updateOperation.fieldPath, modelMap, locale);
861
+ switch (updateOperation.opType) {
862
+ case 'set':
863
+ const field = await convertOperationField({
864
+ operationField: updateOperation.field,
865
+ fieldPath: updateOperation.fieldPath,
866
+ locale: updateOperation.locale,
867
+ modelField,
868
+ modelMap,
869
+ userContext,
870
+ contentSourceInstance: contentSourceData.instance
871
+ });
872
+ return {
873
+ ...updateOperation,
874
+ modelField,
875
+ field
876
+ };
877
+ case 'unset':
878
+ return { ...updateOperation, modelField };
879
+ case 'insert':
880
+ const item = await convertOperationField({
881
+ operationField: updateOperation.item,
882
+ fieldPath: updateOperation.fieldPath,
883
+ locale: updateOperation.locale,
884
+ modelField,
885
+ modelMap,
886
+ userContext,
887
+ contentSourceInstance: contentSourceData.instance
888
+ });
889
+ return {
890
+ ...updateOperation,
891
+ modelField,
892
+ item
893
+ };
894
+ case 'remove':
895
+ return { ...updateOperation, modelField };
896
+ case 'reorder':
897
+ return { ...updateOperation, modelField };
898
+ }
899
+ }
900
+ );
901
+
902
+ const document = await contentSourceData.instance.updateDocument({
903
+ documentId: srcDocumentId,
904
+ modelMap,
905
+ userContext,
906
+ operations
907
+ });
908
+
909
+ // do not update cache in contentSourceData.documents and documentMap,
910
+ // instead wait for contentSource to call onContentChange(contentChangeEvent)
911
+ // and use data from contentChangeEvent to update the cache
912
+ // contentSourceData.documentMap = Object.assign(contentSourceData.documentMap, { [document.srcObjectId]: document });
913
+
914
+ return { srcDocumentId: document.id };
915
+ }
916
+
917
+ async duplicateDocument({
918
+ srcType,
919
+ srcProjectId,
920
+ srcDocumentId,
921
+ object,
922
+ user
923
+ }: {
924
+ srcType: string;
925
+ srcProjectId: string;
926
+ srcDocumentId: string;
927
+ object?: Record<string, any>;
928
+ user?: ContentStoreTypes.User;
929
+ }): Promise<{ srcDocumentId: string }> {
930
+ this.logger.debug('duplicateDocument');
931
+
932
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
933
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
934
+ const document = contentSourceData.documentMap[srcDocumentId];
935
+ if (!document) {
936
+ throw new Error(`no document with id '${srcDocumentId}' was found in ${contentSourceData.id}`);
937
+ }
938
+ const modelMap = contentSourceData.modelMap;
939
+ const model = modelMap[document.srcModelName];
940
+ if (!model) {
941
+ throw new Error(`no model with name '${document.srcModelName}' was found`);
942
+ }
943
+
944
+ const userContext = getUserContextForSrcType(srcType, user);
945
+ // TODO: handle duplicatable and non duplicatable models
946
+ // TODO: handle fields from the provided object
947
+ const documentResult = await contentSourceData.instance.createDocument({
948
+ documentFields: mapStoreFieldsToSourceFields({
949
+ documentFields: document.fields,
950
+ modelFields: model.fields!,
951
+ modelMap: contentSourceData.modelMap
952
+ }),
953
+ model,
954
+ modelMap,
955
+ locale: contentSourceData.defaultLocaleCode,
956
+ userContext
957
+ });
958
+
959
+ return { srcDocumentId: documentResult.id };
960
+ }
961
+
962
+ async uploadAssets({
963
+ srcType,
964
+ srcProjectId,
965
+ assets,
966
+ locale,
967
+ user
968
+ }: {
969
+ srcType: string;
970
+ srcProjectId: string;
971
+ assets: ContentStoreTypes.UploadAssetData[];
972
+ locale?: string;
973
+ user?: ContentStoreTypes.User;
974
+ }): Promise<ContentStoreTypes.APIAsset[]> {
975
+ this.logger.debug('uploadAssets');
976
+
977
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
978
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
979
+ const sourceAssets: ContentSourceTypes.Asset[] = [];
980
+ const userContext = getUserContextForSrcType(srcType, user);
981
+
982
+ locale = locale ?? contentSourceData.defaultLocaleCode;
983
+
984
+ for (const asset of assets) {
985
+ let base64 = undefined;
986
+ if (asset.data) {
987
+ const matchResult = asset.data.match(/;base64,([\s\S]+)$/);
988
+ if (matchResult) {
989
+ base64 = matchResult[1];
990
+ }
991
+ }
992
+ const sourceAsset = await contentSourceData.instance.uploadAsset({
993
+ url: asset.url,
994
+ base64: base64,
995
+ fileName: asset.metadata.name,
996
+ mimeType: asset.metadata.type,
997
+ locale: locale,
998
+ userContext: userContext
999
+ });
1000
+ sourceAssets.push(sourceAsset);
1001
+ }
1002
+ const storeAssets = mapSourceAssetsToStoreAssets({
1003
+ assets: sourceAssets,
1004
+ contentSourceInstance: contentSourceData.instance,
1005
+ defaultLocaleCode: contentSourceData.defaultLocaleCode
1006
+ });
1007
+ return mapStoreAssetsToAPIAssets(storeAssets, locale);
1008
+ }
1009
+
1010
+ async deleteDocument({
1011
+ srcType,
1012
+ srcProjectId,
1013
+ srcDocumentId,
1014
+ user
1015
+ }: {
1016
+ srcType: string;
1017
+ srcProjectId: string;
1018
+ srcDocumentId: string;
1019
+ user?: ContentStoreTypes.User;
1020
+ }) {
1021
+ this.logger.debug('deleteDocument');
1022
+
1023
+ const userContext = getUserContextForSrcType(srcType, user);
1024
+ const contentSourceId = getContentSourceId(srcType, srcProjectId);
1025
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1026
+ await contentSourceData.instance.deleteDocument({ documentId: srcDocumentId, userContext });
1027
+
1028
+ // do not update cache in contentSourceData.documents and documentMap,
1029
+ // instead wait for contentSource to call onContentChange(contentChangeEvent)
1030
+ // and use data from contentChangeEvent to update the cache
1031
+ // delete contentSourceData.documentMap[srcDocumentId];
1032
+ }
1033
+
1034
+ async validateDocuments({
1035
+ objects,
1036
+ locale,
1037
+ user
1038
+ }: {
1039
+ objects: { srcType: string; srcProjectId: string; srcObjectId: string }[];
1040
+ locale?: string;
1041
+ user?: ContentStoreTypes.User;
1042
+ }): Promise<{ errors: ContentStoreTypes.ValidationError[] }> {
1043
+ this.logger.debug('validateDocuments');
1044
+
1045
+ const objectsBySourceId = _.groupBy(objects, (object) => getContentSourceId(object.srcType, object.srcProjectId));
1046
+ let errors: ContentStoreTypes.ValidationError[] = [];
1047
+ for (const [contentSourceId, objects] of Object.entries(objectsBySourceId)) {
1048
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1049
+ locale = locale ?? contentSourceData.defaultLocaleCode;
1050
+ const documentIds = [];
1051
+ const assetIds = [];
1052
+ for (const object of objects) {
1053
+ if (object.srcObjectId in contentSourceData.documentMap) {
1054
+ documentIds.push(object.srcObjectId);
1055
+ } else if (object.srcObjectId in contentSourceData.assetMap) {
1056
+ assetIds.push(object.srcObjectId);
1057
+ }
1058
+ }
1059
+ const userContext = getUserContextForSrcType(contentSourceData.type, user);
1060
+ const validationResult = await contentSourceData.instance.validateDocuments({ documentIds, assetIds, locale, userContext });
1061
+ errors = errors.concat(
1062
+ validationResult.errors.map((validationError) => ({
1063
+ message: validationError.message,
1064
+ srcType: contentSourceData.type,
1065
+ srcProjectId: contentSourceData.projectId,
1066
+ srcObjectType: validationError.objectType,
1067
+ srcObjectId: validationError.objectId,
1068
+ fieldPath: validationError.fieldPath,
1069
+ isUniqueValidation: validationError.isUniqueValidation
1070
+ }))
1071
+ );
1072
+ }
1073
+
1074
+ return { errors };
1075
+
1076
+ /* validate for multiple sources
1077
+ const objectsBySourceId = _.groupBy(objects, (document) => getContentSourceId(document.srcType, document.srcProjectId));
1078
+ const contentSourceIds = Object.keys(objectsBySourceId);
1079
+ return reducePromise(
1080
+ contentSourceIds,
1081
+ async (result: ContentStoreTypes.ValidationError[], contentSourceId) => {
1082
+ const documents = documentsBySourceId[contentSourceId]!;
1083
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1084
+ const validationErrors = await contentSourceData.instance.validateDocuments({ documentIds: documents.map((document) => document.srcObjectId) });
1085
+ return result.concat(validationErrors);
1086
+ },
1087
+ []
1088
+ );
1089
+ */
1090
+ }
1091
+
1092
+ async publishDocuments({ objects, user }: { objects: { srcType: string; srcProjectId: string; srcObjectId: string }[]; user?: ContentStoreTypes.User }) {
1093
+ this.logger.debug('publishDocuments');
1094
+
1095
+ const objectsBySourceId = _.groupBy(objects, (object) => getContentSourceId(object.srcType, object.srcProjectId));
1096
+ for (const [contentSourceId, objects] of Object.entries(objectsBySourceId)) {
1097
+ const contentSourceData = this.getContentSourceDataByIdOrThrow(contentSourceId);
1098
+ const userContext = getUserContextForSrcType(contentSourceData.type, user);
1099
+ const documentIds = [];
1100
+ const assetIds = [];
1101
+ for (const object of objects) {
1102
+ if (object.srcObjectId in contentSourceData.documentMap) {
1103
+ documentIds.push(object.srcObjectId);
1104
+ } else if (object.srcObjectId in contentSourceData.assetMap) {
1105
+ assetIds.push(object.srcObjectId);
1106
+ }
1107
+ }
1108
+ await contentSourceData.instance.publishDocuments({ documentIds, assetIds, userContext });
1109
+ }
1110
+ }
1111
+
1112
+ private getContentSourceDataByIdOrThrow(contentSourceId: string): ContentSourceData {
1113
+ const contentSourceData = this.contentSourceDataById[contentSourceId];
1114
+ if (!contentSourceData) {
1115
+ throw new Error(`no content source for id '${contentSourceId}' was found`);
1116
+ }
1117
+ return contentSourceData;
1118
+ }
1119
+ }
1120
+
1121
+ export function getContentSourceId(contentSourceType: string, srcProjectId: string) {
1122
+ return contentSourceType + ':' + srcProjectId;
1123
+ }
1124
+
1125
+ function getUserContextForSrcType(srcType: string, user?: ContentStoreTypes.User): unknown {
1126
+ return user?.connections?.find((connection) => connection.type === srcType);
1127
+ }
1128
+
1129
+ function mapSourceAssetsToStoreAssets({
1130
+ assets,
1131
+ contentSourceInstance,
1132
+ defaultLocaleCode
1133
+ }: {
1134
+ assets: ContentSourceTypes.Asset[];
1135
+ contentSourceInstance: ContentSourceTypes.ContentSourceInterface;
1136
+ defaultLocaleCode?: string;
1137
+ }): ContentStoreTypes.Asset[] {
1138
+ const extra = {
1139
+ srcType: contentSourceInstance.getContentSourceType(),
1140
+ srcProjectId: contentSourceInstance.getProjectId(),
1141
+ srcProjectUrl: contentSourceInstance.getProjectManageUrl(),
1142
+ srcEnvironment: contentSourceInstance.getProjectEnvironment()
1143
+ };
1144
+ return assets.map((asset) => sourceAssetToStoreAsset({ asset, defaultLocaleCode, extra }));
1145
+ }
1146
+
1147
+ function sourceAssetToStoreAsset({
1148
+ asset,
1149
+ defaultLocaleCode,
1150
+ extra
1151
+ }: {
1152
+ asset: ContentSourceTypes.Asset;
1153
+ defaultLocaleCode?: string;
1154
+ extra: { srcType: string; srcProjectId: string; srcProjectUrl: string; srcEnvironment: string };
1155
+ }): ContentStoreTypes.Asset {
1156
+ return {
1157
+ type: 'asset',
1158
+ ...extra,
1159
+ srcObjectId: asset.id,
1160
+ srcObjectUrl: asset.manageUrl,
1161
+ srcObjectLabel: getObjectLabel(asset.fields, IMAGE_MODEL, defaultLocaleCode),
1162
+ srcModelName: IMAGE_MODEL.name,
1163
+ srcModelLabel: IMAGE_MODEL.label!,
1164
+ isChanged: asset.status === 'added' || asset.status === 'modified',
1165
+ status: asset.status,
1166
+ createdAt: asset.createdAt,
1167
+ createdBy: asset.createdBy,
1168
+ updatedAt: asset.updatedAt,
1169
+ updatedBy: asset.updatedBy,
1170
+ fields: {
1171
+ title: {
1172
+ label: 'Title',
1173
+ ...asset.fields.title
1174
+ },
1175
+ file: {
1176
+ label: 'File',
1177
+ ...asset.fields.file
1178
+ }
1179
+ }
1180
+ };
1181
+ }
1182
+
1183
+ function mapSourceDocumentsToStoreDocuments({
1184
+ documents,
1185
+ contentSourceInstance,
1186
+ modelMap,
1187
+ defaultLocaleCode
1188
+ }: {
1189
+ documents: ContentSourceTypes.Document[];
1190
+ contentSourceInstance: ContentSourceTypes.ContentSourceInterface;
1191
+ modelMap: Record<string, Model>;
1192
+ defaultLocaleCode?: string;
1193
+ }): ContentStoreTypes.Document[] {
1194
+ const extra = {
1195
+ srcType: contentSourceInstance.getContentSourceType(),
1196
+ srcProjectId: contentSourceInstance.getProjectId(),
1197
+ srcProjectUrl: contentSourceInstance.getProjectManageUrl(),
1198
+ srcEnvironment: contentSourceInstance.getProjectEnvironment()
1199
+ };
1200
+ return documents.map((document) =>
1201
+ mapSourceDocumentToStoreDocument({ document, model: modelMap[document.modelName]!, modelMap, defaultLocaleCode, extra })
1202
+ );
1203
+ }
1204
+
1205
+ function mapSourceDocumentToStoreDocument({
1206
+ document,
1207
+ model,
1208
+ modelMap,
1209
+ defaultLocaleCode,
1210
+ extra
1211
+ }: {
1212
+ document: ContentSourceTypes.Document;
1213
+ model: Model;
1214
+ modelMap: Record<string, Model>;
1215
+ defaultLocaleCode?: string;
1216
+ extra: { srcType: string; srcProjectId: string; srcProjectUrl: string; srcEnvironment: string };
1217
+ }): ContentStoreTypes.Document {
1218
+ return {
1219
+ type: 'document',
1220
+ ...extra,
1221
+ srcObjectId: document.id,
1222
+ srcObjectUrl: document.manageUrl,
1223
+ srcObjectLabel: getObjectLabel(document.fields, model, defaultLocaleCode),
1224
+ srcModelLabel: model.label ?? _.startCase(document.modelName),
1225
+ srcModelName: document.modelName,
1226
+ isChanged: document.status === 'added' || document.status === 'modified',
1227
+ status: document.status,
1228
+ createdAt: document.createdAt,
1229
+ createdBy: document.createdBy,
1230
+ updatedAt: document.updatedAt,
1231
+ updatedBy: document.updatedBy,
1232
+ fields: mapSourceFieldsToStoreFields({
1233
+ documentFields: document.fields,
1234
+ modelFields: model.fields ?? [],
1235
+ modelMap,
1236
+ defaultLocaleCode
1237
+ })
1238
+ };
1239
+ }
1240
+
1241
+ function mapSourceFieldsToStoreFields({
1242
+ documentFields,
1243
+ modelFields,
1244
+ modelMap,
1245
+ defaultLocaleCode
1246
+ }: {
1247
+ documentFields: Record<string, ContentSourceTypes.DocumentField>;
1248
+ modelFields: Field[];
1249
+ modelMap: Record<string, Model>;
1250
+ defaultLocaleCode?: string;
1251
+ }): Record<string, ContentStoreTypes.DocumentField> {
1252
+ return modelFields.reduce((result: Record<string, ContentStoreTypes.DocumentField>, modelField) => {
1253
+ const documentField = documentFields[modelField.name];
1254
+ const docField = mapSourceFieldToStoreField({
1255
+ documentField,
1256
+ modelField,
1257
+ modelMap,
1258
+ defaultLocaleCode
1259
+ });
1260
+ docField.label = modelField.label;
1261
+ result[modelField.name] = docField;
1262
+ return result;
1263
+ }, {});
1264
+ }
1265
+
1266
+ function mapSourceFieldToStoreField({
1267
+ documentField,
1268
+ modelField,
1269
+ modelMap,
1270
+ defaultLocaleCode
1271
+ }: {
1272
+ documentField: ContentSourceTypes.DocumentField | undefined;
1273
+ modelField: FieldSpecificProps;
1274
+ modelMap: Record<string, Model>;
1275
+ defaultLocaleCode?: string;
1276
+ }): ContentStoreTypes.DocumentField {
1277
+ if (!documentField) {
1278
+ const isUnset = ['object', 'model', 'reference', 'richText', 'markdown', 'image', 'file', 'json'].includes(modelField.type);
1279
+ return {
1280
+ type: modelField.type,
1281
+ ...(isUnset ? { isUnset } : null),
1282
+ ...(modelField.type === 'list' ? { items: [] } : null)
1283
+ } as ContentStoreTypes.DocumentField;
1284
+ }
1285
+ // TODO: check if need to add "options" to "enum" and subtype/min/max to "number"
1286
+ switch (modelField.type) {
1287
+ case 'object':
1288
+ return mapObjectField(documentField as ContentSourceTypes.DocumentFieldForType<typeof modelField.type>, modelField, modelMap, defaultLocaleCode);
1289
+ case 'model':
1290
+ return mapModelField(documentField as ContentSourceTypes.DocumentModelField, modelField, modelMap, defaultLocaleCode);
1291
+ case 'list':
1292
+ return mapListField(documentField as ContentSourceTypes.DocumentListField, modelField, modelMap, defaultLocaleCode);
1293
+ case 'richText':
1294
+ return mapRichTextField(documentField as ContentSourceTypes.DocumentRichTextField);
1295
+ case 'markdown':
1296
+ return mapMarkdownField(documentField as ContentSourceTypes.DocumentValueField);
1297
+ default:
1298
+ return documentField as ContentStoreTypes.DocumentField;
1299
+ }
1300
+ }
1301
+
1302
+ function mapObjectField(
1303
+ documentField: ContentSourceTypes.DocumentObjectField,
1304
+ modelField: FieldObjectProps,
1305
+ modelMap: Record<string, Model>,
1306
+ defaultLocaleCode?: string
1307
+ ): ContentStoreTypes.DocumentObjectField {
1308
+ if (!isLocalizedField(documentField)) {
1309
+ return {
1310
+ type: documentField.type,
1311
+ srcObjectLabel: getObjectLabel(documentField.fields ?? {}, modelField ?? [], defaultLocaleCode),
1312
+ fields: mapSourceFieldsToStoreFields({
1313
+ documentFields: documentField.fields ?? {},
1314
+ modelFields: modelField.fields ?? [],
1315
+ modelMap,
1316
+ defaultLocaleCode
1317
+ })
1318
+ };
1319
+ }
1320
+ return {
1321
+ type: documentField.type,
1322
+ localized: true,
1323
+ locales: _.mapValues(documentField.locales, (locale) => {
1324
+ return {
1325
+ locale: locale.locale,
1326
+ srcObjectLabel: getObjectLabel(locale.fields ?? {}, modelField, locale.locale),
1327
+ fields: mapSourceFieldsToStoreFields({
1328
+ documentFields: locale.fields ?? {},
1329
+ modelFields: modelField.fields ?? [],
1330
+ modelMap,
1331
+ defaultLocaleCode
1332
+ })
1333
+ };
1334
+ })
1335
+ };
1336
+ }
1337
+
1338
+ function mapModelField(
1339
+ documentField: ContentSourceTypes.DocumentModelField,
1340
+ modelField: FieldModelProps,
1341
+ modelMap: Record<string, Model>,
1342
+ defaultLocaleCode?: string
1343
+ ): ContentStoreTypes.DocumentModelField {
1344
+ if (!isLocalizedField(documentField)) {
1345
+ const model = modelMap[documentField.modelName]!;
1346
+ return {
1347
+ type: documentField.type,
1348
+ srcObjectLabel: getObjectLabel(documentField.fields ?? {}, model, defaultLocaleCode),
1349
+ srcModelName: documentField.modelName,
1350
+ srcModelLabel: model.label ?? _.startCase(model.name),
1351
+ fields: mapSourceFieldsToStoreFields({
1352
+ documentFields: documentField.fields ?? {},
1353
+ modelFields: model.fields ?? [],
1354
+ modelMap,
1355
+ defaultLocaleCode
1356
+ })
1357
+ };
1358
+ }
1359
+ return {
1360
+ type: documentField.type,
1361
+ localized: true,
1362
+ locales: _.mapValues(documentField.locales, (locale) => {
1363
+ const model = modelMap[locale.modelName]!;
1364
+ return {
1365
+ locale: locale.locale,
1366
+ srcObjectLabel: getObjectLabel(locale.fields ?? {}, model, locale.locale),
1367
+ srcModelName: locale.modelName,
1368
+ srcModelLabel: model.label ?? _.startCase(model.name),
1369
+ fields: mapSourceFieldsToStoreFields({
1370
+ documentFields: locale.fields ?? {},
1371
+ modelFields: model.fields ?? [],
1372
+ modelMap,
1373
+ defaultLocaleCode
1374
+ })
1375
+ };
1376
+ })
1377
+ };
1378
+ }
1379
+
1380
+ function mapListField(
1381
+ documentField: ContentSourceTypes.DocumentListField,
1382
+ modelField: FieldListProps,
1383
+ modelMap: Record<string, Model>,
1384
+ defaultLocaleCode?: string
1385
+ ): ContentStoreTypes.DocumentListField {
1386
+ if (!isLocalizedField(documentField)) {
1387
+ return {
1388
+ type: documentField.type,
1389
+ items: documentField.items.map((item) =>
1390
+ mapSourceFieldToStoreField({
1391
+ documentField: item,
1392
+ modelField: modelField.items ?? { type: 'string' },
1393
+ modelMap,
1394
+ defaultLocaleCode
1395
+ })
1396
+ )
1397
+ };
1398
+ }
1399
+ return {
1400
+ type: documentField.type,
1401
+ localized: true,
1402
+ locales: _.mapValues(documentField.locales, (locale) => {
1403
+ return {
1404
+ locale: locale.locale,
1405
+ items: (locale.items ?? []).map((item) =>
1406
+ mapSourceFieldToStoreField({
1407
+ documentField: item,
1408
+ modelField: modelField.items ?? { type: 'string' },
1409
+ modelMap,
1410
+ defaultLocaleCode
1411
+ })
1412
+ )
1413
+ };
1414
+ })
1415
+ };
1416
+ }
1417
+
1418
+ function mapRichTextField(documentField: ContentSourceTypes.DocumentRichTextField): ContentStoreTypes.DocumentRichTextField {
1419
+ if (!isLocalizedField(documentField)) {
1420
+ return {
1421
+ ...documentField,
1422
+ multiElement: true
1423
+ };
1424
+ }
1425
+ return {
1426
+ type: documentField.type,
1427
+ localized: true,
1428
+ locales: _.mapValues(documentField.locales, (locale) => {
1429
+ return {
1430
+ ...locale,
1431
+ multiElement: true
1432
+ };
1433
+ })
1434
+ };
1435
+ }
1436
+
1437
+ function mapMarkdownField(documentField: ContentSourceTypes.DocumentValueField): ContentStoreTypes.DocumentMarkdownField {
1438
+ if (!isLocalizedField(documentField)) {
1439
+ return {
1440
+ type: 'markdown',
1441
+ value: documentField.value,
1442
+ multiElement: true
1443
+ };
1444
+ }
1445
+ return {
1446
+ type: 'markdown',
1447
+ localized: true,
1448
+ locales: _.mapValues(documentField.locales, (locale) => {
1449
+ return {
1450
+ ...locale,
1451
+ multiElement: true
1452
+ };
1453
+ })
1454
+ };
1455
+ }
1456
+
1457
+ function mapStoreFieldsToSourceFields({
1458
+ documentFields,
1459
+ modelFields,
1460
+ modelMap
1461
+ }: {
1462
+ documentFields: Record<string, ContentStoreTypes.DocumentField>;
1463
+ modelFields: Field[];
1464
+ modelMap: Record<string, Model>;
1465
+ }): Record<string, ContentSourceTypes.DocumentField> {
1466
+ // TODO:
1467
+ throw new Error(`duplicateDocument not implemented yet`);
1468
+ }
1469
+
1470
+ function getContentSourceIdForContentSource(contentSource: ContentSourceTypes.ContentSourceInterface): string {
1471
+ return getContentSourceId(contentSource.getContentSourceType(), contentSource.getProjectId());
1472
+ }
1473
+
1474
+ function extractTokensFromString(input: string): string[] {
1475
+ return input.match(/(?<={)[^}]+(?=})/g) || [];
1476
+ }
1477
+
1478
+ function sanitizeSlug(slug: string) {
1479
+ return slug
1480
+ .split('/')
1481
+ .map((part) => slugify(part, { lower: true }))
1482
+ .join('/');
1483
+ }
1484
+
1485
+ function getObjectLabel(
1486
+ documentFields: Record<string, ContentSourceTypes.DocumentField | ContentSourceTypes.AssetFileField>,
1487
+ modelOrObjectField: Model | FieldObjectProps,
1488
+ locale?: string
1489
+ ): string {
1490
+ const labelField = modelOrObjectField.labelField;
1491
+ let label = null;
1492
+ if (labelField) {
1493
+ const field = _.get(documentFields, labelField, null);
1494
+ if (field && ['string', 'url', 'slug', 'text', 'markdown', 'number', 'enum', 'date', 'datetime', 'color', 'image', 'file'].includes(field.type)) {
1495
+ if (isLocalizedField(field) && locale) {
1496
+ label = _.get(field, ['locales', locale, 'value'], null);
1497
+ } else if (!isLocalizedField(field)) {
1498
+ label = _.get(field, 'value', null);
1499
+ }
1500
+ }
1501
+ }
1502
+ if (!label) {
1503
+ label = _.get(modelOrObjectField, 'label');
1504
+ }
1505
+ if (!label && _.has(modelOrObjectField, 'name')) {
1506
+ label = _.startCase(_.get(modelOrObjectField, 'name'));
1507
+ }
1508
+ return label;
1509
+ }
1510
+
1511
+ function mapDocumentsToLocalizedApiObjects(documents: ContentStoreTypes.Document[], locale?: string): ContentStoreTypes.APIDocumentObject[] {
1512
+ return documents.map((document) => documentToLocalizedApiObject(document, locale));
1513
+ }
1514
+
1515
+ function documentToLocalizedApiObject(document: ContentStoreTypes.Document, locale?: string): ContentStoreTypes.APIDocumentObject {
1516
+ const { type, fields, ...rest } = document;
1517
+ return {
1518
+ type: 'object',
1519
+ ...rest,
1520
+ fields: toLocalizedAPIFields(fields, locale)
1521
+ };
1522
+ }
1523
+
1524
+ function toLocalizedAPIFields(docFields: Record<string, ContentStoreTypes.DocumentField>, locale?: string): Record<string, ContentStoreTypes.DocumentFieldAPI> {
1525
+ return _.mapValues(docFields, (docField) => toLocalizedAPIField(docField, locale));
1526
+ }
1527
+
1528
+ function toLocalizedAPIField(docField: ContentStoreTypes.DocumentField, locale?: string, isListItem = false): ContentStoreTypes.DocumentFieldAPI {
1529
+ const hasUnsetFlag = ['object', 'model', 'reference', 'richText', 'markdown', 'image', 'file', 'json'].includes(docField.type);
1530
+ let docFieldLocalized: ContentStoreTypes.DocumentFieldNonLocalized;
1531
+ let unset = false;
1532
+ if (docField.localized) {
1533
+ const { locales, localized, ...base } = docField;
1534
+ const localeProps = locale ? locales[locale] : undefined;
1535
+ docFieldLocalized = {
1536
+ ...base,
1537
+ ...localeProps,
1538
+ ...(hasUnsetFlag && !localeProps ? { isUnset: true } : null)
1539
+ } as ContentStoreTypes.DocumentFieldNonLocalized;
1540
+ } else {
1541
+ docFieldLocalized = docField;
1542
+ }
1543
+
1544
+ locale = locale ?? docFieldLocalized.locale;
1545
+ const commonProps = isListItem
1546
+ ? null
1547
+ : {
1548
+ localized: !!docField.localized,
1549
+ ...(locale ? { locale } : null)
1550
+ };
1551
+
1552
+ if (docFieldLocalized.type === 'object' || docFieldLocalized.type === 'model') {
1553
+ return {
1554
+ ...docFieldLocalized,
1555
+ ...commonProps,
1556
+ ...(docFieldLocalized.isUnset
1557
+ ? null
1558
+ : {
1559
+ fields: toLocalizedAPIFields(docFieldLocalized.fields, locale)
1560
+ })
1561
+ } as ContentStoreTypes.DocumentObjectFieldAPI | ContentStoreTypes.DocumentModelFieldAPI;
1562
+ } else if (docFieldLocalized.type === 'reference') {
1563
+ const { type, refType, ...rest } = docFieldLocalized;
1564
+ // if reference field isUnset === true, it behaves like a regular object
1565
+ if (rest.isUnset) {
1566
+ return {
1567
+ type: 'object',
1568
+ ...rest,
1569
+ ...commonProps
1570
+ };
1571
+ }
1572
+ return {
1573
+ type: 'unresolved_reference',
1574
+ refType: refType === 'asset' ? 'image' : 'object',
1575
+ ...rest,
1576
+ ...commonProps
1577
+ };
1578
+ } else if (docFieldLocalized.type === 'list') {
1579
+ const { items, ...rest } = docFieldLocalized;
1580
+ return {
1581
+ ...rest,
1582
+ ...commonProps,
1583
+ items: items.map((field) => toLocalizedAPIField(field, locale, true))
1584
+ };
1585
+ } else {
1586
+ return {
1587
+ ...docFieldLocalized,
1588
+ ...commonProps
1589
+ };
1590
+ }
1591
+ }
1592
+
1593
+ function mapAssetsToLocalizedApiImages(assets: ContentStoreTypes.Asset[], locale?: string): ContentStoreTypes.APIImageObject[] {
1594
+ return assets.map((asset) => assetToLocalizedApiImage(asset, locale));
1595
+ }
1596
+
1597
+ function assetToLocalizedApiImage(asset: ContentStoreTypes.Asset, locale?: string): ContentStoreTypes.APIImageObject {
1598
+ const { type, fields, ...rest } = asset;
1599
+ return {
1600
+ type: 'image',
1601
+ ...rest,
1602
+ fields: localizeAssetFields(fields, locale)
1603
+ };
1604
+ }
1605
+
1606
+ function localizeAssetFields(assetFields: ContentStoreTypes.AssetFields, locale?: string): ContentStoreTypes.AssetFieldsAPI {
1607
+ const fields: ContentStoreTypes.AssetFieldsAPI = {
1608
+ title: {
1609
+ type: 'string' as const,
1610
+ value: null as any
1611
+ },
1612
+ url: {
1613
+ type: 'string' as const,
1614
+ value: null as any
1615
+ }
1616
+ };
1617
+ const titleFieldNonLocalized = getDocumentFieldForLocale(assetFields.title, locale);
1618
+ fields.title.value = titleFieldNonLocalized?.value;
1619
+ fields.title.locale = locale ?? titleFieldNonLocalized?.locale;
1620
+ const assetFileField = assetFields.file;
1621
+ if (assetFileField.localized) {
1622
+ if (locale) {
1623
+ fields.url.value = assetFileField.locales[locale]?.url ?? null;
1624
+ fields.url.locale = locale;
1625
+ }
1626
+ } else {
1627
+ fields.url.value = assetFileField.url;
1628
+ fields.url.locale = assetFileField.locale;
1629
+ }
1630
+ return fields;
1631
+ }
1632
+
1633
+ function mapStoreAssetsToAPIAssets(assets: ContentStoreTypes.Asset[], locale?: string): ContentStoreTypes.APIAsset[] {
1634
+ return assets.map((asset) => storeAssetToAPIAsset(asset, locale));
1635
+ }
1636
+
1637
+ function storeAssetToAPIAsset(asset: ContentStoreTypes.Asset, locale?: string): ContentStoreTypes.APIAsset {
1638
+ const assetTitleField = asset.fields.title;
1639
+ const localizedTitleField = assetTitleField.localized ? assetTitleField.locales[locale!]! : assetTitleField;
1640
+ const assetFileField = asset.fields.file;
1641
+ const localizedFileField = assetFileField.localized ? assetFileField.locales[locale!]! : assetFileField;
1642
+ return {
1643
+ objectId: asset.srcObjectId,
1644
+ createdAt: asset.createdAt,
1645
+ url: localizedFileField.url,
1646
+ ...omitByNil({
1647
+ title: localizedTitleField.value,
1648
+ fileName: localizedFileField.fileName,
1649
+ contentType: localizedFileField.contentType,
1650
+ size: localizedFileField.size,
1651
+ width: localizedFileField.dimensions?.width,
1652
+ height: localizedFileField.dimensions?.height
1653
+ })
1654
+ };
1655
+ }
1656
+
1657
+ /**
1658
+ * Iterates recursively objects with $$type and $$ref, creating nested objects
1659
+ * as needed and returns standard ContentSourceInterface Documents
1660
+ */
1661
+ async function createDocumentRecursively({
1662
+ object,
1663
+ model,
1664
+ modelMap,
1665
+ locale,
1666
+ userContext,
1667
+ contentSourceInstance
1668
+ }: {
1669
+ object?: Record<string, any>;
1670
+ model: Model;
1671
+ modelMap: Record<string, Model>;
1672
+ locale?: string;
1673
+ userContext: unknown;
1674
+ contentSourceInstance: ContentSourceTypes.ContentSourceInterface;
1675
+ }): Promise<{ document: ContentSourceTypes.Document; newRefDocuments: ContentSourceTypes.Document[] }> {
1676
+ if (model.type === 'page') {
1677
+ const tokens = extractTokensFromString(String(model.urlPath));
1678
+ const slugField = _.last(tokens);
1679
+ if (object && slugField && slugField in object) {
1680
+ const slugFieldValue = object[slugField];
1681
+ object[slugField] = sanitizeSlug(slugFieldValue);
1682
+ }
1683
+ }
1684
+
1685
+ const nestedResult = await createNestedObjectRecursively({
1686
+ object,
1687
+ modelFields: model.fields ?? [],
1688
+ fieldPath: [],
1689
+ modelMap,
1690
+ locale,
1691
+ userContext,
1692
+ contentSourceInstance
1693
+ });
1694
+ const document = await contentSourceInstance.createDocument({
1695
+ documentFields: nestedResult.fields,
1696
+ model,
1697
+ modelMap,
1698
+ locale,
1699
+ userContext
1700
+ });
1701
+ return {
1702
+ document: document,
1703
+ newRefDocuments: nestedResult.newRefDocuments
1704
+ };
1705
+ }
1706
+
1707
+ async function createNestedObjectRecursively({
1708
+ object,
1709
+ modelFields,
1710
+ fieldPath,
1711
+ modelMap,
1712
+ locale,
1713
+ userContext,
1714
+ contentSourceInstance
1715
+ }: {
1716
+ object?: Record<string, any>;
1717
+ modelFields: Field[];
1718
+ fieldPath: (string | number)[];
1719
+ modelMap: Record<string, Model>;
1720
+ locale?: string;
1721
+ userContext: unknown;
1722
+ contentSourceInstance: ContentSourceTypes.ContentSourceInterface;
1723
+ }): Promise<{
1724
+ fields: Record<string, ContentSourceTypes.DocumentFieldNonLocalized>;
1725
+ newRefDocuments: ContentSourceTypes.Document[];
1726
+ }> {
1727
+ const createNestedField = async ({
1728
+ value,
1729
+ modelField,
1730
+ fieldPath
1731
+ }: {
1732
+ value: any;
1733
+ modelField: FieldSpecificProps;
1734
+ fieldPath: (string | number)[];
1735
+ }): Promise<{ field: ContentSourceTypes.DocumentFieldNonLocalized; newRefDocuments: ContentSourceTypes.Document[] }> => {
1736
+ if (modelField.type === 'object') {
1737
+ const result = await createNestedObjectRecursively({
1738
+ object: value,
1739
+ modelFields: modelField.fields,
1740
+ fieldPath,
1741
+ modelMap,
1742
+ locale,
1743
+ userContext,
1744
+ contentSourceInstance
1745
+ });
1746
+ return {
1747
+ field: {
1748
+ type: 'object',
1749
+ fields: result.fields
1750
+ },
1751
+ newRefDocuments: result.newRefDocuments
1752
+ };
1753
+ } else if (modelField.type === 'model') {
1754
+ let { $$type, ...rest } = value;
1755
+ const modelNames = modelField.models;
1756
+ // for backward compatibility check if the object has 'type' instead of '$$type' because older projects use
1757
+ // the 'type' property in default values
1758
+ if (!$$type && 'type' in rest) {
1759
+ $$type = rest.type;
1760
+ rest = _.omit(rest, 'type');
1761
+ }
1762
+ const modelName = $$type ?? (modelNames.length === 1 ? modelNames[0] : null);
1763
+ if (!modelName) {
1764
+ throw new Error(`no $$type was specified for nested model`);
1765
+ }
1766
+ const model = modelMap[modelName];
1767
+ if (!model) {
1768
+ throw new Error(`no model with name '${modelName}' was found`);
1769
+ }
1770
+ const result = await createNestedObjectRecursively({
1771
+ object: rest,
1772
+ modelFields: model.fields ?? [],
1773
+ fieldPath,
1774
+ modelMap,
1775
+ locale,
1776
+ userContext,
1777
+ contentSourceInstance
1778
+ });
1779
+ return {
1780
+ field: {
1781
+ type: 'model',
1782
+ modelName: modelName,
1783
+ fields: result.fields
1784
+ },
1785
+ newRefDocuments: result.newRefDocuments
1786
+ };
1787
+ } else if (modelField.type === 'image') {
1788
+ let refId: string | undefined;
1789
+ if (_.isPlainObject(value)) {
1790
+ refId = value.$$ref;
1791
+ } else {
1792
+ refId = value;
1793
+ }
1794
+ if (!refId) {
1795
+ throw new Error(`reference field must specify a value`);
1796
+ }
1797
+ return {
1798
+ field: {
1799
+ type: 'reference',
1800
+ refType: 'asset',
1801
+ refId: refId
1802
+ },
1803
+ newRefDocuments: []
1804
+ };
1805
+ } else if (modelField.type === 'reference') {
1806
+ let { $$ref: refId = null, $$type: modelName = null, ...rest } = _.isPlainObject(value) ? value : { $$ref: value };
1807
+ if (refId) {
1808
+ return {
1809
+ field: {
1810
+ type: 'reference',
1811
+ refType: 'document',
1812
+ refId: refId
1813
+ },
1814
+ newRefDocuments: []
1815
+ };
1816
+ } else {
1817
+ const modelNames = modelField.models;
1818
+ if (!modelName) {
1819
+ // for backward compatibility check if the object has 'type' instead of '$$type' because older projects use
1820
+ // the 'type' property in default values
1821
+ if ('type' in rest) {
1822
+ modelName = rest.type;
1823
+ rest = _.omit(rest, 'type');
1824
+ } else if (modelNames.length === 1) {
1825
+ modelName = modelNames[0];
1826
+ }
1827
+ }
1828
+ const model = modelMap[modelName];
1829
+ if (!model) {
1830
+ throw new Error(`no model with name '${modelName}' was found`);
1831
+ }
1832
+ const { document, newRefDocuments } = await createDocumentRecursively({
1833
+ object: rest,
1834
+ model: model,
1835
+ modelMap,
1836
+ locale,
1837
+ userContext,
1838
+ contentSourceInstance
1839
+ });
1840
+ return {
1841
+ field: {
1842
+ type: 'reference',
1843
+ refType: 'document',
1844
+ refId: document.id
1845
+ },
1846
+ newRefDocuments: [document, ...newRefDocuments]
1847
+ };
1848
+ }
1849
+ } else if (modelField.type === 'list') {
1850
+ if (!Array.isArray(value)) {
1851
+ throw new Error(`value for list field must be array`);
1852
+ }
1853
+ const itemsField = modelField.items;
1854
+ if (!itemsField) {
1855
+ throw new Error(`list field does not define items`);
1856
+ }
1857
+ const arrayResult = await mapPromise(value, async (item, index) => {
1858
+ return createNestedField({
1859
+ value: item,
1860
+ modelField: itemsField,
1861
+ fieldPath: fieldPath.concat(index)
1862
+ });
1863
+ });
1864
+ return {
1865
+ field: {
1866
+ type: 'list',
1867
+ items: arrayResult.map((result) => result.field)
1868
+ },
1869
+ newRefDocuments: arrayResult.reduce((result: ContentSourceTypes.Document[], { newRefDocuments }) => result.concat(newRefDocuments), [])
1870
+ };
1871
+ }
1872
+ return {
1873
+ field: {
1874
+ type: modelField.type,
1875
+ value: value
1876
+ } as ContentSourceTypes.DocumentFieldNonLocalized,
1877
+ newRefDocuments: []
1878
+ };
1879
+ };
1880
+
1881
+ object = object ?? {};
1882
+ const result: {
1883
+ fields: Record<string, ContentSourceTypes.DocumentFieldNonLocalized>;
1884
+ newRefDocuments: ContentSourceTypes.Document[];
1885
+ } = {
1886
+ fields: {},
1887
+ newRefDocuments: []
1888
+ };
1889
+ const objectFieldNames = Object.keys(object);
1890
+ for (const modelField of modelFields) {
1891
+ const fieldName = modelField.name;
1892
+ let value;
1893
+ if (fieldName in object) {
1894
+ value = object[fieldName];
1895
+ _.pull(objectFieldNames, fieldName);
1896
+ } else if (modelField.const) {
1897
+ value = modelField.const;
1898
+ } else if (!_.isNil(modelField.default)) {
1899
+ value = modelField.default;
1900
+ }
1901
+ if (!_.isNil(value)) {
1902
+ const fieldResult = await createNestedField({
1903
+ value,
1904
+ modelField,
1905
+ fieldPath: fieldPath.concat(fieldName)
1906
+ });
1907
+ result.fields[fieldName] = fieldResult.field;
1908
+ result.newRefDocuments = result.newRefDocuments.concat(fieldResult.newRefDocuments);
1909
+ }
1910
+ }
1911
+ if (objectFieldNames.length > 0) {
1912
+ throw new Error(`no model fields found when creating a document with fields: '${objectFieldNames.join(', ')}'`);
1913
+ }
1914
+
1915
+ return result;
1916
+ }
1917
+
1918
+ function getModelFieldForFieldAtPath(
1919
+ document: ContentStoreTypes.Document,
1920
+ model: Model,
1921
+ fieldPath: (string | number)[],
1922
+ modelMap: Record<string, Model>,
1923
+ locale?: string
1924
+ ): Field {
1925
+ if (_.isEmpty(fieldPath)) {
1926
+ throw new Error('the fieldPath can not be empty');
1927
+ }
1928
+
1929
+ function getField(docField: ContentStoreTypes.DocumentField, modelField: FieldSpecificProps, fieldPath: (string | number)[]): Field {
1930
+ const fieldName = _.head(fieldPath);
1931
+ if (typeof fieldName === 'undefined') {
1932
+ throw new Error('the first fieldPath item must be string');
1933
+ }
1934
+ const childFieldPath = _.tail(fieldPath);
1935
+ let childDocField: ContentStoreTypes.DocumentField | undefined;
1936
+ let childModelField: Field | undefined;
1937
+ switch (docField.type) {
1938
+ case 'object':
1939
+ const localizedObjectField = getDocumentFieldForLocale(docField, locale);
1940
+ if (!localizedObjectField) {
1941
+ throw new Error(`locale for field was not found`);
1942
+ }
1943
+ if (localizedObjectField.isUnset) {
1944
+ throw new Error(`field is not set`);
1945
+ }
1946
+ childDocField = localizedObjectField.fields[fieldName];
1947
+ childModelField = _.find((modelField as FieldObjectProps).fields, (field) => field.name === fieldName);
1948
+ if (!childDocField || !childModelField) {
1949
+ throw new Error(`field ${fieldName} doesn't exist`);
1950
+ }
1951
+ if (childFieldPath.length === 0) {
1952
+ return childModelField;
1953
+ }
1954
+ return getField(childDocField, childModelField, childFieldPath);
1955
+ case 'model':
1956
+ const localizedModelField = getDocumentFieldForLocale(docField, locale);
1957
+ if (!localizedModelField) {
1958
+ throw new Error(`locale for field was not found`);
1959
+ }
1960
+ if (localizedModelField.isUnset) {
1961
+ throw new Error(`field is not set`);
1962
+ }
1963
+ const modelName = localizedModelField.srcModelName;
1964
+ const childModel = modelMap[modelName];
1965
+ if (!childModel) {
1966
+ throw new Error(`model ${modelName} doesn't exist`);
1967
+ }
1968
+ childModelField = _.find(childModel.fields, (field) => field.name === fieldName);
1969
+ childDocField = localizedModelField.fields![fieldName];
1970
+ if (!childDocField || !childModelField) {
1971
+ throw new Error(`field ${fieldName} doesn't exist`);
1972
+ }
1973
+ if (childFieldPath.length === 0) {
1974
+ return childModelField;
1975
+ }
1976
+ return getField(childDocField, childModelField!, childFieldPath);
1977
+ case 'list':
1978
+ const localizedListField = getDocumentFieldForLocale(docField, locale);
1979
+ if (!localizedListField) {
1980
+ throw new Error(`locale for field was not found`);
1981
+ }
1982
+ const listItem = localizedListField.items && localizedListField.items[fieldName as number];
1983
+ const listItemsModel = (modelField as FieldListProps).items;
1984
+ if (!listItem || !listItemsModel) {
1985
+ throw new Error(`field ${fieldName} doesn't exist`);
1986
+ }
1987
+ if (childFieldPath.length === 0) {
1988
+ return modelField as FieldList;
1989
+ }
1990
+ if (!Array.isArray(listItemsModel)) {
1991
+ return getField(listItem, listItemsModel, childFieldPath);
1992
+ } else {
1993
+ const fieldListItems = (listItemsModel as FieldListItems[]).find((listItemsModel) => listItemsModel.type === listItem.type);
1994
+ if (!fieldListItems) {
1995
+ throw new Error('cannot find matching field model');
1996
+ }
1997
+ return getField(listItem, fieldListItems, childFieldPath);
1998
+ }
1999
+ default:
2000
+ if (!_.isEmpty(childFieldPath)) {
2001
+ throw new Error('illegal fieldPath');
2002
+ }
2003
+ return modelField as Field;
2004
+ }
2005
+ }
2006
+
2007
+ const fieldName = _.head(fieldPath);
2008
+ const childFieldPath = _.tail(fieldPath);
2009
+
2010
+ if (typeof fieldName !== 'string') {
2011
+ throw new Error('the first fieldPath item must be string');
2012
+ }
2013
+
2014
+ const childDocField = document.fields[fieldName];
2015
+ const childModelField = _.find(model.fields, { name: fieldName });
2016
+
2017
+ if (!childDocField || !childModelField) {
2018
+ throw new Error(`field ${fieldName} doesn't exist`);
2019
+ }
2020
+
2021
+ if (childFieldPath.length === 0) {
2022
+ return childModelField;
2023
+ }
2024
+
2025
+ return getField(childDocField, childModelField, childFieldPath);
2026
+ }
2027
+
2028
+ async function convertOperationField({
2029
+ operationField,
2030
+ fieldPath,
2031
+ modelField,
2032
+ modelMap,
2033
+ locale,
2034
+ userContext,
2035
+ contentSourceInstance
2036
+ }: {
2037
+ operationField: ContentStoreTypes.UpdateOperationField;
2038
+ fieldPath: (string | number)[];
2039
+ modelField: Field;
2040
+ modelMap: Record<string, Model>;
2041
+ locale?: string;
2042
+ userContext: unknown;
2043
+ contentSourceInstance: ContentSourceTypes.ContentSourceInterface;
2044
+ }): Promise<ContentSourceTypes.UpdateOperationField> {
2045
+ let result;
2046
+ switch (operationField.type) {
2047
+ case 'object':
2048
+ result = await createNestedObjectRecursively({
2049
+ object: operationField.object,
2050
+ modelFields: (modelField as FieldObjectProps).fields,
2051
+ fieldPath: fieldPath,
2052
+ modelMap,
2053
+ locale,
2054
+ userContext,
2055
+ contentSourceInstance
2056
+ });
2057
+ return {
2058
+ type: operationField.type,
2059
+ fields: result.fields
2060
+ };
2061
+ case 'model':
2062
+ const model = modelMap[operationField.modelName];
2063
+ if (!model) {
2064
+ throw new Error(`error updating document, could not find document model: '${operationField.modelName}'`);
2065
+ }
2066
+ result = await createNestedObjectRecursively({
2067
+ object: operationField.object,
2068
+ modelFields: model.fields!,
2069
+ fieldPath,
2070
+ modelMap,
2071
+ locale,
2072
+ userContext,
2073
+ contentSourceInstance
2074
+ });
2075
+ return {
2076
+ type: operationField.type,
2077
+ modelName: operationField.modelName,
2078
+ fields: result.fields
2079
+ };
2080
+ default:
2081
+ return operationField as ContentSourceTypes.UpdateOperationField;
2082
+ }
2083
+ }
2084
+
2085
+ function getDocumentFieldForLocale<Type extends ContentStoreTypes.FieldType>(
2086
+ docField: ContentStoreTypes.DocumentFieldForType<Type>,
2087
+ locale?: string
2088
+ ): ContentStoreTypes.DocumentFieldNonLocalizedForType<Type> | null {
2089
+ if (docField.localized) {
2090
+ if (!locale) {
2091
+ return null;
2092
+ }
2093
+ const { localized, locales, ...base } = docField;
2094
+ const localizedField = locales[locale];
2095
+ if (!localizedField) {
2096
+ return null;
2097
+ }
2098
+ return ({
2099
+ ...base,
2100
+ ...localizedField
2101
+ } as unknown) as ContentStoreTypes.DocumentFieldNonLocalizedForType<Type>;
2102
+ } else {
2103
+ return docField;
2104
+ }
2105
+ }
2106
+
2107
+ function isStackbitConfigFile(filePath: string) {
2108
+ const pathObject = path.parse(filePath);
2109
+ const isInDotStackbitFolder = pathObject.dir.startsWith('.stackbit');
2110
+ const isMainStackbitConfigFile = pathObject.name === 'stackbit' && ['yaml', 'yml'].includes(pathObject.ext.substring(1));
2111
+ return isMainStackbitConfigFile || isInDotStackbitFolder;
2112
+ }