@conduction/nextcloud-vue 2.78.0 → 2.79.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 (115) hide show
  1. package/dist/bundled-packages.json +0 -2
  2. package/dist/esm/bundled-packages.json +0 -2
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +113 -104
  5. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +12 -2
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  8. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  9. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +80 -0
  10. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  11. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +115 -91
  12. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  13. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +139 -1
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  15. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +140 -92
  16. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +77 -0
  18. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  19. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +4 -2
  20. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  21. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +61 -1
  22. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  23. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  24. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +173 -5
  25. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  26. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +181 -99
  27. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  28. package/dist/esm/components/CnFilesBrowser/filesBrowser.js +29 -11
  29. package/dist/esm/components/CnFilesBrowser/filesBrowser.js.map +1 -1
  30. package/dist/esm/components/CnFilesBrowser/openRegisterSource.js +252 -0
  31. package/dist/esm/components/CnFilesBrowser/openRegisterSource.js.map +1 -0
  32. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +1 -1
  33. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  34. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  35. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +25 -2
  36. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +3 -2
  38. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  39. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +100 -3
  40. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  41. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +4 -2
  42. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  43. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js +3 -2
  44. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js.map +1 -1
  45. package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js.map +1 -1
  46. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue2.js.map +1 -1
  47. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue3.js +1 -1
  48. package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue3.js.map +1 -1
  49. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +30 -1
  50. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  51. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  52. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +13 -0
  53. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  54. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  55. package/dist/esm/composables/useAppManifest.js +8 -3
  56. package/dist/esm/composables/useAppManifest.js.map +1 -1
  57. package/dist/esm/index.js +1 -0
  58. package/dist/esm/index.js.map +1 -1
  59. package/dist/esm/l10n/en.json.js +61 -1
  60. package/dist/esm/l10n/en.json.js.map +1 -1
  61. package/dist/esm/l10n/nl.json.js +106 -2
  62. package/dist/esm/l10n/nl.json.js.map +1 -1
  63. package/dist/esm/nextcloud-vue.css +314 -15
  64. package/dist/esm/utils/boardDate.js +193 -0
  65. package/dist/esm/utils/boardDate.js.map +1 -0
  66. package/dist/esm/utils/dynamicProperties.js +5 -1
  67. package/dist/esm/utils/dynamicProperties.js.map +1 -1
  68. package/dist/esm/utils/validateManifestV2.compiled.js +57 -6
  69. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  70. package/dist/nextcloud-vue.cjs.js +2039 -581
  71. package/dist/nextcloud-vue.cjs.js.map +2 -2
  72. package/dist/nextcloud-vue.css +314 -15
  73. package/l10n/en.json +61 -1
  74. package/l10n/nl.json +106 -2
  75. package/package.json +1 -1
  76. package/scripts/build-validators.js +26 -0
  77. package/src/components/CnActionsBar/CnActionsBar.vue +48 -39
  78. package/src/components/CnAppRoot/CnAppRoot.vue +12 -2
  79. package/src/components/CnCellRenderer/CnCellRenderer.vue +119 -2
  80. package/src/components/CnDashboardPage/CnDashboardPage.vue +219 -4
  81. package/src/components/CnDataTable/CnDataTable.md +2 -0
  82. package/src/components/CnDataTable/CnDataTable.vue +81 -2
  83. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +61 -1
  84. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +232 -11
  85. package/src/components/CnFilesBrowser/filesBrowser.js +43 -2
  86. package/src/components/CnFilesBrowser/index.js +1 -0
  87. package/src/components/CnFilesBrowser/openRegisterSource.js +248 -0
  88. package/src/components/CnFormDialog/CnFormDialog.vue +1 -1
  89. package/src/components/CnIndexPage/CnIndexPage.vue +27 -3
  90. package/src/components/CnObjectSidebar/CnFilesTab.vue +103 -4
  91. package/src/components/CnQuickFilterBar/CnQuickFilterBar.vue +3 -2
  92. package/src/components/CnSavedViewsControl/CnSavedViewsControl.vue +1 -1
  93. package/src/components/CnTabsWidget/CnTabsWidget.vue +30 -1
  94. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +13 -0
  95. package/src/components/index.js +1 -1
  96. package/src/composables/useAppManifest.js +8 -3
  97. package/src/css/look-board-detail.css +10 -0
  98. package/src/css/look-board-index.css +92 -14
  99. package/src/css/look-board.css +77 -0
  100. package/src/css/table.css +37 -0
  101. package/src/index.js +4 -0
  102. package/src/schemas/app-manifest-v2.schema.json +46 -1
  103. package/src/utils/boardDate.js +189 -0
  104. package/src/utils/dynamicProperties.js +5 -1
  105. package/src/utils/validateManifestV2.compiled.js +57 -2
  106. package/dist/esm/_virtual/equal.js +0 -4
  107. package/dist/esm/_virtual/equal.js.map +0 -1
  108. package/dist/esm/_virtual/ucs2length.js +0 -4
  109. package/dist/esm/_virtual/ucs2length.js.map +0 -1
  110. package/dist/esm/node_modules/ajv/dist/runtime/equal.js +0 -9
  111. package/dist/esm/node_modules/ajv/dist/runtime/equal.js.map +0 -1
  112. package/dist/esm/node_modules/ajv/dist/runtime/ucs2length.js +0 -25
  113. package/dist/esm/node_modules/ajv/dist/runtime/ucs2length.js.map +0 -1
  114. package/dist/esm/node_modules/fast-deep-equal/index.js +0 -47
  115. package/dist/esm/node_modules/fast-deep-equal/index.js.map +0 -1
@@ -0,0 +1,248 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * A data source for CnFilesBrowser that reads and writes one OpenRegister
6
+ * object's folder through OpenRegister's own files API, not WebDAV.
7
+ *
8
+ * Why: an object's files live in the `openregister` account's home, outside
9
+ * every person's Files app, so a reader has no WebDAV path to them. Every call
10
+ * here goes through the object's access rule on the server, as the signed-in
11
+ * person: a listing needs read on the object, a change needs update. Nothing
12
+ * is shared to make the folder visible.
13
+ *
14
+ * Paths are relative to the object folder and written with a leading slash
15
+ * (`/`, `/Bijlagen`), the same shape the WebDAV browser uses for its own
16
+ * paths, so crumbs and navigation work unchanged.
17
+ *
18
+ * @spec openspec/changes/files-browser-openregister-source/specs/files-browser/spec.md
19
+ */
20
+ import axios from '@nextcloud/axios'
21
+ import { FileType } from '@nextcloud/files'
22
+ import { generateUrl } from '@nextcloud/router'
23
+
24
+ /**
25
+ * A browser path (`/a/b`) as the API's relative path (`a/b`).
26
+ *
27
+ * @param {string} path The browser path.
28
+ * @return {string} The relative path, empty for the root.
29
+ */
30
+ export function toApiPath(path) {
31
+ return String(path || '').replace(/^\/+|\/+$/g, '')
32
+ }
33
+
34
+ /**
35
+ * One API listing entry as a row the browser can render and sort.
36
+ *
37
+ * The shape follows what the browser reads off an `@nextcloud/files` node
38
+ * (basename, type, mime, size, mtime, fileid, path, source, attributes), so
39
+ * the table, the sorter and the icons need no second code path.
40
+ *
41
+ * @param {object} entry The API entry: `{ id, name, type, mimetype, size, mtime, path }`.
42
+ * @return {object} The row.
43
+ */
44
+ export function entryToNode(entry) {
45
+ const isFolder = entry.type === 'folder'
46
+ return {
47
+ source: `openregister:${entry.id}`,
48
+ fileid: entry.id,
49
+ basename: entry.name,
50
+ displayname: entry.name,
51
+ path: `/${toApiPath(entry.path)}`,
52
+ type: isFolder ? FileType.Folder : FileType.File,
53
+ mime: isFolder ? 'httpd/unix-directory' : (entry.mimetype || ''),
54
+ size: typeof entry.size === 'number' ? entry.size : undefined,
55
+ mtime: typeof entry.mtime === 'number' ? new Date(entry.mtime * 1000) : undefined,
56
+ attributes: {},
57
+ }
58
+ }
59
+
60
+ /**
61
+ * The status of a failed request, or null.
62
+ *
63
+ * @param {Error} err The error.
64
+ * @return {number|null} The HTTP status.
65
+ */
66
+ function statusOf(err) {
67
+ return err?.response?.status ?? err?.status ?? null
68
+ }
69
+
70
+ /**
71
+ * The server's own message on a failed request, or null.
72
+ *
73
+ * @param {Error} err The error.
74
+ * @return {string|null} The message.
75
+ */
76
+ function messageOf(err) {
77
+ const data = err?.response?.data
78
+ return (data && typeof data.error === 'string' && data.error !== '') ? data.error : null
79
+ }
80
+
81
+ /**
82
+ * Build the OpenRegister source for one object.
83
+ *
84
+ * @param {object} options The object.
85
+ * @param {string} [options.apiBase] OpenRegister's API base, `/apps/openregister/api`.
86
+ * @param {string} options.register The register slug or id.
87
+ * @param {string} options.schema The schema slug or id.
88
+ * @param {string} options.objectId The object's uuid.
89
+ * @return {object} The source CnFilesBrowser takes as `source`.
90
+ */
91
+ export function createOpenRegisterSource({ apiBase = '/apps/openregister/api', register, schema, objectId }) {
92
+ const objectUrl = (suffix) => generateUrl(`${apiBase}/objects/{register}/{schema}/{objectId}${suffix}`, { register, schema, objectId })
93
+
94
+ /**
95
+ * Re-throw a failed request with its status and the server's message.
96
+ *
97
+ * @param {Error} err The error.
98
+ * @return {never}
99
+ */
100
+ const fail = (err) => {
101
+ const error = new Error(messageOf(err) || err?.message || 'Request failed')
102
+ error.status = statusOf(err)
103
+ throw error
104
+ }
105
+
106
+ return {
107
+ kind: 'openregister',
108
+
109
+ /**
110
+ * List a folder.
111
+ *
112
+ * @param {string} path The browser path.
113
+ * @return {Promise<{folder: object, nodes: Array<object>}>} The folder (with `canChange`) and its rows.
114
+ */
115
+ async list(path) {
116
+ try {
117
+ const { data } = await axios.get(objectUrl('/folder'), { params: { path: toApiPath(path) } })
118
+ if (!Array.isArray(data?.entries)) {
119
+ // Not a folder listing: an OpenRegister without these endpoints.
120
+ const error = new Error('This OpenRegister has no folder endpoint')
121
+ error.status = 501
122
+ throw error
123
+ }
124
+ const relative = toApiPath(data?.path ?? path)
125
+ return {
126
+ folder: {
127
+ fileid: data?.folderId ?? null,
128
+ path: `/${relative}`,
129
+ basename: relative.split('/').pop() || '',
130
+ type: FileType.Folder,
131
+ canChange: data?.canChange === true,
132
+ },
133
+ nodes: data.entries.map(entryToNode),
134
+ }
135
+ } catch (err) {
136
+ if (err?.status && !err.response) {
137
+ throw err
138
+ }
139
+ return fail(err)
140
+ }
141
+ },
142
+
143
+ /**
144
+ * Create a folder.
145
+ *
146
+ * @param {string} path Where, as a browser path.
147
+ * @param {string} name The new folder's name.
148
+ * @return {Promise<object>} The new folder's row.
149
+ */
150
+ async createFolder(path, name) {
151
+ try {
152
+ const { data } = await axios.post(objectUrl('/folder'), { path: toApiPath(path), name })
153
+ return entryToNode(data)
154
+ } catch (err) {
155
+ return fail(err)
156
+ }
157
+ },
158
+
159
+ /**
160
+ * Upload one file into a folder.
161
+ *
162
+ * @param {string} path The target folder, as a browser path.
163
+ * @param {File} file The file.
164
+ * @param {(percent: number) => void} [onProgress] Called with 0..100.
165
+ * @return {Promise<object>} The stored file's row.
166
+ */
167
+ async upload(path, file, onProgress) {
168
+ const form = new FormData()
169
+ form.append('path', toApiPath(path))
170
+ form.append('files[]', file, file.name)
171
+ try {
172
+ const { data } = await axios.post(objectUrl('/folder/upload'), form, {
173
+ onUploadProgress: (progress) => {
174
+ if (progress?.total && typeof onProgress === 'function') {
175
+ onProgress(Math.round((progress.loaded / progress.total) * 100))
176
+ }
177
+ },
178
+ })
179
+ const stored = Array.isArray(data?.stored) ? data.stored : []
180
+ if (stored.length === 0) {
181
+ const error = new Error(data?.rejected?.[0]?.error || 'The upload failed')
182
+ error.status = 400
183
+ throw error
184
+ }
185
+ return entryToNode(stored[0])
186
+ } catch (err) {
187
+ if (err?.status && !err.response) {
188
+ throw err
189
+ }
190
+ return fail(err)
191
+ }
192
+ },
193
+
194
+ /**
195
+ * Rename a file or folder.
196
+ *
197
+ * @param {object} node The row.
198
+ * @param {string} name The new name.
199
+ * @return {Promise<object>} The renamed row.
200
+ */
201
+ async rename(node, name) {
202
+ try {
203
+ const { data } = await axios.put(objectUrl(`/folder/${encodeURIComponent(node.fileid)}`), { name })
204
+ return entryToNode(data)
205
+ } catch (err) {
206
+ return fail(err)
207
+ }
208
+ },
209
+
210
+ /**
211
+ * Delete a file or folder.
212
+ *
213
+ * @param {object} node The row.
214
+ * @return {Promise<void>}
215
+ */
216
+ async remove(node) {
217
+ try {
218
+ await axios.delete(objectUrl(`/folder/${encodeURIComponent(node.fileid)}`))
219
+ } catch (err) {
220
+ fail(err)
221
+ }
222
+ },
223
+
224
+ /**
225
+ * Where a file downloads from: the object's own file endpoint, which
226
+ * checks read on the object.
227
+ *
228
+ * @param {object} node The row.
229
+ * @return {string|null} The url, or null for a folder.
230
+ */
231
+ downloadUrl(node) {
232
+ if (!node?.fileid || node.type === FileType.Folder) {
233
+ return null
234
+ }
235
+ return objectUrl(`/files/${encodeURIComponent(node.fileid)}`)
236
+ },
237
+
238
+ /**
239
+ * A small preview of an image, through the object's preview endpoint.
240
+ *
241
+ * @param {object} node The row.
242
+ * @return {string} The url.
243
+ */
244
+ previewUrl(node) {
245
+ return objectUrl(`/files/${encodeURIComponent(node.fileid)}/preview?width=64&height=64`)
246
+ },
247
+ }
248
+ }
@@ -3891,7 +3891,7 @@ export default {
3891
3891
  const mappedProps = propertiesFromDefinitions(
3892
3892
  Array.isArray(records) ? records : [],
3893
3893
  config,
3894
- { orderFrom: 1000 + Object.keys(properties).length },
3894
+ { orderFrom: 1000 + Object.keys(properties).length, create: this.isCreateMode },
3895
3895
  )
3896
3896
  Object.assign(properties, mappedProps.properties)
3897
3897
  required.push(...mappedProps.required)
@@ -111,7 +111,7 @@
111
111
  :sortValue="sortSelectValue"
112
112
  :showSearch="inlineSearch || isBoardLook"
113
113
  :searchValue="effectiveSearchValue"
114
- :searchPlaceholder="searchPlaceholder"
114
+ :searchPlaceholder="searchPlaceholder ? cnTranslate(searchPlaceholder) : searchPlaceholder"
115
115
  :showCountWithSearch="showCountWithSearch"
116
116
  :refreshing="effectiveRefreshing"
117
117
  :refreshDisabled="refreshDisabled"
@@ -565,6 +565,7 @@
565
565
  :includeColumns="includeColumns"
566
566
  :columnOverrides="columnOverrides"
567
567
  :rowClass="rowClass"
568
+ :rowTitle="rowTitle"
568
569
  :filterable="tableHeaderFilters"
569
570
  :activeFilters="effectiveActiveFilters"
570
571
  :filterRegister="typeof register === 'string' ? register : ''"
@@ -1533,6 +1534,21 @@ export default {
1533
1534
  default: () => [],
1534
1535
  },
1535
1536
 
1537
+ /**
1538
+ * How the board look draws the table's title column (manifest
1539
+ * `config.rowTitle`): `link` (the default) underlines the title,
1540
+ * `plain` draws it as bold text without an underline over a 13px muted
1541
+ * secondary line. The row stays clickable. The Nextcloud look ignores
1542
+ * it. Maps to CnDataTable `rowTitle`.
1543
+ *
1544
+ * @spec openspec/changes/screens-table-rows-parity/specs/index-list-board-look/spec.md#requirement-a-row-title-can-be-plain-text
1545
+ */
1546
+ rowTitle: {
1547
+ type: String,
1548
+ default: 'link',
1549
+ validator: (value) => ['link', 'plain'].includes(value),
1550
+ },
1551
+
1536
1552
  /** Optional MDI icon name. Defaults to schema.icon when a schema is provided. */
1537
1553
  icon: {
1538
1554
  type: String,
@@ -5344,6 +5360,7 @@ export default {
5344
5360
  * filled in when one is known, else `description`.
5345
5361
  *
5346
5362
  * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-an-index-page-title-is-a-manifest-key
5363
+ * @spec openspec/changes/screens-table-footer-and-system-dates-parity/specs/index-list-board-look/spec.md#requirement-the-count-reads-in-the-users-language
5347
5364
  * @return {string}
5348
5365
  */
5349
5366
  headerDescription() {
@@ -5351,9 +5368,16 @@ export default {
5351
5368
  // Board look: the count line is a template over the rows on this page
5352
5369
  // and all rows, "{shown} of {total}" unless the page says otherwise.
5353
5370
  if (this.isBoardLook && typeof total === 'number' && total >= 0) {
5354
- const template = this.countText || this.countSubtitle || '{shown} of {total}'
5371
+ const shown = this.effectiveObjects.length
5372
+ // The default count line is the library's own string, so it
5373
+ // reads "14 van 14" in Dutch; a page's own template goes
5374
+ // through the app's label lookup.
5375
+ if (!this.countText && !this.countSubtitle) {
5376
+ return t('nextcloud-vue', '{shown} of {total}', { shown, total })
5377
+ }
5378
+ const template = this.countText || this.countSubtitle
5355
5379
  return this.cnTranslate(template)
5356
- .replace('{shown}', String(this.effectiveObjects.length))
5380
+ .replace('{shown}', String(shown))
5357
5381
  .replace('{total}', String(total))
5358
5382
  }
5359
5383
  if (this.countSubtitle && typeof total === 'number' && total >= 0) {
@@ -7,7 +7,9 @@
7
7
  <CnFilesBrowser
8
8
  v-if="browserRoot !== null"
9
9
  :rootPath="browserRoot"
10
- :rootLabel="browserRootLabel"
10
+ :rootLabel="effectiveBrowserRootLabel"
11
+ :source="browserSource"
12
+ :deleteLabel="deleteLabel"
11
13
  :uploadButton="uploadButton"
12
14
  :dropOverlay="dropOverlay"
13
15
  :dropHint="dropHint"
@@ -238,7 +240,8 @@ import CnFilePreview from '../CnFilePreview/CnFilePreview.vue'
238
240
  import CnFilesBrowser from '../CnFilesBrowser/CnFilesBrowser.vue'
239
241
  import { useFileOpener } from '../../composables/useFileOpener.js'
240
242
  import { buildHeaders, prefixUrl } from '../../utils/index.js'
241
- import { resolveObjectFolder } from '../CnFilesBrowser/filesBrowser.js'
243
+ import { objectDisplayName, resolveObjectFolderInfo } from '../CnFilesBrowser/filesBrowser.js'
244
+ import { createOpenRegisterSource } from '../CnFilesBrowser/openRegisterSource.js'
242
245
 
243
246
  /**
244
247
  * The mime types Nextcloud Office opens by default for the current person.
@@ -308,8 +311,22 @@ export default {
308
311
  openInOfficeLabel: { type: String, default: () => t('nextcloud-vue', 'Open in Office') },
309
312
  /** Label for the delete action */
310
313
  deleteLabel: { type: String, default: () => t('nextcloud-vue', 'Delete') },
311
- /** What the files browser's root crumb reads; null shows the folder's own name, as the Files app does. */
314
+ /** What the files browser's root crumb reads; null shows the object's name, else the folder's own name. */
312
315
  browserRootLabel: { type: String, default: null },
316
+ /**
317
+ * Where the files browser reads the object's folder from.
318
+ * `openregister` (the default) goes through OpenRegister's files API under
319
+ * the object's access rule, so anyone who may read the object sees its
320
+ * files, without a share. `webdav` is the earlier behaviour: the folder is
321
+ * looked up in the person's own Files over WebDAV and the browser shows
322
+ * only when it is shared with them.
323
+ */
324
+ source: {
325
+ type: String,
326
+ default: 'openregister',
327
+ validator: (value) => ['openregister', 'webdav'].includes(value),
328
+ },
329
+
313
330
  /** Forwarded to CnFilesBrowser: a primary "Add files" button beside the New menu. */
314
331
  uploadButton: { type: Boolean, default: false },
315
332
  /** Forwarded to CnFilesBrowser: the drop state drawn over the list ("Drop to add") and announced. */
@@ -385,6 +402,9 @@ export default {
385
402
  share: false,
386
403
  /** The object's folder as a user-relative path, or null while unresolved or absent. */
387
404
  browserRoot: null,
405
+ /** The browser's data source for `source: 'openregister'`, or null for WebDAV. */
406
+ browserSource: null,
407
+ browserObjectName: null,
388
408
  linkedItems: [],
389
409
  /** File ids whose preview request failed, so the row falls back to the mime icon. */
390
410
  previewFailed: {},
@@ -403,6 +423,19 @@ export default {
403
423
  }
404
424
  },
405
425
 
426
+ computed: {
427
+ /**
428
+ * The root crumb: the host's label, else the object's name (its folder
429
+ * on disk is called after its uuid), else the folder's own name.
430
+ *
431
+ * @return {string|null}
432
+ * @spec openspec/changes/dutch-library-strings-and-files-crumb/specs/dutch-library-strings-and-files-crumb/spec.md#requirement-the-files-browser-names-the-object-in-its-root-crumb
433
+ */
434
+ effectiveBrowserRootLabel() {
435
+ return this.browserRootLabel || this.browserObjectName || null
436
+ },
437
+ },
438
+
406
439
  watch: {
407
440
  // Match every other CnObjectSidebar tab — a single immediate
408
441
  // `objectId` watcher drives all initial loads. Folding the
@@ -423,6 +456,10 @@ export default {
423
456
  linkedItemsUrl() {
424
457
  this.fetchLinkedItems()
425
458
  },
459
+
460
+ source() {
461
+ this.resolveBrowserRoot()
462
+ },
426
463
  },
427
464
 
428
465
  methods: {
@@ -483,16 +520,22 @@ export default {
483
520
 
484
521
  async resolveBrowserRoot() {
485
522
  this.browserRoot = null
523
+ this.browserSource = null
524
+ this.browserObjectName = null
486
525
  if (!this.objectId || !this.register || !this.schema) {
487
526
  return
488
527
  }
528
+ if (this.source === 'openregister') {
529
+ await this.resolveOpenRegisterSource()
530
+ return
531
+ }
489
532
  let remoteUrl
490
533
  try {
491
534
  remoteUrl = generateRemoteUrl('dav')
492
535
  } catch {
493
536
  return
494
537
  }
495
- this.browserRoot = await resolveObjectFolder({
538
+ const info = await resolveObjectFolderInfo({
496
539
  apiBase: this.apiBase,
497
540
  register: this.register,
498
541
  schema: this.schema,
@@ -500,6 +543,62 @@ export default {
500
543
  uid: getCurrentUser()?.uid || '',
501
544
  remoteUrl,
502
545
  })
546
+ this.browserRoot = info === null ? null : info.path
547
+ this.browserObjectName = info === null ? null : info.name
548
+ },
549
+
550
+ /**
551
+ * Use OpenRegister's files API for the browser, when this person may read
552
+ * the object's folder through it. A listing that fails (no read on the
553
+ * object, or an OpenRegister without the folder endpoints) leaves the
554
+ * legacy list, as an unresolved WebDAV folder did.
555
+ *
556
+ * @return {Promise<void>}
557
+ * @spec openspec/changes/files-browser-openregister-source/specs/files-browser/spec.md
558
+ */
559
+ async resolveOpenRegisterSource() {
560
+ const objectId = this.objectId
561
+ const source = createOpenRegisterSource({
562
+ apiBase: this.apiBase,
563
+ register: this.register,
564
+ schema: this.schema,
565
+ objectId,
566
+ })
567
+ try {
568
+ await source.list('/')
569
+ } catch {
570
+ return
571
+ }
572
+ if (objectId !== this.objectId) {
573
+ return
574
+ }
575
+ this.browserSource = source
576
+ this.browserRoot = '/'
577
+ this.fetchObjectName()
578
+ },
579
+
580
+ /**
581
+ * Read the object's name for the root crumb, by the same rule the WebDAV
582
+ * path uses (`objectDisplayName`: never the uuid). The OpenRegister path
583
+ * has no folder lookup to piggyback on, so this is its one object read.
584
+ * A failure keeps the crumb on its default.
585
+ *
586
+ * @return {Promise<void>}
587
+ */
588
+ async fetchObjectName() {
589
+ const objectId = this.objectId
590
+ try {
591
+ const { data } = await axios.get(generateUrl(`${this.apiBase}/objects/{register}/{schema}/{objectId}`, {
592
+ register: this.register,
593
+ schema: this.schema,
594
+ objectId,
595
+ }))
596
+ if (objectId === this.objectId) {
597
+ this.browserObjectName = objectDisplayName(data, objectId)
598
+ }
599
+ } catch {
600
+ // The crumb keeps its default.
601
+ }
503
602
  },
504
603
 
505
604
  async fetchFiles(append = false) {
@@ -115,7 +115,7 @@
115
115
  </template>
116
116
 
117
117
  <script>
118
- import { translate as t } from '@nextcloud/l10n'
118
+ import { translatePlural as n, translate as t } from '@nextcloud/l10n'
119
119
  import { NcPopover, NcSelect } from '@nextcloud/vue'
120
120
  import DotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
121
121
  import CnIcon from '../CnIcon/CnIcon.vue'
@@ -354,7 +354,8 @@ export default {
354
354
  if (this.overflowLabel) {
355
355
  return t('nextcloud-vue', '{label} — show the other filters', { label: this.overflowLabel })
356
356
  }
357
- return t('nextcloud-vue', '{count} more filters', { count: this.overflowEntries.length })
357
+ const count = this.overflowEntries.length
358
+ return n('nextcloud-vue', '{count} more filter', '{count} more filters', count, { count })
358
359
  },
359
360
 
360
361
  /** @return {string} Heading above the hidden chips in the panel. */
@@ -41,7 +41,7 @@
41
41
  data-testid="cn-saved-views-control"
42
42
  :aria-label="isBoardLook ? t('nextcloud-vue', 'Save view') : menuLabel">
43
43
  <template #icon>
44
- <ContentSaveOutline v-if="isBoardLook" :size="18" />
44
+ <ContentSaveOutline v-if="isBoardLook" :size="14" />
45
45
  <BookmarkOutline v-else :size="20" />
46
46
  </template>
47
47
 
@@ -254,6 +254,11 @@ export default {
254
254
  cnLook: { default: 'nextcloud' },
255
255
  /** The tab list's name under the board look, provided by CnDetailPage (`config.tabsLabel`). */
256
256
  cnDetailTabsLabel: { default: null },
257
+ /**
258
+ * The host's translate (CnAppRoot provides `cnTranslate`), so a tab
259
+ * label written in the manifest reads in the user's language.
260
+ */
261
+ cnTranslate: { default: () => (key) => key },
257
262
  },
258
263
 
259
264
  props: {
@@ -471,7 +476,7 @@ export default {
471
476
  key: `${widgetId || 'tab'}-${index}`,
472
477
  id: (tab && typeof tab === 'object' && tab.id) || widgetId || '',
473
478
  widgetId,
474
- label: (tab && tab.label) || (this.isBoard && isActivityWidget(widget) ? t('nextcloud-vue', 'History') : '') || widgetTitleOf(widget) || widgetId || '',
479
+ label: this.tabLabel(tab, widget, widgetId),
475
480
  icon: (tab && tab.icon) || widget?.icon || '',
476
481
  widget,
477
482
  count,
@@ -652,6 +657,30 @@ export default {
652
657
  },
653
658
 
654
659
  methods: {
660
+ /**
661
+ * The label a tab shows: the authored `label`, else (board look) History
662
+ * for the activity widget, else the child widget's title, else its id.
663
+ * A manifest label or title goes through the host's translate; the
664
+ * library's own History is translated by the library catalogue.
665
+ *
666
+ * @param {object|string} tab The `content.tabs[]` entry.
667
+ * @param {object|null} widget The widget definition it names.
668
+ * @param {string} widgetId The widget id.
669
+ * @return {string} The label.
670
+ * @spec openspec/changes/screens-detail-labels-parity/specs/detail-labels-board-look/spec.md#requirement-detail-labels-read-in-the-users-language
671
+ */
672
+ tabLabel(tab, widget, widgetId) {
673
+ const translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key
674
+ if (tab && typeof tab.label === 'string' && tab.label !== '') {
675
+ return translate(tab.label)
676
+ }
677
+ if (this.isBoard && isActivityWidget(widget)) {
678
+ return t('nextcloud-vue', 'History')
679
+ }
680
+ const title = widgetTitleOf(widget)
681
+ return title ? translate(title) : (widgetId || '')
682
+ },
683
+
655
684
  /**
656
685
  * Whether a tab shows: `visible`, `hidden`, or `pending` while its
657
686
  * condition cannot be decided yet (object not loaded, count not back).
@@ -323,6 +323,19 @@ export default {
323
323
  default: false,
324
324
  },
325
325
 
326
+ /**
327
+ * Fit the table to the tile: the title column (a column's `grow: true`,
328
+ * else `title` or `name`, else the first) shrinks and ends in an
329
+ * ellipsis so the trailing columns stay visible. On by default; pass
330
+ * `false` for the table's own auto layout. Forwarded to CnDataTable.
331
+ *
332
+ * @spec openspec/changes/narrow-object-table-widgets/specs/narrow-object-table-widgets/spec.md#requirement-a-narrow-object-table-keeps-its-trailing-columns-in-view
333
+ */
334
+ fitWidth: {
335
+ type: Boolean,
336
+ default: true,
337
+ },
338
+
326
339
  /**
327
340
  * Route NAME for row-click navigation. Mapped onto CnDataTable's
328
341
  * `rowClickRoute` as `{ name: rowRoute, params: { id } }` with the
@@ -109,7 +109,7 @@ export { CnTreeView } from './CnTreeView/index.js'
109
109
  export { CnUserActionMenu } from './CnUserActionMenu/index.js'
110
110
  export { CnNotesCard } from './CnNotesCard/index.js'
111
111
  export { CnTasksCard } from './CnTasksCard/index.js'
112
- export { CnFilesBrowser } from './CnFilesBrowser/index.js'
112
+ export { CnFilesBrowser, createOpenRegisterSource } from './CnFilesBrowser/index.js'
113
113
  export { CnFilesCard } from './CnFilesCard/index.js'
114
114
  export { CnFileManager } from './CnFileManager/index.js'
115
115
  export { CnRelatedFiles } from './CnRelatedFiles/index.js'
@@ -205,6 +205,7 @@ function loadInMemory(input) {
205
205
  * @param {object} options Configuration options (endpoint / fetcher /
206
206
  * getAppConfigValue overrides).
207
207
  * @return {{ manifest: import('vue').Ref<object>, isLoading: import('vue').Ref<boolean>, validationErrors: import('vue').Ref<string[]|null>, unresolvedSentinels: import('vue').Ref<string[]> }}
208
+ * @spec openspec/changes/manifest-validator-self-contained/specs/manifest-validator-self-contained/spec.md#requirement-a-backend-manifest-delta-reaches-the-navigation
208
209
  */
209
210
  function loadFromBackend(appId, bundledManifest, options) {
210
211
  // Shallow by default (manifest-shallow-reactivity-by-default) — see
@@ -284,9 +285,13 @@ function loadFromBackend(appId, bundledManifest, options) {
284
285
  // The shallowRef reassignment is what re-renders consumers; the new
285
286
  // object stays shallow (not deep-observed) unless CnAppRoot upgrades it.
286
287
  manifest.value = resolved
287
- } catch {
288
- // Defensive: any unexpected error leaves the bundled manifest in
289
- // place. Apps without a backend endpoint keep working.
288
+ } catch (error) {
289
+ // Any unexpected error leaves the bundled manifest in place, so
290
+ // apps keep working. Say so: a silent catch here once hid a
291
+ // validator that threw on every manifest, and the backend delta
292
+ // never reached the navigation.
293
+ // eslint-disable-next-line no-console
294
+ console.warn('[useAppManifest] Could not resolve the manifest; keeping the bundled manifest.', error)
290
295
  } finally {
291
296
  isLoading.value = false
292
297
  }
@@ -112,6 +112,16 @@
112
112
  gap: 20px;
113
113
  }
114
114
 
115
+ /* GridStack insets every cell by its 12px margin, the outer edge included, so
116
+ the tab strip started 12px below the top of the body column and stopped 12px
117
+ short of its end, while the side column started at the top. Pulling the grid
118
+ out by that margin puts its first card on the column's edges: the side column
119
+ is level with the strip, and the strip ends where the body column ends. */
120
+ .cn-look-board.cn-detail-page--with-side > .cn-detail-page__body > .cn-detail-page__grid {
121
+ margin: -12px;
122
+ width: auto;
123
+ }
124
+
115
125
  /* ---- Folder tabs ------------------------------------------------------- */
116
126
 
117
127
  /* The strip draws no line of its own (design-system pull request 147). The card under it