@conduction/nextcloud-vue 2.76.0 → 2.78.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 (173) hide show
  1. package/dist/esm/components/CnAiCompanion/CnAiCompanion.vue2.js +23 -0
  2. package/dist/esm/components/CnAiCompanion/CnAiCompanion.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAiCompanion/CnAiCompanion.vue3.js.map +1 -1
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +18 -3
  5. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  6. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +49 -3
  7. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  8. package/dist/esm/components/CnBoardView/CnBoardView.vue2.js +2 -1
  9. package/dist/esm/components/CnBoardView/CnBoardView.vue2.js.map +1 -1
  10. package/dist/esm/components/CnBoardView/CnBoardView.vue3.js.map +1 -1
  11. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +79 -1
  12. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  13. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +229 -203
  14. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  15. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue.js +9 -0
  16. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue.js.map +1 -0
  17. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue2.js +50 -0
  18. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue2.js.map +1 -0
  19. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue3.js +13 -0
  20. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue3.js.map +1 -0
  21. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +37 -0
  22. package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
  23. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js +2 -2
  24. package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +29 -4
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  28. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +48 -6
  29. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  30. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +90 -51
  31. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  32. package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue.js +1 -1
  33. package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue2.js +45 -2
  34. package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue2.js.map +1 -1
  35. package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue3.js.map +1 -1
  36. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +16 -28
  37. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  38. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +12 -24
  39. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  40. package/dist/esm/components/CnFavouriteToggle/CnFavouriteToggle.vue2.js +7 -2
  41. package/dist/esm/components/CnFavouriteToggle/CnFavouriteToggle.vue2.js.map +1 -1
  42. package/dist/esm/components/CnFavouriteToggle/CnFavouriteToggle.vue3.js.map +1 -1
  43. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +114 -1
  44. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  45. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +115 -62
  46. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  47. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue2.js +89 -7
  48. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue2.js.map +1 -1
  49. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue3.js +62 -30
  50. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue3.js.map +1 -1
  51. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +7 -1
  52. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  53. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  54. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js +4 -1
  55. package/dist/esm/components/CnFormPage/CnFormPage.vue2.js.map +1 -1
  56. package/dist/esm/components/CnFormPage/CnFormPage.vue3.js.map +1 -1
  57. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +104 -13
  58. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  59. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +13 -9
  60. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  61. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +199 -7
  62. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  63. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js +155 -119
  64. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  65. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +6 -0
  66. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  67. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +4 -1
  68. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  69. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +36 -0
  70. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  71. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +35 -23
  72. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  73. package/dist/esm/components/CnStatusBadge/CnStatusBadge.vue2.js +6 -2
  74. package/dist/esm/components/CnStatusBadge/CnStatusBadge.vue2.js.map +1 -1
  75. package/dist/esm/components/CnStatusBadge/CnStatusBadge.vue3.js.map +1 -1
  76. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +13 -0
  77. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  78. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  79. package/dist/esm/composables/useFormDraft.js +11 -1
  80. package/dist/esm/composables/useFormDraft.js.map +1 -1
  81. package/dist/esm/composables/useListView.js +4 -0
  82. package/dist/esm/composables/useListView.js.map +1 -1
  83. package/dist/esm/composables/useObjectPresence.js +15 -4
  84. package/dist/esm/composables/useObjectPresence.js.map +1 -1
  85. package/dist/esm/index.js +2 -1
  86. package/dist/esm/index.js.map +1 -1
  87. package/dist/esm/l10n/en.json.js +32 -1
  88. package/dist/esm/l10n/en.json.js.map +1 -1
  89. package/dist/esm/l10n/nl.json.js +32 -1
  90. package/dist/esm/l10n/nl.json.js.map +1 -1
  91. package/dist/esm/nextcloud-vue.css +500 -75
  92. package/dist/esm/store/plugins/interactions.js +20 -10
  93. package/dist/esm/store/plugins/interactions.js.map +1 -1
  94. package/dist/esm/store/useObjectStore.js +17 -0
  95. package/dist/esm/store/useObjectStore.js.map +1 -1
  96. package/dist/esm/utils/badgeVariants.js +32 -0
  97. package/dist/esm/utils/badgeVariants.js.map +1 -0
  98. package/dist/esm/utils/builtInFormatters.js +15 -7
  99. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  100. package/dist/esm/utils/groupAutocomplete.js +63 -1
  101. package/dist/esm/utils/groupAutocomplete.js.map +1 -1
  102. package/dist/esm/utils/lensAvailability.js +90 -0
  103. package/dist/esm/utils/lensAvailability.js.map +1 -0
  104. package/dist/esm/utils/personalLenses.js +13 -6
  105. package/dist/esm/utils/personalLenses.js.map +1 -1
  106. package/dist/esm/utils/recordInteractions.js +31 -13
  107. package/dist/esm/utils/recordInteractions.js.map +1 -1
  108. package/dist/esm/utils/resolveFilterTokens.js +46 -3
  109. package/dist/esm/utils/resolveFilterTokens.js.map +1 -1
  110. package/dist/esm/utils/schema.js +1 -1
  111. package/dist/esm/utils/schema.js.map +1 -1
  112. package/dist/esm/utils/sentinelTokens.js +4 -4
  113. package/dist/esm/utils/sentinelTokens.js.map +1 -1
  114. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  115. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  116. package/dist/esm/utils/widgetVisibility.js +46 -1
  117. package/dist/esm/utils/widgetVisibility.js.map +1 -1
  118. package/dist/nextcloud-vue.cjs.js +4389 -2890
  119. package/dist/nextcloud-vue.cjs.js.map +2 -2
  120. package/dist/nextcloud-vue.css +500 -75
  121. package/l10n/en.json +32 -1
  122. package/l10n/nl.json +32 -1
  123. package/package.json +1 -1
  124. package/src/components/CnAiCompanion/CnAiCompanion.vue +23 -0
  125. package/src/components/CnAppNav/CnAppNav.vue +44 -5
  126. package/src/components/CnBoardView/CnBoardView.vue +2 -1
  127. package/src/components/CnCellRenderer/CnCellRenderer.md +1 -0
  128. package/src/components/CnCellRenderer/CnCellRenderer.vue +106 -9
  129. package/src/components/CnCellRenderer/CnGroupNameCell.vue +61 -0
  130. package/src/components/CnChartWidget/CnChartWidget.vue +39 -2
  131. package/src/components/CnDashboardPage/CnDashboardPage.vue +29 -4
  132. package/src/components/CnDataTable/CnDataTable.md +9 -0
  133. package/src/components/CnDataTable/CnDataTable.vue +87 -22
  134. package/src/components/CnDateRangePicker/CnDateRangePicker.vue +44 -1
  135. package/src/components/CnDetailPage/CnDetailPage.vue +10 -27
  136. package/src/components/CnFavouriteToggle/CnFavouriteToggle.vue +7 -2
  137. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +204 -5
  138. package/src/components/CnFollowToggle/CnFollowToggle.vue +118 -13
  139. package/src/components/CnFormDialog/CnFormDialog.vue +7 -1
  140. package/src/components/CnFormPage/CnFormPage.vue +4 -1
  141. package/src/components/CnIndexPage/CnIndexPage.md +36 -0
  142. package/src/components/CnIndexPage/CnIndexPage.vue +112 -19
  143. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +226 -2
  144. package/src/components/CnObjectSidebar/CnFilesTab.vue +9 -0
  145. package/src/components/CnStatWidget/CnStatWidget.vue +52 -2
  146. package/src/components/CnStatusBadge/CnStatusBadge.vue +6 -2
  147. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +13 -0
  148. package/src/composables/__tests__/useObjectPresence.spec.js +24 -0
  149. package/src/composables/useFormDraft.js +11 -1
  150. package/src/composables/useListView.js +4 -0
  151. package/src/composables/useObjectPresence.js +15 -4
  152. package/src/css/badge.css +31 -0
  153. package/src/css/kpi-card.css +51 -0
  154. package/src/css/look-board-detail.css +40 -2
  155. package/src/css/look-board-index.css +36 -0
  156. package/src/css/look-board.css +8 -0
  157. package/src/css/table.css +82 -17
  158. package/src/index.js +2 -1
  159. package/src/schemas/app-manifest-v2.schema.json +15 -11
  160. package/src/store/plugins/interactions.js +20 -10
  161. package/src/store/useObjectStore.js +17 -0
  162. package/src/utils/badgeVariants.js +29 -0
  163. package/src/utils/builtInFormatters.js +15 -7
  164. package/src/utils/groupAutocomplete.js +75 -0
  165. package/src/utils/index.js +1 -1
  166. package/src/utils/lensAvailability.js +86 -0
  167. package/src/utils/personalLenses.js +13 -6
  168. package/src/utils/recordInteractions.js +31 -13
  169. package/src/utils/resolveFilterTokens.js +45 -2
  170. package/src/utils/schema.js +1 -1
  171. package/src/utils/sentinelTokens.js +4 -4
  172. package/src/utils/validateManifestV2.compiled.js +2 -2
  173. package/src/utils/widgetVisibility.js +45 -0
@@ -3,7 +3,8 @@ import { listWatchers, setFavourite, setReadState, setWatcher, setWatching } fro
3
3
  /**
4
4
  * Interactions plugin for the object store.
5
5
  *
6
- * Per-user interaction calls on a record: favourite, follow (watch) and the
6
+ * Per-user interaction calls on a record: follow (watch, with its
7
+ * notifications switch), the deprecated favourite (a quiet follow), and the
7
8
  * followers list. Each write puts the server's answer into the stored object's
8
9
  * `@self`, so a list showing the same object agrees with the toggle.
9
10
  *
@@ -55,7 +56,10 @@ export function interactionsPlugin() {
55
56
  },
56
57
 
57
58
  /**
58
- * Star a record.
59
+ * Star a record: a quiet follow on the server.
60
+ *
61
+ * @deprecated A favourite is a follow with notifications off since
62
+ * OpenRegister's `merge-follow-and-favourites`; use `watch(type, id, { notify: false })`.
59
63
  *
60
64
  * @param {string} type The registered object type slug
61
65
  * @param {string} objectId The object ID
@@ -65,13 +69,15 @@ export function interactionsPlugin() {
65
69
  const { register, schema } = this._interactionTarget(type)
66
70
  const result = await setFavourite(register, schema, objectId, true)
67
71
  if (result.ok) {
68
- this._patchSelf(type, objectId, { favourite: true })
72
+ this._patchSelf(type, objectId, { favourite: true, watching: true })
69
73
  }
70
74
  return result
71
75
  },
72
76
 
73
77
  /**
74
- * Unstar a record.
78
+ * Unstar a record: unfollow it.
79
+ *
80
+ * @deprecated Use `unwatch`.
75
81
  *
76
82
  * @param {string} type The registered object type slug
77
83
  * @param {string} objectId The object ID
@@ -81,7 +87,7 @@ export function interactionsPlugin() {
81
87
  const { register, schema } = this._interactionTarget(type)
82
88
  const result = await setFavourite(register, schema, objectId, false)
83
89
  if (result.ok) {
84
- this._patchSelf(type, objectId, { favourite: false })
90
+ this._patchSelf(type, objectId, { favourite: false, watching: false, watchNotify: undefined })
85
91
  }
86
92
  return result
87
93
  },
@@ -119,17 +125,21 @@ export function interactionsPlugin() {
119
125
  },
120
126
 
121
127
  /**
122
- * Follow a record.
128
+ * Follow a record, optionally setting its notifications switch.
123
129
  *
124
130
  * @param {string} type The registered object type slug
125
131
  * @param {string} objectId The object ID
132
+ * @param {object} [options] Options
133
+ * @param {boolean} [options.notify] The notifications switch; left out, a new follow notifies
126
134
  * @return {Promise<object>} The outcome
135
+ * @spec openspec/changes/one-follow-control/specs/record-follow/spec.md#requirement-one-follow-control-with-a-notifications-switch
127
136
  */
128
- async watch(type, objectId) {
137
+ async watch(type, objectId, options = {}) {
129
138
  const { register, schema } = this._interactionTarget(type)
130
- const result = await setWatching(register, schema, objectId, true)
139
+ const result = await setWatching(register, schema, objectId, true, options)
131
140
  if (result.ok) {
132
- this._patchSelf(type, objectId, { watching: true })
141
+ const notify = result.data && typeof result.data.notify === 'boolean' ? result.data.notify : true
142
+ this._patchSelf(type, objectId, { watching: true, favourite: true, watchNotify: notify })
133
143
  }
134
144
  return result
135
145
  },
@@ -145,7 +155,7 @@ export function interactionsPlugin() {
145
155
  const { register, schema } = this._interactionTarget(type)
146
156
  const result = await setWatching(register, schema, objectId, false)
147
157
  if (result.ok) {
148
- this._patchSelf(type, objectId, { watching: false })
158
+ this._patchSelf(type, objectId, { watching: false, favourite: false, watchNotify: undefined })
149
159
  }
150
160
  return result
151
161
  },
@@ -6,6 +6,7 @@ import { genericError, networkError, parseResponseError } from '../utils/errors.
6
6
  import { normalizeFacets } from '../utils/facets.js'
7
7
  import { buildHeaders, buildQueryString, capitalize, prefixUrl } from '../utils/headers.js'
8
8
  import { extractId } from '../utils/id.js'
9
+ import { readLensReports } from '../utils/lensAvailability.js'
9
10
  import { dispatchObjectCreated } from '../utils/walkthroughSignals.js'
10
11
  import { mergePluginActions, mergePluginGetters, mergePluginState } from './pluginMerge.js'
11
12
  import { liveUpdatesPlugin } from './plugins/liveUpdates.js'
@@ -86,6 +87,16 @@ function baseState(baseUrl = DEFAULT_BASE_URL) {
86
87
  * @type {{string: object}}
87
88
  */
88
89
  facets: {},
90
+ /**
91
+ * Personal-lens reports of the latest collection response per type:
92
+ * the body's `@self.lenses` (`{ recent: { available, reason } }`),
93
+ * `{}` when it carried none. Written with the rows, so it always
94
+ * describes the page on screen.
95
+ *
96
+ * @type {{string: object}}
97
+ * @spec openspec/changes/lens-says-why-it-is-empty/specs/personal-lens-availability/spec.md#requirement-a-list-response-carries-the-report-of-the-lenses-it-was-asked-for
98
+ */
99
+ lenses: {},
89
100
  /** @type {{baseUrl: string, organisationUuidGetter: (() => string|null)|null, languageGetter: (() => string|null)|null, targetLanguageGetter: (() => string|null)|null}} */
90
101
  _options: {
91
102
  baseUrl: prefixedBaseUrl,
@@ -195,6 +206,7 @@ const baseGetters = {
195
206
  * @return {(type: string) => object}
196
207
  */
197
208
  getFacets: (state) => (type) => state.facets[type] || {},
209
+ getLenses: (state) => (type) => state.lenses[type] || {},
198
210
  }
199
211
 
200
212
  // ── Base actions ────────────────────────────────────────────────────────
@@ -265,6 +277,7 @@ const baseActions = {
265
277
  this.schemas = { ...this.schemas, [slug]: null }
266
278
  this.registers = { ...this.registers, [slug]: null }
267
279
  this.facets = { ...this.facets, [slug]: {} }
280
+ this.lenses = { ...this.lenses, [slug]: {} }
268
281
  },
269
282
 
270
283
  /**
@@ -308,6 +321,7 @@ const baseActions = {
308
321
  this.schemas = omit(this.schemas, slug)
309
322
  this.registers = omit(this.registers, slug)
310
323
  this.facets = omit(this.facets, slug)
324
+ this.lenses = omit(this.lenses, slug)
311
325
  },
312
326
 
313
327
  /**
@@ -441,6 +455,7 @@ const baseActions = {
441
455
  this.collections = {}
442
456
  this.objects = {}
443
457
  this.facets = {}
458
+ this.lenses = {}
444
459
  this.pagination = {}
445
460
  // Errors + loading flags are per-fetch and don't need a reset.
446
461
  },
@@ -737,6 +752,8 @@ const baseActions = {
737
752
  const results = data.results || data
738
753
 
739
754
  this.collections = { ...this.collections, [type]: results }
755
+ // Why a personal lens came back empty, if it says (openregister#4514).
756
+ this.lenses = { ...this.lenses, [type]: readLensReports(data) }
740
757
  this.pagination = {
741
758
  ...this.pagination,
742
759
  [type]: {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * badgeVariants: the colour variants a CnStatusBadge (a status pill) may take.
6
+ *
7
+ * The first six follow the Nextcloud colour roles. `purple` and `teal` are the
8
+ * two tones the drawn boards use for a status that has no Nextcloud role, such
9
+ * as "In behandeling" (work in progress) and "Omgezet naar een zaak" (handed
10
+ * on). One list, so the badge, the board card pill and the manifest schema
11
+ * accept the same names.
12
+ *
13
+ * @module utils/badgeVariants
14
+ * @spec openspec/changes/screens-cell-pill-parity/specs/cell-pill-tones/spec.md#requirement-a-status-pill-has-eight-tones
15
+ */
16
+
17
+ /** Every variant a status pill accepts, in the order the docs list them. */
18
+ export const BADGE_VARIANTS = Object.freeze(['default', 'primary', 'success', 'warning', 'error', 'info', 'purple', 'teal'])
19
+
20
+ /**
21
+ * Whether a value names a badge variant.
22
+ *
23
+ * @param {unknown} variant The candidate variant.
24
+ * @return {boolean} True for one of `BADGE_VARIANTS`.
25
+ * @spec openspec/changes/screens-cell-pill-parity/specs/cell-pill-tones/spec.md#requirement-a-status-pill-has-eight-tones
26
+ */
27
+ export function isBadgeVariant(variant) {
28
+ return typeof variant === 'string' && BADGE_VARIANTS.includes(variant)
29
+ }
@@ -164,13 +164,15 @@ export function formatDaysUntil(value) {
164
164
 
165
165
  /**
166
166
  * Elapsed-time formatter (`daysSince` registry key), i18n'd through the
167
- * library's own translation slug (`nextcloud-vue`): a past date renders
168
- * "N days ago", today renders "Today", and a (nonsensical but harmless)
169
- * future date renders "In N days". Day counts are plural-aware via
170
- * `translatePlural`.
167
+ * library's own translation slug (`nextcloud-vue`) in the Nextcloud user
168
+ * language. Whole calendar days, read as a running phrase so it sits after a
169
+ * title or in a meta line: today renders "today", one day back renders
170
+ * "yesterday", further back renders "N days ago". A (nonsensical but
171
+ * harmless) future date renders "tomorrow" or "in N days". Day counts are
172
+ * plural-aware via `translatePlural`.
171
173
  *
172
174
  * Null-safe per the built-in-formatter contract: `''` for null/empty,
173
- * `String(value)` for unparseable input — never throws.
175
+ * `String(value)` for unparseable input, never throws.
174
176
  *
175
177
  * @param {unknown} value A `Date`, parseable date string, or timestamp.
176
178
  * @return {string} The relative-day phrasing (or ''/original on bad input).
@@ -185,13 +187,19 @@ export function formatDaysSince(value) {
185
187
  }
186
188
  const days = dayDiffFromToday(d)
187
189
  if (days === 0) {
188
- return t('nextcloud-vue', 'Today')
190
+ return t('nextcloud-vue', 'today')
191
+ }
192
+ if (days === -1) {
193
+ return t('nextcloud-vue', 'yesterday')
194
+ }
195
+ if (days === 1) {
196
+ return t('nextcloud-vue', 'tomorrow')
189
197
  }
190
198
  if (days < 0) {
191
199
  const ago = Math.abs(days)
192
200
  return n('nextcloud-vue', '{count} day ago', '{count} days ago', ago, { count: ago })
193
201
  }
194
- return n('nextcloud-vue', 'In {count} day', 'In {count} days', days, { count: days })
202
+ return n('nextcloud-vue', 'in {count} day', 'in {count} days', days, { count: days })
195
203
  }
196
204
 
197
205
  /**
@@ -14,6 +14,7 @@
14
14
 
15
15
  import axios from '@nextcloud/axios'
16
16
  import { generateOcsUrl } from '@nextcloud/router'
17
+ import { reactive } from 'vue'
17
18
 
18
19
  const OCS_HEADERS = {
19
20
  'OCS-APIRequest': 'true',
@@ -117,3 +118,77 @@ export async function resolveNextcloudGroup(gid) {
117
118
  const results = await searchNextcloudGroups(String(gid))
118
119
  return results.find((opt) => opt.id === String(gid)) || fallback
119
120
  }
121
+
122
+ /**
123
+ * Display names already looked up, by group id. Reactive, so every cell that
124
+ * shows the same group updates when its name arrives.
125
+ *
126
+ * @type {Record<string, string>}
127
+ */
128
+ const groupNames = reactive({})
129
+
130
+ /** In-flight lookups by group id, so one id is asked for once. */
131
+ const pendingNames = new Map()
132
+
133
+ /**
134
+ * The cached display name of a group, or `null` when it has not been looked
135
+ * up yet. Reactive: read in a computed or a render, it updates when
136
+ * {@link loadGroupDisplayName} finishes.
137
+ *
138
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-a-group-cell-shows-the-groups-display-name
139
+ * @param {string} gid The group id.
140
+ * @return {string|null} The display name, or null when unknown.
141
+ */
142
+ export function groupDisplayName(gid) {
143
+ if (gid === undefined || gid === null || gid === '') {
144
+ return null
145
+ }
146
+ const name = groupNames[String(gid)]
147
+ return typeof name === 'string' ? name : null
148
+ }
149
+
150
+ /**
151
+ * Look a group's display name up once per page and cache it. Concurrent
152
+ * callers for the same id share one request. A failed or empty lookup caches
153
+ * the id itself, so the cell shows the id and does not ask again.
154
+ *
155
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-a-group-cell-shows-the-groups-display-name
156
+ * @param {string} gid The group id.
157
+ * @return {Promise<string>} The display name, or the id.
158
+ */
159
+ export function loadGroupDisplayName(gid) {
160
+ if (gid === undefined || gid === null || gid === '') {
161
+ return Promise.resolve('')
162
+ }
163
+ const key = String(gid)
164
+ const known = groupDisplayName(key)
165
+ if (known !== null) {
166
+ return Promise.resolve(known)
167
+ }
168
+ if (pendingNames.has(key)) {
169
+ return pendingNames.get(key)
170
+ }
171
+ const request = resolveNextcloudGroup(key)
172
+ .then((option) => (option && option.label) || key)
173
+ .catch(() => key)
174
+ .then((label) => {
175
+ groupNames[key] = label
176
+ pendingNames.delete(key)
177
+ return label
178
+ })
179
+ pendingNames.set(key, request)
180
+ return request
181
+ }
182
+
183
+ /**
184
+ * Forget every cached group name (tests, or after a group was renamed).
185
+ *
186
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-a-group-cell-shows-the-groups-display-name
187
+ * @return {void}
188
+ */
189
+ export function clearGroupNameCache() {
190
+ pendingNames.clear()
191
+ for (const key of Object.keys(groupNames)) {
192
+ delete groupNames[key]
193
+ }
194
+ }
@@ -5,7 +5,7 @@ export { findRepeatingWrite, journeyItemTargets } from './journeyRepeatingWrite.
5
5
  export { columnsFromSchema, fieldsFromSchema, filtersFromSchema, formatValue, validateValue } from './schema.js'
6
6
  export { DYNAMIC_KEY_PREFIX, EXTENDS_FORM_KEY, PREFILL_KEY, splitDynamicFormData, usesArrayValues, valueArrayFor, valueRecordsFor } from './dynamicProperties.js'
7
7
  export { deleteSchema, describeSchemaChange, saveSchema, SchemaBreakingChangeError, SchemaHasObjectsError } from './schemaApi.js'
8
- export { filterWidgetsByVisibility, getCurrentUserGroups, getCurrentUserId, isWidgetVisible, resetVisibilityCache } from './widgetVisibility.js'
8
+ export { filterWidgetsByVisibility, getCurrentUserGroups, getCurrentUserId, isWidgetVisible, peekCurrentUserGroups, resetVisibilityCache } from './widgetVisibility.js'
9
9
  export { isAppInstalled } from './appInstalled.js'
10
10
  export { getAssistantMark } from './assistantMark.js'
11
11
  export { passesContextPredicates } from './visibleIfContext.js'
@@ -0,0 +1,86 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * Why a personal lens (Recent, later Following) came back empty.
6
+ *
7
+ * OpenRegister reports per asked lens, at response level, whether it could
8
+ * answer: `@self.lenses.<lens> = { available, reason }` (openregister#4514,
9
+ * `read-history-on-audit-trail`). `available: false` always comes with an
10
+ * empty page. These helpers read that report and turn it into the short
11
+ * explanation a list shows in place of its generic empty text.
12
+ *
13
+ * The lens name is a key, so a later lens (`watching`) only needs copy.
14
+ *
15
+ * @spec openspec/changes/lens-says-why-it-is-empty/specs/personal-lens-availability/spec.md#requirement-an-unavailable-lens-explains-its-empty-page
16
+ */
17
+ import { translate as t } from '@nextcloud/l10n'
18
+
19
+ /**
20
+ * Built-in explanations per lens and reason. Object-neutral: the library
21
+ * does not know whether the app lists cases, tickets or documents. An app
22
+ * says it in its own words through `lensReasonTexts`.
23
+ *
24
+ * @return {Object<string, Object<string, string>>} Lens name to reason to text.
25
+ */
26
+ function builtInTexts() {
27
+ return {
28
+ recent: {
29
+ 'audit-trail-disabled': t('nextcloud-vue', 'This server does not keep track of what you open.'),
30
+ anonymous: t('nextcloud-vue', 'Log in to see what you opened recently.'),
31
+ 'read-history-unavailable': t('nextcloud-vue', 'Your recent items are not available right now.'),
32
+ },
33
+ }
34
+ }
35
+
36
+ /**
37
+ * The lens reports of a list response body: `@self.lenses`, or `{}` when the
38
+ * body carries none (every response before openregister#4514, and every
39
+ * response that did not ask for a lens).
40
+ *
41
+ * @param {object|null|undefined} body A parsed list response.
42
+ * @return {Object<string, {available: boolean, reason: (string|null)}>} The reports, keyed by lens name.
43
+ * @spec openspec/changes/lens-says-why-it-is-empty/specs/personal-lens-availability/spec.md#requirement-a-list-response-carries-the-report-of-the-lenses-it-was-asked-for
44
+ */
45
+ export function readLensReports(body) {
46
+ const lenses = body && typeof body === 'object' && body['@self'] && typeof body['@self'] === 'object'
47
+ ? body['@self'].lenses
48
+ : null
49
+ return lenses && typeof lenses === 'object' && !Array.isArray(lenses) ? lenses : {}
50
+ }
51
+
52
+ /**
53
+ * The explanation for the first lens that reports `available: false` with a
54
+ * reason there is text for, or `''` when there is none (no report, every lens
55
+ * available, or a reason nobody wrote text for). The caller then shows its
56
+ * own empty text, as before.
57
+ *
58
+ * App text wins over built-in text; within app text, `<lens>.<reason>` wins
59
+ * over `<reason>`.
60
+ *
61
+ * @param {object|null|undefined} reports The lens reports (see `readLensReports`).
62
+ * @param {Object<string, string>|null} [overrides] App text keyed `<lens>.<reason>` or `<reason>`.
63
+ * @return {string} The explanation, or an empty string.
64
+ * @spec openspec/changes/lens-says-why-it-is-empty/specs/personal-lens-availability/spec.md#requirement-an-unavailable-lens-explains-its-empty-page
65
+ */
66
+ export function lensUnavailableText(reports, overrides = null) {
67
+ if (!reports || typeof reports !== 'object') {
68
+ return ''
69
+ }
70
+ const own = overrides && typeof overrides === 'object' ? overrides : {}
71
+ const builtIn = builtInTexts()
72
+ for (const [lens, report] of Object.entries(reports)) {
73
+ if (!report || typeof report !== 'object' || report.available !== false) {
74
+ continue
75
+ }
76
+ const reason = typeof report.reason === 'string' ? report.reason : ''
77
+ if (reason === '') {
78
+ continue
79
+ }
80
+ const text = own[`${lens}.${reason}`] || own[reason] || (builtIn[lens] && builtIn[lens][reason]) || ''
81
+ if (text) {
82
+ return text
83
+ }
84
+ }
85
+ return ''
86
+ }
@@ -2,30 +2,37 @@
2
2
  * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
3
  * SPDX-License-Identifier: EUPL-1.2
4
4
  *
5
- * The personal lenses of an index page (Favourites, Recent, Following) as
6
- * quick filters, appended after the page's own.
5
+ * The personal lenses of an index page (Following, Recent, Unread) as quick
6
+ * filters, appended after the page's own.
7
7
  *
8
- * @spec openspec/changes/record-favourite-and-follow/tasks.md#task-3
8
+ * There is no Favourites lens any more: a favourite is a follow with
9
+ * notifications off (OpenRegister `merge-follow-and-favourites`), so
10
+ * `favourite` is an alias of `watching` and asking for both gives one tab.
11
+ *
12
+ * @spec openspec/changes/one-follow-control/specs/record-follow/spec.md#requirement-index-pages-offer-one-following-lens-and-a-follow-column
9
13
  */
10
14
  import { translate as t } from '@nextcloud/l10n'
11
15
 
12
16
  /** Lens id to the query key OpenRegister reads. */
13
- export const LENS_FILTER_KEYS = { favourite: '_favourite', recent: '_recent', watching: '_watching', unread: '_unread' }
17
+ export const LENS_FILTER_KEYS = { recent: '_recent', watching: '_watching', unread: '_unread' }
18
+
19
+ /** Deprecated lens ids and the lens they now mean. */
20
+ const LENS_ALIASES = { favourite: 'watching' }
14
21
 
15
22
  /**
16
23
  * Quick filters for the requested lenses. Unknown ids are ignored.
17
24
  *
18
- * @param {string[]} lenses Any of `favourite`, `recent`, `watching`, `unread`.
25
+ * @param {string[]} lenses Any of `watching`, `recent`, `unread`, and the deprecated `favourite` (read as `watching`).
19
26
  * @return {Array<{label: string, filter: object, lens: string}>} One tab per lens, in the order asked.
20
27
  */
21
28
  export function personalLensTabs(lenses) {
22
29
  const labels = {
23
- favourite: t('nextcloud-vue', 'Favourites'),
24
30
  recent: t('nextcloud-vue', 'Recent'),
25
31
  watching: t('nextcloud-vue', 'Following'),
26
32
  unread: t('nextcloud-vue', 'Unread'),
27
33
  }
28
34
  return (Array.isArray(lenses) ? lenses : [])
35
+ .map((id) => (Object.hasOwn(LENS_ALIASES, id) ? LENS_ALIASES[id] : id))
29
36
  .filter((id, i, all) => Object.hasOwn(LENS_FILTER_KEYS, id) && all.indexOf(id) === i)
30
37
  .map((id) => ({ label: labels[id], filter: { [LENS_FILTER_KEYS[id]]: true }, lens: id }))
31
38
  }
@@ -2,8 +2,10 @@
2
2
  * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
3
  * SPDX-License-Identifier: EUPL-1.2
4
4
  *
5
- * The per-user interaction calls OpenRegister serves on a record: favourite,
6
- * watch (follow) and the watchers list. Plain functions over `fetch`, shared
5
+ * The per-user interaction calls OpenRegister serves on a record: follow
6
+ * (watch, with its notifications switch), the watchers list, the read state,
7
+ * and the deprecated favourite (a quiet follow since OpenRegister's
8
+ * `merge-follow-and-favourites`). Plain functions over `fetch`, shared
7
9
  * by the interactions store plugin and by the toggles, so a toggle works with
8
10
  * or without the plugin installed.
9
11
  *
@@ -31,13 +33,18 @@ export function interactionUrl(register, schema, id, tail) {
31
33
  /**
32
34
  * Send one interaction call and normalise the answer. Never throws.
33
35
  *
34
- * @param {string} method HTTP method.
35
- * @param {string} url Full URL.
36
+ * @param {string} method HTTP method.
37
+ * @param {string} url Full URL.
38
+ * @param {object|null} [body] JSON body, or null for none.
36
39
  * @return {Promise<{ok: boolean, status: number|null, data: (object|null), message: string}>} The outcome; `message` is the server's, or a network message.
37
40
  */
38
- async function call(method, url) {
41
+ async function call(method, url, body = null) {
39
42
  try {
40
- const response = await fetch(prefixUrl(url), { method, headers: buildHeaders() })
43
+ const init = { method, headers: buildHeaders() }
44
+ if (body !== null) {
45
+ init.body = JSON.stringify(body)
46
+ }
47
+ const response = await fetch(prefixUrl(url), init)
41
48
  if (!response.ok) {
42
49
  const error = await parseResponseError(response, 'record')
43
50
  // The server's own words win over the generic status text.
@@ -61,6 +68,9 @@ async function call(method, url) {
61
68
  /**
62
69
  * Star or unstar a record for the current user.
63
70
  *
71
+ * @deprecated A star is a quiet follow since OpenRegister's
72
+ * `merge-follow-and-favourites`; use `setWatching(..., { notify: false })`.
73
+ *
64
74
  * @param {string} register Register slug.
65
75
  * @param {string} schema Schema slug.
66
76
  * @param {string} id Object id.
@@ -72,16 +82,24 @@ export function setFavourite(register, schema, id, on) {
72
82
  }
73
83
 
74
84
  /**
75
- * Follow or unfollow a record for the current user.
85
+ * Follow or unfollow a record for the current user, optionally setting the
86
+ * follow's notifications switch.
76
87
  *
77
- * @param {string} register Register slug.
78
- * @param {string} schema Schema slug.
79
- * @param {string} id Object id.
80
- * @param {boolean} on True to follow, false to unfollow.
88
+ * `options.notify` is sent only when following and only when it is a boolean:
89
+ * left out, a new follow notifies and an existing follow keeps its setting.
90
+ *
91
+ * @param {string} register Register slug.
92
+ * @param {string} schema Schema slug.
93
+ * @param {string} id Object id.
94
+ * @param {boolean} on True to follow, false to unfollow.
95
+ * @param {object} [options] Options.
96
+ * @param {boolean} [options.notify] The notifications switch.
81
97
  * @return {Promise<object>} The outcome (see `call`).
98
+ * @spec openspec/changes/one-follow-control/specs/record-follow/spec.md#requirement-one-follow-control-with-a-notifications-switch
82
99
  */
83
- export function setWatching(register, schema, id, on) {
84
- return call(on ? 'PUT' : 'DELETE', interactionUrl(register, schema, id, 'watch'))
100
+ export function setWatching(register, schema, id, on, options = {}) {
101
+ const body = on && options && typeof options.notify === 'boolean' ? { notify: options.notify } : null
102
+ return call(on ? 'PUT' : 'DELETE', interactionUrl(register, schema, id, 'watch'), body)
85
103
  }
86
104
 
87
105
  /**
@@ -10,6 +10,13 @@
10
10
  * Supported tokens:
11
11
  * - `@me` → the current Nextcloud user id (`getCurrentUser()` from
12
12
  * `@nextcloud/auth`; `window.OC.currentUser` fallback)
13
+ * - `@myGroups` → the ids of the Nextcloud groups the current user is
14
+ * in, as an ARRAY (an IN filter). `ctx.myGroups` wins;
15
+ * otherwise a reactive peek over `getCurrentUserGroups`.
16
+ * Stays UNRESOLVED while loading and when the user is
17
+ * in no group, so the caller waits instead of sending
18
+ * an empty IN list (which OpenRegister reads as "no
19
+ * constraint"). Spread in place inside an IN-list array.
13
20
  * - `@now` → the current instant, ISO-8601
14
21
  * - `@today` → today at 00:00, `YYYY-MM-DD` (date-prefix comparable)
15
22
  * - `@today±Nd` → relative-date arithmetic at day granularity: N days
@@ -56,6 +63,34 @@
56
63
 
57
64
  import { getCurrentUser } from '@nextcloud/auth'
58
65
  import { TODAY_DELTA_RE } from './sentinelTokens.js'
66
+ import { peekCurrentUserGroups } from './widgetVisibility.js'
67
+
68
+ /** The filter token for the current user's group ids. */
69
+ export const MY_GROUPS_TOKEN = '@myGroups'
70
+
71
+ /**
72
+ * Resolve `@myGroups`: the current user's group ids, or the token itself while
73
+ * they are loading or when there are none (an empty IN list would match every
74
+ * row, so "no team" must mean "wait", never "everything").
75
+ *
76
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/schema-utilities/spec.md#requirement-mygroups-resolves-to-the-current-users-group-ids
77
+ * @param {{myGroups?: string[]}} [ctx] Optional context; `ctx.myGroups` wins.
78
+ * @return {string[]|string} The group ids, or `'@myGroups'` when unresolved.
79
+ */
80
+ function resolveMyGroups(ctx) {
81
+ let groups = ctx && Array.isArray(ctx.myGroups) ? ctx.myGroups : null
82
+ if (groups === null) {
83
+ try {
84
+ groups = peekCurrentUserGroups()
85
+ } catch {
86
+ groups = null
87
+ }
88
+ }
89
+ const ids = Array.isArray(groups)
90
+ ? groups.filter((g) => typeof g === 'string' && g !== '')
91
+ : []
92
+ return ids.length > 0 ? [...ids] : MY_GROUPS_TOKEN
93
+ }
59
94
 
60
95
  /**
61
96
  * Format a Date as `YYYY-MM-DD` (local).
@@ -73,7 +108,7 @@ function ymd(d) {
73
108
  * Resolve a single filter value if it is a dynamic `@`-token, else pass through.
74
109
  *
75
110
  * @param {unknown} v The candidate value.
76
- * @param {{objectId?: (string|number), object?: object, workspace?: object, config?: object, range?: object}} [ctx] Optional
111
+ * @param {{objectId?: (string|number), object?: object, workspace?: object, config?: object, range?: object, myGroups?: string[]}} [ctx] Optional
77
112
  * context for `@objectId` / `@object.<field>` (detail page),
78
113
  * `@workspace.<key>` (page-level workspace state), and `@config.<key>`
79
114
  * (page-level app config) tokens.
@@ -84,6 +119,9 @@ export function resolveFilterValue(v, ctx) {
84
119
  return v
85
120
  }
86
121
  const now = new Date()
122
+ if (v === MY_GROUPS_TOKEN) {
123
+ return resolveMyGroups(ctx)
124
+ }
87
125
  if (v === '@objectId') {
88
126
  return (ctx && ctx.objectId !== undefined && ctx.objectId !== null) ? String(ctx.objectId) : v
89
127
  }
@@ -313,8 +351,13 @@ export function resolveFilterTokens(filter, ctx) {
313
351
  if (!filter || typeof filter !== 'object') {
314
352
  return filter
315
353
  }
354
+ // An item that resolves to a list (`@myGroups`) is spread in place, so
355
+ // `['@myGroups', 'archive']` stays one flat IN list.
316
356
  const resolveOne = (v) => (Array.isArray(v)
317
- ? v.map((item) => resolveFilterValue(item, ctx))
357
+ ? v.flatMap((item) => {
358
+ const r = resolveFilterValue(item, ctx)
359
+ return Array.isArray(r) ? r : [r]
360
+ })
318
361
  : resolveFilterValue(v, ctx))
319
362
  const out = {}
320
363
  for (const [k, v] of Object.entries(filter)) {
@@ -385,7 +385,7 @@ function isUserProp(prop) {
385
385
  * @param {object} prop A schema property definition (or `items` for an array).
386
386
  * @return {boolean} True when the property marks a Nextcloud group.
387
387
  */
388
- function isGroupProp(prop) {
388
+ export function isGroupProp(prop) {
389
389
  if (!prop || typeof prop !== 'object') {
390
390
  return false
391
391
  }
@@ -26,7 +26,7 @@
26
26
  * A token is valid only in the context whose resolver runs on it:
27
27
  *
28
28
  * - **filter** — `resolveFilterTokens`, resolved at fetch time:
29
- * `@me`, `@now`, `@today`, `@today±Nd`, `@monthStart`,
29
+ * `@me`, `@myGroups`, `@now`, `@today`, `@today±Nd`, `@monthStart`,
30
30
  * `@quarterStart`, `@yearStart`.
31
31
  * - **config** — `IAppConfig`-sourced: `@resolve:<key>`, `@config.<key>`
32
32
  * (trailing `?` marks optional).
@@ -67,7 +67,7 @@
67
67
  * @type {Readonly<Record<string, string>>}
68
68
  */
69
69
  export const SENTINEL_TOKEN_PATTERNS = Object.freeze({
70
- filter: '^@(?:me(?:\\.(?:displayName|email))?|now|today|monthStart|quarterStart|yearStart)$|^@today[+-][0-9]+d$',
70
+ filter: '^@(?:me(?:\\.(?:displayName|email))?|myGroups|now|today|monthStart|quarterStart|yearStart)$|^@today[+-][0-9]+d$',
71
71
  config: '^@resolve:[a-z][a-z0-9_-]*$|^@config\\.[A-Za-z][A-Za-z0-9_.]*\\??$',
72
72
  object: '^@objectId$|^@object\\.[A-Za-z][A-Za-z0-9_]*$',
73
73
  workspace: '^@workspace\\.[A-Za-z][A-Za-z0-9_]*\\??$',
@@ -116,8 +116,8 @@ export const SENTINEL_CONTEXTS = Object.freeze([
116
116
  export const SENTINEL_VOCABULARY = Object.freeze({
117
117
  filter: {
118
118
  resolver: 'resolveFilterTokens',
119
- description: 'Relative fetch-time tokens (current user / relative dates).',
120
- members: ['@me', '@me.displayName', '@me.email', '@now', '@today', '@today±Nd', '@monthStart', '@quarterStart', '@yearStart'],
119
+ description: 'Relative fetch-time tokens (current user, the current user\'s groups as an IN list, relative dates).',
120
+ members: ['@me', '@me.displayName', '@me.email', '@myGroups', '@now', '@today', '@today±Nd', '@monthStart', '@quarterStart', '@yearStart'],
121
121
  },
122
122
  config: {
123
123
  resolver: 'resolveManifestSentinels / resolveFilterTokens',