apostrophe 3.64.0 → 3.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,21 @@
1
1
  # Changelog
2
2
 
3
- ## 3.64.0 (2024-04-18)
3
+ ## 3.66.0 (2024-05-15)
4
4
 
5
- ### Adds
5
+ ### Fixes
6
+
7
+ * Autocrop image attachments for referenced documents when replacing an image in the Media Manager.
8
+ * Backports some internal A4 UI logic for metadata to make the new `document-versions` comparison feature compatible with A3.
9
+
10
+ # Changelog
11
+
12
+ ## 3.65.0 (2024-05-06)
13
+
14
+ * Adds a `publicBundle` option to `@apostrophecms/asset`. When set to `false`, the `ui/src` public asset bundle is not built at all in most cases
15
+ except as part of the admin UI bundle which depends on it. For use with external front ends such as [apostrophe-astro](https://github.com/apostrophecms/apostrophe-astro).
16
+ Thanks to Michelin for contributing this feature.
17
+
18
+ ## 3.64.0 (2024-04-18)
6
19
 
7
20
  ### Fixes
8
21
 
@@ -34,8 +47,6 @@ that these keys would be present.
34
47
  * `field.help` and `field.htmlHelp` are now correctly translated when displayed in a tooltip.
35
48
  This was also an expectation for the multisite module.
36
49
 
37
- ## UNRELEASED
38
-
39
50
  ### Adds
40
51
 
41
52
  * Add side by side comparison support in AposSchema component.
@@ -42,7 +42,10 @@ module.exports = {
42
42
  watchDebounceMs: 1000,
43
43
  // Object containing instructions for remapping existing bundles.
44
44
  // See the modulre reference documentation for more information.
45
- rebundleModules: undefined
45
+ rebundleModules: undefined,
46
+ // In case of external front end like Astro, this option allows to
47
+ // disable the build of the public UI assets.
48
+ publicBundle: true
46
49
  },
47
50
 
48
51
  async init(self) {
@@ -169,7 +172,9 @@ module.exports = {
169
172
  // to the same relative path `/public/apos-frontend/namespace/modules/modulename`.
170
173
  // Inherited files are also copied, with the deepest subclass overriding in the
171
174
  // event of a conflict
172
- await moduleOverrides(`${bundleDir}/modules`, 'public');
175
+ if (self.options.publicBundle) {
176
+ await moduleOverrides(`${bundleDir}/modules`, 'public');
177
+ }
173
178
 
174
179
  for (const [ name, options ] of Object.entries(self.builds)) {
175
180
  // If the option is not present always rebuild everything...
@@ -180,11 +185,12 @@ module.exports = {
180
185
  } else if (!rebuild) {
181
186
  let checkTimestamp = false;
182
187
 
183
- // Only builds contributing to the apos admin UI (currently just "apos")
188
+ // If options.publicBundle, only builds contributing to the apos admin UI (currently just "apos")
184
189
  // are candidates to skip the build simply because package-lock.json is
185
190
  // older than the bundle. All other builds frequently contain
186
191
  // project level code
187
- if (options.apos) {
192
+ // Else we can skip also for the src bundle
193
+ if (options.apos || !self.options.publicBundle) {
188
194
  const bundleExists = await fs.pathExists(bundleDir);
189
195
 
190
196
  if (!bundleExists) {
@@ -437,7 +443,7 @@ module.exports = {
437
443
  modulesPrefix: `${self.getAssetBaseUrl()}/modules`
438
444
  }));
439
445
  }
440
- if (options.apos) {
446
+ if (options.apos || !self.options.publicBundle) {
441
447
  const now = Date.now().toString();
442
448
  fs.writeFileSync(`${bundleDir}/${name}-build-timestamp.txt`, now);
443
449
  }
@@ -1300,7 +1306,7 @@ module.exports = {
1300
1306
  `;
1301
1307
  self.builds = {
1302
1308
  src: {
1303
- scenes: [ 'public', 'apos' ],
1309
+ scenes: [ 'apos' ],
1304
1310
  webpack: true,
1305
1311
  outputs: [ 'css', 'js' ],
1306
1312
  label: 'apostrophe:modernBuild',
@@ -1310,13 +1316,6 @@ module.exports = {
1310
1316
  condition: 'module',
1311
1317
  prologue: self.srcPrologue
1312
1318
  },
1313
- public: {
1314
- scenes: [ 'public', 'apos' ],
1315
- outputs: [ 'css', 'js' ],
1316
- label: 'apostrophe:rawCssAndJs',
1317
- // Just concatenates
1318
- webpack: false
1319
- },
1320
1319
  apos: {
1321
1320
  scenes: [ 'apos' ],
1322
1321
  outputs: [ 'js' ],
@@ -1337,6 +1336,16 @@ module.exports = {
1337
1336
  // We could add an apos-ie11 bundle that just pushes a "sorry charlie" prologue,
1338
1337
  // if we chose
1339
1338
  };
1339
+ if (self.options.publicBundle) {
1340
+ self.builds.public = {
1341
+ scenes: [ 'public', 'apos' ],
1342
+ outputs: [ 'css', 'js' ],
1343
+ label: 'apostrophe:rawCssAndJs',
1344
+ // Just concatenates
1345
+ webpack: false
1346
+ };
1347
+ self.builds.src.scenes.push('public');
1348
+ }
1340
1349
  },
1341
1350
  // Filter the given css performing any necessary transformations,
1342
1351
  // such as support for the /modules path regardless of where
@@ -194,6 +194,8 @@
194
194
  "hideInNavigation": "Hide in Navigation",
195
195
  "home": "Home",
196
196
  "image": "Image",
197
+ "imageOnReplaceAutocropMessage": "The replaced image has been autocropped for \"{{ title }}\".",
198
+ "imageOnReplaceAutocropError": "An error occurred while autocropping the replaced image for {{ title }}",
197
199
  "imageDescription": "Add an inline image",
198
200
  "imageFile": "Image File",
199
201
  "imagePlaceholder": "Image placeholder",
@@ -122,6 +122,19 @@ module.exports = {
122
122
  }
123
123
  }
124
124
  },
125
+ handlers(self) {
126
+ return {
127
+ beforeUpdate: {
128
+ // Ensure the crop fields are updated on publishing an image
129
+ async autoCropRelations(req, piece, options) {
130
+ if (piece.aposMode !== 'published') {
131
+ return;
132
+ }
133
+ await self.updateImageCropRelationships(req, piece);
134
+ }
135
+ }
136
+ };
137
+ },
125
138
  commands(self) {
126
139
  return {
127
140
  remove: [
@@ -254,26 +267,7 @@ module.exports = {
254
267
  return withinOnePercent(testRatio, configuredRatio);
255
268
  }
256
269
  async function autocrop(image, widgetOptions) {
257
- const nativeRatio = image.attachment.width / image.attachment.height;
258
- const configuredRatio = widgetOptions.aspectRatio[0] / widgetOptions.aspectRatio[1];
259
- let crop;
260
- if (configuredRatio >= nativeRatio) {
261
- const height = image.attachment.width / configuredRatio;
262
- crop = {
263
- top: Math.floor((image.attachment.height - height) / 2),
264
- left: 0,
265
- width: image.attachment.width,
266
- height: Math.floor(height)
267
- };
268
- } else {
269
- const width = image.attachment.height * configuredRatio;
270
- crop = {
271
- top: 0,
272
- left: Math.floor((image.attachment.width - width) / 2),
273
- width: Math.floor(width),
274
- height: image.attachment.height
275
- };
276
- }
270
+ const crop = self.calculateAutocrop(image, widgetOptions.aspectRatio);
277
271
  await self.apos.attachment.crop(req, image.attachment._id, crop);
278
272
  image._fields = crop;
279
273
  // For ease of testing send back the cropped image URLs now
@@ -457,7 +451,169 @@ module.exports = {
457
451
  name: 'dummy',
458
452
  width: 0
459
453
  }).name;
454
+ },
455
+ // Given an image piece and a aspect ratio array, calculate the crop
456
+ // that would be applied to the image when autocropping it to the
457
+ // given aspect ratio.
458
+ calculateAutocrop(image, aspecRatio) {
459
+ let crop;
460
+ const configuredRatio = aspecRatio[0] / aspecRatio[1];
461
+ const nativeRatio = image.attachment.width / image.attachment.height;
462
+
463
+ if (configuredRatio >= nativeRatio) {
464
+ const height = image.attachment.width / configuredRatio;
465
+ crop = {
466
+ top: Math.floor((image.attachment.height - height) / 2),
467
+ left: 0,
468
+ width: image.attachment.width,
469
+ height: Math.floor(height)
470
+ };
471
+ } else {
472
+ const width = image.attachment.height * configuredRatio;
473
+ crop = {
474
+ top: 0,
475
+ left: Math.floor((image.attachment.width - width) / 2),
476
+ width: Math.floor(width),
477
+ height: image.attachment.height
478
+ };
479
+ }
480
+
481
+ return crop;
482
+ },
483
+ async updateImageCropRelationships(req, piece) {
484
+ if (!piece.relatedReverseIds?.length) {
485
+ return;
486
+ }
487
+ if (
488
+ !piece._prevAttachmentId ||
489
+ !piece.attachment ||
490
+ piece._prevAttachmentId === piece.attachment._id
491
+ ) {
492
+ return;
493
+ }
494
+ const croppedIndex = {};
495
+ for (const docId of piece.relatedReverseIds) {
496
+ await self.updateImageCropsForRelationship(req, docId, piece, croppedIndex);
497
+ }
498
+ },
499
+ // This handler operates on all documents of a given aposDocId. The `piece`
500
+ // argument is the image piece that has been updated.
501
+ // - Auto re-crop the image, using the same width/height ratio if the
502
+ // image has been cropped.
503
+ // - Remove any existing focal point data.
504
+ //
505
+ // `croppedIndex` is used to avoid re-cropping the same image when updating multiple
506
+ // documents. It's internally mutated by the handler.
507
+ async updateImageCropsForRelationship(req, aposDocId, piece, croppedIndex = {}) {
508
+ const dbDocs = await self.apos.doc.db.find({
509
+ aposDocId
510
+ }).toArray();
511
+ const changeSets = dbDocs.flatMap(doc => getDocRelations(doc, piece));
512
+ for (const changeSet of changeSets) {
513
+ try {
514
+ const cropFields = await autocrop(changeSet.image, changeSet.cropFields, croppedIndex);
515
+ const $set = {
516
+ [changeSet.docDotPath]: cropFields
517
+ };
518
+ self.logDebug(req, 'replace-autocrop', {
519
+ docId: changeSet.docId,
520
+ docTitle: changeSet.doc.title,
521
+ imageId: piece._id,
522
+ imageTitle: piece.title,
523
+ $set
524
+ });
525
+ await self.apos.doc.db.updateOne({
526
+ _id: changeSet.docId
527
+ }, {
528
+ $set
529
+ });
530
+ } catch (e) {
531
+ self.apos.util.error(e);
532
+ await self.apos.notify(
533
+ req,
534
+ req.t(
535
+ 'apostrophe:imageOnReplaceAutocropError',
536
+ {
537
+ title: changeSet.doc.title
538
+ }
539
+ ),
540
+ {
541
+ type: 'danger',
542
+ dismiss: true
543
+ }
544
+ );
545
+ }
546
+ }
547
+
548
+ if (changeSets.length) {
549
+ return self.apos.notify(
550
+ req,
551
+ req.t(
552
+ 'apostrophe:imageOnReplaceAutocropMessage',
553
+ {
554
+ title: changeSets[0].doc.title
555
+ }
556
+ ),
557
+ {
558
+ type: 'success',
559
+ dismiss: true
560
+ }
561
+ );
562
+ }
563
+
564
+ function getDocRelations(doc, imagePiece) {
565
+ const results = [];
566
+ self.apos.doc.walk(doc, function (o, key, value, dotPath, ancestors) {
567
+ if (!value || typeof value !== 'object' || !Array.isArray(value.imageIds)) {
568
+ return;
569
+ }
570
+ if (!value.imageIds.includes(imagePiece.aposDocId)) {
571
+ return;
572
+ }
573
+ if (!value.imageFields?.[imagePiece.aposDocId]) {
574
+ return;
575
+ }
576
+ const cropFields = value.imageFields[imagePiece.aposDocId];
577
+ // We check for crop OR focal point data (because
578
+ // focal point has to be reset when the image is replaced).
579
+ if (!cropFields.width && typeof cropFields.x !== 'number') {
580
+ return;
581
+ }
582
+ results.push({
583
+ docId: doc._id,
584
+ docDotPath: `${dotPath}.imageFields.${imagePiece.aposDocId}`,
585
+ doc,
586
+ cropFields,
587
+ image: imagePiece,
588
+ value
589
+ });
590
+ });
591
+ return results;
592
+ }
593
+
594
+ async function autocrop(image, oldFields, croppedIndex) {
595
+ let crop = { ...oldFields };
596
+ if (crop.width) {
597
+ crop = self.calculateAutocrop(image, [ crop.width, crop.height ]);
598
+ }
599
+
600
+ const hash = cropHash(image, crop);
601
+ if (crop.width && !croppedIndex[hash]) {
602
+ await self.apos.attachment.crop(req, image.attachment._id, crop);
603
+ croppedIndex[hash] = true;
604
+ }
605
+ return {
606
+ ...crop,
607
+ x: null,
608
+ y: null
609
+ };
610
+ }
611
+
612
+ function cropHash(image, crop) {
613
+ return `${image.attachment._id}-${crop.top}-${crop.left}-${crop.width}-${crop.height}`;
614
+ }
460
615
  }
616
+
461
617
  };
462
618
  },
463
619
  extendMethods(self) {
@@ -515,6 +671,15 @@ module.exports = {
515
671
  }
516
672
  return [ self.apos.launder.integer(a[0]), self.apos.launder.integer(a[1]) ];
517
673
  }
674
+ },
675
+ prevAttachment: {
676
+ after(results) {
677
+ for (const result of results) {
678
+ if (result.attachment) {
679
+ result._prevAttachmentId = result.attachment._id;
680
+ }
681
+ }
682
+ }
518
683
  }
519
684
  }
520
685
  };
@@ -2300,7 +2300,7 @@ database.`);
2300
2300
  // Apostrophe queries used to fetch Apostrophe pages
2301
2301
  // consult this method.
2302
2302
  getBaseUrl(req) {
2303
- const hostname = self.apos.i18n.locales[req.locale].hostname;
2303
+ const hostname = self.apos.i18n.locales[req.locale]?.hostname;
2304
2304
 
2305
2305
  return hostname
2306
2306
  ? `${req.protocol}://${hostname}`
@@ -441,6 +441,13 @@ module.exports = {
441
441
  });
442
442
  },
443
443
 
444
+ // Wrapper around isEqual method to get modified fields between two documents
445
+ // instead of just getting a boolean, it will return an array of the modified fields
446
+
447
+ getChanges(req, schema, one, two) {
448
+ return self.isEqual(req, schema, one, two, { getChanges: true });
449
+ },
450
+
444
451
  // Compare two objects and return true only if their schema fields are equal.
445
452
  //
446
453
  // Note that for relationship fields this comparison is based on the idsStorage
@@ -451,22 +458,40 @@ module.exports = {
451
458
  // This method is invoked by the doc module to compare draft and published
452
459
  // documents and set the modified property of the draft, just before updating the
453
460
  // published version.
461
+ //
462
+ // When passing the option `getChange: true` it'll return an array of changed fields
463
+ // in this case the method won't short circuit by directly returning false
464
+ // when finding a changed field
454
465
 
455
- isEqual(req, schema, one, two) {
466
+ isEqual(req, schema, one, two, options = {}) {
467
+ const changedFields = [];
456
468
  for (const field of schema) {
457
469
  const fieldType = self.fieldTypes[field.type];
458
- if (!fieldType.isEqual) {
459
- if ((!_.isEqual(one[field.name], two[field.name])) &&
460
- !((one[field.name] == null) && (two[field.name] == null))) {
461
- return false;
462
- }
463
- } else {
470
+
471
+ if (fieldType.isEqual) {
464
472
  if (!fieldType.isEqual(req, field, one, two)) {
473
+ if (options.getChanges) {
474
+ changedFields.push(field.name);
475
+ } else {
476
+ return false;
477
+ }
478
+ }
479
+ continue;
480
+ }
481
+
482
+ if (
483
+ !_.isEqual(one[field.name], two[field.name]) &&
484
+ !((one[field.name] == null) && (two[field.name] == null))
485
+ ) {
486
+ if (options.getChanges) {
487
+ changedFields.push(field.name);
488
+ } else {
465
489
  return false;
466
490
  }
467
491
  }
468
492
  }
469
- return true;
493
+
494
+ return options.getChanges ? changedFields : true;
470
495
  },
471
496
 
472
497
  // Index the object's fields for participation in Apostrophe search unless
@@ -40,6 +40,7 @@
40
40
  <component
41
41
  v-show="displayComponent(field)"
42
42
  v-model="fieldState[field.name]"
43
+ :class="{ 'apos-field__wrapper--highlight': highlight(field.name) }"
43
44
  :is="fieldComponentMap[field.type]"
44
45
  :following-values="followingValues[field.name]"
45
46
  :condition-met="conditionalFields?.if[field.name]"
@@ -59,6 +60,7 @@
59
60
  v-if="hasCompareMeta"
60
61
  v-show="displayComponent(field)"
61
62
  v-model="compareMetaState[field.name]"
63
+ :class="{ 'apos-field__wrapper--highlight': highlight(field.name) }"
62
64
  :is="fieldComponentMap[field.type]"
63
65
  :following-values="followingValues[field.name]"
64
66
  :condition-met="conditionalFields?.if[field.name]"
@@ -139,4 +141,9 @@ export default {
139
141
  }
140
142
  }
141
143
  }
144
+
145
+ :deep(.apos-field__wrapper--highlight > .apos-field) {
146
+ padding: 10px;
147
+ background: var(--a-highlight);
148
+ }
142
149
  </style>
@@ -328,6 +328,9 @@ export default {
328
328
  },
329
329
  onUpdateDocData(data) {
330
330
  this.$emit('update-doc-data', data);
331
+ },
332
+ highlight(fieldName) {
333
+ return this.meta[fieldName]?.['@apostrophecms/schema:highlight'];
331
334
  }
332
335
  }
333
336
  };
@@ -8,6 +8,7 @@
8
8
  --a-warning-dark: #a75c07;
9
9
  --a-warning-fade: #ffce0030;
10
10
  --a-progress-bg: #2c354d;
11
+ --a-highlight: #fff6e8;
11
12
 
12
13
  --a-danger-button-hover: #c00717;
13
14
  --a-danger-button-active: #a10000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.64.0",
3
+ "version": "3.66.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/test/images.js CHANGED
@@ -1,9 +1,12 @@
1
1
  const t = require('../test-lib/test.js');
2
- const assert = require('assert');
2
+ const assert = require('assert/strict');
3
3
  const fs = require('fs');
4
+ const fsp = require('fs/promises');
4
5
  const path = require('path');
5
6
  const FormData = require('form-data');
6
7
 
8
+ const publicFolderPath = path.join(process.cwd(), 'test/public');
9
+
7
10
  describe('Images', function() {
8
11
 
9
12
  let apos;
@@ -73,7 +76,8 @@ describe('Images', function() {
73
76
  // Test pieces.list()
74
77
  it('should clean up any existing images for testing', async function() {
75
78
  try {
76
- const response = await apos.doc.db.deleteMany({ type: '@apostrophecms/image' }
79
+ const response = await apos.doc.db.deleteMany(
80
+ { type: '@apostrophecms/image' }
77
81
  );
78
82
  assert(response.result.ok === 1);
79
83
  } catch (e) {
@@ -263,6 +267,140 @@ describe('Images', function() {
263
267
  assert.strictEqual(fields.height, 225);
264
268
  });
265
269
 
270
+ it('should update crop fields when replacing an image attachment', async function () {
271
+ await t.destroy(apos);
272
+ await fsp.rm(path.join(publicFolderPath, 'uploads'), {
273
+ recursive: true,
274
+ force: true
275
+ });
276
+ apos = await t.create({
277
+ root: module,
278
+ modules: {
279
+ 'test-piece': {
280
+ extend: '@apostrophecms/piece-type',
281
+ fields: {
282
+ add: {
283
+ main: {
284
+ type: 'area',
285
+ options: {
286
+ widgets: {
287
+ '@apostrophecms/image': {
288
+ aspectRatio: [ 3, 2 ]
289
+ }
290
+ }
291
+ }
292
+ }
293
+ }
294
+ }
295
+ }
296
+ }
297
+ });
298
+ await insertUser({
299
+ title: 'admin',
300
+ username: 'admin',
301
+ password: 'admin',
302
+ email: 'ad@min.com',
303
+ role: 'admin'
304
+ });
305
+
306
+ // Upload an image (landscape), crop it, insert a piece with the cropped image
307
+ jar = await login('admin');
308
+ const formData = new FormData();
309
+ const stream = fs.createReadStream(
310
+ path.join(apos.rootDir, '/public/test-image-landscape.jpg')
311
+ );
312
+ formData.append('file', stream);
313
+ const attachment = await apos.http.post('/api/v1/@apostrophecms/attachment/upload', {
314
+ body: formData,
315
+ jar
316
+ });
317
+ stream.close();
318
+ image = await apos.http.post('/api/v1/@apostrophecms/image', {
319
+ body: {
320
+ title: 'Test Image Landscape',
321
+ attachment
322
+ },
323
+ jar
324
+ });
325
+ assert.equal(image._prevAttachmentId, attachment._id);
326
+ const crop = await apos.http.post('/api/v1/@apostrophecms/image/autocrop', {
327
+ body: {
328
+ relationship: [ image ],
329
+ widgetOptions: {
330
+ aspectRatio: [ 3, 2 ]
331
+ }
332
+ },
333
+ jar
334
+ });
335
+ let piece = await apos.http.post('/api/v1/test-piece', {
336
+ jar,
337
+ body: {
338
+ title: 'Test Piece',
339
+ slug: 'test-piece',
340
+ type: 'test-piece',
341
+ main: {
342
+ metaType: 'area',
343
+ items: [
344
+ {
345
+ type: '@apostrophecms/image',
346
+ metaType: 'widget',
347
+ imageIds: [ image.aposDocId ],
348
+ imageFields: {
349
+ [image.aposDocId]: crop.relationship[0]._fields
350
+ },
351
+ _image: [ crop.relationship[0] ]
352
+ }
353
+ ]
354
+ }
355
+ }
356
+ });
357
+
358
+ let imageFields = piece.main.items[0].imageFields[image.aposDocId];
359
+ assert(imageFields, 'imageFields should be present when creating the piece');
360
+ assert.equal(imageFields.width / imageFields.height, 3 / 2, 'aspect ratio should be 3:2');
361
+ await fsp.access(
362
+ path.join(
363
+ publicFolderPath,
364
+ attachment._urls.original.replace(
365
+ '.jpg',
366
+ `.${imageFields.left}.${imageFields.top}.${imageFields.width}.${imageFields.height}.jpg`
367
+ )
368
+ )
369
+ );
370
+
371
+ // Replace the image with portrait orientation, verify that the aspect ratio is preserved
372
+ const formDataPortrait = new FormData();
373
+ const streamPortrait = fs.createReadStream(path.join(apos.rootDir, '/public/test-image.jpg'));
374
+ formDataPortrait.append('file', streamPortrait);
375
+ const attachmentPortrait = await apos.http.post('/api/v1/@apostrophecms/attachment/upload', {
376
+ body: formDataPortrait,
377
+ jar
378
+ });
379
+ image = await apos.http.put(`/api/v1/@apostrophecms/image/${image._id}`, {
380
+ body: {
381
+ title: 'Test Image Portrait',
382
+ attachment: attachmentPortrait
383
+ },
384
+ jar
385
+ });
386
+ streamPortrait.close();
387
+ piece = await apos.http.get(`/api/v1/test-piece/${piece._id}`, {
388
+ jar
389
+ });
390
+ imageFields = piece.main.items[0].imageFields[image.aposDocId];
391
+ assert(imageFields, 'imageFields should be present after replacing the image attachment');
392
+ assert.equal(imageFields.width / imageFields.height, 3 / 2, 'aspect ratio should be 3:2');
393
+ await fsp.access(
394
+ path.join(
395
+ publicFolderPath,
396
+ attachmentPortrait._urls.original.replace(
397
+ '.jpg',
398
+ `.${imageFields.left}.${imageFields.top}.${imageFields.width}.${imageFields.height}.jpg`
399
+ )
400
+ )
401
+ );
402
+ });
403
+
266
404
  async function insertUser(info) {
267
405
  const user = apos.user.newInstance();
268
406
  assert(user);