apostrophe 3.56.0 → 3.58.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 (26) hide show
  1. package/.github/workflows/main.yml +2 -7
  2. package/.github/workflows/outdated-dependencies.yml +43 -0
  3. package/CHANGELOG.md +37 -0
  4. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBar.vue +0 -5
  5. package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +12 -2
  6. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +96 -129
  7. package/modules/@apostrophecms/attachment/index.js +59 -6
  8. package/modules/@apostrophecms/doc-type/index.js +7 -1
  9. package/modules/@apostrophecms/http/index.js +4 -0
  10. package/modules/@apostrophecms/i18n/i18n/it.json +517 -0
  11. package/modules/@apostrophecms/i18n/index.js +8 -0
  12. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +1 -0
  13. package/modules/@apostrophecms/job/index.js +10 -2
  14. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +5 -1
  15. package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +1 -0
  16. package/modules/@apostrophecms/permission/index.js +7 -2
  17. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
  18. package/modules/@apostrophecms/schema/lib/addFieldTypes.js +5 -0
  19. package/modules/@apostrophecms/schema/ui/apos/components/AposInputArea.vue +1 -0
  20. package/modules/@apostrophecms/ui/ui/apos/components/AposCellDate.vue +1 -1
  21. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  22. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +4 -0
  23. package/modules/@apostrophecms/widget-type/ui/apos/mixins/AposWidgetMixin.js +6 -0
  24. package/package.json +3 -3
  25. package/test/pages.js +0 -1
  26. package/test/pieces.js +3 -2
@@ -1,5 +1,3 @@
1
- # This is a basic workflow to help you get started with Actions
2
-
3
1
  name: Tests
4
2
 
5
3
  # Controls when the action will run.
@@ -20,8 +18,8 @@ jobs:
20
18
  runs-on: ubuntu-latest
21
19
  strategy:
22
20
  matrix:
23
- node-version: [16, 18]
24
- mongodb-version: [4.4, 5.0, 6.0]
21
+ node-version: [18, 20]
22
+ mongodb-version: [4.4, 5.0, 6.0, 7.0]
25
23
 
26
24
  # Steps represent a sequence of tasks that will be executed as part of the job
27
25
  steps:
@@ -38,9 +36,6 @@ jobs:
38
36
  with:
39
37
  mongodb-version: ${{ matrix.mongodb-version }}
40
38
 
41
- # Exit status must be succesful in the end
42
- - run: ( ( node --version | grep v14 ) && npm install -g npm@8 ) || echo "npm OK"
43
-
44
39
  - run: npm install
45
40
 
46
41
  - run: npm test
@@ -0,0 +1,43 @@
1
+ name: Check outdated dependencies
2
+ on:
3
+ schedule:
4
+ # Runs every Monday at 8:00
5
+ - cron: "0 8 * * MON"
6
+ # Allows you to run this workflow manually from the Actions tab
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ check_outdated_dependencies:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Git checkout
14
+ uses: actions/checkout@v2
15
+ - name: Use Node.js 18
16
+ uses: actions/setup-node@v1
17
+ with:
18
+ node-version: 18
19
+ - name: Install dependencies
20
+ run: npm install
21
+ - name: Check outdated dependencies
22
+ run: |
23
+ echo "$(npm outdated)" > output
24
+ npm outdated
25
+ - name: Report Status
26
+ if: failure()
27
+ run: |
28
+ outdated_dependencies=$(cat output)
29
+
30
+ repo="${{ github.repository }}"
31
+ repo_url="${{ github.server_url }}/$repo"
32
+ run_url="$repo_url/actions/runs/${{ github.run_id }}"
33
+
34
+ text="ℹ️ The <$repo_url|$repo> project has outdated dependencies:
35
+ \`\`\`
36
+ $outdated_dependencies
37
+ \`\`\`
38
+ <$run_url|View Run>"
39
+
40
+ payload="{\"text\": \"$text\"}"
41
+ curl -X POST -H 'Content-type: application/json' --data "$payload" $SLACK_WEBHOOK_URL
42
+ env:
43
+ SLACK_WEBHOOK_URL: ${{ secrets.ACTION_MONITORING_SLACK }}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.58.0 (2023-10-12)
4
+
5
+ ### Fixes
6
+
7
+ * Ensure Apostrophe can make appropriate checks by always including `type` in the projection even if it is not explicitly listed.
8
+ * Never try to annotate a widget with permissions the way we annotate a document, even if the widget is simulating a document.
9
+ * The `areas` query builder now works properly when an array of area names has been specified.
10
+
11
+ ### Adds
12
+
13
+ * Widget schema can now follow the parent schema via the similar to introduced in the `array` field type syntax (`<` prefix). In order a parent followed field to be available to the widget schema, the area field should follow it. For example, if area follows the root schema `title` field via `following: ['title']`, any field from a widget schema inside that area can do `following: ['<title']`.
14
+ * The values of fields followed by an `area` field are now available in custom widget preview Vue components (registered with widget option `options.widget = 'MyComponentPreview'`). Those components will also receive additional `areaField` prop (the parent area field definition object).
15
+ * Allows to insert attachments with a given ID, as well as with `docIds` and `archivedDocIds` to preserve related docs.
16
+ * Adds an `update` method to the attachment module, that updates the mongoDB doc and the associated file.
17
+ * Adds an option to the `http` `remote` method to allow receiving the original response from `node-fetch` that is a stream.
18
+
19
+ ## 3.57.0 2023-09-27
20
+
21
+ ### Changes
22
+ * Removes a 25px gap used to prevent in-context widget UI from overlapping with the admin bar
23
+ * Simplifies the way in-context widget state is rendered via modifier classes
24
+ ### Adds
25
+
26
+ * Widgets detect whether or not their in-context editing UI will collide with the admin bar and adjust it appropriately.
27
+ * Italian translation i18n file created for the Apostrophe Admin-UI. Thanks to [Antonello Zanini](https://github.com/Tonel) for this contribution.
28
+ * Fixed date in piece type being displayed as current date in column when set as undefined and without default value. Thanks to [TheSaddestBread](https://github.com/AllanKoder) for this contribution.
29
+
30
+ ### Fixes
31
+
32
+ * Bumped dependency on `oembetter` to ensure Vimeo starts working again
33
+ for everyone with this release. This is necessary because Vimeo stopped
34
+ offering oembed discovery meta tags on their video pages.
35
+
36
+ ### Fixes
37
+
38
+ * The `118n` module now ignores non-JSON files within the i18n folder of any module and does not crash the build process.
39
+
3
40
  ## 3.56.0 (2023-09-13)
4
41
 
5
42
  ### Adds
@@ -138,9 +138,4 @@ export default {
138
138
  }
139
139
  }
140
140
 
141
- // make space for a widget's breadcrumbs that are flush with the admin bar
142
- .apos-admin-bar-spacer {
143
- margin-bottom: 25px;
144
- }
145
-
146
141
  </style>
@@ -43,9 +43,11 @@
43
43
  :i="i"
44
44
  :options="options"
45
45
  :next="next"
46
+ :following-values="followingValues"
46
47
  :doc-id="docId"
47
48
  :context-menu-options="contextMenuOptions"
48
49
  :field-id="fieldId"
50
+ :field="field"
49
51
  :disabled="field && field.readOnly"
50
52
  :widget-hovered="hoveredWidget"
51
53
  :non-foreign-widget-hovered="hoveredNonForeignWidget"
@@ -109,6 +111,12 @@ export default {
109
111
  return [];
110
112
  }
111
113
  },
114
+ followingValues: {
115
+ type: Object,
116
+ default() {
117
+ return {};
118
+ }
119
+ },
112
120
  choices: {
113
121
  type: Array,
114
122
  required: true
@@ -370,7 +378,8 @@ export default {
370
378
  value: widget,
371
379
  options: this.widgetOptionsByType(widget.type),
372
380
  type: widget.type,
373
- docId: this.docId
381
+ docId: this.docId,
382
+ parentFollowingValues: this.followingValues
374
383
  });
375
384
  apos.area.activeEditor = null;
376
385
  apos.bus.$off('apos-refreshing', cancelRefresh);
@@ -467,7 +476,8 @@ export default {
467
476
  value: null,
468
477
  options: this.widgetOptionsByType(name),
469
478
  type: name,
470
- docId: this.docId
479
+ docId: this.docId,
480
+ parentFollowingValues: this.followingValues
471
481
  });
472
482
  apos.area.activeEditor = null;
473
483
  if (widget) {
@@ -7,17 +7,19 @@
7
7
  :data-area-label="widgetLabel"
8
8
  :data-apos-widget-foreign="foreign ? 1 : 0"
9
9
  :data-apos-widget-id="widget._id"
10
+ ref="widget"
10
11
  >
11
12
  <div
12
13
  class="apos-area-widget-inner"
13
- :class="ui.container"
14
+ :class="containerClasses"
14
15
  @mouseover="mouseover($event)"
15
16
  @mouseleave="mouseleave"
16
17
  @click="getFocus($event, widget._id)"
17
18
  >
18
19
  <div
19
20
  class="apos-area-widget-controls apos-area-widget__label"
20
- :class="ui.labels"
21
+ ref="label"
22
+ :class="labelsClasses"
21
23
  >
22
24
  <ol class="apos-area-widget__breadcrumbs">
23
25
  <li class="apos-area-widget__breadcrumb apos-area-widget__breadcrumb--widget-icon">
@@ -56,7 +58,7 @@
56
58
  </div>
57
59
  <div
58
60
  class="apos-area-widget-controls apos-area-widget-controls--add apos-area-widget-controls--add--top"
59
- :class="ui.addTop"
61
+ :class="addClasses"
60
62
  >
61
63
  <AposAreaMenu
62
64
  v-if="!foreign"
@@ -73,7 +75,7 @@
73
75
  </div>
74
76
  <div
75
77
  class="apos-area-widget-controls apos-area-widget-controls--modify"
76
- :class="ui.controls"
78
+ :class="controlsClasses"
77
79
  >
78
80
  <AposWidgetControls
79
81
  v-if="!foreign"
@@ -98,7 +100,7 @@
98
100
  -->
99
101
  <div
100
102
  class="apos-area-widget-guard"
101
- :class="{'apos-is-disabled': focused}"
103
+ :class="{'apos-is-disabled': isFocused}"
102
104
  />
103
105
  <!-- Still used for contextual editing components -->
104
106
  <component
@@ -109,7 +111,7 @@
109
111
  :value="widget"
110
112
  @update="$emit('update', $event)"
111
113
  :doc-id="docId"
112
- :focused="focused"
114
+ :focused="isFocused"
113
115
  :key="generation"
114
116
  />
115
117
  <component
@@ -119,16 +121,18 @@
119
121
  :type="widget.type"
120
122
  :id="widget._id"
121
123
  :area-field-id="fieldId"
124
+ :area-field="field"
125
+ :following-values="followingValuesWithParent"
122
126
  :value="widget"
123
127
  :foreign="foreign"
124
128
  @edit="$emit('edit', i);"
125
129
  :doc-id="docId"
126
130
  :rendering="rendering"
127
- :key="generation"
131
+ :key="`${generation}-preview`"
128
132
  />
129
133
  <div
130
134
  class="apos-area-widget-controls apos-area-widget-controls--add apos-area-widget-controls--add--bottom"
131
- :class="ui.addBottom"
135
+ :class="addClasses"
132
136
  >
133
137
  <AposAreaMenu
134
138
  v-if="!foreign"
@@ -148,7 +152,6 @@
148
152
  </template>
149
153
 
150
154
  <script>
151
- import { klona } from 'klona';
152
155
  import AposIndicator from '../../../../ui/ui/apos/components/AposIndicator.vue';
153
156
 
154
157
  export default {
@@ -190,10 +193,20 @@ export default {
190
193
  return {};
191
194
  }
192
195
  },
196
+ followingValues: {
197
+ type: Object,
198
+ default() {
199
+ return {};
200
+ }
201
+ },
193
202
  next: {
194
203
  type: Array,
195
204
  required: true
196
205
  },
206
+ field: {
207
+ type: Object,
208
+ required: true
209
+ },
197
210
  fieldId: {
198
211
  type: String,
199
212
  required: true
@@ -225,38 +238,15 @@ export default {
225
238
  },
226
239
  emits: [ 'clone', 'up', 'down', 'remove', 'edit', 'cut', 'copy', 'update', 'add', 'changed' ],
227
240
  data() {
228
- const initialState = {
229
- controls: {
230
- show: false
231
- },
232
- container: {
233
- highlight: false,
234
- focus: false
235
- },
236
- add: {
237
- bottom: {
238
- show: false,
239
- focus: false
240
- },
241
- top: {
242
- show: false,
243
- focus: false
244
- }
245
- },
246
- labels: {
247
- show: false
248
- }
249
- };
250
241
  return {
251
- blankState: klona(initialState),
252
- state: klona(initialState),
253
- highlightable: false,
254
- focused: false,
242
+ mounted: false, // hack around needing DOM to be rendered for computed classes
243
+ isSuppressed: false,
255
244
  classes: {
256
245
  show: 'apos-is-visible',
257
246
  open: 'apos-is-open',
258
247
  focus: 'apos-is-focused',
259
- highlight: 'apos-is-highlighted'
248
+ highlight: 'apos-is-highlighted',
249
+ adjust: 'apos-is-ui-adjusted'
260
250
  },
261
251
  breadcrumbs: {
262
252
  $lastEl: null,
@@ -266,6 +256,14 @@ export default {
266
256
  };
267
257
  },
268
258
  computed: {
259
+ // Passed only to the preview layer (custom preview components).
260
+ followingValuesWithParent() {
261
+ return Object.entries(this.followingValues || {})
262
+ .reduce((acc, [ key, value ]) => {
263
+ acc[`<${key}`] = value;
264
+ return acc;
265
+ }, {});
266
+ },
269
267
  bottomContextMenuOptions() {
270
268
  return {
271
269
  ...this.contextMenuOptions,
@@ -294,73 +292,56 @@ export default {
294
292
  moduleOptions() {
295
293
  return window.apos.area;
296
294
  },
297
- isSuppressed() {
298
- if (this.focused) {
299
- return false;
300
- }
301
-
302
- if (this.highlightable) {
295
+ isFocused() {
296
+ if (this.isSuppressed) {
303
297
  return false;
298
+ } else {
299
+ if (this.widgetFocused === this.widget._id) {
300
+ document.addEventListener('click', this.unfocus);
301
+ }
302
+ return this.widgetFocused === this.widget._id;
304
303
  }
305
-
306
- return !(this.hovered || this.nonForeignHovered);
307
304
  },
308
- hovered() {
305
+ isHovered() {
309
306
  return this.widgetHovered === this.widget._id;
310
307
  },
308
+ isHighlighted() {
309
+ const $parent = this.getParent();
310
+ return $parent && $parent.dataset.areaWidget === this.widgetFocused;
311
+ },
311
312
  nonForeignHovered() {
312
313
  return this.nonForeignWidgetHovered === this.widget._id;
313
314
  },
314
- // Sets up all the interaction classes based on the current
315
- // state. If our widget is suppressed, return a blank UI state and reset
316
- // our real one.
317
- ui() {
318
- const state = {
319
- controls: this.state.controls.show ? this.classes.show : null,
320
- labels: this.state.labels.show ? this.classes.show : null,
321
- container: this.state.container.focus ? this.classes.focus
322
- : ((this.state.container.highlight || this.nonForeignHovered) ? this.classes.highlight : null),
323
- addTop: this.state.add.top.focus ? this.classes.focus
324
- : ((this.state.add.top.show || this.nonForeignHovered) ? this.classes.show : null),
325
- addBottom: this.state.add.bottom.focus ? this.classes.focus
326
- : ((this.state.add.bottom.show || this.nonForeignHovered) ? this.classes.show : null)
315
+ controlsClasses() {
316
+ return {
317
+ [this.classes.show]: this.isFocused
327
318
  };
328
-
329
- if (this.isSuppressed) {
330
- this.resetState();
331
- return this.blankState;
319
+ },
320
+ containerClasses() {
321
+ const classes = {
322
+ [this.classes.highlight]: this.isHighlighted || this.isHovered,
323
+ [this.classes.focus]: this.isFocused
324
+ };
325
+ if (this.mounted) {
326
+ classes[this.classes.adjust] = this.adjustUi();
332
327
  }
333
-
334
- return state;
328
+ return classes;
329
+ },
330
+ labelsClasses() {
331
+ return {
332
+ [this.classes.show]: this.isHovered || this.isFocused
333
+ };
334
+ },
335
+ addClasses() {
336
+ return {
337
+ [this.classes.show]: this.isHovered || this.isFocused
338
+ };
335
339
  },
336
340
  foreign() {
337
341
  // Cast to boolean is necessary to satisfy prop typing
338
342
  return !!(this.docId && (window.apos.adminBar.contextId !== this.docId));
339
343
  }
340
344
  },
341
- watch: {
342
- widgetFocused (newVal) {
343
- if (newVal === this.widget._id) {
344
- this.focus();
345
- } else {
346
- // reset everything
347
- this.resetState();
348
- this.focused = false;
349
- }
350
- const $parent = this.getParent();
351
- if (
352
- $parent &&
353
- $parent.dataset.areaWidget === newVal
354
- ) {
355
- // Our parent was focused
356
- this.resetState();
357
- this.state.container.highlight = true;
358
- this.highlightable = true;
359
- } else {
360
- this.highlightable = false;
361
- }
362
- }
363
- },
364
345
  created() {
365
346
  if (this.options.groups) {
366
347
  for (const group of Object.keys(this.options.groups)) {
@@ -372,6 +353,7 @@ export default {
372
353
  }
373
354
  },
374
355
  mounted() {
356
+ this.mounted = true;
375
357
  // AposAreaEditor is listening for keyboard input that triggers
376
358
  // a 'focus my parent' plea
377
359
  apos.bus.$on('widget-focus-parent', this.focusParent);
@@ -392,11 +374,21 @@ export default {
392
374
  apos.bus.$off('widget-focus-parent', this.focusParent);
393
375
  },
394
376
  methods: {
377
+
378
+ // Determine whether or not we should adjust the label based on its position to the admin bar
379
+ adjustUi() {
380
+ const { height: labelHeight } = this.$refs.label.getBoundingClientRect();
381
+ const { top: widgetTop } = this.$refs.widget.getBoundingClientRect();
382
+ const adminBarHeight = window.apos.modules['@apostrophecms/admin-bar'].height;
383
+ const offsetTop = widgetTop + window.scrollY;
384
+ return offsetTop - labelHeight < adminBarHeight;
385
+ },
386
+
395
387
  // Focus parent, useful for obtrusive UI
396
388
  focusParent() {
397
389
  // Something above us asked the focused widget to try and focus its parent
398
390
  // We only care about this if we're focused ...
399
- if (this.focused) {
391
+ if (this.isFocused) {
400
392
  const $parent = this.getParent();
401
393
  // .. And have a parent
402
394
  if ($parent) {
@@ -408,6 +400,7 @@ export default {
408
400
  // Ask the parent AposAreaEditor to make us focused
409
401
  getFocus(e, id) {
410
402
  e.stopPropagation();
403
+ this.isSuppressed = false;
411
404
  apos.bus.$emit('widget-focus', id);
412
405
  },
413
406
 
@@ -416,15 +409,6 @@ export default {
416
409
  if (e) {
417
410
  e.stopPropagation();
418
411
  }
419
- if (this.focused) {
420
- return;
421
- }
422
- if (!this.foreign) {
423
- this.state.add.top.show = true;
424
- this.state.add.bottom.show = true;
425
- }
426
- this.state.container.highlight = true;
427
- this.state.labels.show = true;
428
412
  const closest = this.foreign && this.$el.closest('[data-apos-widget-foreign="0"]');
429
413
  const closestId = closest && closest.getAttribute('data-apos-widget-id');
430
414
  apos.bus.$emit('widget-hover', {
@@ -434,41 +418,16 @@ export default {
434
418
  },
435
419
 
436
420
  mouseleave() {
437
- if (!this.highlightable) {
438
- // Force highlight when a parent has been focused
439
- this.state.container.highlight = false;
440
- }
441
- if (!this.focused) {
442
- this.state.labels.show = false;
443
- this.state.add.top.show = false;
444
- this.state.add.bottom.show = false;
445
- }
446
- if (this.hovered) {
421
+ if (this.isHovered) {
447
422
  apos.bus.$emit('widget-hover', {
448
423
  _id: null,
449
424
  nonForeignId: null
450
425
  });
451
426
  }
452
427
  },
453
-
454
- focus(e) {
455
- if (e) {
456
- e.stopPropagation();
457
- }
458
- this.focused = true;
459
- this.state.container.focus = true;
460
- this.state.controls.show = true;
461
- this.state.add.top.show = true;
462
- this.state.add.bottom.show = true;
463
- this.state.labels.show = true;
464
- document.addEventListener('click', this.unfocus);
465
- },
466
-
467
428
  unfocus(event) {
468
429
  if (!this.$el.contains(event.target)) {
469
- this.focused = false;
470
- this.resetState();
471
- this.highlightable = false;
430
+ this.isSuppressed = true;
472
431
  document.removeEventListener('click', this.unfocus);
473
432
  apos.bus.$emit('widget-focus', null);
474
433
  }
@@ -485,12 +444,11 @@ export default {
485
444
  }
486
445
  },
487
446
 
488
- resetState() {
489
- this.state = klona(this.blankState);
490
- },
491
-
492
447
  getParent() {
493
- return apos.util.closest(this.$el.parentNode, '[data-area-widget]');
448
+ if (!this.mounted) {
449
+ return false;
450
+ }
451
+ return this.$el.parentNode ? apos.util.closest(this.$el.parentNode, '[data-area-widget]') : false;
494
452
  },
495
453
 
496
454
  // Hacky way to get the parents tree of a widget
@@ -555,6 +513,14 @@ export default {
555
513
  box-shadow: none;
556
514
  }
557
515
  }
516
+ &.apos-is-ui-adjusted {
517
+ .apos-area-widget-controls--modify {
518
+ transform: translate3d(-10px, 50px, 0);
519
+ }
520
+ .apos-area-widget__label {
521
+ transform: translate(-10px, 10px);
522
+ }
523
+ }
558
524
 
559
525
  .apos-area-widget-inner &:after {
560
526
  display: none;
@@ -704,6 +670,7 @@ export default {
704
670
  right: 0;
705
671
  display: flex;
706
672
  transform: translateY(-100%);
673
+ transition: opacity 0.3s ease;
707
674
  }
708
675
 
709
676
  .apos-area-widget-inner .apos-area-widget-inner .apos-area-widget__label {
@@ -386,8 +386,7 @@ module.exports = {
386
386
  // This method returns `attachment` where `attachment` is an attachment
387
387
  // object, suitable for passing to the `url` API and for use as the value
388
388
  // of a `type: 'attachment'` schema field.
389
- async insert(req, file, options) {
390
- options = options || {};
389
+ async insert(req, file, options = {}) {
391
390
  let extension = path.extname(file.name);
392
391
  if (extension && extension.length) {
393
392
  extension = extension.substr(1);
@@ -402,16 +401,21 @@ module.exports = {
402
401
  extensions: accepted.join(req.t('apostrophe:listJoiner'))
403
402
  }));
404
403
  }
404
+
405
+ if (options.attachmentId && await self.apos.attachment.db.findOne({ _id: options.attachmentId })) {
406
+ throw self.apos.error('invalid', 'duplicate');
407
+ }
408
+
405
409
  const info = {
406
- _id: self.apos.util.generateId(),
410
+ _id: options.attachmentId ?? self.apos.util.generateId(),
407
411
  group: group.name,
408
412
  createdAt: new Date(),
409
413
  name: self.apos.util.slugify(path.basename(file.name, path.extname(file.name))),
410
414
  title: self.apos.util.sortify(path.basename(file.name, path.extname(file.name))),
411
415
  extension: extension,
412
416
  type: 'attachment',
413
- docIds: [],
414
- archivedDocIds: []
417
+ docIds: options.docIds ?? [],
418
+ archivedDocIds: options.archivedDocIds ?? []
415
419
  };
416
420
  if (!(options.permissions === false)) {
417
421
  if (!self.apos.permission.can(req, 'upload-attachment')) {
@@ -432,7 +436,11 @@ module.exports = {
432
436
  }
433
437
  if (self.isSized(extension)) {
434
438
  // For images we correct automatically for common file extension mistakes
435
- const result = await Promise.promisify(self.uploadfs.copyImageIn)(file.path, '/attachments/' + info._id + '-' + info.name, { sizes: self.imageSizes });
439
+ const result = await Promise.promisify(self.uploadfs.copyImageIn)(
440
+ file.path,
441
+ '/attachments/' + info._id + '-' + info.name,
442
+ { sizes: self.imageSizes }
443
+ );
436
444
  info.extension = result.extension;
437
445
  info.width = result.width;
438
446
  info.height = result.height;
@@ -454,6 +462,51 @@ module.exports = {
454
462
  await self.db.insertOne(info);
455
463
  return info;
456
464
  },
465
+
466
+ async update(req, file, attachment) {
467
+ const existing = await self.db.findOne({ _id: attachment._id });
468
+ if (!existing) {
469
+ throw self.apos.error('notfound');
470
+ }
471
+
472
+ const projection = {
473
+ _id: 1,
474
+ archived: 1
475
+ };
476
+
477
+ const existingRelatedDocs = await self.apos.doc.db
478
+ .find({
479
+ _id: {
480
+ $in: [
481
+ ...existing.docIds,
482
+ ...existing.archivedDocIds,
483
+ ...attachment.docIds,
484
+ ...attachment.archivedDocIds
485
+ ]
486
+ }
487
+ }, { projection })
488
+ .toArray();
489
+
490
+ const { docIds, archivedDocIds } = existingRelatedDocs
491
+ .reduce(({ docIds, archivedDocIds }, doc) => {
492
+ return {
493
+ docIds: [ ...docIds, ...!doc.archived ? [ doc._id ] : [] ],
494
+ archivedDocIds: [ ...archivedDocIds, ...doc.archived ? [ doc._id ] : [] ]
495
+ };
496
+ }, {
497
+ docIds: [],
498
+ archivedDocIds: []
499
+ });
500
+
501
+ await self.alterAttachment(existing, 'remove');
502
+ await self.db.deleteOne({ _id: existing._id });
503
+ await self.insert(req, file, {
504
+ attachmentId: attachment._id,
505
+ docIds: _.uniq([ ...docIds, ...existing.docIds || [] ]),
506
+ archivedDocIds: _.uniq([ ...archivedDocIds, ...existing.archivedDocIds || [] ])
507
+ });
508
+ },
509
+
457
510
  // Given a path to a local svg file, sanitize any XSS attack vectors that
458
511
  // may be present in the file. The caller is responsible for catching any
459
512
  // exception thrown and treating that as an invalid file but there is no
@@ -1624,6 +1624,12 @@ module.exports = {
1624
1624
  // to the projection instead.
1625
1625
  const add = [];
1626
1626
  const remove = [];
1627
+
1628
+ // Add type in projection by default
1629
+ if (!_.isEmpty(projection)) {
1630
+ add.push('type');
1631
+ }
1632
+
1627
1633
  for (const [ key, val ] of Object.entries(projection)) {
1628
1634
  if (!val) {
1629
1635
  // For a negative projection this is just
@@ -2228,7 +2234,7 @@ module.exports = {
2228
2234
  const dotPath = info.dotPath;
2229
2235
  if (setting && Array.isArray(setting)) {
2230
2236
  if (!_.includes(setting, dotPath)) {
2231
- return;
2237
+ continue;
2232
2238
  }
2233
2239
  }
2234
2240
  if (doc._edit) {