@conduction/nextcloud-vue 2.56.0-beta.46 → 2.56.0-beta.47

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 (121) hide show
  1. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +57 -4
  2. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +44 -17
  4. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  5. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue.js +1 -1
  6. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js +44 -2
  7. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
  8. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +140 -59
  9. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
  10. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +20 -6
  12. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  13. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +3 -2
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +2 -36
  16. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  18. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +5 -3
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  21. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  22. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +6 -4
  23. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  24. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  25. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js +46 -0
  26. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue3.js +6 -2
  28. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue.js +1 -1
  30. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +82 -9
  31. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
  32. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js +104 -90
  33. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
  34. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js +9 -0
  35. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js.map +1 -0
  36. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js +355 -0
  37. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js.map +1 -0
  38. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js +462 -0
  39. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js.map +1 -0
  40. package/dist/esm/components/CnFormDialog/CnFormDialog.vue.js +1 -1
  41. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +21 -0
  42. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  43. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +402 -391
  44. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  45. package/dist/esm/components/CnIcon/semanticIcons.js +2 -2
  46. package/dist/esm/components/CnIcon/semanticIcons.js.map +1 -1
  47. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue.js +1 -1
  48. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js +22 -1
  49. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js.map +1 -1
  50. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js +20 -14
  51. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js.map +1 -1
  52. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue.js +1 -1
  53. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js +2 -0
  54. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js.map +1 -1
  55. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js +15 -5
  56. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js.map +1 -1
  57. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue.js +1 -1
  58. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue2.js +8 -30
  59. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue2.js.map +1 -1
  60. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue3.js.map +1 -1
  61. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js +9 -0
  62. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js.map +1 -0
  63. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js +164 -0
  64. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js.map +1 -0
  65. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js +93 -0
  66. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js.map +1 -0
  67. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue.js +1 -1
  68. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +12 -0
  69. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  70. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  71. package/dist/esm/l10n/en.json.js +24 -1
  72. package/dist/esm/l10n/en.json.js.map +1 -1
  73. package/dist/esm/l10n/nl.json.js +24 -1
  74. package/dist/esm/l10n/nl.json.js.map +1 -1
  75. package/dist/esm/library-style.js +1 -1
  76. package/dist/esm/nextcloud-vue.css +887 -247
  77. package/dist/esm/utils/featureCatalog.js +135 -0
  78. package/dist/esm/utils/featureCatalog.js.map +1 -0
  79. package/dist/esm/utils/fillTemplate.js +74 -3
  80. package/dist/esm/utils/fillTemplate.js.map +1 -1
  81. package/dist/esm/utils/headerMeta.js +62 -12
  82. package/dist/esm/utils/headerMeta.js.map +1 -1
  83. package/dist/esm/utils/roadmapIssues.js +86 -0
  84. package/dist/esm/utils/roadmapIssues.js.map +1 -0
  85. package/dist/esm/utils/userDisplayName.js +47 -0
  86. package/dist/esm/utils/userDisplayName.js.map +1 -0
  87. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  88. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  89. package/dist/nextcloud-vue.cjs.js +14820 -12966
  90. package/dist/nextcloud-vue.cjs.js.map +2 -2
  91. package/dist/nextcloud-vue.css +887 -247
  92. package/l10n/en.json +24 -1
  93. package/l10n/nl.json +24 -1
  94. package/package.json +1 -1
  95. package/src/components/CnAppRoot/CnAppRoot.md +2 -0
  96. package/src/components/CnAppRoot/CnAppRoot.vue +66 -5
  97. package/src/components/CnChoiceCards/CnChoiceCards.md +27 -0
  98. package/src/components/CnChoiceCards/CnChoiceCards.vue +168 -3
  99. package/src/components/CnDashboardPage/CnDashboardPage.vue +45 -6
  100. package/src/components/CnDataTable/CnDataTable.vue +2 -36
  101. package/src/components/CnDetailPage/CnDetailPage.vue +5 -3
  102. package/src/components/CnFactsWidget/CnFactsWidget.vue +6 -4
  103. package/src/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue +51 -1
  104. package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +98 -13
  105. package/src/components/CnFeaturesBoard/CnFeaturesBoard.vue +1053 -0
  106. package/src/components/CnFormDialog/CnFormDialog.vue +606 -562
  107. package/src/components/CnNcWidgetWidget/CnNcWidgetWidget.vue +23 -2
  108. package/src/components/CnNotificationPreferences/CnNotificationPreferences.vue +5 -0
  109. package/src/components/CnRoadmapTab/CnRoadmapTab.vue +6 -28
  110. package/src/components/CnUserSettingsDialog/CnUserSettingsDialog.vue +305 -0
  111. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +12 -0
  112. package/src/css/dialog.css +47 -0
  113. package/src/css/look-board-detail.css +8 -0
  114. package/src/css/look-board.css +33 -0
  115. package/src/schemas/app-manifest-v2.schema.json +9 -3
  116. package/src/utils/featureCatalog.js +132 -0
  117. package/src/utils/fillTemplate.js +73 -2
  118. package/src/utils/headerMeta.js +62 -12
  119. package/src/utils/roadmapIssues.js +82 -0
  120. package/src/utils/userDisplayName.js +54 -0
  121. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -0,0 +1,132 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * featureCatalog: the feature list of the Features and roadmap page, read the
6
+ * way the board look draws it. A feature carries a status (available, beta,
7
+ * in progress or planned), an optional category and optional per-language
8
+ * titles (`title_nl`, `summary_nl`) next to the English ones.
9
+ *
10
+ * @module utils/featureCatalog
11
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md
12
+ */
13
+
14
+ /** The statuses, in the order the filter and the captions list them. */
15
+ export const FEATURE_STATUSES = Object.freeze(['available', 'beta', 'in_progress', 'planned'])
16
+
17
+ /** The badge variant of each status. */
18
+ export const FEATURE_STATUS_VARIANTS = Object.freeze({
19
+ available: 'success',
20
+ beta: 'warning',
21
+ in_progress: 'primary',
22
+ planned: 'purple',
23
+ })
24
+
25
+ const STATUS_ALIASES = Object.freeze({
26
+ available: 'available',
27
+ stable: 'available',
28
+ done: 'available',
29
+ released: 'available',
30
+ shipped: 'available',
31
+ beta: 'beta',
32
+ preview: 'beta',
33
+ in_progress: 'in_progress',
34
+ 'in-progress': 'in_progress',
35
+ inprogress: 'in_progress',
36
+ building: 'in_progress',
37
+ development: 'in_progress',
38
+ planned: 'planned',
39
+ roadmap: 'planned',
40
+ proposed: 'planned',
41
+ soon: 'planned',
42
+ })
43
+
44
+ /**
45
+ * The status key of a feature. A feature without a status is one the app
46
+ * ships, as every entry of the list was before statuses existed.
47
+ *
48
+ * @param {unknown} raw The feature's `status`.
49
+ * @return {string} One of FEATURE_STATUSES.
50
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-a-feature-has-one-of-four-statuses
51
+ */
52
+ export function featureStatus(raw) {
53
+ if (typeof raw !== 'string' || raw.trim() === '') {
54
+ return 'available'
55
+ }
56
+ return STATUS_ALIASES[raw.trim().toLowerCase()] || 'available'
57
+ }
58
+
59
+ /**
60
+ * A text field of a feature in the reader's language: `title_nl` for a Dutch
61
+ * reader when the feature has one, else `title`.
62
+ *
63
+ * @param {object} feature The feature.
64
+ * @param {string} field `title` or `summary`.
65
+ * @param {string} [language] The reader's language tag (`nl`, `nl_NL`, `de-DE`).
66
+ * @return {string} The text, or ''.
67
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-a-feature-reads-in-the-readers-language
68
+ */
69
+ export function featureText(feature, field, language = '') {
70
+ if (!feature || typeof feature !== 'object') {
71
+ return ''
72
+ }
73
+ const base = String(language || '').split(/[-_]/)[0].toLowerCase()
74
+ const own = base ? feature[`${field}_${base}`] : undefined
75
+ if (typeof own === 'string' && own.trim() !== '') {
76
+ return own
77
+ }
78
+ return typeof feature[field] === 'string' ? feature[field] : ''
79
+ }
80
+
81
+ /**
82
+ * How many features carry each status.
83
+ *
84
+ * @param {Array<{status: string}>} features Features with a normalised status.
85
+ * @return {{all: number, available: number, beta: number, in_progress: number, planned: number}} The counts.
86
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-the-status-filter-shows-a-count-per-status
87
+ */
88
+ export function countByStatus(features) {
89
+ const counts = { all: features.length, available: 0, beta: 0, in_progress: 0, planned: 0 }
90
+ for (const feature of features) {
91
+ counts[feature.status] += 1
92
+ }
93
+ return counts
94
+ }
95
+
96
+ /**
97
+ * The features in the reader's language, grouped by category. Groups follow
98
+ * the order of `categories`; features without a known category come first,
99
+ * under no caption. Inside a group the features keep their given order.
100
+ *
101
+ * @param {Array<object>} features The raw features.
102
+ * @param {object} [categories] Category key to caption.
103
+ * @param {string} [language] The reader's language tag.
104
+ * @return {Array<{key: string, label: string, features: Array<object>}>} The groups; a group without features is left out.
105
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-features-are-grouped-by-category
106
+ */
107
+ export function groupFeatures(features, categories = {}, language = '') {
108
+ const captions = categories && typeof categories === 'object' && !Array.isArray(categories) ? categories : {}
109
+ const groups = [{ key: '', label: '', features: [] }]
110
+ const byKey = { '': groups[0] }
111
+ for (const [key, label] of Object.entries(captions)) {
112
+ const group = { key, label: String(label), features: [] }
113
+ groups.push(group)
114
+ byKey[key] = group
115
+ }
116
+ for (const raw of Array.isArray(features) ? features : []) {
117
+ if (!raw || typeof raw !== 'object') {
118
+ continue
119
+ }
120
+ const feature = {
121
+ slug: String(raw.slug || raw.id || ''),
122
+ title: featureText(raw, 'title', language) || String(raw.slug || ''),
123
+ summary: featureText(raw, 'summary', language),
124
+ status: featureStatus(raw.status),
125
+ expected: typeof raw.expected === 'string' ? raw.expected : '',
126
+ docsUrl: typeof raw.docsUrl === 'string' ? raw.docsUrl : '',
127
+ }
128
+ const group = byKey[typeof raw.category === 'string' ? raw.category : ''] || byKey['']
129
+ group.features.push(feature)
130
+ }
131
+ return groups.filter((group) => group.features.length > 0)
132
+ }
@@ -5,6 +5,7 @@ import { boardDateLocale, formatBoardDate, formatBoardWeekday, formatRelativeDat
5
5
  import { parseDateValue } from './dateVariant.js'
6
6
  import { formatMoney } from './formatMetric.js'
7
7
  import { readPath } from './readPath.js'
8
+ import { userDisplayName } from './userDisplayName.js'
8
9
 
9
10
  const TOKEN = /\{([^{}\s|]+)(?:\|([a-z]+)(?::([^{}\s]+))?)?\}/g
10
11
 
@@ -32,11 +33,37 @@ export function formatBoardDateTime(value, now = new Date()) {
32
33
  return `${day}, ${time}`
33
34
  }
34
35
 
36
+ /**
37
+ * A date with the month written out: "7 oktober", or "6 oktober 2026"
38
+ * outside the current year or when `withYear` asks for it.
39
+ *
40
+ * @param {unknown} value A Date, a timestamp or an ISO string.
41
+ * @param {Date} now The reference moment.
42
+ * @param {boolean} [withYear] Always write the year.
43
+ * @return {string} The text, or '' when the value is not a date.
44
+ * @spec openspec/changes/screens-header-meta-tokens/specs/detail-page-board-look/spec.md#requirement-the-header-meta-line-formats-users-long-dates-and-enum-labels
45
+ */
46
+ export function formatLongDate(value, now = new Date(), withYear = false) {
47
+ const date = parseDateValue(value)
48
+ if (date === null) {
49
+ return ''
50
+ }
51
+ const options = { day: 'numeric', month: 'long' }
52
+ if (withYear || date.getFullYear() !== now.getFullYear()) {
53
+ options.year = 'numeric'
54
+ }
55
+ try {
56
+ return new Intl.DateTimeFormat(boardDateLocale(), options).format(date)
57
+ } catch {
58
+ return new Intl.DateTimeFormat(undefined, options).format(date)
59
+ }
60
+ }
61
+
35
62
  /**
36
63
  * Format one token value.
37
64
  *
38
65
  * @param {unknown} value The raw value.
39
- * @param {string} format `date`, `datetime`, `weekday`, `time`, `relative`, `relativetime`, `days` or '' (as is).
66
+ * @param {string} format `date`, `datetime`, `weekday`, `longdate`, `time`, `user`, `currency`, `relative`, `relativetime`, `days` or '' (as is).
40
67
  * @param {Date} now The reference moment.
41
68
  * @param {string} [arg] The format's argument (`currency:EUR`, or a row path holding the code).
42
69
  * @param {object|null} [object] The row, for a currency read from a field.
@@ -56,6 +83,12 @@ function formatValue(value, format, now, arg = '', object = null, config = null)
56
83
  if (format === 'weekday') {
57
84
  return formatBoardWeekday(value, now, { today: arg === 'today' })
58
85
  }
86
+ if (format === 'longdate') {
87
+ return formatLongDate(value, now, arg === 'year')
88
+ }
89
+ if (format === 'user') {
90
+ return userDisplayName(value)
91
+ }
59
92
  if (format === 'time') {
60
93
  const date = parseDateValue(value)
61
94
  return date === null ? '' : formatTimeOfDay(date)
@@ -88,7 +121,10 @@ function formatValue(value, format, now, arg = '', object = null, config = null)
88
121
  * Every `{path}` token is a dot path into the object; `{path|date}` and
89
122
  * `{path|datetime}` format it as the boards write dates, `{path|weekday}` adds
90
123
  * the short weekday ("wo 7 okt"; `{path|weekday:today}` reads "Vandaag, wo 7 okt"
91
- * for today), `{path|time}` is the time of day ("08:45"), `{path|currency}`,
124
+ * for today), `{path|longdate}` writes the month out ("7 oktober";
125
+ * `{path|longdate:year}` always adds the year), `{path|time}` is the time
126
+ * of day ("08:45"), `{path|user}` is a user id's display name (looked up
127
+ * once, '' until it arrives), `{path|currency}`,
92
128
  * `{path|currency:EUR}` and `{path|currency:field}` write an amount in a
93
129
  * currency (a code, or the row field that holds it; without either, or when
94
130
  * the row field is empty, the app's reporting currency), `{path|relative}` and
@@ -147,3 +183,38 @@ export function firstFilled(templates, object, translate = (key) => key, now = n
147
183
  }
148
184
  return ''
149
185
  }
186
+
187
+ /**
188
+ * Fill a template such as `"{identifier} · {requester}"` field by field
189
+ * (a table column's `secondary` line, a detail page's meta line).
190
+ *
191
+ * A field without a value takes its separator with it: the text between two
192
+ * fields belongs to the field after it, so an empty requester leaves
193
+ * "2026-0002", not "2026-0002 ·", and an empty first field does not leave a
194
+ * leading separator either. Text before the first field and after the last
195
+ * one is kept as written. A template whose fields all have values fills
196
+ * exactly as before.
197
+ *
198
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-an-empty-field-takes-its-separator-with-it
199
+ * @param {string} template The template, with `{field}` placeholders.
200
+ * @param {(key: string) => string} valueOf The text of a field ('' when empty).
201
+ * @return {string} The filled template.
202
+ */
203
+ export function joinTemplateParts(template, valueOf) {
204
+ // split() with a capture group alternates literal, field, literal, …
205
+ const parts = template.split(/\{([^}]+)\}/)
206
+ const lead = parts[0]
207
+ const tail = parts[parts.length - 1]
208
+ let body = ''
209
+ for (let i = 1; i < parts.length; i += 2) {
210
+ const value = valueOf(parts[i].trim())
211
+ if (value === '') {
212
+ continue
213
+ }
214
+ // The literal before this field: the separator, unless nothing has
215
+ // been written yet (then there is nothing to separate from).
216
+ const separator = i === 1 ? '' : parts[i - 1]
217
+ body += (body === '' ? '' : separator) + value
218
+ }
219
+ return lead + body + tail
220
+ }
@@ -1,27 +1,77 @@
1
1
  // SPDX-FileCopyrightText: 2026 Conduction B.V.
2
2
  // SPDX-License-Identifier: EUPL-1.2
3
3
 
4
- import { fillTemplate } from './fillTemplate.js'
4
+ import { fillTemplate, joinTemplateParts } from './fillTemplate.js'
5
+ import { readPath } from './readPath.js'
6
+
7
+ /**
8
+ * The text of one plain `{path}` token: an enum field reads as its label
9
+ * (`x-enum-labels` / `enumLabels` on the schema property, through the
10
+ * translate function), anything else as the stored value.
11
+ *
12
+ * @param {object} object The record.
13
+ * @param {string} path The dotted path.
14
+ * @param {object|null} schema The record's schema.
15
+ * @param {(text: string) => string} translate The translate function.
16
+ * @return {string} The text, or '' when the field is empty.
17
+ */
18
+ function plainTokenText(object, path, schema, translate) {
19
+ const value = readPath(object, path)
20
+ if (value === null || value === undefined || value === '' || typeof value === 'object') {
21
+ return ''
22
+ }
23
+ const property = schema && schema.properties && !path.includes('.') ? schema.properties[path] : null
24
+ const labels = property && (property.enumLabels || property['x-enum-labels'])
25
+ if (labels && typeof labels === 'object' && typeof labels[String(value)] === 'string') {
26
+ return translate(labels[String(value)])
27
+ }
28
+ return String(value)
29
+ }
5
30
 
6
31
  /**
7
32
  * Fill a header meta template such as `"via {channel}"` or
8
- * `"received {createdAt|relative}"` from a record.
33
+ * `"{amount|currency:EUR} · {cashier|user} · {at|longdate} {at|time}"` from
34
+ * a record.
9
35
  *
10
36
  * Every `{path}` token (a dotted path into the record) is replaced by its
11
- * value; `{path|date}`, `{path|datetime}`, `{path|relative}`,
12
- * `{path|relativetime}` and `{path|days}` format a date as a record-list
13
- * row does (the shared `fillTemplate`). A token with no value drops the
14
- * WHOLE line, because "via " is worse than nothing. A template without
15
- * tokens is returned as written.
37
+ * value, an enum field by its label. `{path|format}` tokens format as a
38
+ * record-list row does (the shared `fillTemplate`: date, datetime, weekday,
39
+ * longdate, time, user, currency, relative, relativetime, days). A token
40
+ * without a value drops itself and the separator before it, so a missing
41
+ * cashier leaves "€ 96,41 · 7 oktober"; a line whose every token is empty
42
+ * is '' ("via " is worse than nothing). A template without tokens is
43
+ * returned as written.
16
44
  *
17
45
  * @param {string} template The template, already translated.
18
46
  * @param {object|null} object The record.
19
- * @param {Date} [now] The reference moment for dates.
20
- * @return {string} The line, or '' when it cannot be filled.
21
- * @spec openspec/changes/screens-detail-page-parity/specs/detail-page-board-look/spec.md#requirement-the-detail-header-is-two-rows-on-the-ground
47
+ * @param {Date|{now?: Date, schema?: object, translate?: Function, config?: object}} [options] The reference moment, or options: `schema` for enum labels, `translate` for those labels, `config` for the reporting currency.
48
+ * @return {string} The line, or '' when nothing can be filled.
49
+ * @spec openspec/changes/screens-header-meta-tokens/specs/detail-page-board-look/spec.md#requirement-the-header-meta-line-formats-users-long-dates-and-enum-labels
22
50
  */
23
- export function resolveHeaderMeta(template, object, now = new Date()) {
24
- return fillTemplate(template, object, now)
51
+ export function resolveHeaderMeta(template, object, options = {}) {
52
+ const settings = options instanceof Date ? { now: options } : (options || {})
53
+ const now = settings.now || new Date()
54
+ const translate = typeof settings.translate === 'function' ? settings.translate : (text) => text
55
+ if (typeof template !== 'string' || template.trim() === '') {
56
+ return ''
57
+ }
58
+ if (!/\{[^}]+\}/.test(template)) {
59
+ return template.trim()
60
+ }
61
+ if (!object || typeof object !== 'object') {
62
+ return ''
63
+ }
64
+ let filled = 0
65
+ const line = joinTemplateParts(template, (token) => {
66
+ const text = token.includes('|')
67
+ ? fillTemplate(`{${token}}`, object, now, settings.config ? { config: settings.config } : null)
68
+ : plainTokenText(object, token, settings.schema || null, translate)
69
+ if (text !== '') {
70
+ filled += 1
71
+ }
72
+ return text
73
+ })
74
+ return filled === 0 ? '' : line.trim()
25
75
  }
26
76
 
27
77
  /**
@@ -0,0 +1,82 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * roadmapIssues: the forge issues a roadmap shows, fetched through
6
+ * OpenRegister's `github-issue-proxy` with the curated label filter
7
+ * (`enhancement,feature`, per openregister design D23), most-reacted first.
8
+ *
9
+ * @module utils/roadmapIssues
10
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-the-next-card-lists-the-most-requested-issues
11
+ */
12
+
13
+ import axios from '@nextcloud/axios'
14
+ import { generateUrl } from '@nextcloud/router'
15
+
16
+ /**
17
+ * Fetch the roadmap issues of a repository.
18
+ *
19
+ * `degraded` names why there are none: `pat_not_configured` (OpenRegister has
20
+ * no GitHub token), `rate_limited` (HTTP 429), `error` (anything else), or
21
+ * null when the fetch worked.
22
+ *
23
+ * @param {string} repo `<owner>/<repo>` on the forge.
24
+ * @return {Promise<{items: Array<object>, degraded: (string|null)}>} The issues, most-reacted first.
25
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-the-next-card-lists-the-most-requested-issues
26
+ */
27
+ export async function fetchRoadmapIssues(repo) {
28
+ try {
29
+ const response = await axios.get(generateUrl('/apps/openregister/api/github/issues'), {
30
+ params: { repo, labels: 'enhancement,feature' },
31
+ })
32
+ const data = response.data || {}
33
+ if (data.hint === 'github_pat_not_configured') {
34
+ return { items: [], degraded: 'pat_not_configured' }
35
+ }
36
+ return { items: sortByReactions(Array.isArray(data.items) ? data.items : []), degraded: null }
37
+ } catch (e) {
38
+ const status = e?.response?.status
39
+ const code = e?.response?.data?.error
40
+ if (status === 429 || code === 'github_rate_limited' || code === 'rate_limited') {
41
+ return { items: [], degraded: 'rate_limited' }
42
+ }
43
+ return { items: [], degraded: 'error' }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Issues ordered by their reaction count, most first.
49
+ *
50
+ * @param {Array<object>} items The issues.
51
+ * @return {Array<object>} A sorted copy.
52
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-the-next-card-lists-the-most-requested-issues
53
+ */
54
+ export function sortByReactions(items) {
55
+ return [...items].sort((a, b) => (b.reactions?.total_count || 0) - (a.reactions?.total_count || 0))
56
+ }
57
+
58
+ /**
59
+ * The roadmap status of an issue from its labels: the first label that
60
+ * `labelStatuses` maps (case-insensitive) decides, else ''.
61
+ *
62
+ * @param {object} issue The issue, with `labels: [{name}]` or `labels: ['name']`.
63
+ * @param {object} labelStatuses Label name to status (`in_progress`, `planned`).
64
+ * @return {string} The status, or '' for no badge.
65
+ * @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-the-next-card-lists-the-most-requested-issues
66
+ */
67
+ export function issueStatus(issue, labelStatuses) {
68
+ if (!labelStatuses || typeof labelStatuses !== 'object') {
69
+ return ''
70
+ }
71
+ const map = {}
72
+ for (const [label, status] of Object.entries(labelStatuses)) {
73
+ map[label.toLowerCase()] = status
74
+ }
75
+ for (const label of Array.isArray(issue?.labels) ? issue.labels : []) {
76
+ const name = String(typeof label === 'string' ? label : label?.name || '').toLowerCase()
77
+ if (map[name]) {
78
+ return map[name]
79
+ }
80
+ }
81
+ return ''
82
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * userDisplayName: a user id's display name for synchronous text (a template
6
+ * token), looked up once through `resolveNextcloudUser` and kept in a
7
+ * reactive cache, so a computed that read it renders again when the name
8
+ * arrives.
9
+ *
10
+ * @module utils/userDisplayName
11
+ * @spec openspec/changes/screens-header-meta-tokens/specs/detail-page-board-look/spec.md#requirement-the-header-meta-line-formats-users-long-dates-and-enum-labels
12
+ */
13
+
14
+ import { reactive } from 'vue'
15
+ import { resolveNextcloudUser } from './userAutocomplete.js'
16
+
17
+ const names = reactive({})
18
+
19
+ /**
20
+ * The display name of a user id: the cached name, or '' while it is being
21
+ * looked up (the first call starts the lookup). A user that cannot be
22
+ * resolved reads as its id.
23
+ *
24
+ * @param {unknown} uid The user id.
25
+ * @return {string} The display name, or '' while loading.
26
+ * @spec openspec/changes/screens-header-meta-tokens/specs/detail-page-board-look/spec.md#requirement-the-header-meta-line-formats-users-long-dates-and-enum-labels
27
+ */
28
+ export function userDisplayName(uid) {
29
+ if (uid === null || uid === undefined || uid === '' || typeof uid === 'object') {
30
+ return ''
31
+ }
32
+ const id = String(uid)
33
+ if (typeof names[id] === 'string') {
34
+ return names[id]
35
+ }
36
+ if (names[id] === undefined) {
37
+ names[id] = null
38
+ resolveNextcloudUser(id)
39
+ .then((option) => { names[id] = option && option.label ? String(option.label) : id })
40
+ .catch(() => { names[id] = id })
41
+ }
42
+ return ''
43
+ }
44
+
45
+ /**
46
+ * Forget every cached name (tests).
47
+ *
48
+ * @return {void}
49
+ */
50
+ export function clearUserDisplayNames() {
51
+ for (const key of Object.keys(names)) {
52
+ delete names[key]
53
+ }
54
+ }