@routevn/creator-model 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/model.js +905 -66
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@routevn/creator-model",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/model.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  const COLLECTION_KEYS = [
20
20
  "scenes",
21
+ "files",
21
22
  "images",
22
23
  "sounds",
23
24
  "videos",
@@ -32,6 +33,33 @@ const COLLECTION_KEYS = [
32
33
  ];
33
34
  const ROOT_KEYS = ["project", "story", ...COLLECTION_KEYS];
34
35
  const isString = (value) => typeof value === "string";
36
+ const FILE_ITEM_TYPES = [
37
+ "image",
38
+ "image-thumbnail",
39
+ "audio",
40
+ "audio-waveform",
41
+ "video",
42
+ "video-thumbnail",
43
+ "font",
44
+ ];
45
+ const IMAGE_FILE_REFERENCE_TYPES = {
46
+ fileId: ["image"],
47
+ thumbnailFileId: ["image-thumbnail"],
48
+ };
49
+ const SOUND_FILE_REFERENCE_TYPES = {
50
+ fileId: ["audio"],
51
+ waveformDataFileId: ["audio-waveform"],
52
+ };
53
+ const VIDEO_FILE_REFERENCE_TYPES = {
54
+ fileId: ["video"],
55
+ thumbnailFileId: ["video-thumbnail"],
56
+ };
57
+ const FONT_FILE_REFERENCE_TYPES = {
58
+ fileId: ["font"],
59
+ };
60
+ const CHARACTER_FILE_REFERENCE_TYPES = {
61
+ fileId: ["image"],
62
+ };
35
63
  const isHexColor = (value) =>
36
64
  typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value);
37
65
  const LIVE_TWEEN_PROPERTY_KEYS = [
@@ -104,7 +132,7 @@ const LAYOUT_ELEMENT_BASE_TYPES = [
104
132
  "container-ref-choice-item",
105
133
  "container-ref-dialogue-line",
106
134
  ];
107
- export const SCHEMA_VERSION = 1;
135
+ export const SCHEMA_VERSION = 2;
108
136
  const LAYOUT_CONTAINER_ELEMENT_TYPES = [
109
137
  "folder",
110
138
  "container",
@@ -482,6 +510,79 @@ const validateLineItems = ({ items, path, errorFactory }) => {
482
510
  }
483
511
  };
484
512
 
513
+ const validateFileItems = ({ items, path, errorFactory }) => {
514
+ for (const [itemId, item] of Object.entries(items)) {
515
+ const itemPath = `${path}.${itemId}`;
516
+
517
+ if (item?.type !== "folder" && !FILE_ITEM_TYPES.includes(item?.type)) {
518
+ return invalidFromErrorFactory(
519
+ errorFactory,
520
+ `${itemPath}.type must be 'folder' or a supported file type`,
521
+ );
522
+ }
523
+
524
+ {
525
+ const result = validateAllowedKeys({
526
+ value: item,
527
+ allowedKeys:
528
+ item.type === "folder"
529
+ ? ["id", "type", "name"]
530
+ : ["id", "type", "mimeType", "size", "sha256"],
531
+ path: itemPath,
532
+ errorFactory,
533
+ });
534
+ if (result?.valid === false) {
535
+ return result;
536
+ }
537
+ }
538
+
539
+ if (!isNonEmptyString(item.id)) {
540
+ return invalidFromErrorFactory(
541
+ errorFactory,
542
+ `${itemPath}.id must be a non-empty string`,
543
+ );
544
+ }
545
+
546
+ if (item.id !== itemId) {
547
+ return invalidFromErrorFactory(
548
+ errorFactory,
549
+ `${itemPath}.id must match item key '${itemId}'`,
550
+ );
551
+ }
552
+
553
+ if (item.type === "folder") {
554
+ if (!isNonEmptyString(item.name)) {
555
+ return invalidFromErrorFactory(
556
+ errorFactory,
557
+ `${itemPath}.name must be a non-empty string`,
558
+ );
559
+ }
560
+ continue;
561
+ }
562
+
563
+ if (!isNonEmptyString(item.mimeType)) {
564
+ return invalidFromErrorFactory(
565
+ errorFactory,
566
+ `${itemPath}.mimeType must be a non-empty string`,
567
+ );
568
+ }
569
+
570
+ if (!isFiniteNumber(item.size)) {
571
+ return invalidFromErrorFactory(
572
+ errorFactory,
573
+ `${itemPath}.size must be a finite number`,
574
+ );
575
+ }
576
+
577
+ if (!isNonEmptyString(item.sha256)) {
578
+ return invalidFromErrorFactory(
579
+ errorFactory,
580
+ `${itemPath}.sha256 must be a non-empty string`,
581
+ );
582
+ }
583
+ }
584
+ };
585
+
485
586
  const validateImageItems = ({ items, path, errorFactory }) => {
486
587
  for (const [itemId, item] of Object.entries(items)) {
487
588
  const itemPath = `${path}.${itemId}`;
@@ -1748,6 +1849,7 @@ const validateLayoutElementData = ({
1748
1849
  "variableId",
1749
1850
  "$when",
1750
1851
  "click",
1852
+ "rightClick",
1751
1853
  "change",
1752
1854
  ];
1753
1855
 
@@ -1881,6 +1983,10 @@ const validateLayoutElementData = ({
1881
1983
  return invalidFromErrorFactory(errorFactory, `${path}.click must be an object when provided`);
1882
1984
  }
1883
1985
 
1986
+ if (data.rightClick !== undefined && !isPlainObject(data.rightClick)) {
1987
+ return invalidFromErrorFactory(errorFactory, `${path}.rightClick must be an object when provided`);
1988
+ }
1989
+
1884
1990
  if (data.change !== undefined && !isPlainObject(data.change)) {
1885
1991
  return invalidFromErrorFactory(errorFactory, `${path}.change must be an object when provided`);
1886
1992
  }
@@ -1932,6 +2038,7 @@ const validateLayoutElementItems = ({ items, path, errorFactory }) => {
1932
2038
  "variableId",
1933
2039
  "$when",
1934
2040
  "click",
2041
+ "rightClick",
1935
2042
  "change",
1936
2043
  ],
1937
2044
  path: itemPath,
@@ -2588,6 +2695,17 @@ const validateCollection = ({ collection, path }) => {
2588
2695
  return result;
2589
2696
  }
2590
2697
  }
2698
+ } else if (path === "state.files") {
2699
+ {
2700
+ const result = validateFileItems({
2701
+ items: collection.items,
2702
+ path: `${path}.items`,
2703
+ errorFactory: createStateValidationError,
2704
+ });
2705
+ if (result?.valid === false) {
2706
+ return result;
2707
+ }
2708
+ }
2591
2709
  } else if (path === "state.sounds") {
2592
2710
  {
2593
2711
  const result = validateSoundItems({
@@ -2795,6 +2913,7 @@ const validateCollection = ({ collection, path }) => {
2795
2913
  }
2796
2914
  }
2797
2915
  } else if (
2916
+ path === "state.files" ||
2798
2917
  path === "state.transforms" ||
2799
2918
  path === "state.variables" ||
2800
2919
  path === "state.textStyles" ||
@@ -2824,6 +2943,53 @@ const validateCollection = ({ collection, path }) => {
2824
2943
  }
2825
2944
  };
2826
2945
 
2946
+ const validateFileReference = ({
2947
+ state,
2948
+ fileId,
2949
+ path,
2950
+ allowedTypes,
2951
+ details = {},
2952
+ errorFactory = createPreconditionValidationError,
2953
+ }) => {
2954
+ if (fileId === undefined || fileId === null) {
2955
+ return VALID_RESULT;
2956
+ }
2957
+
2958
+ const expectedTypeMessage =
2959
+ Array.isArray(allowedTypes) && allowedTypes.length > 0
2960
+ ? `${path} must reference an existing non-folder file with type ${allowedTypes
2961
+ .map((type) => `'${type}'`)
2962
+ .join(" or ")}`
2963
+ : `${path} must reference an existing non-folder file`;
2964
+ const file = state.files?.items?.[fileId];
2965
+ if (!isPlainObject(file) || file.type === "folder") {
2966
+ return invalidFromErrorFactory(
2967
+ errorFactory,
2968
+ expectedTypeMessage,
2969
+ Array.isArray(allowedTypes) && allowedTypes.length > 0
2970
+ ? {
2971
+ ...details,
2972
+ expectedFileTypes: [...allowedTypes],
2973
+ }
2974
+ : details,
2975
+ );
2976
+ }
2977
+
2978
+ if (Array.isArray(allowedTypes) && allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
2979
+ return invalidFromErrorFactory(
2980
+ errorFactory,
2981
+ expectedTypeMessage,
2982
+ {
2983
+ ...details,
2984
+ expectedFileTypes: [...allowedTypes],
2985
+ actualFileType: file.type,
2986
+ },
2987
+ );
2988
+ }
2989
+
2990
+ return VALID_RESULT;
2991
+ };
2992
+
2827
2993
  export const assertInvariants = ({ state }) => {
2828
2994
  if (!isPlainObject(state)) {
2829
2995
  return invalidInvariant("state must be an object");
@@ -2935,6 +3101,166 @@ export const assertInvariants = ({ state }) => {
2935
3101
  }
2936
3102
  }
2937
3103
 
3104
+ for (const [imageId, image] of Object.entries(state.images.items)) {
3105
+ if (image.type !== "image") {
3106
+ continue;
3107
+ }
3108
+
3109
+ {
3110
+ const result = validateFileReference({
3111
+ state,
3112
+ fileId: image.fileId,
3113
+ path: "image.fileId",
3114
+ allowedTypes: IMAGE_FILE_REFERENCE_TYPES.fileId,
3115
+ details: { imageId, fileId: image.fileId },
3116
+ errorFactory: createInvariantValidationError,
3117
+ });
3118
+ if (!result.valid) {
3119
+ return result;
3120
+ }
3121
+ }
3122
+
3123
+ if (image.thumbnailFileId !== undefined) {
3124
+ const result = validateFileReference({
3125
+ state,
3126
+ fileId: image.thumbnailFileId,
3127
+ path: "image.thumbnailFileId",
3128
+ allowedTypes: IMAGE_FILE_REFERENCE_TYPES.thumbnailFileId,
3129
+ details: { imageId, thumbnailFileId: image.thumbnailFileId },
3130
+ errorFactory: createInvariantValidationError,
3131
+ });
3132
+ if (!result.valid) {
3133
+ return result;
3134
+ }
3135
+ }
3136
+ }
3137
+
3138
+ for (const [soundId, sound] of Object.entries(state.sounds.items)) {
3139
+ if (sound.type !== "sound") {
3140
+ continue;
3141
+ }
3142
+
3143
+ {
3144
+ const result = validateFileReference({
3145
+ state,
3146
+ fileId: sound.fileId,
3147
+ path: "sound.fileId",
3148
+ allowedTypes: SOUND_FILE_REFERENCE_TYPES.fileId,
3149
+ details: { soundId, fileId: sound.fileId },
3150
+ errorFactory: createInvariantValidationError,
3151
+ });
3152
+ if (!result.valid) {
3153
+ return result;
3154
+ }
3155
+ }
3156
+
3157
+ if (sound.waveformDataFileId !== undefined && sound.waveformDataFileId !== null) {
3158
+ const result = validateFileReference({
3159
+ state,
3160
+ fileId: sound.waveformDataFileId,
3161
+ path: "sound.waveformDataFileId",
3162
+ allowedTypes: SOUND_FILE_REFERENCE_TYPES.waveformDataFileId,
3163
+ details: { soundId, waveformDataFileId: sound.waveformDataFileId },
3164
+ errorFactory: createInvariantValidationError,
3165
+ });
3166
+ if (!result.valid) {
3167
+ return result;
3168
+ }
3169
+ }
3170
+ }
3171
+
3172
+ for (const [videoId, video] of Object.entries(state.videos.items)) {
3173
+ if (video.type !== "video") {
3174
+ continue;
3175
+ }
3176
+
3177
+ {
3178
+ const result = validateFileReference({
3179
+ state,
3180
+ fileId: video.fileId,
3181
+ path: "video.fileId",
3182
+ allowedTypes: VIDEO_FILE_REFERENCE_TYPES.fileId,
3183
+ details: { videoId, fileId: video.fileId },
3184
+ errorFactory: createInvariantValidationError,
3185
+ });
3186
+ if (!result.valid) {
3187
+ return result;
3188
+ }
3189
+ }
3190
+
3191
+ {
3192
+ const result = validateFileReference({
3193
+ state,
3194
+ fileId: video.thumbnailFileId,
3195
+ path: "video.thumbnailFileId",
3196
+ allowedTypes: VIDEO_FILE_REFERENCE_TYPES.thumbnailFileId,
3197
+ details: { videoId, thumbnailFileId: video.thumbnailFileId },
3198
+ errorFactory: createInvariantValidationError,
3199
+ });
3200
+ if (!result.valid) {
3201
+ return result;
3202
+ }
3203
+ }
3204
+ }
3205
+
3206
+ for (const [fontId, font] of Object.entries(state.fonts.items)) {
3207
+ if (font.type !== "font") {
3208
+ continue;
3209
+ }
3210
+
3211
+ const result = validateFileReference({
3212
+ state,
3213
+ fileId: font.fileId,
3214
+ path: "font.fileId",
3215
+ allowedTypes: FONT_FILE_REFERENCE_TYPES.fileId,
3216
+ details: { fontId, fileId: font.fileId },
3217
+ errorFactory: createInvariantValidationError,
3218
+ });
3219
+ if (!result.valid) {
3220
+ return result;
3221
+ }
3222
+ }
3223
+
3224
+ for (const [characterId, character] of Object.entries(state.characters.items)) {
3225
+ if (character.type !== "character") {
3226
+ continue;
3227
+ }
3228
+
3229
+ if (character.fileId !== undefined) {
3230
+ const result = validateFileReference({
3231
+ state,
3232
+ fileId: character.fileId,
3233
+ path: "character.fileId",
3234
+ allowedTypes: CHARACTER_FILE_REFERENCE_TYPES.fileId,
3235
+ details: { characterId, fileId: character.fileId },
3236
+ errorFactory: createInvariantValidationError,
3237
+ });
3238
+ if (!result.valid) {
3239
+ return result;
3240
+ }
3241
+ }
3242
+
3243
+ for (const [spriteId, sprite] of Object.entries(
3244
+ character.sprites?.items || {},
3245
+ )) {
3246
+ if (sprite.type !== "image") {
3247
+ continue;
3248
+ }
3249
+
3250
+ const result = validateFileReference({
3251
+ state,
3252
+ fileId: sprite.fileId,
3253
+ path: "character.sprite.fileId",
3254
+ allowedTypes: CHARACTER_FILE_REFERENCE_TYPES.fileId,
3255
+ details: { characterId, spriteId, fileId: sprite.fileId },
3256
+ errorFactory: createInvariantValidationError,
3257
+ });
3258
+ if (!result.valid) {
3259
+ return result;
3260
+ }
3261
+ }
3262
+ }
3263
+
2938
3264
  const assertImageReference = ({ layoutId, elementId, field, targetId }) => {
2939
3265
  const image = state.images.items[targetId];
2940
3266
  if (!isPlainObject(image) || image.type === "folder") {
@@ -3924,6 +4250,108 @@ const validateFontUpdateData = ({ data, errorFactory }) => {
3924
4250
  }
3925
4251
  };
3926
4252
 
4253
+ const validateFileCreateData = ({ data, errorFactory }) => {
4254
+ if (!isPlainObject(data)) {
4255
+ return invalidFromErrorFactory(
4256
+ errorFactory,
4257
+ "payload.data must be an object",
4258
+ );
4259
+ }
4260
+
4261
+ if (data.type !== "folder" && !FILE_ITEM_TYPES.includes(data.type)) {
4262
+ return invalidFromErrorFactory(
4263
+ errorFactory,
4264
+ "payload.data.type must be 'folder' or a supported file type",
4265
+ );
4266
+ }
4267
+
4268
+ {
4269
+ const result = validateAllowedKeys({
4270
+ value: data,
4271
+ allowedKeys:
4272
+ data.type === "folder"
4273
+ ? ["type", "name"]
4274
+ : ["type", "mimeType", "size", "sha256"],
4275
+ path: "payload.data",
4276
+ errorFactory,
4277
+ });
4278
+ if (result?.valid === false) {
4279
+ return result;
4280
+ }
4281
+ }
4282
+
4283
+ if (data.type === "folder") {
4284
+ if (!isNonEmptyString(data.name)) {
4285
+ return invalidFromErrorFactory(
4286
+ errorFactory,
4287
+ "payload.data.name must be a non-empty string",
4288
+ );
4289
+ }
4290
+ return;
4291
+ }
4292
+
4293
+ if (!isNonEmptyString(data.mimeType)) {
4294
+ return invalidFromErrorFactory(
4295
+ errorFactory,
4296
+ "payload.data.mimeType must be a non-empty string",
4297
+ );
4298
+ }
4299
+
4300
+ if (!isFiniteNumber(data.size)) {
4301
+ return invalidFromErrorFactory(
4302
+ errorFactory,
4303
+ "payload.data.size must be a finite number",
4304
+ );
4305
+ }
4306
+
4307
+ if (!isNonEmptyString(data.sha256)) {
4308
+ return invalidFromErrorFactory(
4309
+ errorFactory,
4310
+ "payload.data.sha256 must be a non-empty string",
4311
+ );
4312
+ }
4313
+ };
4314
+
4315
+ const validateReferencedFilesInData = ({
4316
+ state,
4317
+ data,
4318
+ fields,
4319
+ fieldTypes = {},
4320
+ nullableFields = [],
4321
+ details = {},
4322
+ errorFactory = createPreconditionValidationError,
4323
+ }) => {
4324
+ for (const field of fields) {
4325
+ const fileId = data[field];
4326
+
4327
+ if (fileId === undefined) {
4328
+ continue;
4329
+ }
4330
+
4331
+ if (fileId === null && nullableFields.includes(field)) {
4332
+ continue;
4333
+ }
4334
+
4335
+ const result = validateFileReference({
4336
+ state,
4337
+ fileId,
4338
+ path: `payload.data.${field}`,
4339
+ allowedTypes: fieldTypes[field],
4340
+ details: {
4341
+ ...details,
4342
+ field,
4343
+ fileId,
4344
+ },
4345
+ errorFactory,
4346
+ });
4347
+ if (!result.valid) {
4348
+ return result;
4349
+ }
4350
+ }
4351
+
4352
+ return VALID_RESULT;
4353
+ };
4354
+
3927
4355
  const validateColorCreateData = ({ data, errorFactory }) => {
3928
4356
  if (!isPlainObject(data)) {
3929
4357
  return invalidFromErrorFactory(errorFactory, "payload.data must be an object");
@@ -5114,6 +5542,8 @@ const createFolderedCollectionCommandDefinitions = ({
5114
5542
  }),
5115
5543
  validateCreateState = () => {},
5116
5544
  validateUpdateState = () => {},
5545
+ validateDeleteState = () => {},
5546
+ includeUpdate = true,
5117
5547
  }) => {
5118
5548
  const existingMessage = `payload.${idField} must reference an existing ${itemLabel}`;
5119
5549
  const duplicateMessage = `payload.${idField} must not already exist`;
@@ -5242,75 +5672,79 @@ const createFolderedCollectionCommandDefinitions = ({
5242
5672
  return state;
5243
5673
  },
5244
5674
  },
5675
+ ...(includeUpdate
5676
+ ? [
5677
+ {
5678
+ type: `${familyName}.update`,
5679
+ validatePayload: ({ payload }) => {
5680
+ let result = captureValidation(() =>
5681
+ validateExactKeys({
5682
+ value: payload,
5683
+ expectedKeys: [idField, "data"],
5684
+ path: "payload",
5685
+ errorFactory: createPayloadValidationError,
5686
+ }),
5687
+ );
5688
+ if (!result.valid) {
5689
+ return result;
5690
+ }
5691
+
5692
+ if (!isNonEmptyString(payload[idField])) {
5693
+ return invalidPayload(
5694
+ `payload.${idField} must be a non-empty string`,
5695
+ );
5696
+ }
5697
+
5698
+ result = captureValidation(() =>
5699
+ updateDataValidator({
5700
+ data: payload.data,
5701
+ errorFactory: createPayloadValidationError,
5702
+ }),
5703
+ );
5704
+ if (!result.valid) {
5705
+ return result;
5706
+ }
5707
+
5708
+ return VALID_RESULT;
5709
+ },
5710
+ validateAgainstState: ({ state, payload }) => {
5711
+ const currentItem = state[collectionKey].items[payload[idField]];
5712
+ if (!isPlainObject(currentItem)) {
5713
+ return invalidPrecondition(existingMessage);
5714
+ }
5715
+
5716
+ const result = captureValidation(() =>
5717
+ validateUpdateState({
5718
+ state,
5719
+ payload,
5720
+ currentItem,
5721
+ }),
5722
+ );
5723
+ if (!result.valid) {
5724
+ return result;
5725
+ }
5726
+
5727
+ return VALID_RESULT;
5728
+ },
5729
+ reduce: ({ state, payload }) => {
5730
+ const currentItem = state[collectionKey].items[payload[idField]];
5731
+ state[collectionKey].items[payload[idField]] = updateItem({
5732
+ state,
5733
+ payload,
5734
+ currentItem,
5735
+ });
5736
+ return state;
5737
+ },
5738
+ },
5739
+ ]
5740
+ : []),
5245
5741
  {
5246
- type: `${familyName}.update`,
5742
+ type: `${familyName}.delete`,
5247
5743
  validatePayload: ({ payload }) => {
5248
5744
  let result = captureValidation(() =>
5249
5745
  validateExactKeys({
5250
5746
  value: payload,
5251
- expectedKeys: [idField, "data"],
5252
- path: "payload",
5253
- errorFactory: createPayloadValidationError,
5254
- }),
5255
- );
5256
- if (!result.valid) {
5257
- return result;
5258
- }
5259
-
5260
- if (!isNonEmptyString(payload[idField])) {
5261
- return invalidPayload(
5262
- `payload.${idField} must be a non-empty string`,
5263
- );
5264
- }
5265
-
5266
- result = captureValidation(() =>
5267
- updateDataValidator({
5268
- data: payload.data,
5269
- errorFactory: createPayloadValidationError,
5270
- }),
5271
- );
5272
- if (!result.valid) {
5273
- return result;
5274
- }
5275
-
5276
- return VALID_RESULT;
5277
- },
5278
- validateAgainstState: ({ state, payload }) => {
5279
- const currentItem = state[collectionKey].items[payload[idField]];
5280
- if (!isPlainObject(currentItem)) {
5281
- return invalidPrecondition(existingMessage);
5282
- }
5283
-
5284
- const result = captureValidation(() =>
5285
- validateUpdateState({
5286
- state,
5287
- payload,
5288
- currentItem,
5289
- }),
5290
- );
5291
- if (!result.valid) {
5292
- return result;
5293
- }
5294
-
5295
- return VALID_RESULT;
5296
- },
5297
- reduce: ({ state, payload }) => {
5298
- const currentItem = state[collectionKey].items[payload[idField]];
5299
- state[collectionKey].items[payload[idField]] = updateItem({
5300
- state,
5301
- payload,
5302
- currentItem,
5303
- });
5304
- return state;
5305
- },
5306
- },
5307
- {
5308
- type: `${familyName}.delete`,
5309
- validatePayload: ({ payload }) => {
5310
- let result = captureValidation(() =>
5311
- validateExactKeys({
5312
- value: payload,
5313
- expectedKeys: [deleteArrayField],
5747
+ expectedKeys: [deleteArrayField],
5314
5748
  path: "payload",
5315
5749
  errorFactory: createPayloadValidationError,
5316
5750
  }),
@@ -5342,6 +5776,16 @@ const createFolderedCollectionCommandDefinitions = ({
5342
5776
  }
5343
5777
  }
5344
5778
 
5779
+ const result = captureValidation(() =>
5780
+ validateDeleteState({
5781
+ state,
5782
+ payload,
5783
+ }),
5784
+ );
5785
+ if (!result.valid) {
5786
+ return result;
5787
+ }
5788
+
5345
5789
  return VALID_RESULT;
5346
5790
  },
5347
5791
  reduce: ({ state, payload }) => {
@@ -5501,6 +5945,143 @@ const getCharacterSpriteCollection = ({ state, characterId }) =>
5501
5945
  const getLayoutElementCollection = ({ state, layoutId }) =>
5502
5946
  state.layouts.items[layoutId]?.elements;
5503
5947
 
5948
+ const findReferencedFileUsage = ({ state, fileId }) => {
5949
+ for (const [imageId, image] of Object.entries(state.images.items)) {
5950
+ if (image.type !== "image") {
5951
+ continue;
5952
+ }
5953
+
5954
+ if (image.fileId === fileId) {
5955
+ return {
5956
+ kind: "image",
5957
+ field: "fileId",
5958
+ ownerId: imageId,
5959
+ };
5960
+ }
5961
+
5962
+ if (image.thumbnailFileId === fileId) {
5963
+ return {
5964
+ kind: "image",
5965
+ field: "thumbnailFileId",
5966
+ ownerId: imageId,
5967
+ };
5968
+ }
5969
+ }
5970
+
5971
+ for (const [soundId, sound] of Object.entries(state.sounds.items)) {
5972
+ if (sound.type !== "sound") {
5973
+ continue;
5974
+ }
5975
+
5976
+ if (sound.fileId === fileId) {
5977
+ return {
5978
+ kind: "sound",
5979
+ field: "fileId",
5980
+ ownerId: soundId,
5981
+ };
5982
+ }
5983
+
5984
+ if (sound.waveformDataFileId === fileId) {
5985
+ return {
5986
+ kind: "sound",
5987
+ field: "waveformDataFileId",
5988
+ ownerId: soundId,
5989
+ };
5990
+ }
5991
+ }
5992
+
5993
+ for (const [videoId, video] of Object.entries(state.videos.items)) {
5994
+ if (video.type !== "video") {
5995
+ continue;
5996
+ }
5997
+
5998
+ if (video.fileId === fileId) {
5999
+ return {
6000
+ kind: "video",
6001
+ field: "fileId",
6002
+ ownerId: videoId,
6003
+ };
6004
+ }
6005
+
6006
+ if (video.thumbnailFileId === fileId) {
6007
+ return {
6008
+ kind: "video",
6009
+ field: "thumbnailFileId",
6010
+ ownerId: videoId,
6011
+ };
6012
+ }
6013
+ }
6014
+
6015
+ for (const [fontId, font] of Object.entries(state.fonts.items)) {
6016
+ if (font.type !== "font") {
6017
+ continue;
6018
+ }
6019
+
6020
+ if (font.fileId === fileId) {
6021
+ return {
6022
+ kind: "font",
6023
+ field: "fileId",
6024
+ ownerId: fontId,
6025
+ };
6026
+ }
6027
+ }
6028
+
6029
+ for (const [characterId, character] of Object.entries(state.characters.items)) {
6030
+ if (character.type !== "character") {
6031
+ continue;
6032
+ }
6033
+
6034
+ if (character.fileId === fileId) {
6035
+ return {
6036
+ kind: "character",
6037
+ field: "fileId",
6038
+ ownerId: characterId,
6039
+ };
6040
+ }
6041
+
6042
+ for (const [spriteId, sprite] of Object.entries(
6043
+ character.sprites?.items || {},
6044
+ )) {
6045
+ if (sprite.type !== "image") {
6046
+ continue;
6047
+ }
6048
+
6049
+ if (sprite.fileId === fileId) {
6050
+ return {
6051
+ kind: "character.sprite",
6052
+ field: "fileId",
6053
+ ownerId: spriteId,
6054
+ characterId,
6055
+ };
6056
+ }
6057
+ }
6058
+ }
6059
+
6060
+ return null;
6061
+ };
6062
+
6063
+ const collectDeletedFileIds = ({ state, fileIds }) => {
6064
+ const deletedIds = new Set();
6065
+
6066
+ for (const fileId of fileIds) {
6067
+ const node = findTreeNode({
6068
+ nodes: state.files.tree,
6069
+ nodeId: fileId,
6070
+ });
6071
+
6072
+ if (!node) {
6073
+ deletedIds.add(fileId);
6074
+ continue;
6075
+ }
6076
+
6077
+ for (const deletedId of collectTreeDescendantIds({ node })) {
6078
+ deletedIds.add(deletedId);
6079
+ }
6080
+ }
6081
+
6082
+ return deletedIds;
6083
+ };
6084
+
5504
6085
  const COMMAND_DEFINITIONS = [
5505
6086
  {
5506
6087
  type: "project.create",
@@ -5525,6 +6106,51 @@ const COMMAND_DEFINITIONS = [
5525
6106
  validateAgainstState: () => {},
5526
6107
  reduce: ({ payload }) => structuredClone(payload.state),
5527
6108
  },
6109
+ ...createFolderedCollectionCommandDefinitions({
6110
+ familyName: "file",
6111
+ collectionKey: "files",
6112
+ idField: "fileId",
6113
+ itemLabel: "file item",
6114
+ createDataValidator: validateFileCreateData,
6115
+ updateDataValidator: () => VALID_RESULT,
6116
+ includeUpdate: false,
6117
+ createItem: ({ payload }) =>
6118
+ payload.data.type === "folder"
6119
+ ? {
6120
+ id: payload.fileId,
6121
+ type: "folder",
6122
+ name: payload.data.name,
6123
+ }
6124
+ : {
6125
+ id: payload.fileId,
6126
+ type: payload.data.type,
6127
+ mimeType: payload.data.mimeType,
6128
+ size: payload.data.size,
6129
+ sha256: payload.data.sha256,
6130
+ },
6131
+ validateDeleteState: ({ state, payload }) => {
6132
+ for (const fileId of collectDeletedFileIds({
6133
+ state,
6134
+ fileIds: payload.fileIds,
6135
+ })) {
6136
+ const usage = findReferencedFileUsage({ state, fileId });
6137
+ if (!usage) {
6138
+ continue;
6139
+ }
6140
+
6141
+ return invalidPrecondition(
6142
+ `payload.fileIds cannot delete a referenced file`,
6143
+ {
6144
+ fileId,
6145
+ referenceKind: usage.kind,
6146
+ referenceField: usage.field,
6147
+ referenceOwnerId: usage.ownerId,
6148
+ ...(usage.characterId ? { characterId: usage.characterId } : {}),
6149
+ },
6150
+ );
6151
+ }
6152
+ },
6153
+ }),
5528
6154
  {
5529
6155
  type: "story.update",
5530
6156
  validatePayload: ({ payload }) => {
@@ -6871,6 +7497,21 @@ const COMMAND_DEFINITIONS = [
6871
7497
  );
6872
7498
  }
6873
7499
  }
7500
+
7501
+ if (payload.data.type === "image") {
7502
+ const result = validateReferencedFilesInData({
7503
+ state,
7504
+ data: payload.data,
7505
+ fields: ["fileId", "thumbnailFileId"],
7506
+ fieldTypes: IMAGE_FILE_REFERENCE_TYPES,
7507
+ details: {
7508
+ imageId: payload.imageId,
7509
+ },
7510
+ });
7511
+ if (!result.valid) {
7512
+ return result;
7513
+ }
7514
+ }
6874
7515
  },
6875
7516
  reduce: ({ state, payload }) => {
6876
7517
  const nextImage = {
@@ -6961,6 +7602,7 @@ const COMMAND_DEFINITIONS = [
6961
7602
  if (
6962
7603
  currentImage.type === "folder" &&
6963
7604
  (payload.data.fileId !== undefined ||
7605
+ payload.data.thumbnailFileId !== undefined ||
6964
7606
  payload.data.fileType !== undefined ||
6965
7607
  payload.data.fileSize !== undefined ||
6966
7608
  payload.data.width !== undefined ||
@@ -6970,6 +7612,21 @@ const COMMAND_DEFINITIONS = [
6970
7612
  "folder image items cannot update file fields",
6971
7613
  );
6972
7614
  }
7615
+
7616
+ if (currentImage.type === "image") {
7617
+ const result = validateReferencedFilesInData({
7618
+ state,
7619
+ data: payload.data,
7620
+ fields: ["fileId", "thumbnailFileId"],
7621
+ fieldTypes: IMAGE_FILE_REFERENCE_TYPES,
7622
+ details: {
7623
+ imageId: payload.imageId,
7624
+ },
7625
+ });
7626
+ if (!result.valid) {
7627
+ return result;
7628
+ }
7629
+ }
6973
7630
  },
6974
7631
  reduce: ({ state, payload }) => {
6975
7632
  const currentImage = state.images.items[payload.imageId];
@@ -7274,6 +7931,22 @@ const COMMAND_DEFINITIONS = [
7274
7931
  );
7275
7932
  }
7276
7933
  }
7934
+
7935
+ if (payload.data.type === "sound") {
7936
+ const result = validateReferencedFilesInData({
7937
+ state,
7938
+ data: payload.data,
7939
+ fields: ["fileId", "waveformDataFileId"],
7940
+ fieldTypes: SOUND_FILE_REFERENCE_TYPES,
7941
+ nullableFields: ["waveformDataFileId"],
7942
+ details: {
7943
+ soundId: payload.soundId,
7944
+ },
7945
+ });
7946
+ if (!result.valid) {
7947
+ return result;
7948
+ }
7949
+ }
7277
7950
  },
7278
7951
  reduce: ({ state, payload }) => {
7279
7952
  const nextSound = {
@@ -7370,6 +8043,22 @@ const COMMAND_DEFINITIONS = [
7370
8043
  "folder sound items cannot update file fields",
7371
8044
  );
7372
8045
  }
8046
+
8047
+ if (currentSound.type === "sound") {
8048
+ const result = validateReferencedFilesInData({
8049
+ state,
8050
+ data: payload.data,
8051
+ fields: ["fileId", "waveformDataFileId"],
8052
+ fieldTypes: SOUND_FILE_REFERENCE_TYPES,
8053
+ nullableFields: ["waveformDataFileId"],
8054
+ details: {
8055
+ soundId: payload.soundId,
8056
+ },
8057
+ });
8058
+ if (!result.valid) {
8059
+ return result;
8060
+ }
8061
+ }
7373
8062
  },
7374
8063
  reduce: ({ state, payload }) => {
7375
8064
  const currentSound = state.sounds.items[payload.soundId];
@@ -7674,6 +8363,21 @@ const COMMAND_DEFINITIONS = [
7674
8363
  );
7675
8364
  }
7676
8365
  }
8366
+
8367
+ if (payload.data.type === "video") {
8368
+ const result = validateReferencedFilesInData({
8369
+ state,
8370
+ data: payload.data,
8371
+ fields: ["fileId", "thumbnailFileId"],
8372
+ fieldTypes: VIDEO_FILE_REFERENCE_TYPES,
8373
+ details: {
8374
+ videoId: payload.videoId,
8375
+ },
8376
+ });
8377
+ if (!result.valid) {
8378
+ return result;
8379
+ }
8380
+ }
7677
8381
  },
7678
8382
  reduce: ({ state, payload }) => {
7679
8383
  const nextVideo = {
@@ -7772,6 +8476,21 @@ const COMMAND_DEFINITIONS = [
7772
8476
  "folder video items cannot update file fields",
7773
8477
  );
7774
8478
  }
8479
+
8480
+ if (currentVideo.type === "video") {
8481
+ const result = validateReferencedFilesInData({
8482
+ state,
8483
+ data: payload.data,
8484
+ fields: ["fileId", "thumbnailFileId"],
8485
+ fieldTypes: VIDEO_FILE_REFERENCE_TYPES,
8486
+ details: {
8487
+ videoId: payload.videoId,
8488
+ },
8489
+ });
8490
+ if (!result.valid) {
8491
+ return result;
8492
+ }
8493
+ }
7775
8494
  },
7776
8495
  reduce: ({ state, payload }) => {
7777
8496
  const currentVideo = state.videos.items[payload.videoId];
@@ -8456,6 +9175,21 @@ const COMMAND_DEFINITIONS = [
8456
9175
  );
8457
9176
  }
8458
9177
  }
9178
+
9179
+ if (payload.data.type === "font") {
9180
+ const result = validateReferencedFilesInData({
9181
+ state,
9182
+ data: payload.data,
9183
+ fields: ["fileId"],
9184
+ fieldTypes: FONT_FILE_REFERENCE_TYPES,
9185
+ details: {
9186
+ fontId: payload.fontId,
9187
+ },
9188
+ });
9189
+ if (!result.valid) {
9190
+ return result;
9191
+ }
9192
+ }
8459
9193
  },
8460
9194
  reduce: ({ state, payload }) => {
8461
9195
  const nextFont = {
@@ -8542,6 +9276,21 @@ const COMMAND_DEFINITIONS = [
8542
9276
  "folder font items cannot update font fields",
8543
9277
  );
8544
9278
  }
9279
+
9280
+ if (currentFont.type === "font") {
9281
+ const result = validateReferencedFilesInData({
9282
+ state,
9283
+ data: payload.data,
9284
+ fields: ["fileId"],
9285
+ fieldTypes: FONT_FILE_REFERENCE_TYPES,
9286
+ details: {
9287
+ fontId: payload.fontId,
9288
+ },
9289
+ });
9290
+ if (!result.valid) {
9291
+ return result;
9292
+ }
9293
+ }
8545
9294
  },
8546
9295
  reduce: ({ state, payload }) => {
8547
9296
  const currentFont = state.fonts.items[payload.fontId];
@@ -9319,7 +10068,50 @@ const COMMAND_DEFINITIONS = [
9319
10068
 
9320
10069
  return item;
9321
10070
  },
9322
- validateUpdateState: ({ payload, currentItem }) => {
10071
+ validateCreateState: ({ state, payload }) => {
10072
+ if (payload.data.type !== "character") {
10073
+ return;
10074
+ }
10075
+
10076
+ if (payload.data.fileId !== undefined) {
10077
+ const result = validateReferencedFilesInData({
10078
+ state,
10079
+ data: payload.data,
10080
+ fields: ["fileId"],
10081
+ fieldTypes: CHARACTER_FILE_REFERENCE_TYPES,
10082
+ details: {
10083
+ characterId: payload.characterId,
10084
+ },
10085
+ });
10086
+ if (!result.valid) {
10087
+ return result;
10088
+ }
10089
+ }
10090
+
10091
+ for (const [spriteId, sprite] of Object.entries(
10092
+ payload.data.sprites?.items || {},
10093
+ )) {
10094
+ if (sprite.type !== "image") {
10095
+ continue;
10096
+ }
10097
+
10098
+ const result = validateFileReference({
10099
+ state,
10100
+ fileId: sprite.fileId,
10101
+ path: "payload.data.sprites.items.*.fileId",
10102
+ allowedTypes: CHARACTER_FILE_REFERENCE_TYPES.fileId,
10103
+ details: {
10104
+ characterId: payload.characterId,
10105
+ spriteId,
10106
+ fileId: sprite.fileId,
10107
+ },
10108
+ });
10109
+ if (!result.valid) {
10110
+ return result;
10111
+ }
10112
+ }
10113
+ },
10114
+ validateUpdateState: ({ state, payload, currentItem }) => {
9323
10115
  if (
9324
10116
  currentItem.type === "folder" &&
9325
10117
  Object.keys(payload.data).some((key) => key !== "name")
@@ -9328,6 +10120,21 @@ const COMMAND_DEFINITIONS = [
9328
10120
  "folder character items cannot update character fields",
9329
10121
  );
9330
10122
  }
10123
+
10124
+ if (currentItem.type === "character") {
10125
+ const result = validateReferencedFilesInData({
10126
+ state,
10127
+ data: payload.data,
10128
+ fields: ["fileId"],
10129
+ fieldTypes: CHARACTER_FILE_REFERENCE_TYPES,
10130
+ details: {
10131
+ characterId: payload.characterId,
10132
+ },
10133
+ });
10134
+ if (!result.valid) {
10135
+ return result;
10136
+ }
10137
+ }
9331
10138
  },
9332
10139
  }),
9333
10140
  ...createFolderedCollectionCommandDefinitions({
@@ -9471,6 +10278,22 @@ const COMMAND_DEFINITIONS = [
9471
10278
  );
9472
10279
  }
9473
10280
  }
10281
+
10282
+ if (payload.data.type === "image") {
10283
+ const result = validateReferencedFilesInData({
10284
+ state,
10285
+ data: payload.data,
10286
+ fields: ["fileId"],
10287
+ fieldTypes: CHARACTER_FILE_REFERENCE_TYPES,
10288
+ details: {
10289
+ characterId: payload.characterId,
10290
+ spriteId: payload.spriteId,
10291
+ },
10292
+ });
10293
+ if (!result.valid) {
10294
+ return result;
10295
+ }
10296
+ }
9474
10297
  },
9475
10298
  reduce: ({ state, payload }) => {
9476
10299
  const collection = getCharacterSpriteCollection({
@@ -9563,6 +10386,22 @@ const COMMAND_DEFINITIONS = [
9563
10386
  "folder sprite items cannot update image fields",
9564
10387
  );
9565
10388
  }
10389
+
10390
+ if (currentItem.type === "image") {
10391
+ const result = validateReferencedFilesInData({
10392
+ state,
10393
+ data: payload.data,
10394
+ fields: ["fileId"],
10395
+ fieldTypes: CHARACTER_FILE_REFERENCE_TYPES,
10396
+ details: {
10397
+ characterId: payload.characterId,
10398
+ spriteId: payload.spriteId,
10399
+ },
10400
+ });
10401
+ if (!result.valid) {
10402
+ return result;
10403
+ }
10404
+ }
9566
10405
  },
9567
10406
  reduce: ({ state, payload }) => {
9568
10407
  const collection = getCharacterSpriteCollection({