@conduction/nextcloud-vue 2.90.0 → 2.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +10 -5
  2. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  3. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  4. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
  5. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +15 -1
  6. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  7. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +1 -1
  8. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  9. package/dist/esm/components/CnDialog/CnDialog.vue2.js +1 -1
  10. package/dist/esm/components/CnDialog/CnDialog.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDialog/CnDialog.vue3.js.map +1 -1
  12. package/dist/esm/components/CnDialogHeader/CnDialogHeader.vue2.js +1 -1
  13. package/dist/esm/components/CnDialogHeader/CnDialogHeader.vue2.js.map +1 -1
  14. package/dist/esm/components/CnDialogHeader/CnDialogHeader.vue3.js.map +1 -1
  15. package/dist/esm/components/CnFormDialog/CnFormDialog.vue.js +1 -1
  16. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +18 -10
  17. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  18. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +16 -8
  19. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  20. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue.js +1 -1
  21. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js +16 -1
  22. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js.map +1 -1
  23. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js +1 -1
  24. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js.map +1 -1
  25. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue.js +1 -1
  26. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js +156 -3
  27. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js.map +1 -1
  28. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js +168 -52
  29. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js.map +1 -1
  30. package/dist/esm/components/CnModuleCard/CnModuleCard.vue.js +9 -0
  31. package/dist/esm/components/CnModuleCard/CnModuleCard.vue.js.map +1 -0
  32. package/dist/esm/components/CnModuleCard/CnModuleCard.vue2.js +83 -0
  33. package/dist/esm/components/CnModuleCard/CnModuleCard.vue2.js.map +1 -0
  34. package/dist/esm/components/CnModuleCard/CnModuleCard.vue3.js +119 -0
  35. package/dist/esm/components/CnModuleCard/CnModuleCard.vue3.js.map +1 -0
  36. package/dist/esm/components/CnObjectSidebar/CnObjectSidebar.vue.js +1 -1
  37. package/dist/esm/components/CnObjectSidebar/CnObjectSidebar.vue2.js +6 -3
  38. package/dist/esm/components/CnObjectSidebar/CnObjectSidebar.vue2.js.map +1 -1
  39. package/dist/esm/components/CnObjectSidebar/CnObjectSidebar.vue3.js.map +1 -1
  40. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  41. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +33 -2
  42. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  43. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  44. package/dist/esm/components/CnTextWidget/CnTextWidget.vue.js +1 -1
  45. package/dist/esm/components/CnTextWidget/CnTextWidget.vue2.js +63 -4
  46. package/dist/esm/components/CnTextWidget/CnTextWidget.vue2.js.map +1 -1
  47. package/dist/esm/components/CnTextWidget/CnTextWidget.vue3.js +19 -2
  48. package/dist/esm/components/CnTextWidget/CnTextWidget.vue3.js.map +1 -1
  49. package/dist/esm/index.js +2 -0
  50. package/dist/esm/index.js.map +1 -1
  51. package/dist/esm/l10n/en.json.js +22 -1
  52. package/dist/esm/l10n/en.json.js.map +1 -1
  53. package/dist/esm/l10n/nl.json.js +21 -1
  54. package/dist/esm/l10n/nl.json.js.map +1 -1
  55. package/dist/esm/library-style.js +1 -1
  56. package/dist/esm/nextcloud-vue.css +328 -114
  57. package/dist/esm/utils/boardDate.js +35 -1
  58. package/dist/esm/utils/boardDate.js.map +1 -1
  59. package/dist/esm/utils/fillTemplate.js +12 -3
  60. package/dist/esm/utils/fillTemplate.js.map +1 -1
  61. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  62. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  63. package/dist/nextcloud-vue.cjs.js +11569 -10871
  64. package/dist/nextcloud-vue.cjs.js.map +2 -2
  65. package/dist/nextcloud-vue.css +328 -114
  66. package/l10n/en.json +22 -1
  67. package/l10n/nl.json +21 -1
  68. package/package.json +1 -1
  69. package/src/components/CnDataTable/CnDataTable.vue +10 -5
  70. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +16 -2
  71. package/src/components/CnDialog/CnDialog.vue +1 -1
  72. package/src/components/CnDialog/index.js +7 -0
  73. package/src/components/CnDialogHeader/CnDialogHeader.vue +1 -1
  74. package/src/components/CnDialogHeader/index.js +7 -0
  75. package/src/components/CnFormDialog/CnFormDialog.md +1 -0
  76. package/src/components/CnFormDialog/CnFormDialog.vue +13 -0
  77. package/src/components/CnIndexPage/CnIndexPageWidgets.vue +17 -2
  78. package/src/components/CnLinkCardsPage/CnLinkCardsPage.vue +338 -5
  79. package/src/components/CnModuleCard/CnModuleCard.vue +258 -0
  80. package/src/components/CnObjectSidebar/CnObjectSidebar.vue +6 -3
  81. package/src/components/CnTabsWidget/CnTabsWidget.vue +33 -2
  82. package/src/components/CnTextWidget/CnTextWidget.vue +89 -4
  83. package/src/components/index.js +2 -0
  84. package/src/index.js +2 -0
  85. package/src/schemas/app-manifest-v2.schema.json +59 -2
  86. package/src/utils/boardDate.js +34 -0
  87. package/src/utils/fillTemplate.js +12 -3
  88. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -187,7 +187,7 @@ import { resolveTabCount } from '../../utils/detailActionModel.js'
187
187
  import { fetchListTotal } from '../../utils/fetchFilterCounts.js'
188
188
  import { isActivityWidget } from '../../utils/headerMeta.js'
189
189
  import { PANEL_ACTION_SINK } from '../../utils/panelActions.js'
190
- import { evaluateVisibleWhen, evaluateVisibleWhenLocal, isLocallyDecidableVisibleWhen } from '../../utils/visibleWhen.js'
190
+ import { evaluateVisibleWhen, evaluateVisibleWhenLocal, evaluateWorkspaceCondition, isLocallyDecidableVisibleWhen, isWorkspaceCondition } from '../../utils/visibleWhen.js'
191
191
  import { widgetTitleOf } from '../../utils/widgetDispatch.js'
192
192
  import { CnActionsMenu } from '../CnActionsMenu/index.js'
193
193
 
@@ -331,6 +331,13 @@ export default {
331
331
  * `visibleWhen` reads them as `@context.<name>`. Null on any other page.
332
332
  */
333
333
  cnDetailVisibilityRows: { default: null },
334
+ /**
335
+ * The page's workspace bag (CnDashboardPage provides it), which a
336
+ * page control such as a chips filter writes. A widget in a stack is
337
+ * left out while its workspace `visibleWhen` does not hold. Null off a
338
+ * dashboard.
339
+ */
340
+ cnWorkspaceContext: { default: null },
334
341
  /**
335
342
  * The host's translate (CnAppRoot provides `cnTranslate`), so a tab
336
343
  * label written in the manifest reads in the user's language.
@@ -588,7 +595,8 @@ export default {
588
595
  // widget is skipped. The first that resolves is the panel's.
589
596
  const ids = this.tabWidgetIds(tab)
590
597
  const widgets = ids.length > 1
591
- ? ids.map((id) => this.availableWidgets.find((w) => w && w.id === id) || null).filter(Boolean)
598
+ ? ids.map((id) => this.availableWidgets.find((w) => w && w.id === id) || null)
599
+ .filter((w) => w && !this.hiddenByWorkspace(w))
592
600
  : []
593
601
  const widget = ids.length > 1
594
602
  ? (widgets[0] || null)
@@ -837,6 +845,29 @@ export default {
837
845
  },
838
846
 
839
847
  methods: {
848
+ /**
849
+ * Whether a page control hides a widget of a stack right now: its
850
+ * `visibleWhen` (on the widget, its content or its props) is a
851
+ * workspace condition that does not hold. Off a dashboard there is no
852
+ * workspace, and nothing is hidden. Other conditions are not looked at
853
+ * here.
854
+ *
855
+ * @param {object} widget The widget definition.
856
+ * @return {boolean} True when the widget must not draw.
857
+ * @spec openspec/changes/tabs-stack-follows-page-controls/specs/tabs-widget/spec.md#requirement-a-widget-in-a-tab-stack-follows-the-page-controls
858
+ */
859
+ hiddenByWorkspace(widget) {
860
+ const cond = widget.visibleWhen || widget.content?.visibleWhen || widget.props?.visibleWhen || null
861
+ if (!isWorkspaceCondition(cond)) {
862
+ return false
863
+ }
864
+ const bag = this.cnWorkspaceContext
865
+ if (!bag || typeof bag !== 'object') {
866
+ return false
867
+ }
868
+ return !evaluateWorkspaceCondition(cond, 'value' in bag ? bag.value : bag)
869
+ },
870
+
840
871
  /**
841
872
  * The grid a stack lays its widgets out in, as one column span (of 12)
842
873
  * per widget in order, or null for the plain vertical stack. A tab says
@@ -60,6 +60,17 @@
60
60
  :style="contentStyle">
61
61
  {{ placeholderText }}
62
62
  </span>
63
+ <a
64
+ v-if="link"
65
+ class="cn-text-widget__link"
66
+ data-testid="cn-text-widget-link"
67
+ :href="link.url"
68
+ :target="link.external ? '_blank' : null"
69
+ :rel="link.external ? 'noopener noreferrer' : null"
70
+ @click="openLink">
71
+ {{ link.label }}
72
+ <ChevronRight :size="16" />
73
+ </a>
63
74
  </div>
64
75
  </template>
65
76
 
@@ -67,6 +78,8 @@
67
78
  import { translate as t } from '@nextcloud/l10n'
68
79
  import DOMPurify from 'dompurify'
69
80
  import { Marked } from 'marked'
81
+ import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
82
+ import { followLinkClick, resolveHref } from '../../utils/linkNavigation.js'
70
83
  import { isPlaceholderCell } from '../../utils/textTable.js'
71
84
 
72
85
  // Per-module `Marked` instance so configuration is scoped here and does NOT
@@ -120,13 +133,23 @@ function ensureDomPurifyHook() {
120
133
  export default {
121
134
  name: 'CnTextWidget',
122
135
 
136
+ components: { ChevronRight },
137
+
138
+ inject: {
139
+ /** The app's translate function, provided by CnAppRoot. Identity outside one. */
140
+ cnTranslate: { default: () => (key) => key },
141
+ },
142
+
123
143
  props: {
124
144
  /**
125
145
  * Persisted widget content blob: `{text, fontSize, color,
126
- * backgroundColor, textAlign, contentMode, tableMode, tableData}`.
146
+ * backgroundColor, textAlign, contentMode, tableMode, tableData, link}`.
127
147
  * All keys are optional; missing keys collapse to documented defaults.
148
+ * `text` and `link.label` go through the app's translate function.
149
+ * `link` is `{label, route}` (a page id, or a path starting with `/`) or
150
+ * `{label, href}` (an external URL), drawn under the text.
128
151
  *
129
- * @type {{text: string, fontSize: string, color: string, backgroundColor: string, textAlign: string, contentMode: string, tableMode: boolean, tableData: object}}
152
+ * @type {{text: string, fontSize: string, color: string, backgroundColor: string, textAlign: string, contentMode: string, tableMode: boolean, tableData: object, link: {label: string, route: string, href: string}}}
130
153
  */
131
154
  content: {
132
155
  type: Object,
@@ -135,9 +158,34 @@ export default {
135
158
  },
136
159
 
137
160
  computed: {
138
- /** The author text, or '' when absent / non-string. */
161
+ /** The author text through the app's translate function, or '' when absent / non-string. */
139
162
  text() {
140
- return typeof this.content?.text === 'string' ? this.content.text : ''
163
+ return typeof this.content?.text === 'string' ? this.tr(this.content.text) : ''
164
+ },
165
+
166
+ /**
167
+ * The link under the text, from `content.link`: `{ label, route }`
168
+ * (a page id, or a router path when it starts with `/`) or
169
+ * `{ label, href }` (an external URL, opened in a new tab). The label
170
+ * goes through the app's translate function. Null without a label or
171
+ * a target.
172
+ *
173
+ * @return {{label: string, url: string, location: (object|null), external: boolean}|null} The link.
174
+ */
175
+ link() {
176
+ const link = this.content?.link
177
+ if (!link || typeof link !== 'object' || typeof link.label !== 'string' || link.label === '') {
178
+ return null
179
+ }
180
+ if (typeof link.route === 'string' && link.route !== '') {
181
+ const location = link.route.startsWith('/') ? { path: link.route } : { name: link.route }
182
+ const url = resolveHref(location, this.$router)
183
+ return url ? { label: this.tr(link.label), url, location, external: false } : null
184
+ }
185
+ if (typeof link.href === 'string' && link.href !== '') {
186
+ return { label: this.tr(link.label), url: link.href, location: null, external: true }
187
+ }
188
+ return null
141
189
  },
142
190
 
143
191
  /** Whether the text has any non-whitespace content. */
@@ -253,6 +301,7 @@ export default {
253
301
  'justify-content': 'center',
254
302
  overflow: 'auto',
255
303
  'background-color': this.backgroundColor,
304
+ ...(this.link ? { 'flex-direction': 'column', gap: '8px', 'align-items': 'flex-start' } : {}),
256
305
  }
257
306
  },
258
307
 
@@ -287,6 +336,27 @@ export default {
287
336
  },
288
337
 
289
338
  methods: {
339
+ /**
340
+ * A manifest string through the app's translate function.
341
+ *
342
+ * @param {string} text The string.
343
+ * @return {string} The translated string.
344
+ */
345
+ tr(text) {
346
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
347
+ },
348
+
349
+ /**
350
+ * Route a plain click on an in-app link, so the page does not reload.
351
+ *
352
+ * @param {MouseEvent} event The click.
353
+ */
354
+ openLink(event) {
355
+ if (this.link && !this.link.external) {
356
+ followLinkClick(event, this.link.location, this.$router)
357
+ }
358
+ },
359
+
290
360
  /**
291
361
  * Run cell text through DOMPurify so the table-mode render path has
292
362
  * the same XSS-stripping guarantees as the text-mode path.
@@ -518,4 +588,19 @@ export default {
518
588
  color: var(--color-text-maxcontrast);
519
589
  font-style: italic;
520
590
  }
591
+
592
+ .cn-text-widget__link {
593
+ display: inline-flex;
594
+ align-items: center;
595
+ gap: 2px;
596
+ font-size: 14px;
597
+ font-weight: 600;
598
+ color: var(--color-primary-element);
599
+ text-decoration: none;
600
+ }
601
+
602
+ .cn-text-widget__link:hover,
603
+ .cn-text-widget__link:focus-visible {
604
+ text-decoration: underline;
605
+ }
521
606
  </style>
@@ -43,6 +43,8 @@ export { CnDeleteDialog } from './CnDeleteDialog/index.js'
43
43
  export { CnCopyDialog } from './CnCopyDialog/index.js'
44
44
  export { CnFieldHelper } from './CnFieldHelper/index.js'
45
45
  export { CnFormDialog } from './CnFormDialog/index.js'
46
+ export { CnDialog } from './CnDialog/index.js'
47
+ export { CnDialogHeader } from './CnDialogHeader/index.js'
46
48
  export { CnFormBuilder } from './CnFormBuilder/index.js'
47
49
  export { CnAdvancedFormDialog, CnMetadataTab, CnPropertiesTab, CnPropertyValueCell } from './CnAdvancedFormDialog/index.js'
48
50
  export { CnMassDeleteDialog } from './CnMassDeleteDialog/index.js'
package/src/index.js CHANGED
@@ -99,6 +99,8 @@ export {
99
99
  CnDetailGrid,
100
100
  CnDetailPage,
101
101
  CnDetailWidgetHost,
102
+ CnDialog,
103
+ CnDialogHeader,
102
104
  CnDividerWidget,
103
105
  CnDividerWidgetForm,
104
106
  CnDocumentReviewList,
@@ -3,7 +3,7 @@
3
3
  "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
4
4
  "title": "Conduction App Manifest v2",
5
5
  "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
6
- "version": "2.102.0",
6
+ "version": "2.108.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -1651,6 +1651,59 @@
1651
1651
  }
1652
1652
  }
1653
1653
  },
1654
+ "linkModule": {
1655
+ "type": "object",
1656
+ "required": ["id", "label"],
1657
+ "additionalProperties": false,
1658
+ "description": "One module on a type='links' page. The app decides whether it is in the menu (`enabled`, or `enabledIf` against runtime). Every card opens; a module that is off links to the admin settings (`settings`).",
1659
+ "properties": {
1660
+ "id": { "type": "string", "minLength": 1, "description": "Unique within the page's modules." },
1661
+ "label": { "type": "string", "minLength": 1, "description": "i18n key / the module's title." },
1662
+ "description": { "type": "string", "description": "i18n key / one or two lines under the title." },
1663
+ "icon": { "type": "string", "description": "MDI icon name." },
1664
+ "enabled": { "type": "boolean", "description": "Whether the module is in the menu. Wins over enabledIf." },
1665
+ "enabledIf": { "$ref": "#/$defs/visibleIfCondition", "description": "Condition, evaluated like visibleIf, that says the module is in the menu." },
1666
+ "route": { "type": "string", "minLength": 1, "description": "Where Open goes: a route name or a path starting with '/'. Defaults to the first page." },
1667
+ "params": { "type": "object", "additionalProperties": { "type": ["string", "number"] } },
1668
+ "query": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
1669
+ "pages": {
1670
+ "type": "array",
1671
+ "description": "The module's pages, drawn as links and named in the 'N pages' line.",
1672
+ "items": {
1673
+ "type": "object",
1674
+ "required": ["label"],
1675
+ "additionalProperties": false,
1676
+ "anyOf": [{ "required": ["route"] }, { "required": ["href"] }],
1677
+ "properties": {
1678
+ "label": { "type": "string", "minLength": 1 },
1679
+ "route": { "type": "string", "minLength": 1 },
1680
+ "params": { "type": "object", "additionalProperties": { "type": ["string", "number"] } },
1681
+ "query": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
1682
+ "href": { "type": "string", "minLength": 1 },
1683
+ "highlight": { "type": "boolean", "description": "Named in the line of a module with more than four pages ('15 pages, including ...')." }
1684
+ }
1685
+ }
1686
+ },
1687
+ "settings": {
1688
+ "type": "object",
1689
+ "additionalProperties": false,
1690
+ "anyOf": [{ "required": ["route"] }, { "required": ["href"] }],
1691
+ "description": "Where an admin puts a module that is off in the menu (the admin settings), shown on its card as '<prefix> <label>'.",
1692
+ "properties": {
1693
+ "label": { "type": "string", "description": "i18n key / link text. Defaults to 'Admin settings'." },
1694
+ "prefix": { "type": "string", "description": "i18n key / text before the link. Defaults to 'Put it in the menu:'." },
1695
+ "route": { "type": "string", "minLength": 1 },
1696
+ "params": { "type": "object", "additionalProperties": { "type": ["string", "number"] } },
1697
+ "query": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
1698
+ "href": { "type": "string", "minLength": 1 },
1699
+ "permission": { "type": "string" },
1700
+ "visibleIf": { "$ref": "#/$defs/visibleIfCondition" }
1701
+ }
1702
+ },
1703
+ "permission": { "type": "string", "description": "Permission the user must hold for the module to render." },
1704
+ "visibleIf": { "$ref": "#/$defs/visibleIfCondition", "description": "Display condition, evaluated the way a menu entry's is." }
1705
+ }
1706
+ },
1654
1707
  "linkCard": {
1655
1708
  "type": "object",
1656
1709
  "required": ["id", "label"],
@@ -4751,7 +4804,11 @@
4751
4804
  "description": { "type": "string" },
4752
4805
  "emptyLabel": { "type": "string" },
4753
4806
  "categories": { "type": "object", "additionalProperties": { "type": "string", "minLength": 1 } },
4754
- "cards": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/linkCard" } }
4807
+ "cards": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/linkCard" } },
4808
+ "modules": { "type": "array", "items": { "$ref": "#/$defs/linkModule" }, "description": "Module cards drawn above the link cards (board PqModules): per module an icon, title, in-the-menu/off badge, description, its pages, an Open button and, when off, a link to the admin settings." },
4809
+ "modulesTitle": { "type": "string", "description": "i18n key / heading of the modules section. Defaults to 'Modules'." },
4810
+ "footnote": { "type": "string", "description": "i18n key / a note under the module cards." },
4811
+ "groupStyle": { "type": "string", "enum": ["cards", "list"], "default": "cards", "description": "cards: a grid of link cards per group. list: one card per group with a row per link (icon, label, description, chevron or external-link icon)." }
4755
4812
  }
4756
4813
  }
4757
4814
  },
@@ -88,6 +88,40 @@ export function formatBoardDate(value, now = new Date(), locale = boardDateLocal
88
88
  }
89
89
  }
90
90
 
91
+ /**
92
+ * A date with its short weekday, the way an agenda writes it: "wo 7 okt", or
93
+ * "wo 14 feb 2024" outside the current year. With `today` set, today's date
94
+ * reads "Vandaag, wo 7 okt".
95
+ *
96
+ * @param {unknown} value A Date, a timestamp or an ISO string.
97
+ * @param {Date} [now] The reference moment (defaults to the current time).
98
+ * @param {object} [options] Options.
99
+ * @param {boolean} [options.today] Put "Today, " before today's date.
100
+ * @param {string} [options.locale] BCP 47 tag (defaults to `boardDateLocale()`).
101
+ * @return {string} The date, or '' when the value is not a date.
102
+ * @spec openspec/changes/screens-cell-date-parity/specs/cell-date-age/spec.md#requirement-a-board-date-cell-reads-day-and-short-month-in-the-user-language
103
+ */
104
+ export function formatBoardWeekday(value, now = new Date(), options = {}) {
105
+ const date = parseDateValue(value)
106
+ if (date === null) {
107
+ return ''
108
+ }
109
+ const parts = { weekday: 'short', day: 'numeric', month: 'short' }
110
+ if (date.getFullYear() !== now.getFullYear()) {
111
+ parts.year = 'numeric'
112
+ }
113
+ let text
114
+ try {
115
+ text = new Intl.DateTimeFormat(options.locale || boardDateLocale(), parts).format(date)
116
+ } catch {
117
+ text = new Intl.DateTimeFormat(undefined, parts).format(date)
118
+ }
119
+ if (options.today === true && ageInDays(date, now) === 0) {
120
+ return t('nextcloud-vue', 'Today, {date}', { date: text })
121
+ }
122
+ return text
123
+ }
124
+
91
125
  /**
92
126
  * Whole calendar days from the date until `now` (0 today, 1 yesterday), or
93
127
  * null when the value is not a date. A future date gives a negative number.
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Conduction B.V.
2
2
  // SPDX-License-Identifier: EUPL-1.2
3
3
 
4
- import { boardDateLocale, formatBoardDate, formatRelativeDate } from './boardDate.js'
4
+ import { boardDateLocale, formatBoardDate, formatBoardWeekday, formatRelativeDate, formatTimeOfDay } from './boardDate.js'
5
5
  import { parseDateValue } from './dateVariant.js'
6
6
  import { formatMoney } from './formatMetric.js'
7
7
  import { readPath } from './readPath.js'
@@ -36,7 +36,7 @@ export function formatBoardDateTime(value, now = new Date()) {
36
36
  * Format one token value.
37
37
  *
38
38
  * @param {unknown} value The raw value.
39
- * @param {string} format `date`, `datetime`, `relative`, `relativetime`, `days` or '' (as is).
39
+ * @param {string} format `date`, `datetime`, `weekday`, `time`, `relative`, `relativetime`, `days` or '' (as is).
40
40
  * @param {Date} now The reference moment.
41
41
  * @param {string} [arg] The format's argument (`currency:EUR`, or a row path holding the code).
42
42
  * @param {object|null} [object] The row, for a currency read from a field.
@@ -53,6 +53,13 @@ function formatValue(value, format, now, arg = '', object = null, config = null)
53
53
  if (format === 'datetime') {
54
54
  return formatBoardDateTime(value, now)
55
55
  }
56
+ if (format === 'weekday') {
57
+ return formatBoardWeekday(value, now, { today: arg === 'today' })
58
+ }
59
+ if (format === 'time') {
60
+ const date = parseDateValue(value)
61
+ return date === null ? '' : formatTimeOfDay(date)
62
+ }
56
63
  if (format === 'currency') {
57
64
  // `{amount|currency:EUR}` names the code, `{amount|currency:price.code}`
58
65
  // reads it from the row, `{amount|currency}` is the app's reporting
@@ -79,7 +86,9 @@ function formatValue(value, format, now, arg = '', object = null, config = null)
79
86
  * `"Termijn {deadline|date}"` from an object.
80
87
  *
81
88
  * Every `{path}` token is a dot path into the object; `{path|date}` and
82
- * `{path|datetime}` format it as the boards write dates, `{path|currency}`,
89
+ * `{path|datetime}` format it as the boards write dates, `{path|weekday}` adds
90
+ * 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}`,
83
92
  * `{path|currency:EUR}` and `{path|currency:field}` write an amount in a
84
93
  * currency (a code, or the row field that holds it; without either, or when
85
94
  * the row field is empty, the app's reporting currency), `{path|relative}` and