apostrophe 4.28.0 → 4.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +33 -3
  2. package/README.md +142 -0
  3. package/defaults.js +1 -0
  4. package/lib/safe-json-script.js +27 -0
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarLocale.vue +1 -1
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +1 -0
  7. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +3 -5
  8. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +13 -1
  9. package/modules/@apostrophecms/asset/lib/globalIcons.js +3 -0
  10. package/modules/@apostrophecms/attachment/index.js +43 -1
  11. package/modules/@apostrophecms/color-field/index.js +7 -1
  12. package/modules/@apostrophecms/doc/index.js +11 -1
  13. package/modules/@apostrophecms/doc-type/index.js +165 -32
  14. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +1 -1
  15. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +104 -59
  16. package/modules/@apostrophecms/file/index.js +109 -9
  17. package/modules/@apostrophecms/i18n/i18n/de.json +0 -2
  18. package/modules/@apostrophecms/i18n/i18n/en.json +40 -1
  19. package/modules/@apostrophecms/i18n/i18n/es.json +0 -1
  20. package/modules/@apostrophecms/i18n/i18n/fr.json +0 -1
  21. package/modules/@apostrophecms/i18n/i18n/it.json +0 -1
  22. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +0 -1
  23. package/modules/@apostrophecms/i18n/i18n/sk.json +0 -1
  24. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nBatchReporting.js +18 -1
  25. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nLocalizeActions.js +50 -0
  26. package/modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue +56 -13
  27. package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +8 -2
  28. package/modules/@apostrophecms/layout-column-widget/index.js +156 -163
  29. package/modules/@apostrophecms/layout-widget/index.js +7 -2
  30. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +6 -11
  31. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +3 -5
  32. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +4 -4
  33. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -16
  34. package/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs +7 -27
  35. package/modules/@apostrophecms/layout-widget/views/column.html +7 -9
  36. package/modules/@apostrophecms/login/index.js +39 -40
  37. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +17 -2
  38. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +3 -2
  39. package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +1 -0
  40. package/modules/@apostrophecms/page/index.js +2 -0
  41. package/modules/@apostrophecms/piece-type/index.js +3 -1
  42. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
  43. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +5 -0
  44. package/modules/@apostrophecms/recently-edited/index.js +831 -0
  45. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +54 -0
  46. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedCombo.vue +454 -0
  47. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilterTag.vue +75 -0
  48. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilters.vue +287 -0
  49. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +16 -0
  50. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedManager.vue +346 -0
  51. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedBatch.js +193 -0
  52. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedData.js +276 -0
  53. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFetch.js +199 -0
  54. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFilters.js +100 -0
  55. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +8 -4
  56. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +1 -1
  57. package/modules/@apostrophecms/styles/index.js +10 -0
  58. package/modules/@apostrophecms/styles/lib/apiRoutes.js +6 -0
  59. package/modules/@apostrophecms/styles/lib/handlers.js +5 -0
  60. package/modules/@apostrophecms/styles/lib/methods.js +9 -3
  61. package/modules/@apostrophecms/styles/lib/presets.js +119 -0
  62. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +3 -8
  63. package/modules/@apostrophecms/styles/ui/apos/composables/AposStyles.js +1 -3
  64. package/modules/@apostrophecms/styles/ui/apos/render-factory.js +29 -0
  65. package/modules/@apostrophecms/styles/ui/apos/universal/backgroundHelpers.mjs +140 -0
  66. package/modules/@apostrophecms/styles/ui/apos/universal/customRules.mjs +105 -0
  67. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +195 -15
  68. package/modules/@apostrophecms/template/index.js +22 -6
  69. package/modules/@apostrophecms/ui/ui/apos/components/AposCellContextMenu.vue +2 -0
  70. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +18 -4
  71. package/modules/@apostrophecms/ui/ui/apos/composables/useInfiniteScroll.js +91 -0
  72. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  73. package/modules/@apostrophecms/ui/ui/apos/stores/modal.js +5 -2
  74. package/modules/@apostrophecms/ui/ui/apos/utils/index.js +9 -0
  75. package/modules/@apostrophecms/url/index.js +38 -4
  76. package/modules/@apostrophecms/widget-type/index.js +22 -6
  77. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +8 -4
  78. package/package.json +19 -19
  79. package/test/files.js +129 -0
  80. package/test/layout-widget-migration.js +719 -0
  81. package/test/login-requirements.js +1 -1
  82. package/test/pieces-public-api.js +80 -0
  83. package/test/pieces.js +25 -0
  84. package/test/recently-edited.js +2311 -0
  85. package/test/schemas.js +39 -3
  86. package/test/static-build.js +642 -0
  87. package/test/styles.js +2569 -0
  88. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposLayoutColControlDialog.vue +0 -171
@@ -1261,6 +1261,17 @@ module.exports = {
1261
1261
  result = await actionModule.update(toReq, update);
1262
1262
  }
1263
1263
 
1264
+ // Record when this document was localized. Uses a direct DB
1265
+ // update so the timestamp survives regardless of the schema
1266
+ // convert pipeline. Covers both single and batch localization
1267
+ // (localizeBatch calls this method per document).
1268
+ const localizedAt = new Date();
1269
+ await self.apos.doc.db.updateOne(
1270
+ { _id: result._id },
1271
+ { $set: { localizedAt } }
1272
+ );
1273
+ result.localizedAt = localizedAt;
1274
+
1264
1275
  await self.emit('afterLocalize', req, draft, result, eventOptions);
1265
1276
 
1266
1277
  return result;
@@ -1610,6 +1621,59 @@ module.exports = {
1610
1621
  return doc;
1611
1622
  },
1612
1623
 
1624
+ // Returns true if the named filter is permitted to expose distinct
1625
+ // values through the `choices` / `counts` query builders given the
1626
+ // publicApiProjection. When no publicApiProjection is in effect
1627
+ // (authenticated API callers), all fields are permitted.
1628
+ //
1629
+ // This guards against leaking distinct values of fields excluded by
1630
+ // `publicApiProjection`, since MongoDB's `distinct` operator ignores
1631
+ // projections. Projections set explicitly by authenticated users are
1632
+ // not restricted — they are a voluntary narrowing of query results,
1633
+ // not a security boundary.
1634
+ choicesFieldAllowedByProjection(filter, projection) {
1635
+ if (!projection || !Object.keys(projection).length) {
1636
+ return true;
1637
+ }
1638
+ // Builders that aren't named after a top-level schema field are
1639
+ // not gated by the projection.
1640
+ const field = self.schema.find(f => f.name === filter);
1641
+ if (!field) {
1642
+ return true;
1643
+ }
1644
+ const topLevel = filter.split('.')[0];
1645
+ const values = Object.values(projection);
1646
+ const hasInclusion = values.some(v => v && v !== 0);
1647
+ const hasExclusion = values.some(v => v === 0 || v === false);
1648
+ if (hasInclusion) {
1649
+ // Inclusion projection: field must be explicitly included.
1650
+ return Boolean(projection[topLevel]);
1651
+ }
1652
+ if (hasExclusion) {
1653
+ // Exclusion projection: field must not be explicitly excluded.
1654
+ return projection[topLevel] !== 0 && projection[topLevel] !== false;
1655
+ }
1656
+ return true;
1657
+ },
1658
+
1659
+ // Returns true if the current user is permitted to view the named
1660
+ // schema field according to any schema-level `viewPermission`.
1661
+ // Non-schema filters are permitted. A dot-notated filter is matched
1662
+ // against the top-level schema field, since `viewPermission` is
1663
+ // declared on top-level fields (see `removeForbiddenFields`).
1664
+ choicesFieldAllowedByViewPermission(req, filter) {
1665
+ const topLevel = filter.split('.')[0];
1666
+ const field = self.schema.find(f => f.name === topLevel);
1667
+ if (!field || !field.viewPermission) {
1668
+ return true;
1669
+ }
1670
+ return self.apos.permission.can(
1671
+ req,
1672
+ field.viewPermission.action,
1673
+ field.viewPermission.type
1674
+ );
1675
+ },
1676
+
1613
1677
  composeFilters() {
1614
1678
  // TODO: keep in sync with page/index.js composeFilters
1615
1679
  self.filters = Object.entries(self.filters)
@@ -2157,24 +2221,6 @@ module.exports = {
2157
2221
  }
2158
2222
  },
2159
2223
 
2160
- // `.attachments(true)` annotates all attachment fields in the
2161
- // returned documents with URLs as documented for the
2162
- // `apos.attachment.all` method. Used by our REST APIs.
2163
-
2164
- attachments: {
2165
- def: true,
2166
- after(results) {
2167
- const attachments = query.get('attachments');
2168
-
2169
- if (attachments) {
2170
- self.apos.attachment.all(results, { annotate: true });
2171
- }
2172
- },
2173
- launder(b) {
2174
- return self.apos.launder.boolean(b);
2175
- }
2176
- },
2177
-
2178
2224
  // `.autocomplete('sta')` limits results to docs which are a good match
2179
2225
  // for a partial string beginning with `sta`, for instance `station`.
2180
2226
  // Appropriate words must exist in the title or other text schema fields
@@ -2427,6 +2473,28 @@ module.exports = {
2427
2473
  }
2428
2474
  },
2429
2475
 
2476
+ // `.attachments(true)` annotates all attachment fields in the
2477
+ // returned documents with URLs as documented for the
2478
+ // `apos.attachment.all` method. Used by our REST APIs.
2479
+ //
2480
+ // Must appear after the `relationships` builder so that
2481
+ // relationship `_fields` (e.g. crop coordinates) are
2482
+ // available when `attachment.all` generates `_urls`.
2483
+
2484
+ attachments: {
2485
+ def: true,
2486
+ after(results) {
2487
+ const attachments = query.get('attachments');
2488
+
2489
+ if (attachments) {
2490
+ self.apos.attachment.all(results, { annotate: true });
2491
+ }
2492
+ },
2493
+ launder(b) {
2494
+ return self.apos.launder.boolean(b);
2495
+ }
2496
+ },
2497
+
2430
2498
  // `.addUrls(true)`. Invokes the `addUrls` method of all doc type
2431
2499
  // managers with relevant docs among the results, if they have one.
2432
2500
  //
@@ -2450,20 +2518,47 @@ module.exports = {
2450
2518
  if (!val) {
2451
2519
  return;
2452
2520
  }
2453
- const byType = {};
2454
- for (const doc of results) {
2455
- byType[doc.type] = byType[doc.type] || [];
2456
- byType[doc.type].push(doc);
2521
+
2522
+ async function addUrlsByType(reqForUrls, docs) {
2523
+ const byType = {};
2524
+ for (const doc of docs) {
2525
+ byType[doc.type] = byType[doc.type] || [];
2526
+ byType[doc.type].push(doc);
2527
+ }
2528
+ for (const type of Object.keys(byType)) {
2529
+ const manager = self.apos.doc.getManager(type);
2530
+ if (manager?.addUrls) {
2531
+ await manager.addUrls(reqForUrls, byType[type]);
2532
+ }
2533
+ }
2457
2534
  }
2458
- const interesting = Object.keys(byType).filter(type => {
2459
- // Don't freak out if the projection was really conservative
2460
- // and the type is unknown, etc.
2461
- const manager = self.apos.doc.getManager(type);
2462
- return manager && manager.addUrls;
2463
- });
2464
- for (const type of interesting) {
2465
- await self.apos.doc.getManager(type).addUrls(req, byType[type]);
2535
+
2536
+ // When locale(null) was used, docs may span multiple
2537
+ // locales. Group by locale and resolve URLs with a
2538
+ // locale-appropriate req so each doc gets the correct
2539
+ // _url for its own locale.
2540
+ if (query.get('locale') === null) {
2541
+ const locales = self.apos.i18n.locales;
2542
+ const byLocale = {};
2543
+ for (const doc of results) {
2544
+ const locale =
2545
+ doc.aposLocale?.split(':')[0] || req.locale;
2546
+ if (!locales[locale]) {
2547
+ continue;
2548
+ }
2549
+ byLocale[locale] = byLocale[locale] || [];
2550
+ byLocale[locale].push(doc);
2551
+ }
2552
+ for (const [ locale, localeDocs ] of Object.entries(byLocale)) {
2553
+ const localeReq = locale === req.locale
2554
+ ? req
2555
+ : req.clone({ locale });
2556
+ await addUrlsByType(localeReq, localeDocs);
2557
+ }
2558
+ return;
2466
2559
  }
2560
+
2561
+ await addUrlsByType(req, results);
2467
2562
  }
2468
2563
  },
2469
2564
 
@@ -2510,10 +2605,34 @@ module.exports = {
2510
2605
  pageUrl: {
2511
2606
  def: true,
2512
2607
  after(results) {
2608
+ const req = query.req;
2609
+ const crossLocale = query.get('locale') === null;
2610
+ const localeReqs = {};
2513
2611
  for (const result of results) {
2514
- if ((!result.archived) && result.slug && self.apos.page.isPage(result)) {
2515
- result._url = `${query.req.prefix}${result.slug}`;
2612
+ if (
2613
+ (!result.archived) &&
2614
+ result.slug &&
2615
+ self.apos.page.isPage(result)
2616
+ ) {
2617
+ const urlReq = getReqForLocale(result);
2618
+ result._url = `${urlReq.prefix}${result.slug}`;
2619
+ }
2620
+ }
2621
+ function getReqForLocale(doc) {
2622
+ if (!crossLocale || !doc.aposLocale) {
2623
+ return req;
2624
+ }
2625
+ const locale = doc.aposLocale.split(':')[0];
2626
+ if (!self.apos.i18n.locales[locale]) {
2627
+ return req;
2628
+ }
2629
+ if (!localeReqs[locale]) {
2630
+ localeReqs[locale] =
2631
+ locale === req.locale
2632
+ ? req
2633
+ : req.clone({ locale });
2516
2634
  }
2635
+ return localeReqs[locale];
2517
2636
  }
2518
2637
  }
2519
2638
  },
@@ -2641,6 +2760,7 @@ module.exports = {
2641
2760
  const choices = {};
2642
2761
  const baseQuery = query.get('choices-query-prefinalize');
2643
2762
  baseQuery.set('choices-query-prefinalize', null);
2763
+ const publicApiProjection = query.get('publicApiProjection');
2644
2764
  for (const filter of filters) {
2645
2765
  // The choices for each filter should reflect the effect of all
2646
2766
  // filters except this one (filtering by topic pairs down the list
@@ -2656,6 +2776,19 @@ module.exports = {
2656
2776
  if (!query.builders[filter].launder) {
2657
2777
  continue;
2658
2778
  }
2779
+ // Do not leak distinct values of fields excluded by the
2780
+ // publicApiProjection. MongoDB's `distinct` ignores projections,
2781
+ // so we must enforce this ourselves here. Only applies to the
2782
+ // public API; authenticated users who set their own projections
2783
+ // are not restricted.
2784
+ if (!self.choicesFieldAllowedByProjection(filter, publicApiProjection)) {
2785
+ continue;
2786
+ }
2787
+ // Do not leak distinct values of fields the current user does
2788
+ // not have permission to view via a schema-level viewPermission.
2789
+ if (!self.choicesFieldAllowedByViewPermission(query.req, filter)) {
2790
+ continue;
2791
+ }
2659
2792
  // Now shut it off
2660
2793
  _query[filter](null);
2661
2794
  choices[filter] = await _query.toChoices(filter, { counts: query.get('counts') });
@@ -990,7 +990,7 @@ export default {
990
990
 
991
991
  const isLocalized = await apos.modal.execute('AposI18nLocalize', {
992
992
  doc: saved || this.original,
993
- locale,
993
+ targetLocale: locale,
994
994
  moduleName: this.moduleName,
995
995
  shouldRedirect: false
996
996
  });
@@ -92,6 +92,16 @@ export default {
92
92
  moduleLabels: {
93
93
  type: Object,
94
94
  default: null
95
+ },
96
+ showUnpublish: {
97
+ type: Boolean,
98
+ default() {
99
+ return true;
100
+ }
101
+ },
102
+ crossLocale: {
103
+ type: Boolean,
104
+ default: false
95
105
  }
96
106
  },
97
107
  emits: [ 'menu-open', 'menu-close', 'close' ],
@@ -105,6 +115,12 @@ export default {
105
115
  },
106
116
  computed: {
107
117
  menu() {
118
+ // TODO: Register core context actions (edit, preview,
119
+ // copy, localize, archive, etc.) as custom operations
120
+ // via `addContextOperation`. This would let us unify
121
+ // cross-locale gating through the `crossLocale`
122
+ // flag on each operation, eliminating the manual
123
+ // `!this.crossLocale` wrapping below.
108
124
  const menu = [
109
125
  // TODO
110
126
  // ...(this.isModifiedFromPublished ? [
@@ -128,64 +144,76 @@ export default {
128
144
  }
129
145
  ]
130
146
  : []),
131
- ...((this.showDismissSubmission && this.canDismissSubmission)
147
+ // In cross-locale mode, only Edit and Preview are safe.
148
+ // All other built-in ops are hidden.
149
+ ...(!this.crossLocale
132
150
  ? [
133
- {
134
- label: 'apostrophe:dismissSubmission',
135
- action: 'dismissSubmission'
136
- }
137
- ]
138
- : []),
139
- ...(this.showCopy && this.canCopy
140
- ? [
141
- {
142
- label: 'apostrophe:duplicate',
143
- action: 'copy'
144
- }
145
- ]
146
- : []),
147
- ...(this.canLocalize
148
- ? [
149
- {
150
- label: 'apostrophe:localize',
151
- action: 'localize'
152
- }
151
+ ...((this.showDismissSubmission && this.canDismissSubmission)
152
+ ? [
153
+ {
154
+ label: 'apostrophe:dismissSubmission',
155
+ action: 'dismissSubmission'
156
+ }
157
+ ]
158
+ : []),
159
+ ...(this.showCopy && this.canCopy
160
+ ? [
161
+ {
162
+ label: 'apostrophe:duplicate',
163
+ action: 'copy'
164
+ }
165
+ ]
166
+ : []),
167
+ ...(this.canLocalize
168
+ ? [
169
+ {
170
+ label: 'apostrophe:localize',
171
+ action: 'localize'
172
+ }
173
+ ]
174
+ : [])
153
175
  ]
154
176
  : []),
155
177
  ...this.customMenusByContext,
156
- ...((this.showDiscardDraft && this.canDiscardDraft)
178
+ ...(!this.crossLocale
157
179
  ? [
158
- {
159
- label: this.context.lastPublishedAt ? 'apostrophe:discardDraft' : 'apostrophe:deleteDraft',
160
- action: 'discardDraft',
161
- modifiers: [ 'danger' ]
162
- }
163
- ]
164
- : []),
165
- ...(this.showArchive && this.canArchive
166
- ? [
167
- {
168
- label: 'apostrophe:archive',
169
- action: 'archive',
170
- modifiers: [ 'danger' ]
171
- }
172
- ]
173
- : []),
174
- ...(this.canUnpublish
175
- ? [
176
- {
177
- label: 'apostrophe:unpublish',
178
- action: 'unpublish',
179
- modifiers: [ 'danger' ]
180
- }
181
- ]
182
- : []),
183
- ...(this.showRestore && this.canRestore
184
- ? [
185
- {
186
- label: 'apostrophe:restore',
187
- action: 'restore'
188
- }
180
+ ...((this.showDiscardDraft && this.canDiscardDraft)
181
+ ? [
182
+ {
183
+ label: this.context.lastPublishedAt
184
+ ? 'apostrophe:discardDraft'
185
+ : 'apostrophe:deleteDraft',
186
+ action: 'discardDraft',
187
+ modifiers: [ 'danger' ]
188
+ }
189
+ ]
190
+ : []),
191
+ ...(this.showArchive && this.canArchive
192
+ ? [
193
+ {
194
+ label: 'apostrophe:archive',
195
+ action: 'archive',
196
+ modifiers: [ 'danger' ]
197
+ }
198
+ ]
199
+ : []),
200
+ ...(this.showUnpublish && this.canUnpublish
201
+ ? [
202
+ {
203
+ label: 'apostrophe:unpublish',
204
+ action: 'unpublish',
205
+ modifiers: [ 'danger' ]
206
+ }
207
+ ]
208
+ : []),
209
+ ...(this.showRestore && this.canRestore
210
+ ? [
211
+ {
212
+ label: 'apostrophe:restore',
213
+ action: 'restore'
214
+ }
215
+ ]
216
+ : [])
189
217
  ]
190
218
  : [])
191
219
  ];
@@ -208,8 +236,14 @@ export default {
208
236
  },
209
237
  customOperationsByContext() {
210
238
  return this.customOperations.filter(({
211
- manuallyPublished, hasUrl, conditions, context, if: ifProps, moduleIf
239
+ manuallyPublished, hasUrl, conditions, context,
240
+ if: ifProps, moduleIf, crossLocale
212
241
  }) => {
242
+ // In cross-locale mode, only show operations explicitly marked as safe
243
+ if (this.crossLocale && !crossLocale) {
244
+ return false;
245
+ }
246
+
213
247
  if (typeof manuallyPublished === 'boolean' && manuallyPublished !== this.manuallyPublished) {
214
248
  return false;
215
249
  }
@@ -433,13 +467,19 @@ export default {
433
467
  this[item.action](this.context);
434
468
  },
435
469
  async edit(doc) {
470
+ const props = {
471
+ moduleName: this.moduleName,
472
+ docId: doc._id,
473
+ type: doc.type
474
+ };
475
+ // Cross-locale mode suported.
476
+ if (this.crossLocale && doc.aposLocale) {
477
+ props.locale = doc.aposLocale.split(':')[0];
478
+ }
436
479
  await apos.modal.execute(
437
480
  doc._aposEditorModal || this.moduleOptions.components.editorModal,
438
- {
439
- moduleName: this.moduleName,
440
- docId: doc._id,
441
- type: doc.type
442
- });
481
+ props
482
+ );
443
483
  },
444
484
  preview(doc) {
445
485
  window.open(doc._url, '_blank').focus();
@@ -490,6 +530,11 @@ export default {
490
530
  ...docProps(doc),
491
531
  ...operation.props
492
532
  };
533
+ // In cross-locale mode, pass the doc's locale so the modal
534
+ // opens in the correct locale context.
535
+ if (this.crossLocale && doc.aposLocale) {
536
+ props.locale = doc.aposLocale.split(':')[0];
537
+ }
493
538
  if (operation.type === 'event') {
494
539
  apos.bus.$emit(operation.action, props);
495
540
  return;
@@ -105,19 +105,112 @@ module.exports = {
105
105
  attachmentDocIds: []
106
106
  };
107
107
  },
108
- addUrls(req, files) {
108
+ // Returns the pretty URL base path (e.g. `/files`) when
109
+ // pretty URLs are enabled, or `null` otherwise.
110
+ // The returned path is prefix-qualified. If `options.relative`
111
+ // is true, the path is relative (no origin).
112
+ getPrettyUrlBase(req, { relative = false } = {}) {
113
+ if (!self.options.prettyUrls) {
114
+ return null;
115
+ }
116
+ const base = relative
117
+ ? self.apos.url.getBaseUrl(req, {
118
+ relative: true,
119
+ localePrefix: true
120
+ })
121
+ : req.prefix;
122
+ return `${base}${self.options.prettyUrlDir}`;
123
+ },
124
+ // Returns the pretty-URL path component for a file doc.
125
+ // E.g. `/my-document.pdf` — just the filename portion,
126
+ // without any base, prefix, or origin.
127
+ // Returns `null` when pretty URLs are disabled or the
128
+ // doc has no attachment. Works with any object that has
129
+ // `slug` and `attachment.extension` (including the
130
+ // lightweight projections used by `applyPrettyUrlPaths`).
131
+ getPrettyPath(doc) {
132
+ if (!self.options.prettyUrls || !doc.attachment) {
133
+ return null;
134
+ }
135
+ const slug = doc.slug.replace(self.options.slugPrefix || '', '');
136
+ return `/${slug}.${doc.attachment.extension}`;
137
+ },
138
+ // When `options.relative` is true, pretty URLs are built
139
+ // with only the prefix + prettyUrlDir (no origin).
140
+ addUrls(req, files, { relative = false } = {}) {
141
+ const prettyBase = self.getPrettyUrlBase(req, { relative });
109
142
  for (const file of files) {
110
- // Watch out for projections with no attachment property
111
- // (the slug-taken route does that)
112
- if (self.options.prettyUrls && file.attachment) {
113
- const { extension } = file.attachment;
114
- const baseUrl = self.apos.url.getBaseUrl(req, { prefix: true });
115
- file._url = `${baseUrl}${self.options.prettyUrlDir}/${file.slug.replace(self.options.slugPrefix || '', '')}.${extension}`;
143
+ const prettyPath = self.getPrettyPath(file);
144
+ if (prettyBase && prettyPath) {
145
+ file._url = `${prettyBase}${prettyPath}`;
116
146
  file.attachment._prettyUrl = file._url;
117
- } else {
147
+ } else if (file.attachment) {
118
148
  file._url = self.apos.attachment.url(file.attachment);
119
149
  }
120
150
  }
151
+ },
152
+ // Mutates `attachmentMeta.results` in-place, replacing
153
+ // uploadfs paths with pretty URL paths for file pieces
154
+ // that have pretty URLs enabled. Adds a `base` property
155
+ // to each affected entry so the consumer can override the
156
+ // global `uploadsUrl` for those entries.
157
+ //
158
+ // Uses a lightweight direct DB query (no builders) to
159
+ // find file docs by attachment ID. Returns nothing —
160
+ // the mutation is performed on the input object.
161
+ //
162
+ // Does nothing when pretty URLs are disabled.
163
+ // For the shape of the `attachmentMeta` object, see the
164
+ // `@apostrophecms/url:getAllAttachmentMetadata` method documentation.
165
+ async applyPrettyUrlPaths(req, attachmentMeta) {
166
+ if (!self.options.prettyUrls || !attachmentMeta?.results?.length) {
167
+ return;
168
+ }
169
+
170
+ const base = self.getPrettyUrlBase(req, { relative: true });
171
+ const batchSize = 100;
172
+
173
+ for (let i = 0; i < attachmentMeta.results.length; i += batchSize) {
174
+ const batch = attachmentMeta.results.slice(i, i + batchSize);
175
+ const batchIds = batch.map(a => a._id);
176
+
177
+ // Lightweight direct query — no builders, no relationship
178
+ // resolution. Only needs slug and attachment ID/extension.
179
+ const fileDocs = await self.apos.doc.db.find({
180
+ type: self.options.name,
181
+ 'attachment._id': { $in: batchIds },
182
+ aposLocale: `${req.locale}:${req.mode}`,
183
+ archived: { $ne: true }
184
+ }, {
185
+ projection: {
186
+ slug: 1,
187
+ 'attachment._id': 1,
188
+ 'attachment.extension': 1
189
+ }
190
+ }).toArray();
191
+
192
+ if (!fileDocs.length) {
193
+ continue;
194
+ }
195
+
196
+ const byAttId = new Map();
197
+ for (const entry of batch) {
198
+ byAttId.set(entry._id, entry);
199
+ }
200
+
201
+ for (const doc of fileDocs) {
202
+ const entry = byAttId.get(doc.attachment._id);
203
+ if (!entry) {
204
+ continue;
205
+ }
206
+ const prettyPath = self.getPrettyPath(doc);
207
+ if (!prettyPath) {
208
+ continue;
209
+ }
210
+ entry.urls = [ { path: prettyPath } ];
211
+ entry.base = base;
212
+ }
213
+ }
121
214
  }
122
215
  };
123
216
  },
@@ -155,7 +248,14 @@ module.exports = {
155
248
  const uglyUrl = self.apos.attachment.url(file.attachment, {
156
249
  prettyUrl: false
157
250
  });
158
- return await streamProxy(req, uglyUrl, { error: self.apos.util.error });
251
+ // For relative URLs (local uploadfs, not CDN), resolve
252
+ // against the current server's origin so the proxy can
253
+ // make the self-request. During static builds
254
+ // `attachment.url()` may return only a path.
255
+ const proxyUrl = uglyUrl.startsWith('/')
256
+ ? `${req.protocol}://${req.get('host')}${uglyUrl}`
257
+ : uglyUrl;
258
+ return await streamProxy(req, proxyUrl, { error: self.apos.util.error });
159
259
  } catch (e) {
160
260
  self.apos.util.error('Error in pretty URL route:', e);
161
261
  return res.status(500).send('error');
@@ -71,7 +71,6 @@
71
71
  "batchRestoreProgress": "Wiederherstellung {{ type }}...",
72
72
  "batchTagProgress": "Taggen von {{ count }} {{ type }}...",
73
73
  "batchUntagProgress": "Entfernen der Markierung von {{ count }} {{ type }}...",
74
- "batchRestoreProgress": "Wiederherstellung {{ type }}...",
75
74
  "boxFieldAriaLabelAll": "Wert - alle Seiten",
76
75
  "boxFieldAriaLabelIndividual": "- {{ side }} Wert",
77
76
  "boxFieldEditAll": "Alle Werte bearbeiten",
@@ -325,7 +324,6 @@
325
324
  "localizeLocalized": "Lokalisierte",
326
325
  "localizeNewRelated": "Übersetze alle neuen zugehörige Dokumente",
327
326
  "localizeNotYetLocalized": "Noch nicht lokalisiert",
328
- "localized": "{{ type }} {{ title }} auf {{ locale }} übersetzt",
329
327
  "localizedBatch": "Verarbeitet {{ count }} {{ type }}",
330
328
  "localizedBatchFailed": "Lokalisierung von {{ type }} fehlgeschlagen",
331
329
  "localizedBatchWithFailures": "Verarbeitet {{ count }} {{ type }} ({{ bad }} von {{ total }} fehlgeschlagen)",