@conduction/nextcloud-vue 2.56.0-beta.21 → 2.56.0-beta.23

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 (66) hide show
  1. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +29 -0
  2. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  4. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +18 -0
  5. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  6. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +9 -6
  7. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  8. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +16 -1
  9. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  10. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +5 -4
  11. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  12. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue2.js +25 -0
  13. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue2.js.map +1 -1
  14. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue3.js +3 -1
  15. package/dist/esm/components/CnFlowDetail/CnFlowDetail.vue3.js.map +1 -1
  16. package/dist/esm/components/CnGraphCanvas/CnFlowNode.vue2.js +36 -1
  17. package/dist/esm/components/CnGraphCanvas/CnFlowNode.vue2.js.map +1 -1
  18. package/dist/esm/components/CnGraphCanvas/CnFlowNode.vue3.js.map +1 -1
  19. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue2.js +37 -0
  20. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue2.js.map +1 -1
  21. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue3.js +4 -2
  22. package/dist/esm/components/CnGraphCanvas/CnGraphCanvas.vue3.js.map +1 -1
  23. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +16 -1
  24. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  25. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +6 -6
  26. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  27. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js +53 -5
  28. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js.map +1 -1
  29. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js +1 -1
  30. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js.map +1 -1
  31. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue2.js +133 -0
  32. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue2.js.map +1 -1
  33. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue3.js +22 -14
  34. package/dist/esm/components/CnPageRenderer/CnPageRenderer.vue3.js.map +1 -1
  35. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +15 -1
  36. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  37. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +3 -2
  38. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  39. package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue2.js +15 -1
  40. package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue2.js.map +1 -1
  41. package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue3.js +2 -2
  42. package/dist/esm/components/CnWidgetGrid/CnWidgetGrid.vue3.js.map +1 -1
  43. package/dist/esm/index.js +1 -0
  44. package/dist/esm/index.js.map +1 -1
  45. package/dist/esm/utils/notificationRuleLabel.js +132 -0
  46. package/dist/esm/utils/notificationRuleLabel.js.map +1 -0
  47. package/dist/nextcloud-vue.cjs.js +578 -47
  48. package/dist/nextcloud-vue.cjs.js.map +2 -2
  49. package/package.json +1 -1
  50. package/scripts/openregister-reliance.js +206 -0
  51. package/src/components/CnAppRoot/CnAppRoot.md +1 -0
  52. package/src/components/CnAppRoot/CnAppRoot.vue +29 -0
  53. package/src/components/CnDetailPage/CnDetailPage.vue +24 -3
  54. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +22 -4
  55. package/src/components/CnFlowDetail/CnFlowDetail.vue +27 -0
  56. package/src/components/CnGraphCanvas/CnFlowNode.vue +36 -1
  57. package/src/components/CnGraphCanvas/CnGraphCanvas.vue +40 -1
  58. package/src/components/CnIndexPage/CnIndexPage.vue +17 -2
  59. package/src/components/CnNotificationPreferences/CnNotificationPreferences.vue +54 -6
  60. package/src/components/CnPageRenderer/CnPageRenderer.vue +147 -6
  61. package/src/components/CnTabsWidget/CnTabsWidget.vue +17 -2
  62. package/src/components/CnWidgetGrid/CnWidgetGrid.vue +19 -3
  63. package/src/index.js +2 -0
  64. package/src/utils/notificationRuleLabel.js +129 -0
  65. package/webpack/index.d.ts +16 -0
  66. package/webpack/index.js +70 -0
@@ -32,7 +32,7 @@
32
32
  :modelValue="entry.enabled"
33
33
  :disabled="entry.saving"
34
34
  @update:modelValue="onToggle(entry, $event)">
35
- {{ notificationLabel(entry.notification) }}
35
+ {{ notificationLabel(entry) }}
36
36
  </NcCheckboxRadioSwitch>
37
37
  <NcButton v-if="entry.source === 'user-override'"
38
38
  variant="tertiary"
@@ -49,7 +49,7 @@
49
49
 
50
50
  <script>
51
51
  import axios from '@nextcloud/axios'
52
- import { translate as t } from '@nextcloud/l10n'
52
+ import { getLanguage, translate as t } from '@nextcloud/l10n'
53
53
  import { generateUrl } from '@nextcloud/router'
54
54
  /**
55
55
  * CnNotificationPreferences
@@ -82,6 +82,7 @@ import { generateUrl } from '@nextcloud/router'
82
82
  import { NcAppSettingsSection, NcButton, NcCheckboxRadioSwitch, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
83
83
  import BellOffOutline from 'vue-material-design-icons/BellOffOutline.vue'
84
84
  import BellOutline from 'vue-material-design-icons/BellOutline.vue'
85
+ import { notificationRuleLabel } from '../../utils/notificationRuleLabel.js'
85
86
 
86
87
  const PREFS_PATH = '/apps/openregister/api/notification-preferences'
87
88
 
@@ -106,6 +107,28 @@ export default {
106
107
  * than an always-empty pane.
107
108
  */
108
109
  cnAppId: { default: () => '' },
110
+ /**
111
+ * App-supplied labels for its notification rules, provided by the
112
+ * CnAppRoot ancestor from its `notificationLabels` prop. Keyed
113
+ * `<schema>.<key>` or `<key>`.
114
+ */
115
+ cnNotificationLabels: { default: () => ({}) },
116
+ },
117
+
118
+ props: {
119
+ /**
120
+ * Labels for the notification rules, keyed `<schema>.<key>` or
121
+ * `<key>`; values are strings or per-locale maps (`{ nl, en }`).
122
+ * Optional: inside CnAppRoot the app's `notificationLabels` arrive by
123
+ * inject, and a rule without a label still reads as words, never as
124
+ * its raw key. A prop given here wins over the injected map.
125
+ *
126
+ * @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
127
+ */
128
+ labels: {
129
+ type: Object,
130
+ default: null,
131
+ },
109
132
  },
110
133
 
111
134
  data() {
@@ -231,18 +254,43 @@ export default {
231
254
  },
232
255
 
233
256
  /**
234
- * Human-readable label for a notification key, falling back to the key.
257
+ * Human-readable label for a notification rule. Never the raw key: the
258
+ * rule's own label, then the app's, then the library's wording for the
259
+ * generic keys, then the key split into words.
235
260
  *
236
- * @param {string} key The notification annotation key.
261
+ * @param {object} entry The preference row.
237
262
  * @return {string} A display label.
263
+ *
264
+ * @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
238
265
  */
239
- notificationLabel(key) {
266
+ notificationLabel(entry) {
240
267
  const known = {
241
268
  object_created: t('nextcloud-vue', 'When an item is created'),
242
269
  object_updated: t('nextcloud-vue', 'When an item is updated'),
243
270
  object_transitioned: t('nextcloud-vue', 'When an item is assigned or changes status'),
244
271
  }
245
- return known[key] || key
272
+ const language = this.currentLanguage()
273
+ const injected = typeof this.cnNotificationLabels === 'function'
274
+ ? this.cnNotificationLabels()
275
+ : this.cnNotificationLabels
276
+ return notificationRuleLabel(entry, {
277
+ labels: { ...(injected || {}), ...(this.labels || {}) },
278
+ known,
279
+ language,
280
+ })
281
+ },
282
+
283
+ /**
284
+ * The person's language, `en` when it cannot be read.
285
+ *
286
+ * @return {string} The language code.
287
+ */
288
+ currentLanguage() {
289
+ try {
290
+ return (typeof getLanguage === 'function' && getLanguage()) || 'en'
291
+ } catch {
292
+ return 'en'
293
+ }
246
294
  },
247
295
 
248
296
  /**
@@ -120,7 +120,8 @@
120
120
  v-if="widgetsBySlot.has('body')"
121
121
  :widgets="widgetsBySlot.get('body')"
122
122
  :editable="bodyEditable"
123
- slotName="body" />
123
+ slotName="body"
124
+ @selectObject="onRelatedObjectOpen" />
124
125
  <component
125
126
  :is="resolvedComponent"
126
127
  v-else-if="resolvedComponent"
@@ -130,6 +131,7 @@
130
131
  @rowClick="onRowOpen"
131
132
  @rowAuxClick="onRowOpen"
132
133
  @editOpen="onRowOpen"
134
+ @relatedObjectClick="onRelatedObjectOpen"
133
135
  @configure="showConfigModal = true">
134
136
  <!-- This `<template v-for>` defines dynamic SLOTS, not a
135
137
  rendered list, so the rule's advice is inverted here:
@@ -173,27 +175,32 @@
173
175
  <CnWidgetGrid
174
176
  v-if="widgetsBySlot.has('header-actions')"
175
177
  :widgets="widgetsBySlot.get('header-actions')"
176
- slotName="header-actions" />
178
+ slotName="header-actions"
179
+ @selectObject="onRelatedObjectOpen" />
177
180
  <!-- footer slot -->
178
181
  <CnWidgetGrid
179
182
  v-if="widgetsBySlot.has('footer')"
180
183
  :widgets="widgetsBySlot.get('footer')"
181
- slotName="footer" />
184
+ slotName="footer"
185
+ @selectObject="onRelatedObjectOpen" />
182
186
  <!-- modal slot -->
183
187
  <CnWidgetGrid
184
188
  v-if="widgetsBySlot.has('modal')"
185
189
  :widgets="widgetsBySlot.get('modal')"
186
- slotName="modal" />
190
+ slotName="modal"
191
+ @selectObject="onRelatedObjectOpen" />
187
192
  <!-- sidebar slot (gated by cnPageSidebarVisible) -->
188
193
  <CnWidgetGrid
189
194
  v-if="widgetsBySlot.has('sidebar') && pageSidebarVisibleValue"
190
195
  :widgets="widgetsBySlot.get('sidebar')"
191
- slotName="sidebar" />
196
+ slotName="sidebar"
197
+ @selectObject="onRelatedObjectOpen" />
192
198
  <!-- dynamic tab:* and section:* slots -->
193
199
  <template v-for="dynamicSlot in dynamicSlotKeys" :key="dynamicSlot">
194
200
  <CnWidgetGrid
195
201
  :widgets="widgetsBySlot.get(dynamicSlot)"
196
- :slotName="dynamicSlot" />
202
+ :slotName="dynamicSlot"
203
+ @selectObject="onRelatedObjectOpen" />
197
204
  </template>
198
205
  </template>
199
206
 
@@ -206,6 +213,7 @@
206
213
  @rowClick="onRowOpen"
207
214
  @rowAuxClick="onRowOpen"
208
215
  @editOpen="onRowOpen"
216
+ @relatedObjectClick="onRelatedObjectOpen"
209
217
  @configure="showConfigModal = true">
210
218
  <!-- Dynamic slot definition, not a rendered list — see the note on
211
219
  the identical block above. -->
@@ -300,6 +308,53 @@ import { resolveRouteSentinels } from '../../utils/resolveRouteSentinels.js'
300
308
  import { parseSortKeys } from '../../utils/routeFilters.js'
301
309
  import { buildRouteParams, routePathFor } from '../../utils/routeParams.js'
302
310
  import { isNewTabHandled } from '../../utils/rowAuxClick.js'
311
+
312
+ /**
313
+ * OpenRegister registers and schemas already described by id, so a click on
314
+ * a related object costs at most one request per register or schema for as
315
+ * long as this module stays loaded (the SPA's lifetime, across page
316
+ * changes). Keyed by `${kind}:${id}`; a failed lookup is dropped again so the
317
+ * next click retries it.
318
+ *
319
+ * @type {Map<string, Promise<{ id: string, slug: string }>>}
320
+ */
321
+ const entityDescriptorCache = new Map()
322
+
323
+ /**
324
+ * Describe an OpenRegister register or schema (`{ id, slug }`) by its id or
325
+ * slug, or null when it cannot be fetched. An object's `@self` names its
326
+ * register and schema by id while a manifest may name them by slug; this is
327
+ * the bridge. Lazy-imports axios and the router helper like the widgets do.
328
+ *
329
+ * @param {'registers'|'schemas'} kind The collection to ask.
330
+ * @param {string|number} idOrSlug The id (or slug) to describe.
331
+ * @return {Promise<{ id: string, slug: string }|null>} The descriptor.
332
+ */
333
+ function describeOpenRegisterEntity(kind, idOrSlug) {
334
+ const key = `${kind}:${idOrSlug}`
335
+ if (!entityDescriptorCache.has(key)) {
336
+ entityDescriptorCache.set(key, (async () => {
337
+ try {
338
+ const [{ default: axios }, { generateUrl }] = await Promise.all([
339
+ import('@nextcloud/axios'),
340
+ import('@nextcloud/router'),
341
+ ])
342
+ const url = generateUrl('/apps/openregister/api/{kind}/{id}', { kind, id: String(idOrSlug) })
343
+ const res = await axios.get(url)
344
+ const data = res && res.data
345
+ if (!data || typeof data !== 'object') {
346
+ throw new Error(`no descriptor for ${key}`)
347
+ }
348
+ return { id: String(data.id ?? idOrSlug), slug: String(data.slug ?? '') }
349
+ } catch (error) {
350
+ // One failed request must not pin every later click to nowhere.
351
+ entityDescriptorCache.delete(key)
352
+ throw error
353
+ }
354
+ })())
355
+ }
356
+ return entityDescriptorCache.get(key).catch(() => null)
357
+ }
303
358
  import { CnMassExportDialog } from '../CnMassExportDialog/index.js'
304
359
  import { defaultPageTypes } from './pageTypes.js'
305
360
 
@@ -1830,6 +1885,92 @@ export default {
1830
1885
  })
1831
1886
  },
1832
1887
 
1888
+ /**
1889
+ * Open the detail page of an object clicked in a Related widget —
1890
+ * reaching here as `CnDetailPage`'s `related-object-click`, or as
1891
+ * `CnWidgetGrid`'s `select-object` on a v2 widget-grid page. Nothing
1892
+ * listened to either, so the rows looked like links and went nowhere.
1893
+ *
1894
+ * @param {object} raw The clicked object as the Related widget holds it.
1895
+ * @return {Promise<void>}
1896
+ */
1897
+ async onRelatedObjectOpen(raw) {
1898
+ const target = await this.relatedObjectTarget(raw)
1899
+ if (target) {
1900
+ this.$router.push(target).catch(() => {})
1901
+ }
1902
+ },
1903
+
1904
+ /**
1905
+ * Where a related object opens: the first `type: 'detail'` page bound
1906
+ * to the object's register and schema, with the id in whichever param
1907
+ * that page's route declares. The object's `@self` names register and
1908
+ * schema by id and a manifest may name them by slug, so a miss on the
1909
+ * literal pair describes both (one request each, cached) and matches
1910
+ * again. Null, silently, when there is no router or the object carries
1911
+ * no register, schema or id (nothing to open); null with a warning,
1912
+ * like a dead row click, when no detail page matches or the router
1913
+ * lacks the route.
1914
+ *
1915
+ * @param {object} raw The clicked object.
1916
+ * @return {Promise<object|null>} The router location, or null.
1917
+ */
1918
+ async relatedObjectTarget(raw) {
1919
+ const router = this.$router
1920
+ if (!router || !raw || typeof raw !== 'object') {
1921
+ return null
1922
+ }
1923
+ const self = raw['@self'] || {}
1924
+ const id = raw.id ?? self.id ?? self.uuid ?? raw.uuid
1925
+ const register = self.register ?? raw.register
1926
+ const schema = self.schema ?? raw.schema
1927
+ const missing = (v) => v === undefined || v === null || v === ''
1928
+ if (missing(id) || missing(register) || missing(schema)) {
1929
+ return null
1930
+ }
1931
+ let page = this.detailPageFor([String(register)], [String(schema)])
1932
+ if (!page) {
1933
+ const [reg, sch] = await Promise.all([
1934
+ describeOpenRegisterEntity('registers', register),
1935
+ describeOpenRegisterEntity('schemas', schema),
1936
+ ])
1937
+ page = this.detailPageFor(
1938
+ [String(register), reg?.id, reg?.slug].filter(Boolean),
1939
+ [String(schema), sch?.id, sch?.slug].filter(Boolean),
1940
+ )
1941
+ }
1942
+ if (!page) {
1943
+ // eslint-disable-next-line no-console
1944
+ console.warn(`[CnPageRenderer] No detail page is bound to register "${register}" and schema "${schema}", so related object "${id}" opens nowhere.`)
1945
+ return null
1946
+ }
1947
+ if (this.routeNameIsKnown(page.id) === false) {
1948
+ // eslint-disable-next-line no-console
1949
+ console.warn(`[CnPageRenderer] Detail page "${page.id}" is not a route the router has, so related object "${id}" opens nowhere.`)
1950
+ return null
1951
+ }
1952
+ const path = page.route ?? routePathFor(router, page.id)
1953
+ return { name: page.id, params: buildRouteParams(path, id, this.$route?.params) }
1954
+ },
1955
+
1956
+ /**
1957
+ * The first `type: 'detail'` page whose register and schema are both
1958
+ * among the given names (ids or slugs, compared as strings).
1959
+ *
1960
+ * @param {Array<string>} registerNames Names the register goes by.
1961
+ * @param {Array<string>} schemaNames Names the schema goes by.
1962
+ * @return {object|null} The page, or null.
1963
+ */
1964
+ detailPageFor(registerNames, schemaNames) {
1965
+ for (const page of this.detailPageByRegisterSchema.values()) {
1966
+ const cfg = page.config || {}
1967
+ if (registerNames.includes(String(cfg.register)) && schemaNames.includes(String(cfg.schema))) {
1968
+ return page
1969
+ }
1970
+ }
1971
+ return null
1972
+ },
1973
+
1833
1974
  /**
1834
1975
  * Whether the router has a route by this name.
1835
1976
  *
@@ -88,7 +88,8 @@
88
88
  :integrationContext="integrationContext"
89
89
  :cnRegistry="cnRegistry"
90
90
  @geoSaved="onGeoSaved"
91
- @openIntegration="onOpenIntegration" />
91
+ @openIntegration="onOpenIntegration"
92
+ @selectObject="onSelectObject" />
92
93
  <NcEmptyContent v-else :name="missingLabel(entry)" />
93
94
  </CnTab>
94
95
  </CnTabs>
@@ -357,7 +358,7 @@ export default {
357
358
  },
358
359
  },
359
360
 
360
- emits: ['geo-saved', 'open-integration'],
361
+ emits: ['geo-saved', 'open-integration', 'select-object'],
361
362
 
362
363
  data() {
363
364
  return {
@@ -530,6 +531,20 @@ export default {
530
531
  this.$emit('open-integration', integrationId)
531
532
  },
532
533
 
534
+ /**
535
+ * Re-emit a related child's object click; the page opens the object.
536
+ *
537
+ * @param {object} raw The clicked object.
538
+ * @return {void}
539
+ */
540
+ onSelectObject(raw) {
541
+ /**
542
+ * @event select-object Re-emitted from a related child whose object row was clicked. Payload is the raw object.
543
+ * @type {object}
544
+ */
545
+ this.$emit('select-object', raw)
546
+ },
547
+
533
548
  /**
534
549
  * Follow the strip's own selection, so keyboard navigation moves the
535
550
  * hoisted Actions menu too.
@@ -39,7 +39,8 @@
39
39
  <div class="grid-stack-item-content">
40
40
  <component
41
41
  :is="widget.component"
42
- v-bind="widget.props" />
42
+ v-bind="widget.props"
43
+ @selectObject="onSelectObject" />
43
44
  </div>
44
45
  </div>
45
46
  </div>
@@ -58,7 +59,8 @@
58
59
  :style="cnGridCellStyle(widget, gridColumns)">
59
60
  <component
60
61
  :is="widget.component"
61
- v-bind="widget.props" />
62
+ v-bind="widget.props"
63
+ @selectObject="onSelectObject" />
62
64
  </div>
63
65
  </template>
64
66
  </div>
@@ -161,7 +163,7 @@ export default {
161
163
  },
162
164
  },
163
165
 
164
- emits: ['layout-change'],
166
+ emits: ['layout-change', 'select-object'],
165
167
 
166
168
  data() {
167
169
  return {
@@ -371,6 +373,20 @@ export default {
371
373
  },
372
374
 
373
375
  methods: {
376
+ /**
377
+ * Pass a related widget's object click up; the page opens the object.
378
+ *
379
+ * @param {object} raw The clicked object.
380
+ * @return {void}
381
+ */
382
+ onSelectObject(raw) {
383
+ /**
384
+ * @event select-object An object row in a related widget of this grid was clicked. Payload is the raw object.
385
+ * @type {object}
386
+ */
387
+ this.$emit('select-object', raw)
388
+ },
389
+
374
390
  // Expose the shared grid helpers to the template.
375
391
  cnGridCellStyle,
376
392
  hasGridRow,
package/src/index.js CHANGED
@@ -476,6 +476,8 @@ export {
476
476
  } from './utils/folderCustomization.js'
477
477
  export { currentTheme, useCurrentTheme } from './composables/useCurrentTheme.js'
478
478
  export { ROADMAP_LABEL_BLOCKLIST } from './utils/roadmapLabelBlocklist.js'
479
+ // The words a person reads for a notification rule key (never the raw key).
480
+ export { humaniseRuleKey, notificationRuleLabel } from './utils/notificationRuleLabel.js'
479
481
 
480
482
  // Shared field validators. These are UX affordances that run in the browser —
481
483
  // they do NOT replace server-side validation, which stays authoritative at the
@@ -0,0 +1,129 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * The label a person reads for one notification rule in their settings.
6
+ *
7
+ * A rule is declared in a schema's `x-openregister-notifications` map under a
8
+ * KEY (`caseAssigned`, `clientUpdated`, `onCreate`). The key is an identifier
9
+ * for a developer. The settings pane used to print it as is, so a person saw
10
+ * "substitutionRegisteredForSubstitute" next to a switch and had to guess.
11
+ *
12
+ * The label is taken from the first source that has one, most specific first:
13
+ *
14
+ * 1. the rule's own words, when OpenRegister passes them on the preference
15
+ * entry (`label`, then `title`), as a plain string or a per-locale map
16
+ * like the dialect's `subject` (`{ "nl": "…", "en": "…" }`);
17
+ * 2. the app's own label for the rule, from the `labels` map the app hands
18
+ * to CnAppRoot (`notificationLabels`), keyed `<schema>.<key>` or `<key>`;
19
+ * 3. the library's wording for the generic object-event keys;
20
+ * 4. the key made readable: `caseAssigned` becomes "Case assigned".
21
+ *
22
+ * The rule's `subject` is used only when it carries no `{{placeholder}}`: it
23
+ * is the notification TITLE template, and "Case "{{title}}" assigned to you"
24
+ * reads worse as a setting than the readable key does.
25
+ *
26
+ * Pure: no store, no fetch, no Vue.
27
+ *
28
+ * @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
29
+ */
30
+
31
+ /**
32
+ * Pick the string for the current language out of a string or per-locale map.
33
+ *
34
+ * @param {string|object|null|undefined} value A string, or `{ nl, en, … }`.
35
+ * @param {string} [language] The current language, e.g. `nl` or `de_DE`.
36
+ * @return {string} The resolved text, or '' when there is none.
37
+ */
38
+ export function resolveLocalised(value, language = 'en') {
39
+ if (typeof value === 'string') {
40
+ return value.trim()
41
+ }
42
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
43
+ return ''
44
+ }
45
+ const lang = String(language || 'en')
46
+ const base = lang.split(/[-_]/)[0]
47
+ for (const candidate of [lang, base, 'en']) {
48
+ if (typeof value[candidate] === 'string' && value[candidate].trim() !== '') {
49
+ return value[candidate].trim()
50
+ }
51
+ }
52
+ const first = Object.values(value).find((text) => typeof text === 'string' && text.trim() !== '')
53
+ return first ? first.trim() : ''
54
+ }
55
+
56
+ /**
57
+ * Make a rule key readable: split camelCase, snake_case and kebab-case into
58
+ * words, keep acronyms, and capitalise only the first word.
59
+ *
60
+ * `caseAssigned` → "Case assigned", `object_created` → "Object created",
61
+ * `newBRPRecord` → "New BRP record".
62
+ *
63
+ * @param {string} key The rule key.
64
+ * @return {string} A readable sentence-case label, or the key when it has no words.
65
+ */
66
+ export function humaniseRuleKey(key) {
67
+ const words = String(key || '')
68
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
69
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
70
+ .split(/[\s_\-.]+/)
71
+ .filter(Boolean)
72
+ if (words.length === 0) {
73
+ return String(key || '')
74
+ }
75
+ return words
76
+ .map((word, index) => {
77
+ const isAcronym = word.length > 1 && word === word.toUpperCase() && /[A-Z]/.test(word)
78
+ if (isAcronym) {
79
+ return word
80
+ }
81
+ const lower = word.toLowerCase()
82
+ return index === 0 ? lower.charAt(0).toUpperCase() + lower.slice(1) : lower
83
+ })
84
+ .join(' ')
85
+ }
86
+
87
+ /**
88
+ * The label for one preference entry.
89
+ *
90
+ * @param {object} entry A preference entry: `{ schema, notification, label?, title?, subject? }`.
91
+ * @param {object} [options] Where else a label may come from.
92
+ * @param {object} [options.labels] App-supplied labels keyed `<schema>.<key>` or `<key>`;
93
+ * values are strings or per-locale maps.
94
+ * @param {object} [options.known] Library wording for generic keys, keyed by rule key.
95
+ * @param {string} [options.language] The current language.
96
+ * @return {string} The label to show.
97
+ */
98
+ export function notificationRuleLabel(entry, { labels = {}, known = {}, language = 'en' } = {}) {
99
+ const key = String(entry?.notification ?? '')
100
+ const schema = String(entry?.schema ?? '')
101
+
102
+ for (const field of ['label', 'title']) {
103
+ const own = resolveLocalised(entry?.[field], language)
104
+ if (own) {
105
+ return own
106
+ }
107
+ }
108
+
109
+ const map = labels && typeof labels === 'object' ? labels : {}
110
+ for (const lookup of [schema ? `${schema}.${key}` : '', key]) {
111
+ if (lookup && Object.hasOwn(map, lookup)) {
112
+ const fromApp = resolveLocalised(map[lookup], language)
113
+ if (fromApp) {
114
+ return fromApp
115
+ }
116
+ }
117
+ }
118
+
119
+ if (known && typeof known[key] === 'string' && known[key]) {
120
+ return known[key]
121
+ }
122
+
123
+ const subject = resolveLocalised(entry?.subject, language)
124
+ if (subject && !subject.includes('{{')) {
125
+ return subject
126
+ }
127
+
128
+ return humaniseRuleKey(key)
129
+ }
@@ -28,3 +28,19 @@ export interface CnPublicPathOptions {
28
28
  * @param options Where the public path is read from at runtime.
29
29
  */
30
30
  export function withPublicPath<T extends object | object[]>(config: T, options?: CnPublicPathOptions): T
31
+
32
+ export interface CnAppVersionDefineOptions {
33
+ /** The initial-state key the app's page provides its version under. Defaults to `version`. */
34
+ key?: string
35
+ }
36
+
37
+ /**
38
+ * A `webpack.DefinePlugin` expression for `appVersion` that reads the
39
+ * installed app version from the page's initial state at runtime, falling
40
+ * back to `fallback` (the build-time version) when the state is absent.
41
+ *
42
+ * @param appId The app id.
43
+ * @param fallback The build-time version.
44
+ * @param options Which initial-state key to read.
45
+ */
46
+ export function appVersionDefine(appId: string, fallback?: string, options?: CnAppVersionDefineOptions): string
package/webpack/index.js CHANGED
@@ -116,7 +116,77 @@ function withPublicPath(config, options = {}) {
116
116
  }
117
117
  }
118
118
 
119
+ /**
120
+ * The `appVersion` define, resolved in the browser from the INSTALLED app.
121
+ *
122
+ * THE PROBLEM
123
+ * -----------
124
+ * `@nextcloud/vue` prints `<app name> <appVersion>` in the footer of every
125
+ * NcAppSettingsDialog, and reads `appVersion` as a global the app's webpack
126
+ * config defines. Apps defined it from `package.json` (a `0.1.0` nobody bumps,
127
+ * so pipelinq read "pipelinq 0.1.0") or from `appinfo/info.xml` at build time.
128
+ * The second is still wrong on every release: the release workflow writes the
129
+ * release version into `info.xml` after the bundle is built, so dossiq
130
+ * 0.4.48-beta said "dossiq 0.4.47-unstable". A build cannot know the version
131
+ * it will be installed as.
132
+ *
133
+ * THE FIX
134
+ * -------
135
+ * Define `appVersion` as an EXPRESSION, not a literal. It runs once in the
136
+ * browser and reads the app's `version` initial state, which the app's page
137
+ * controller provides from what Nextcloud has installed
138
+ * (`IAppConfig::getValueString($appId, 'installed_version')`). It reads the
139
+ * same hidden input `@nextcloud/initial-state` `loadState()` reads; it cannot
140
+ * import that package because a define is pasted into every module as code.
141
+ * When the state is missing (a page that does not provide it, a test, a
142
+ * worker without a document) it falls back to the build-time version.
143
+ *
144
+ * USAGE
145
+ * -----
146
+ * ```js
147
+ * // webpack.config.js
148
+ * const { appVersionDefine } = require('@conduction/nextcloud-vue/webpack')
149
+ * new webpack.DefinePlugin({ appVersion: appVersionDefine('dossiq', readAppVersion()) })
150
+ * ```
151
+ * ```php
152
+ * // the controller that renders the app's page
153
+ * $this->initialState->provideInitialState('version', $this->appConfig->getValueString('dossiq', 'installed_version', ''));
154
+ * ```
155
+ *
156
+ * @param {string} appId The app id, as used for its initial state.
157
+ * @param {string} [fallback] The version to show when no initial state is
158
+ * present, normally the build-time `info.xml` version.
159
+ * @param {object} [options] Options.
160
+ * @param {string} [options.key] The initial-state key. Defaults to `version`.
161
+ *
162
+ * @return {string} A JavaScript expression for `webpack.DefinePlugin`.
163
+ *
164
+ * @throws {TypeError} When `appId` is empty.
165
+ *
166
+ * @spec openspec/changes/notification-rule-labels-and-runtime-version/specs/notification-preferences/spec.md
167
+ */
168
+ function appVersionDefine(appId, fallback = '', options = {}) {
169
+ const id = String(appId || '').trim()
170
+ if (id === '') {
171
+ throw new TypeError('appVersionDefine() needs the app id whose installed version it reads')
172
+ }
173
+ const key = String(options.key || 'version')
174
+ const elementId = JSON.stringify('initial-state-' + id + '-' + key)
175
+ const fallbackLiteral = JSON.stringify(String(fallback ?? ''))
176
+ return '(function () {'
177
+ + ' try {'
178
+ + ' var el = typeof document !== "undefined" && document.getElementById(' + elementId + ');'
179
+ + ' if (el && el.value) {'
180
+ + ' var v = JSON.parse(atob(el.value));'
181
+ + ' if (typeof v === "string" && v !== "") { return v; }'
182
+ + ' }'
183
+ + ' } catch (e) {}'
184
+ + ' return ' + fallbackLiteral + ';'
185
+ + ' })()'
186
+ }
187
+
119
188
  module.exports = {
120
189
  AUTO_PUBLIC_PATH,
190
+ appVersionDefine,
121
191
  withPublicPath,
122
192
  }