apostrophe 3.65.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,17 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## 3.65.0 (2024-05-06)
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
6
11
 
7
- * 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
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
8
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).
9
16
  Thanks to Michelin for contributing this feature.
10
17
 
11
18
  ## 3.64.0 (2024-04-18)
12
19
 
13
- ### Adds
14
-
15
20
  ### Fixes
16
21
 
17
22
  * Add the missing `metaType` property to newly inserted widgets.
@@ -42,8 +47,6 @@ that these keys would be present.
42
47
  * `field.help` and `field.htmlHelp` are now correctly translated when displayed in a tooltip.
43
48
  This was also an expectation for the multisite module.
44
49
 
45
- ## UNRELEASED
46
-
47
50
  ### Adds
48
51
 
49
52
  * Add side by side comparison support in AposSchema component.
@@ -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.65.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);