apostrophe 3.37.0 → 3.38.1

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,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.38.1 (2023-01-23)
4
+
5
+ ### Fixes
6
+
7
+ * Version 3.38.0 introduced a regression that temporarily broke support for user-edited content in locales with names like `de-de` (note the lowercase country name). This was inadvertently introduced in an effort to improve support for locale fallback when generating static translations of the admin interface. Version 3.38.1 brings back the content that temporarily appeared to be missing for these locales (it was never removed from the database), and also achieves the original goal. **However, if you created content for such locales using `3.38.0` (released five days ago) and wish to keep that content,** rather than reverting to the content from before `3.38.0`, see below.
8
+
9
+ ### Adds
10
+
11
+ * The new `i18n:rename-locale` task can be used to move all content from one locale name to another, using the `--old` and `--new` options. By default, any duplicate keys for content existing in both locales will stop the process. However you can specify which content to keep in the event of a duplicate key error using the `--keep=localename` option. Note that the value of `--new` should match the a locale name that is currently configured for the `@apostrophecms/i18n` module.
12
+
13
+ Example:
14
+
15
+ ```
16
+ # If you always had de-de configured as a locale, but created
17
+ # a lot of content with Apostrophe 3.38.0 which incorrectly stored
18
+ # it under de-DE, you can copy that content. In this case we opt
19
+ # to keep de-de content in the event of any conflicts
20
+ node app @apostrophecms/i18n:rename-locale --old=de-DE --new=de-de --keep=de-de
21
+ ```
22
+
23
+ ## 3.38.0 (2023-01-18)
24
+
25
+ ### Adds
26
+
27
+ * Emit a `beforeSave` event from the `@apostrophecms:notification` module, with `req` and the `notification` as arguments, in order to give the possibility to override the notification.
28
+ * Emit a `beforeInsert` event from the `@apostrophecms:attachment` module, with `req` and the `doc` as arguments, in order to give the possibility to override the attachment.
29
+ * Emit a `beforeSaveSafe` event from the `@apostrophecms:user` module, with `req`, `safeUser` and `user` as arguments, in order to give the possibility to override properties of the `safeUser` object which contains password hashes and other information too sensitive to be stored in the aposDocs collection.
30
+ * Automatically convert failed uppercase URLs to their lowercase version - can be disabled with `redirectFailedUpperCaseUrls: false` in `@apostrophecms/page/index.js` options. This only comes into play if a 404 is about to happen.
31
+ * Automatically convert country codes in locales like `xx-yy` to `xx-YY` before passing them to `i18next`, which is strict about uppercase country codes.
32
+
33
+ ### Fixes
34
+
35
+ * Documents kept as the `previous` version for undo purposes were not properly marked as such, breaking the public language switcher in some cases. This was fixed and a migration was added for existing data.
36
+ * Uploading an image in an apostrophe area with `minSize` requirements will not trigger an unexpected error anymore. If the image is too small, a notification will be displayed with the minimum size requirements. The `Edit Image` modal will now display the minimum size requirements, if any, above the `Browse Images` field.
37
+ * Some browsers saw the empty `POST` response for new notifications as invalid XML. It will now return an empty JSON object with the `Content-Type` set to `application/json`.
38
+
3
39
  ## 3.37.0 (2023-01-06)
4
40
 
5
41
  ### Adds
@@ -15,14 +51,14 @@
15
51
  e.g. `A permission.can() call was made with a type that has no manager: @apostrophecms/polymorphic-type`.
16
52
  * The module `webpack.extensions` configuration is not applied to the core Admin UI build anymore. This is the correct and intended behavior as explained in the [relevant documentation](https://v3.docs.apostrophecms.org/guide/webpack.html#extending-webpack-configuration).
17
53
  * The `previewImage` option now works properly for widget modules loaded from npm and those that subclass them. Specifically, the preview image may be provided in the `public/` subdirectory of the original module, the project-level configuration of it, or a subclass.
18
-
54
+
19
55
  ## 3.36.0 (2022-12-22)
20
56
 
21
57
  ### Adds
22
58
 
23
- * `shortcut` option for piece modules, allowing easy re-mapping of the manager command shortcut per module.
59
+ * `shortcut` option for piece modules, allowing easy re-mapping of the manager command shortcut per module.
24
60
 
25
- ### Fixes
61
+ ### Fixes
26
62
 
27
63
  * Ensure there are no conflicting command shortcuts for the core modules.
28
64
 
@@ -443,6 +443,9 @@ module.exports = {
443
443
  await Promise.promisify(self.uploadfs.copyIn)(file.path, '/attachments/' + info._id + '-' + info.name + '.' + info.extension);
444
444
  }
445
445
  info.createdAt = new Date();
446
+
447
+ await self.emit('beforeInsert', req, info);
448
+
446
449
  await self.db.insertOne(info);
447
450
  return info;
448
451
  },
@@ -37,6 +37,7 @@ module.exports = {
37
37
  await self.createIndexes();
38
38
  self.addLegacyMigrations();
39
39
  self.addCacheFieldMigration();
40
+ self.addSetPreviousDocsAposModeMigration();
40
41
  },
41
42
  restApiRoutes(self) {
42
43
  return {
@@ -1288,6 +1289,20 @@ module.exports = {
1288
1289
  addCacheFieldMigration() {
1289
1290
  self.apos.migration.add('add-cache-invalidated-at-field', self.setCacheField);
1290
1291
  },
1292
+
1293
+ async addSetPreviousDocsAposModeMigration () {
1294
+ self.apos.migration.add('set-previous-docs-apos-mode', async () => {
1295
+ await self.apos.doc.db.updateMany({
1296
+ _id: { $regex: ':previous$' },
1297
+ aposMode: { $ne: 'previous' }
1298
+ }, {
1299
+ $set: {
1300
+ aposMode: 'previous'
1301
+ }
1302
+ });
1303
+ });
1304
+ },
1305
+
1291
1306
  ...require('./lib/legacy-migrations')(self)
1292
1307
  };
1293
1308
  }
@@ -847,6 +847,7 @@ module.exports = {
847
847
  if (previousPublished) {
848
848
  previousPublished._id = previousPublished._id.replace(':published', ':previous');
849
849
  previousPublished.aposLocale = previousPublished.aposLocale.replace(':published', ':previous');
850
+ previousPublished.aposMode = 'previous';
850
851
  Object.assign(previousPublished, await self.getDeduplicationSet(req, previousPublished));
851
852
  await self.apos.doc.db.replaceOne({
852
853
  _id: previousPublished._id
@@ -561,6 +561,16 @@ module.exports = {
561
561
  for (const [ name, options ] of Object.entries(self.namespaces)) {
562
562
  if (options.browser) {
563
563
  i18n[name] = self.i18next.getResourceBundle(locale, name);
564
+ if (!i18n[name]) {
565
+ // Attempt fallback to language only. This is not
566
+ // the full fallback support of i18next because that
567
+ // is difficult to tap into when calling getResourceBundle,
568
+ // but it should work for most situations
569
+ const [ lang, country ] = locale.split('-');
570
+ if (country) {
571
+ i18n[name] = self.i18next.getResourceBundle(lang, name);
572
+ }
573
+ }
564
574
  }
565
575
  }
566
576
  return i18n;
@@ -644,6 +654,83 @@ module.exports = {
644
654
  );
645
655
  }
646
656
  };
657
+ },
658
+ tasks(self) {
659
+ return {
660
+ 'rename-locale': {
661
+ usage: 'Usage: node app @apostrophecms/i18n:rename-locale --old=de-DE --new=de-de --keep=de-de',
662
+ async task(argv) {
663
+ const oldLocale = self.apos.launder.string(argv.old);
664
+ const newLocale = self.apos.launder.string(argv.new);
665
+ const keep = self.apos.launder.string(argv.keep);
666
+ let renamed = 0;
667
+ let kept = 0;
668
+ if (!oldLocale) {
669
+ throw new Error('You must specify --old');
670
+ }
671
+ if (!newLocale) {
672
+ throw new Error('You must specify --new');
673
+ }
674
+ if (oldLocale === newLocale) {
675
+ throw new Error('The old and new locales must be different');
676
+ }
677
+ if (keep && (!(keep === oldLocale) && !(keep === newLocale))) {
678
+ throw new Error('--keep must match --old or --new');
679
+ }
680
+ await self.apos.migration.eachDoc({ aposLocale: new RegExp(`^${self.apos.util.regExpQuote(oldLocale)}:`) }, async doc => {
681
+ const newDoc = {
682
+ ...doc,
683
+ aposLocale: doc.aposLocale.replace(oldLocale, newLocale),
684
+ _id: doc._id.replace(`:${oldLocale}`, `:${newLocale}`)
685
+ };
686
+ try {
687
+ // Remove old first to cut down on duplicate key conflicts due to
688
+ // custom properties
689
+ await self.apos.doc.db.removeOne({ _id: doc._id });
690
+ await self.apos.doc.db.insertOne(newDoc);
691
+ renamed++;
692
+ } catch (e) {
693
+ // First reinsert old doc to prevent content loss on new doc insert failure
694
+ await self.apos.doc.db.insertOne(doc);
695
+ if (!self.apos.doc.isUniqueError(e)) {
696
+ throw e;
697
+ }
698
+ const existing = await self.apos.doc.db.findOne({ _id: newDoc._id });
699
+ if (!existing) {
700
+ // We don't know the cause of this error
701
+ throw e;
702
+ }
703
+ if (keep === newLocale) {
704
+ // New content already exists in new locale, delete old locale
705
+ // and keep new
706
+ await self.apos.doc.db.removeOne({ _id: doc._id });
707
+ kept++;
708
+ } else if (keep === oldLocale) {
709
+ // We want to keep the old locale's content. Once again we
710
+ // need to remove the old doc first to cut down on conflicts
711
+ try {
712
+ await self.apos.doc.db.removeOne({ _id: doc._id });
713
+ await self.apos.doc.db.deleteOne({ _id: newDoc._id });
714
+ await self.apos.doc.db.insertOne(newDoc);
715
+ } catch (e) {
716
+ // Reinsert old doc to prevent content loss on new doc insert failure
717
+ await self.apos.doc.db.insertOne(doc);
718
+ throw e;
719
+ }
720
+ kept++;
721
+ } else {
722
+ console.error('A conflict occurred. Use --keep to specify a locale to keep and retry');
723
+ throw e;
724
+ }
725
+ }
726
+ });
727
+ console.log(`Renamed ${renamed} documents from ${oldLocale} to ${newLocale}`);
728
+ if (keep) {
729
+ console.log(`Due to conflicts, kept ${kept} documents from ${keep}`);
730
+ }
731
+ }
732
+ }
733
+ };
647
734
  }
648
735
  };
649
736
 
@@ -318,6 +318,21 @@ export default {
318
318
  this.uploading = false;
319
319
  await this.getMedia();
320
320
 
321
+ if (Array.isArray(imgIds) && imgIds.length && this.items.length === 0) {
322
+ const [ widgetOptions = {} ] = apos.area.widgetOptions;
323
+ const [ width, height ] = widgetOptions.minSize || [];
324
+ await apos.notify('apostrophe:minSize', {
325
+ type: 'danger',
326
+ icon: 'alert-circle-icon',
327
+ dismiss: true,
328
+ interpolate: {
329
+ width,
330
+ height
331
+ }
332
+ });
333
+ this.updateEditing(null);
334
+ return;
335
+ }
321
336
  if (Array.isArray(imgIds) && imgIds.length) {
322
337
  this.checked = this.checked.concat(imgIds);
323
338
 
@@ -219,7 +219,8 @@ export default {
219
219
  // database.
220
220
  this.checked.forEach(id => {
221
221
  if (this.checkedDocs.findIndex(doc => doc._id === id) === -1) {
222
- this.checkedDocs.push(this.items.find(item => item._id === id));
222
+ const found = this.items.find(item => item._id === id);
223
+ found && this.checkedDocs.push(found);
223
224
  }
224
225
  });
225
226
 
@@ -92,6 +92,7 @@ module.exports = {
92
92
  async post(req) {
93
93
  const type = self.apos.launder.select(req.body.type, [
94
94
  'danger',
95
+ 'error',
95
96
  'warning',
96
97
  'success',
97
98
  'info'
@@ -143,9 +144,14 @@ module.exports = {
143
144
  put(req, _id) {
144
145
  throw self.apos.error('unimplemented');
145
146
  },
146
- patch(req, _id) {
147
+ async patch(req, _id) {
147
148
  const dismissed = self.apos.launder.boolean(req.body.dismissed);
148
149
  if (dismissed) {
150
+ await self.emit('beforeSave', req, {
151
+ _id,
152
+ dismissed
153
+ });
154
+
149
155
  return self.db.updateOne({ _id }, {
150
156
  $set: {
151
157
  dismissed
@@ -283,6 +289,8 @@ module.exports = {
283
289
 
284
290
  Object.assign(notification, copiedOptions);
285
291
 
292
+ await self.emit('beforeSave', req, notification);
293
+
286
294
  // We await here rather than returning because we expressly do not
287
295
  // want to leak mongodb metadata to the browser
288
296
  await self.db.updateOne(
@@ -302,6 +310,8 @@ module.exports = {
302
310
  noteId: notification._id
303
311
  };
304
312
  }
313
+
314
+ return {};
305
315
  },
306
316
 
307
317
  // The dismiss method accepts the following arguments:
@@ -317,6 +327,11 @@ module.exports = {
317
327
  await pause(delay);
318
328
 
319
329
  try {
330
+ await self.emit('beforeSave', req, {
331
+ _id: noteId,
332
+ dismissed: true
333
+ });
334
+
320
335
  await self.db.updateOne(
321
336
  {
322
337
  _id: noteId
@@ -33,7 +33,8 @@ module.exports = {
33
33
  orphan: true,
34
34
  title: 'Archive'
35
35
  }
36
- ]
36
+ ],
37
+ redirectFailedUpperCaseUrls: true
37
38
  },
38
39
  batchOperations: {
39
40
  add: {
@@ -1565,6 +1566,7 @@ database.`);
1565
1566
  if (self.isFound(req)) {
1566
1567
  return;
1567
1568
  }
1569
+
1568
1570
  if (req.user && (req.mode === 'published')) {
1569
1571
  // Try again in draft mode
1570
1572
  try {
@@ -1613,6 +1615,12 @@ database.`);
1613
1615
  // Nonfatal, we were just probing
1614
1616
  }
1615
1617
  }
1618
+
1619
+ // If uppercase letters in URL, try with lowercase
1620
+ if (self.options.redirectFailedUpperCaseUrls && /[A-Z]/.test(req.path)) {
1621
+ req.redirect = self.apos.url.build(req.path.toLowerCase(), req.query);
1622
+ }
1623
+
1616
1624
  // Give all modules a chance to save the day
1617
1625
  await self.emit('notFound', req);
1618
1626
  // Are we happy now?
@@ -6,6 +6,17 @@
6
6
  :modifiers="modifiers"
7
7
  >
8
8
  <template #additional>
9
+ <div
10
+ v-if="minSize[0] || minSize[1]"
11
+ class="apos-field__min-size"
12
+ >
13
+ {{
14
+ $t('apostrophe:minSize', {
15
+ width: minSize[0] || '???',
16
+ height: minSize[1] || '???'
17
+ })
18
+ }}
19
+ </div>
9
20
  <AposMinMaxCount
10
21
  :field="field"
11
22
  :value="next"
@@ -122,6 +133,11 @@ export default {
122
133
  modifiers.push('block');
123
134
  }
124
135
  return modifiers;
136
+ },
137
+ minSize() {
138
+ const [ widgetOptions = {} ] = apos.area.widgetOptions;
139
+
140
+ return widgetOptions.minSize || [];
125
141
  }
126
142
  },
127
143
  watch: {
@@ -287,4 +303,12 @@ export default {
287
303
  padding: 0;
288
304
  }
289
305
  }
306
+
307
+ .apos-field__min-size {
308
+ @include type-help;
309
+ display: flex;
310
+ flex-grow: 1;
311
+ margin-bottom: $spacing-base;
312
+ font-weight: var(--a-weight-bold);
313
+ }
290
314
  </style>
@@ -17,7 +17,7 @@ export default {
17
17
  }
18
18
 
19
19
  i18next.init({
20
- lng: i18n.locale,
20
+ lng: canonicalize(i18n.locale),
21
21
  fallbackLng,
22
22
  resources: {},
23
23
  debug: i18n.debug,
@@ -45,11 +45,11 @@ export default {
45
45
  });
46
46
 
47
47
  for (const [ ns, phrases ] of Object.entries(i18n.i18n[i18n.locale])) {
48
- i18next.addResourceBundle(i18n.locale, ns, phrases, true, true);
48
+ i18next.addResourceBundle(canonicalize(i18n.locale), ns, phrases, true, true);
49
49
  }
50
50
  if (i18n.locale !== i18n.defaultLocale) {
51
51
  for (const [ ns, phrases ] of Object.entries(i18n.i18n[i18n.defaultLocale])) {
52
- i18next.addResourceBundle(i18n.defaultLocale, ns, phrases, true, true);
52
+ i18next.addResourceBundle(canonicalize(i18n.defaultLocale), ns, phrases, true, true);
53
53
  }
54
54
  }
55
55
  if ((i18n.locale !== 'en') && (i18n.defaultLocale !== 'en')) {
@@ -107,5 +107,15 @@ export default {
107
107
  return result;
108
108
  }
109
109
  };
110
+
111
+ function canonicalize(locale) {
112
+ const [ language, territory ] = locale.split('-');
113
+ if (territory) {
114
+ return `${language}-${territory.toUpperCase()}`;
115
+ }
116
+ return locale;
117
+ }
118
+
110
119
  }
120
+
111
121
  };
@@ -292,6 +292,9 @@ module.exports = {
292
292
 
293
293
  await self.hashPassword(doc, safeUser);
294
294
  await self.hashSecrets(doc, safeUser);
295
+
296
+ await self.emit('beforeSaveSafe', req, safeUser, doc);
297
+
295
298
  if (action === 'insert') {
296
299
  await self.safe.insertOne(safeUser);
297
300
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.37.0",
3
+ "version": "3.38.1",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -209,11 +209,12 @@ describe('Draft / Published', function() {
209
209
  }), testDraftProduct);
210
210
  });
211
211
 
212
- it('"previous published" should be deduplicated at this point', async function() {
212
+ it('"previous published" should be deduplicated at this point, and previous have the right props', async function() {
213
213
  const previous = await apos.doc.db.findOne({
214
214
  _id: testDraftProduct._id.replace(':draft', ':previous')
215
215
  });
216
216
  assert(previous);
217
+ assert(previous.aposMode === 'previous');
217
218
  assert.strictEqual(previous.slug, `deduplicate-${previous.aposDocId}-test-product`);
218
219
  });
219
220
 
@@ -539,6 +540,7 @@ describe('Draft / Published', function() {
539
540
  }), {
540
541
  aposDocId: sibling.aposDocId
541
542
  }).children(true).toObject();
543
+
542
544
  assert(siblingPublished && siblingPublished._children && siblingPublished._children[0] && siblingPublished._children[0]._id === grandchild._id.replace(':draft', ':published'));
543
545
  });
544
546
 
@@ -556,17 +558,20 @@ describe('Draft / Published', function() {
556
558
  const draftItem = {
557
559
  ...baseItem,
558
560
  _id: 'some-page:en:draft',
559
- aposLocale: 'en:draft'
561
+ aposLocale: 'en:draft',
562
+ aposMode: 'draft'
560
563
  };
561
564
  const publishedItem = {
562
565
  ...baseItem,
563
566
  _id: 'some-page:en:published',
564
- aposLocale: 'en:published'
567
+ aposLocale: 'en:published',
568
+ aposMode: 'published'
565
569
  };
566
570
  const previousItem = {
567
571
  ...baseItem,
568
572
  _id: 'some-page:en:previous',
569
- aposLocale: 'en:previous'
573
+ aposLocale: 'en:previous',
574
+ aposMode: 'previous'
570
575
  };
571
576
 
572
577
  let draft;
@@ -10,4 +10,6 @@
10
10
  {% for tab in data.home._children %}
11
11
  <a href="{{ tab._url }}">Tab: {{ tab.slug }}</a>
12
12
  {% endfor %}
13
+
14
+ URL: {{ data.page._url }}
13
15
  {% endblock %}
package/test/pages.js CHANGED
@@ -215,6 +215,24 @@ describe('Pages', function() {
215
215
  assert(page.path === `${homeId.replace(':en:published', '')}/parent/child`);
216
216
  });
217
217
 
218
+ it('should convert an uppercase URL to its lowercase version', async function() {
219
+ const response = await apos.http.get('/PArent/cHild', {
220
+ fullResponse: true
221
+ });
222
+ assert(response.body.match(/URL: \/parent\/child/));
223
+ });
224
+
225
+ it('should NOT convert an uppercase URL if redirectFailedUpperCaseUrls is false', async function() {
226
+ apos.page.options.redirectFailedUpperCaseUrls = false;
227
+ try {
228
+ await apos.http.get('/PArent/cHild', {
229
+ fullResponse: true
230
+ });
231
+ } catch (error) {
232
+ assert(error.status === 404);
233
+ }
234
+ });
235
+
218
236
  it('should be able to include the ancestors of a page', async function() {
219
237
  const cursor = apos.page.find(apos.task.getAnonReq(), { slug: '/parent/child' });
220
238
 
@@ -1151,8 +1169,6 @@ describe('Pages', function() {
1151
1169
 
1152
1170
  it('should grant public access to a draft after having enabled draft sharing', async function() {
1153
1171
  const publicUrl = generatePublicUrl(shareResponse);
1154
- console.log('publicUrl', publicUrl);
1155
-
1156
1172
  const response = await apos.http.get(shareResponse._url, { fullResponse: true });
1157
1173
  const publicResponse = await apos.http.get(publicUrl, { fullResponse: true });
1158
1174