@conduction/nextcloud-vue 0.1.0-beta.1 → 0.1.0-beta.10

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 (197) hide show
  1. package/README.md +226 -0
  2. package/css/index.css +5 -0
  3. package/dist/nextcloud-vue.cjs +67614 -0
  4. package/dist/nextcloud-vue.cjs.js +76311 -5905
  5. package/dist/nextcloud-vue.cjs.js.map +1 -1
  6. package/dist/nextcloud-vue.cjs.map +1 -0
  7. package/dist/nextcloud-vue.css +3279 -203
  8. package/dist/nextcloud-vue.esm.js +76240 -5882
  9. package/dist/nextcloud-vue.esm.js.map +1 -1
  10. package/package.json +89 -63
  11. package/src/components/CnActionsBar/CnActionsBar.vue +254 -0
  12. package/src/components/CnActionsBar/index.js +1 -0
  13. package/src/components/CnAdvancedFormDialog/CnAdvancedFormDialog.vue +569 -0
  14. package/src/components/CnAdvancedFormDialog/CnDataTab.vue +217 -0
  15. package/src/components/CnAdvancedFormDialog/CnMetadataTab.vue +121 -0
  16. package/src/components/CnAdvancedFormDialog/CnPropertiesTab.vue +422 -0
  17. package/src/components/CnAdvancedFormDialog/CnPropertyValueCell.vue +247 -0
  18. package/src/components/CnAdvancedFormDialog/index.js +1 -0
  19. package/src/components/CnCard/CnCard.vue +415 -0
  20. package/src/components/CnCard/index.js +1 -0
  21. package/src/components/CnCardGrid/CnCardGrid.vue +23 -20
  22. package/src/components/CnCardGrid/index.js +1 -1
  23. package/src/components/CnCellRenderer/index.js +1 -1
  24. package/src/components/CnChartWidget/CnChartWidget.vue +318 -0
  25. package/src/components/CnChartWidget/index.js +1 -0
  26. package/src/components/CnConfigurationCard/index.js +1 -1
  27. package/src/components/CnContextMenu/CnContextMenu.vue +142 -0
  28. package/src/components/CnContextMenu/index.js +1 -0
  29. package/src/components/CnCopyDialog/CnCopyDialog.vue +257 -0
  30. package/src/components/CnCopyDialog/index.js +1 -0
  31. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +229 -0
  32. package/src/components/CnDashboardGrid/index.js +1 -0
  33. package/src/components/CnDashboardPage/CnDashboardPage.vue +396 -0
  34. package/src/components/CnDashboardPage/index.js +1 -0
  35. package/src/components/CnDataTable/CnDataTable.vue +24 -16
  36. package/src/components/CnDataTable/index.js +1 -1
  37. package/src/components/CnDeleteDialog/CnDeleteDialog.vue +177 -0
  38. package/src/components/CnDeleteDialog/index.js +1 -0
  39. package/src/components/CnDetailCard/CnDetailCard.vue +225 -0
  40. package/src/components/CnDetailCard/index.js +1 -0
  41. package/src/components/CnDetailGrid/CnDetailGrid.vue +254 -0
  42. package/src/components/CnDetailGrid/index.js +1 -0
  43. package/src/components/CnDetailPage/CnDetailPage.vue +431 -0
  44. package/src/components/CnDetailPage/index.js +1 -0
  45. package/src/components/CnFacetSidebar/CnFacetSidebar.vue +12 -2
  46. package/src/components/CnFacetSidebar/index.js +1 -1
  47. package/src/components/CnFilterBar/index.js +1 -1
  48. package/src/components/CnFormDialog/CnFormDialog.vue +934 -0
  49. package/src/components/CnFormDialog/index.js +1 -0
  50. package/src/components/CnIcon/CnIcon.vue +89 -0
  51. package/src/components/CnIcon/index.js +1 -0
  52. package/src/components/CnIndexPage/CnIndexPage.vue +589 -291
  53. package/src/components/CnIndexPage/index.js +1 -1
  54. package/src/components/CnIndexSidebar/CnIndexSidebar.vue +535 -0
  55. package/src/components/CnIndexSidebar/index.js +1 -0
  56. package/src/components/CnInfoWidget/CnInfoWidget.vue +219 -0
  57. package/src/components/CnInfoWidget/index.js +1 -0
  58. package/src/components/CnItemCard/CnItemCard.vue +134 -0
  59. package/src/components/CnItemCard/index.js +1 -0
  60. package/src/components/CnJsonViewer/CnJsonViewer.vue +283 -0
  61. package/src/components/CnJsonViewer/index.js +1 -0
  62. package/src/components/CnKpiGrid/CnKpiGrid.vue +5 -1
  63. package/src/components/CnKpiGrid/index.js +1 -1
  64. package/src/components/CnMassActionBar/CnMassActionBar.vue +6 -5
  65. package/src/components/CnMassActionBar/index.js +1 -1
  66. package/src/components/CnMassCopyDialog/CnMassCopyDialog.vue +16 -9
  67. package/src/components/CnMassCopyDialog/index.js +1 -1
  68. package/src/components/CnMassDeleteDialog/CnMassDeleteDialog.vue +16 -9
  69. package/src/components/CnMassDeleteDialog/index.js +1 -1
  70. package/src/components/CnMassExportDialog/CnMassExportDialog.vue +8 -7
  71. package/src/components/CnMassExportDialog/index.js +1 -1
  72. package/src/components/CnMassImportDialog/CnMassImportDialog.vue +20 -17
  73. package/src/components/CnMassImportDialog/index.js +1 -1
  74. package/src/components/CnNoteCard/CnNoteCard.vue +149 -0
  75. package/src/components/CnNoteCard/index.js +1 -0
  76. package/src/components/CnNotesCard/CnNotesCard.vue +415 -0
  77. package/src/components/CnNotesCard/index.js +1 -0
  78. package/src/components/CnObjectCard/CnObjectCard.vue +3 -1
  79. package/src/components/CnObjectCard/index.js +1 -1
  80. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +853 -0
  81. package/src/components/CnObjectDataWidget/index.js +1 -0
  82. package/src/components/CnObjectMetadataWidget/CnObjectMetadataWidget.vue +288 -0
  83. package/src/components/CnObjectMetadataWidget/index.js +1 -0
  84. package/src/components/CnObjectSidebar/CnAuditTrailTab.vue +368 -0
  85. package/src/components/CnObjectSidebar/CnFilesTab.vue +286 -0
  86. package/src/components/CnObjectSidebar/CnNotesTab.vue +249 -0
  87. package/src/components/CnObjectSidebar/CnObjectSidebar.vue +254 -0
  88. package/src/components/CnObjectSidebar/CnTagsTab.vue +258 -0
  89. package/src/components/CnObjectSidebar/CnTasksTab.vue +482 -0
  90. package/src/components/CnObjectSidebar/index.js +6 -0
  91. package/src/components/CnPageHeader/CnPageHeader.vue +61 -0
  92. package/src/components/CnPageHeader/index.js +1 -0
  93. package/src/components/CnPagination/CnPagination.vue +7 -6
  94. package/src/components/CnPagination/index.js +1 -1
  95. package/src/components/CnProgressBar/CnProgressBar.vue +262 -0
  96. package/src/components/CnProgressBar/index.js +1 -0
  97. package/src/components/CnRegisterMapping/CnRegisterMapping.vue +792 -0
  98. package/src/components/CnRegisterMapping/index.js +1 -0
  99. package/src/components/CnRowActions/CnRowActions.vue +25 -3
  100. package/src/components/CnRowActions/index.js +1 -1
  101. package/src/components/CnSchemaFormDialog/CnSchemaConfigurationTab.vue +226 -0
  102. package/src/components/CnSchemaFormDialog/CnSchemaFormDialog.vue +787 -0
  103. package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +305 -0
  104. package/src/components/CnSchemaFormDialog/CnSchemaPropertyActions.vue +1398 -0
  105. package/src/components/CnSchemaFormDialog/CnSchemaSecurityTab.vue +236 -0
  106. package/src/components/CnSchemaFormDialog/index.js +1 -0
  107. package/src/components/CnSettingsCard/index.js +1 -1
  108. package/src/components/CnSettingsSection/index.js +1 -1
  109. package/src/components/CnStatsBlock/CnStatsBlock.vue +89 -19
  110. package/src/components/CnStatsBlock/index.js +1 -1
  111. package/src/components/CnStatsPanel/CnStatsPanel.vue +320 -0
  112. package/src/components/CnStatsPanel/index.js +1 -0
  113. package/src/components/CnStatusBadge/CnStatusBadge.vue +15 -2
  114. package/src/components/CnStatusBadge/index.js +1 -1
  115. package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +544 -0
  116. package/src/components/CnTabbedFormDialog/index.js +1 -0
  117. package/src/components/CnTableWidget/CnTableWidget.vue +332 -0
  118. package/src/components/CnTableWidget/index.js +1 -0
  119. package/src/components/CnTasksCard/CnTasksCard.vue +373 -0
  120. package/src/components/CnTasksCard/index.js +1 -0
  121. package/src/components/CnTileWidget/CnTileWidget.vue +159 -0
  122. package/src/components/CnTileWidget/index.js +1 -0
  123. package/src/components/CnTimelineStages/CnTimelineStages.vue +292 -0
  124. package/src/components/CnTimelineStages/index.js +1 -0
  125. package/src/components/CnUserActionMenu/CnUserActionMenu.vue +435 -0
  126. package/src/components/CnUserActionMenu/index.js +1 -0
  127. package/src/components/CnVersionInfoCard/index.js +1 -1
  128. package/src/components/CnWidgetRenderer/CnWidgetRenderer.vue +180 -0
  129. package/src/components/CnWidgetRenderer/index.js +1 -0
  130. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +246 -0
  131. package/src/components/CnWidgetWrapper/index.js +1 -0
  132. package/src/components/index.js +57 -25
  133. package/src/composables/index.js +5 -3
  134. package/src/composables/useContextMenu.js +126 -0
  135. package/src/composables/useDashboardView.js +286 -0
  136. package/src/composables/useDetailView.js +290 -132
  137. package/src/composables/useListView.js +364 -153
  138. package/src/composables/useSubResource.js +142 -142
  139. package/src/constants/metadata.js +30 -0
  140. package/src/css/CnSchemaFormDialog.css +546 -0
  141. package/src/css/__sample_nextcloud_tokens.css +110 -0
  142. package/src/css/actions-bar.css +54 -0
  143. package/src/css/badge.css +83 -51
  144. package/src/css/card.css +129 -128
  145. package/src/css/context-menu.css +20 -0
  146. package/src/css/dashboard.css +70 -0
  147. package/src/css/detail-page.css +235 -0
  148. package/src/css/detail.css +68 -68
  149. package/src/css/index-page.css +44 -0
  150. package/src/css/index-sidebar.css +193 -0
  151. package/src/css/index.css +17 -8
  152. package/src/css/layout.css +90 -90
  153. package/src/css/page-header.css +35 -0
  154. package/src/css/pagination.css +72 -72
  155. package/src/css/table.css +142 -143
  156. package/src/css/timeline-stages.css +220 -0
  157. package/src/css/utilities.css +46 -46
  158. package/src/index.js +91 -50
  159. package/src/mixins/gridLayout.js +118 -0
  160. package/src/store/createCrudStore.js +360 -0
  161. package/src/store/createSubResourcePlugin.js +125 -135
  162. package/src/store/index.js +4 -3
  163. package/src/store/plugins/auditTrails.js +357 -17
  164. package/src/store/plugins/files.js +250 -186
  165. package/src/store/plugins/index.js +7 -4
  166. package/src/store/plugins/lifecycle.js +180 -180
  167. package/src/store/plugins/registerMapping.js +195 -0
  168. package/src/store/plugins/relations.js +68 -68
  169. package/src/store/plugins/search.js +385 -0
  170. package/src/store/plugins/selection.js +104 -0
  171. package/src/store/useObjectStore.js +823 -625
  172. package/src/types/auditTrail.d.ts +32 -32
  173. package/src/types/file.d.ts +23 -23
  174. package/src/types/index.d.ts +35 -35
  175. package/src/types/notification.d.ts +36 -36
  176. package/src/types/object.d.ts +40 -40
  177. package/src/types/organisation.d.ts +41 -41
  178. package/src/types/register.d.ts +25 -25
  179. package/src/types/schema.d.ts +39 -39
  180. package/src/types/shared.d.ts +79 -79
  181. package/src/types/source.d.ts +14 -14
  182. package/src/types/task.d.ts +31 -31
  183. package/src/utils/errors.js +96 -96
  184. package/src/utils/getTheme.js +9 -0
  185. package/src/utils/headers.js +80 -44
  186. package/src/utils/id.js +13 -0
  187. package/src/utils/index.js +4 -3
  188. package/src/utils/schema.js +422 -287
  189. package/src/utils/widgetVisibility.js +162 -0
  190. package/src/components/CnDetailViewLayout/CnDetailViewLayout.vue +0 -88
  191. package/src/components/CnDetailViewLayout/index.js +0 -1
  192. package/src/components/CnEmptyState/CnEmptyState.vue +0 -78
  193. package/src/components/CnEmptyState/index.js +0 -1
  194. package/src/components/CnListViewLayout/CnListViewLayout.vue +0 -80
  195. package/src/components/CnListViewLayout/index.js +0 -1
  196. package/src/components/CnViewModeToggle/CnViewModeToggle.vue +0 -77
  197. package/src/components/CnViewModeToggle/index.js +0 -1
@@ -1,625 +1,823 @@
1
- import { defineStore } from 'pinia'
2
- import { buildHeaders, buildQueryString } from '../utils/headers.js'
3
- import { parseResponseError, networkError, genericError } from '../utils/errors.js'
4
-
5
- /**
6
- * Generic Pinia store for OpenRegister object CRUD operations.
7
- *
8
- * Provides a unified interface for managing objects across registers and schemas.
9
- * Apps register their object types with schema/register IDs, then use type slugs
10
- * for all operations. Supports plugins for sub-resources (files, audit trails, etc.).
11
- *
12
- * @example
13
- * // Basic usage (CRUD only)
14
- * import { useObjectStore } from '@conduction/nextcloud-vue'
15
- * const store = useObjectStore()
16
- *
17
- * @example
18
- * // With plugins
19
- * import { createObjectStore, filesPlugin, auditTrailsPlugin } from '@conduction/nextcloud-vue'
20
- * const useMyStore = createObjectStore('object', {
21
- * plugins: [filesPlugin(), auditTrailsPlugin()],
22
- * })
23
- */
24
-
25
- const DEFAULT_STORE_ID = 'conduction-objects'
26
- const DEFAULT_BASE_URL = '/apps/openregister/api/objects'
27
-
28
- /**
29
- * Capitalize the first letter of a string.
30
- *
31
- * @param {string} str Input string
32
- * @return {string} Capitalized string
33
- */
34
- function capitalize(str) {
35
- return str.charAt(0).toUpperCase() + str.slice(1)
36
- }
37
-
38
- /**
39
- * Merge plugin state factories into a single state object.
40
- *
41
- * @param {Array} plugins Array of plugin definitions
42
- * @return {object} Merged state object
43
- */
44
- function mergePluginState(plugins) {
45
- const merged = {}
46
- for (const plugin of plugins) {
47
- if (plugin.state) {
48
- Object.assign(merged, plugin.state())
49
- }
50
- }
51
- return merged
52
- }
53
-
54
- /**
55
- * Merge plugin getters into a single getters object.
56
- *
57
- * @param {Array} plugins Array of plugin definitions
58
- * @return {object} Merged getters object
59
- */
60
- function mergePluginGetters(plugins) {
61
- const merged = {}
62
- for (const plugin of plugins) {
63
- if (plugin.getters) {
64
- Object.assign(merged, plugin.getters)
65
- }
66
- }
67
- return merged
68
- }
69
-
70
- /**
71
- * Merge plugin actions into a single actions object.
72
- *
73
- * @param {Array} plugins Array of plugin definitions
74
- * @return {object} Merged actions object
75
- */
76
- function mergePluginActions(plugins) {
77
- const merged = {}
78
- for (const plugin of plugins) {
79
- if (plugin.actions) {
80
- Object.assign(merged, plugin.actions)
81
- }
82
- }
83
- return merged
84
- }
85
-
86
- // ── Base state ──────────────────────────────────────────────────────────
87
-
88
- function baseState() {
89
- return {
90
- /** @type {Object<string, {schema: string, register: string}>} */
91
- objectTypeRegistry: {},
92
- /** @type {Object<string, Array>} */
93
- collections: {},
94
- /** @type {Object<string, Object<string, object>>} */
95
- objects: {},
96
- /** @type {Object<string, boolean>} */
97
- loading: {},
98
- /** @type {Object<string, import('../utils/errors.js').ApiError|null>} */
99
- errors: {},
100
- /** @type {Object<string, {total: number, page: number, pages: number, limit: number}>} */
101
- pagination: {},
102
- /** @type {Object<string, string>} */
103
- searchTerms: {},
104
- /** @type {Object<string, object|null>} */
105
- schemas: {},
106
- /** @type {{baseUrl: string}} */
107
- _options: {
108
- baseUrl: DEFAULT_BASE_URL,
109
- },
110
- }
111
- }
112
-
113
- // ── Base getters ────────────────────────────────────────────────────────
114
-
115
- const baseGetters = {
116
- /**
117
- * Get all registered object type slugs.
118
- * @return {string[]}
119
- */
120
- objectTypes: (state) => Object.keys(state.objectTypeRegistry),
121
-
122
- /**
123
- * Get the collection array for a type.
124
- * @return {Function} (type: string) => Array
125
- */
126
- getCollection: (state) => (type) => state.collections[type] || [],
127
-
128
- /**
129
- * Get a single cached object by type and ID.
130
- * @return {Function} (type: string, id: string) => object|null
131
- */
132
- getObject: (state) => (type, id) => state.objects[type]?.[id] || null,
133
-
134
- /**
135
- * Alias for getObject — check cache without fetching.
136
- * @return {Function} (type: string, id: string) => object|null
137
- */
138
- getCachedObject: (state) => (type, id) => state.objects[type]?.[id] || null,
139
-
140
- /**
141
- * Check if a type is currently loading.
142
- * @return {Function} (type: string) => boolean
143
- */
144
- isLoading: (state) => (type) => state.loading[type] || false,
145
-
146
- /**
147
- * Get the current error for a type.
148
- * @return {Function} (type: string) => ApiError|null
149
- */
150
- getError: (state) => (type) => state.errors[type] || null,
151
-
152
- /**
153
- * Get pagination state for a type.
154
- * @return {Function} (type: string) => {total, page, pages, limit}
155
- */
156
- getPagination: (state) => (type) =>
157
- state.pagination[type] || { total: 0, page: 1, pages: 1, limit: 20 },
158
-
159
- /**
160
- * Get the current search term for a type.
161
- * @return {Function} (type: string) => string
162
- */
163
- getSearchTerm: (state) => (type) => state.searchTerms[type] || '',
164
-
165
- /**
166
- * Get a cached schema for a type.
167
- * @return {Function} (type: string) => object|null
168
- */
169
- getSchema: (state) => (type) => state.schemas[type] || null,
170
- }
171
-
172
- // ── Base actions ────────────────────────────────────────────────────────
173
-
174
- const baseActions = {
175
- /**
176
- * Configure the store with custom options.
177
- * Call once before using the store if you need a custom base URL.
178
- *
179
- * @param {object} options Configuration options
180
- * @param {string} [options.baseUrl] Custom base URL for API calls
181
- */
182
- configure(options) {
183
- Object.assign(this._options, options)
184
- },
185
-
186
- /**
187
- * Register an object type for CRUD operations.
188
- *
189
- * @param {string} slug Short name for the type (e.g. 'client', 'case')
190
- * @param {string} schemaId OpenRegister schema ID
191
- * @param {string} registerId OpenRegister register ID
192
- */
193
- registerObjectType(slug, schemaId, registerId) {
194
- this.objectTypeRegistry[slug] = { schema: schemaId, register: registerId }
195
- this.collections[slug] = []
196
- this.objects[slug] = {}
197
- this.loading[slug] = false
198
- this.errors[slug] = null
199
- this.pagination[slug] = { total: 0, page: 1, pages: 1, limit: 20 }
200
- this.searchTerms[slug] = ''
201
- this.schemas[slug] = null
202
- },
203
-
204
- /**
205
- * Unregister an object type and clean up all its state.
206
- *
207
- * @param {string} slug The type slug to unregister
208
- */
209
- unregisterObjectType(slug) {
210
- delete this.objectTypeRegistry[slug]
211
- delete this.collections[slug]
212
- delete this.objects[slug]
213
- delete this.loading[slug]
214
- delete this.errors[slug]
215
- delete this.pagination[slug]
216
- delete this.searchTerms[slug]
217
- delete this.schemas[slug]
218
- },
219
-
220
- /**
221
- * Get the type config or throw if not registered.
222
- *
223
- * @param {string} type The type slug
224
- * @return {{schema: string, register: string}} Type configuration
225
- * @throws {Error} If the type is not registered
226
- */
227
- _getTypeConfig(type) {
228
- const config = this.objectTypeRegistry[type]
229
- if (!config) {
230
- throw new Error(`Object type "${type}" is not registered in the store. Call registerObjectType('${type}', schemaId, registerId) first.`)
231
- }
232
- return config
233
- },
234
-
235
- /**
236
- * Build the API URL for a type and optional object ID.
237
- *
238
- * @param {string} type The type slug
239
- * @param {string|null} [id=null] Optional object ID
240
- * @return {string} Full API URL path
241
- */
242
- _buildUrl(type, id = null) {
243
- const config = this._getTypeConfig(type)
244
- let url = `${this._options.baseUrl}/${config.register}/${config.schema}`
245
- if (id) {
246
- url += `/${id}`
247
- }
248
- return url
249
- },
250
-
251
- /**
252
- * Clear the error state for a type.
253
- *
254
- * @param {string} type The type slug
255
- */
256
- clearError(type) {
257
- this.errors[type] = null
258
- },
259
-
260
- /**
261
- * Set the search term for a type.
262
- *
263
- * @param {string} type The type slug
264
- * @param {string} term The search term
265
- */
266
- setSearchTerm(type, term) {
267
- this.searchTerms[type] = term
268
- },
269
-
270
- /**
271
- * Clear the search term for a type.
272
- *
273
- * @param {string} type The type slug
274
- */
275
- clearSearchTerm(type) {
276
- this.searchTerms[type] = ''
277
- },
278
-
279
- /**
280
- * Fetch the schema definition for a registered type.
281
- * Uses cache only fetches once per type per session.
282
- *
283
- * @param {string} type The registered type slug
284
- * @return {Promise<object|null>} The schema object or null on error
285
- */
286
- async fetchSchema(type) {
287
- const config = this._getTypeConfig(type)
288
-
289
- if (this.schemas[type]) {
290
- return this.schemas[type]
291
- }
292
-
293
- try {
294
- const response = await fetch(
295
- `/apps/openregister/api/schemas/${config.schema}`,
296
- { method: 'GET', headers: buildHeaders() },
297
- )
298
-
299
- if (!response.ok) return null
300
-
301
- const schema = await response.json()
302
- this.schemas[type] = schema
303
- return schema
304
- } catch {
305
- return null
306
- }
307
- },
308
-
309
- /**
310
- * Fetch a collection of objects for a registered type.
311
- *
312
- * @param {string} type The registered type slug
313
- * @param {object} [params={}] Query parameters (_limit, _page, _search, _order, filters)
314
- * @return {Promise<Array>} The fetched collection (also stored in state)
315
- */
316
- async fetchCollection(type, params = {}) {
317
- this.loading[type] = true
318
- this.errors[type] = null
319
-
320
- try {
321
- const url = this._buildUrl(type) + buildQueryString(params)
322
-
323
- const response = await fetch(url, {
324
- method: 'GET',
325
- headers: buildHeaders(),
326
- })
327
-
328
- if (!response.ok) {
329
- this.errors[type] = await parseResponseError(response, type)
330
- console.error(`Error fetching ${type} collection:`, this.errors[type])
331
- return []
332
- }
333
-
334
- const data = await response.json()
335
-
336
- this.collections[type] = data.results || data
337
- this.pagination[type] = {
338
- total: data.total || (data.results || data).length,
339
- page: data.page || 1,
340
- pages: data.pages || 1,
341
- limit: params._limit || 20,
342
- }
343
-
344
- return this.collections[type]
345
- } catch (error) {
346
- this.errors[type] = error.name === 'TypeError'
347
- ? networkError(error)
348
- : genericError(error)
349
- console.error(`Error fetching ${type} collection:`, error)
350
- return []
351
- } finally {
352
- this.loading[type] = false
353
- }
354
- },
355
-
356
- /**
357
- * Fetch a single object by type and ID.
358
- *
359
- * @param {string} type The registered type slug
360
- * @param {string} id The object ID or UUID
361
- * @return {Promise<object|null>} The fetched object (also cached in state)
362
- */
363
- async fetchObject(type, id) {
364
- this.loading[type] = true
365
- this.errors[type] = null
366
-
367
- try {
368
- const url = this._buildUrl(type, id)
369
-
370
- const response = await fetch(url, {
371
- method: 'GET',
372
- headers: buildHeaders(),
373
- })
374
-
375
- if (!response.ok) {
376
- this.errors[type] = await parseResponseError(response, type)
377
- console.error(`Error fetching ${type}/${id}:`, this.errors[type])
378
- return null
379
- }
380
-
381
- const data = await response.json()
382
-
383
- if (!this.objects[type]) {
384
- this.objects[type] = {}
385
- }
386
- this.objects[type][id] = data
387
-
388
- return data
389
- } catch (error) {
390
- this.errors[type] = error.name === 'TypeError'
391
- ? networkError(error)
392
- : genericError(error)
393
- console.error(`Error fetching ${type}/${id}:`, error)
394
- return null
395
- } finally {
396
- this.loading[type] = false
397
- }
398
- },
399
-
400
- /**
401
- * Create or update an object. Uses POST for new objects, PUT for updates.
402
- *
403
- * @param {string} type The registered type slug
404
- * @param {object} objectData The object data (include `id` for updates)
405
- * @return {Promise<object|null>} The saved object or null on error
406
- */
407
- async saveObject(type, objectData) {
408
- this.loading[type] = true
409
- this.errors[type] = null
410
-
411
- try {
412
- const isUpdate = !!objectData.id
413
- const url = isUpdate
414
- ? this._buildUrl(type, objectData.id)
415
- : this._buildUrl(type)
416
- const method = isUpdate ? 'PUT' : 'POST'
417
-
418
- const response = await fetch(url, {
419
- method,
420
- headers: buildHeaders(),
421
- body: JSON.stringify(objectData),
422
- })
423
-
424
- if (!response.ok) {
425
- this.errors[type] = await parseResponseError(response, type)
426
- console.error(`Error saving ${type}:`, this.errors[type])
427
- return null
428
- }
429
-
430
- const data = await response.json()
431
-
432
- if (!this.objects[type]) {
433
- this.objects[type] = {}
434
- }
435
- const savedId = data.id || objectData.id
436
- this.objects[type][savedId] = data
437
-
438
- return data
439
- } catch (error) {
440
- this.errors[type] = error.name === 'TypeError'
441
- ? networkError(error)
442
- : genericError(error)
443
- console.error(`Error saving ${type}:`, error)
444
- return null
445
- } finally {
446
- this.loading[type] = false
447
- }
448
- },
449
-
450
- /**
451
- * Delete an object by type and ID.
452
- *
453
- * @param {string} type The registered type slug
454
- * @param {string} id The object ID
455
- * @return {Promise<boolean>} True if deleted successfully
456
- */
457
- async deleteObject(type, id) {
458
- this.loading[type] = true
459
- this.errors[type] = null
460
-
461
- try {
462
- const url = this._buildUrl(type, id)
463
-
464
- const response = await fetch(url, {
465
- method: 'DELETE',
466
- headers: buildHeaders(),
467
- })
468
-
469
- if (!response.ok) {
470
- this.errors[type] = await parseResponseError(response, type)
471
- console.error(`Error deleting ${type}/${id}:`, this.errors[type])
472
- return false
473
- }
474
-
475
- if (this.objects[type]) {
476
- delete this.objects[type][id]
477
- }
478
- if (this.collections[type]) {
479
- this.collections[type] = this.collections[type].filter(
480
- (obj) => obj.id !== id,
481
- )
482
- }
483
-
484
- return true
485
- } catch (error) {
486
- this.errors[type] = error.name === 'TypeError'
487
- ? networkError(error)
488
- : genericError(error)
489
- console.error(`Error deleting ${type}/${id}:`, error)
490
- return false
491
- } finally {
492
- this.loading[type] = false
493
- }
494
- },
495
-
496
- /**
497
- * Batch-resolve references by fetching multiple objects by their IDs.
498
- * Uses the cache first, only fetches uncached objects.
499
- *
500
- * @param {string} type The registered type slug
501
- * @param {string[]} ids Array of object IDs to resolve
502
- * @return {Promise<Object<string, object>>} Map of id -> object
503
- */
504
- async resolveReferences(type, ids) {
505
- if (!ids || ids.length === 0) return {}
506
-
507
- const uniqueIds = [...new Set(ids.filter(Boolean))]
508
- const result = {}
509
- const toFetch = []
510
-
511
- for (const id of uniqueIds) {
512
- const cached = this.objects[type]?.[id]
513
- if (cached) {
514
- result[id] = cached
515
- } else {
516
- toFetch.push(id)
517
- }
518
- }
519
-
520
- if (toFetch.length > 0) {
521
- const fetches = toFetch.map(async (id) => {
522
- try {
523
- const url = this._buildUrl(type, id)
524
- const response = await fetch(url, {
525
- method: 'GET',
526
- headers: buildHeaders(),
527
- })
528
- if (response.ok) {
529
- const data = await response.json()
530
- if (!this.objects[type]) this.objects[type] = {}
531
- this.objects[type][id] = data
532
- result[id] = data
533
- }
534
- } catch {
535
- // Non-blocking — leave unresolved
536
- }
537
- })
538
- await Promise.all(fetches)
539
- }
540
-
541
- return result
542
- },
543
- }
544
-
545
- // ── Store factory ───────────────────────────────────────────────────────
546
-
547
- /**
548
- * Create the object store definition with a given store ID and optional plugins.
549
- *
550
- * Plugins are merged into the store at definition time. Each plugin provides
551
- * additional state, getters, and actions (e.g. for sub-resources like files,
552
- * audit trails, relations).
553
- *
554
- * @param {string} storeId Pinia store identifier
555
- * @param {Array} [plugins=[]] Array of plugin definitions
556
- * @return {Function} Pinia store composable
557
- */
558
- function defineObjectStore(storeId, plugins = []) {
559
- const pluginState = mergePluginState(plugins)
560
- const pluginGetters = mergePluginGetters(plugins)
561
- const pluginActions = mergePluginActions(plugins)
562
-
563
- return defineStore(storeId, {
564
- state: () => ({
565
- ...baseState(),
566
- ...pluginState,
567
- }),
568
-
569
- getters: {
570
- ...baseGetters,
571
- ...pluginGetters,
572
- },
573
-
574
- actions: {
575
- ...baseActions,
576
- ...pluginActions,
577
-
578
- /**
579
- * Clear all sub-resource data from active plugins.
580
- * Calls each plugin's clear method (e.g. clearFiles, clearAuditTrails).
581
- */
582
- clearAllSubResources() {
583
- for (const plugin of plugins) {
584
- const clearFn = `clear${capitalize(plugin.name)}`
585
- if (typeof this[clearFn] === 'function') {
586
- this[clearFn]()
587
- }
588
- }
589
- },
590
- },
591
- })
592
- }
593
-
594
- /**
595
- * Default object store instance with ID 'conduction-objects'.
596
- *
597
- * @example
598
- * import { useObjectStore } from '@conduction/nextcloud-vue'
599
- * const store = useObjectStore()
600
- */
601
- export const useObjectStore = defineObjectStore(DEFAULT_STORE_ID)
602
-
603
- /**
604
- * Factory function to create an object store with a custom Pinia store ID
605
- * and optional plugins for sub-resources.
606
- *
607
- * @param {string} storeId Custom Pinia store identifier
608
- * @param {object} [options={}] Configuration options
609
- * @param {Array} [options.plugins=[]] Array of sub-resource plugins
610
- * @return {Function} Pinia store composable
611
- *
612
- * @example
613
- * // Basic (backwards compatible)
614
- * const useMyStore = createObjectStore('object')
615
- *
616
- * @example
617
- * // With plugins
618
- * import { filesPlugin, auditTrailsPlugin } from '@conduction/nextcloud-vue'
619
- * const useMyStore = createObjectStore('object', {
620
- * plugins: [filesPlugin(), auditTrailsPlugin()],
621
- * })
622
- */
623
- export function createObjectStore(storeId, options = {}) {
624
- return defineObjectStore(storeId, options.plugins || [])
625
- }
1
+ import { defineStore } from 'pinia'
2
+ import { buildHeaders, buildQueryString, prefixUrl, capitalize } from '../utils/headers.js'
3
+ import { parseResponseError, networkError, genericError } from '../utils/errors.js'
4
+ import { extractId } from '../utils/id.js'
5
+
6
+ /**
7
+ * Generic Pinia store for OpenRegister object CRUD operations.
8
+ *
9
+ * Provides a unified interface for managing objects across registers and schemas.
10
+ * Apps register their object types with schema/register IDs, then use type slugs
11
+ * for all operations. Supports plugins for sub-resources (files, audit trails, etc.).
12
+ *
13
+ * @example
14
+ * // Basic usage (CRUD only)
15
+ * import { useObjectStore } from '@conduction/nextcloud-vue'
16
+ * const store = useObjectStore()
17
+ *
18
+ * @example
19
+ * // With plugins
20
+ * import { createObjectStore, filesPlugin, auditTrailsPlugin } from '@conduction/nextcloud-vue'
21
+ * const useMyStore = createObjectStore('object', {
22
+ * plugins: [filesPlugin(), auditTrailsPlugin()],
23
+ * })
24
+ */
25
+
26
+ const DEFAULT_STORE_ID = 'conduction-objects'
27
+ const DEFAULT_BASE_URL = '/apps/openregister/api/objects'
28
+
29
+ /**
30
+ * Merge plugin state factories into a single state object.
31
+ *
32
+ * @param {Array} plugins Array of plugin definitions
33
+ * @return {object} Merged state object
34
+ */
35
+ function mergePluginState(plugins) {
36
+ const merged = {}
37
+ for (const plugin of plugins) {
38
+ if (plugin.state) {
39
+ Object.assign(merged, plugin.state())
40
+ }
41
+ }
42
+ return merged
43
+ }
44
+
45
+ /**
46
+ * Merge plugin getters into a single getters object.
47
+ *
48
+ * @param {Array} plugins Array of plugin definitions
49
+ * @return {object} Merged getters object
50
+ */
51
+ function mergePluginGetters(plugins) {
52
+ const merged = {}
53
+ for (const plugin of plugins) {
54
+ if (plugin.getters) {
55
+ Object.assign(merged, plugin.getters)
56
+ }
57
+ }
58
+ return merged
59
+ }
60
+
61
+ /**
62
+ * Merge plugin actions into a single actions object.
63
+ *
64
+ * @param {Array} plugins Array of plugin definitions
65
+ * @return {object} Merged actions object
66
+ */
67
+ function mergePluginActions(plugins) {
68
+ const merged = {}
69
+ for (const plugin of plugins) {
70
+ if (plugin.actions) {
71
+ Object.assign(merged, plugin.actions)
72
+ }
73
+ }
74
+ return merged
75
+ }
76
+
77
+ // ── Base state ──────────────────────────────────────────────────────────
78
+
79
+ function baseState(baseUrl = DEFAULT_BASE_URL) {
80
+ return {
81
+ /** @type {{string: {schema: string, register: string}}} */
82
+ objectTypeRegistry: {},
83
+ /** @type {{string: Array}} */
84
+ collections: {},
85
+ /** @type {{string: {string: object}}} */
86
+ objects: {},
87
+ /** @type {{string: boolean}} */
88
+ loading: {},
89
+ /** @type {{string: import('../utils/errors.js').ApiError|null}} */
90
+ errors: {},
91
+ /** @type {{string: {total: number, page: number, pages: number, limit: number}}} */
92
+ pagination: {},
93
+ /** @type {{string: string}} */
94
+ searchTerms: {},
95
+ /** @type {{string: object|null}} */
96
+ schemas: {},
97
+ /** @type {{string: object|null}} */
98
+ registers: {},
99
+ /**
100
+ * Facet data per type for CnIndexSidebar: { fieldName: { values: [{value, count}] } }
101
+ * @type {{string: object}}
102
+ */
103
+ facets: {},
104
+ /** @type {{baseUrl: string}} */
105
+ _options: {
106
+ baseUrl,
107
+ },
108
+ }
109
+ }
110
+
111
+ // ── Base getters ────────────────────────────────────────────────────────
112
+
113
+ const baseGetters = {
114
+ /**
115
+ * Get all registered object type slugs.
116
+ * @param {object} state Pinia state
117
+ * @return {string[]}
118
+ */
119
+ objectTypes: (state) => Object.keys(state.objectTypeRegistry),
120
+
121
+ /**
122
+ * Get the collection array for a type.
123
+ * @param {object} state Pinia state
124
+ * @return {Function} (type: string) => Array
125
+ */
126
+ getCollection: (state) => (type) => state.collections[type] || [],
127
+
128
+ /**
129
+ * Get a single cached object by type and ID.
130
+ * @param {object} state Pinia state
131
+ * @return {Function} (type: string, id: string) => object|null
132
+ */
133
+ getObject: (state) => (type, id) => state.objects[type]?.[id] || null,
134
+
135
+ /**
136
+ * Alias for getObject check cache without fetching.
137
+ * @param {object} state Pinia state
138
+ * @return {Function} (type: string, id: string) => object|null
139
+ */
140
+ getCachedObject: (state) => (type, id) => state.objects[type]?.[id] || null,
141
+
142
+ /**
143
+ * Check if a type is currently loading.
144
+ * @param {object} state Pinia state
145
+ * @return {Function} (type: string) => boolean
146
+ */
147
+ isLoading: (state) => (type) => state.loading[type] || false,
148
+
149
+ /**
150
+ * Get the current error for a type.
151
+ * @param {object} state Pinia state
152
+ * @return {Function} (type: string) => ApiError|null
153
+ */
154
+ getError: (state) => (type) => state.errors[type] || null,
155
+
156
+ /**
157
+ * Get pagination state for a type.
158
+ * @param {object} state Pinia state
159
+ * @return {Function} (type: string) => {total, page, pages, limit}
160
+ */
161
+ getPagination: (state) => (type) =>
162
+ state.pagination[type] || { total: 0, page: 1, pages: 1, limit: 20 },
163
+
164
+ /**
165
+ * Get the current search term for a type.
166
+ * @param {object} state Pinia state
167
+ * @return {Function} (type: string) => string
168
+ */
169
+ getSearchTerm: (state) => (type) => state.searchTerms[type] || '',
170
+
171
+ /**
172
+ * Get a cached schema for a type.
173
+ * @param {object} state Pinia state
174
+ * @return {Function} (type: string) => object|null
175
+ */
176
+ getSchema: (state) => (type) => state.schemas[type] || null,
177
+
178
+ /**
179
+ * Get a cached register for a type.
180
+ * @param {object} state Pinia state
181
+ * @return {Function} (type: string) => object|null
182
+ */
183
+ getRegister: (state) => (type) => state.registers[type] || null,
184
+
185
+ /**
186
+ * Get facet data for a type (CnIndexSidebar-compatible format).
187
+ * @param {object} state Pinia state
188
+ * @return {Function} (type: string) => object
189
+ */
190
+ getFacets: (state) => (type) => state.facets[type] || {},
191
+ }
192
+
193
+ // ── Base actions ────────────────────────────────────────────────────────
194
+
195
+ const baseActions = {
196
+ /**
197
+ * Configure the store with custom options.
198
+ * Call once before using the store if you need a custom base URL.
199
+ *
200
+ * @param {object} options Configuration options
201
+ * @param {string} [options.baseUrl] Custom base URL for API calls
202
+ */
203
+ configure(options) {
204
+ Object.assign(this._options, options)
205
+ },
206
+
207
+ /**
208
+ * Create a standard object type slug.
209
+ *
210
+ * takes a unspecified number of props and joins them from first to left with a `-`.
211
+ * However it is recommended to give it 1 register and 1 schema in that order.
212
+ * @param {*} params - unspecified number of props
213
+ * @return {string}
214
+ */
215
+ createObjectTypeSlug(...params) {
216
+ const contentIds = params.map((x) => extractId(x))
217
+
218
+ return contentIds.join('-')
219
+ },
220
+
221
+ /**
222
+ * Register an object type for CRUD operations.
223
+ *
224
+ * @param {string} slug Short name for the type (e.g. 'client', 'case')
225
+ * @param {string} schemaId OpenRegister schema ID
226
+ * @param {string} registerId OpenRegister register ID
227
+ */
228
+ registerObjectType(slug, schemaId, registerId) {
229
+ // Replace entire objects so Vue 2 reactivity detects the change
230
+ // (Vue 2 cannot track new properties added to existing reactive objects)
231
+ this.objectTypeRegistry = { ...this.objectTypeRegistry, [slug]: { schema: schemaId, register: registerId } }
232
+ this.collections = { ...this.collections, [slug]: [] }
233
+ this.objects = { ...this.objects, [slug]: {} }
234
+ this.loading = { ...this.loading, [slug]: false }
235
+ this.errors = { ...this.errors, [slug]: null }
236
+ this.pagination = { ...this.pagination, [slug]: { total: 0, page: 1, pages: 1, limit: 20 } }
237
+ this.searchTerms = { ...this.searchTerms, [slug]: '' }
238
+ this.schemas = { ...this.schemas, [slug]: null }
239
+ this.registers = { ...this.registers, [slug]: null }
240
+ this.facets = { ...this.facets, [slug]: {} }
241
+ },
242
+
243
+ /**
244
+ * Unregister an object type and clean up all its state.
245
+ *
246
+ * @param {string} slug The type slug to unregister
247
+ */
248
+ unregisterObjectType(slug) {
249
+ const omit = (obj, key) => {
250
+ const { [key]: _, ...rest } = obj
251
+ return rest
252
+ }
253
+ this.objectTypeRegistry = omit(this.objectTypeRegistry, slug)
254
+ this.collections = omit(this.collections, slug)
255
+ this.objects = omit(this.objects, slug)
256
+ this.loading = omit(this.loading, slug)
257
+ this.errors = omit(this.errors, slug)
258
+ this.pagination = omit(this.pagination, slug)
259
+ this.searchTerms = omit(this.searchTerms, slug)
260
+ this.schemas = omit(this.schemas, slug)
261
+ this.registers = omit(this.registers, slug)
262
+ this.facets = omit(this.facets, slug)
263
+ },
264
+
265
+ /**
266
+ * Get the type config or throw if not registered.
267
+ *
268
+ * @param {string} type The type slug
269
+ * @return {{schema: string, register: string}} Type configuration
270
+ * @throws {Error} If the type is not registered
271
+ */
272
+ _getTypeConfig(type) {
273
+ const config = this.objectTypeRegistry[type]
274
+ if (!config) {
275
+ throw new Error(`Object type "${type}" is not registered in the store. Call registerObjectType('${type}', schemaId, registerId) first.`)
276
+ }
277
+ return config
278
+ },
279
+
280
+ /**
281
+ * Build the API URL for a type and optional object ID.
282
+ *
283
+ * @param {string} type The type slug
284
+ * @param {string|null} [id] Optional object ID
285
+ * @return {string} Full API URL path
286
+ */
287
+ _buildUrl(type, id = null) {
288
+ const config = this._getTypeConfig(type)
289
+ let url = `${this._options.baseUrl}/${config.register}/${config.schema}`
290
+ if (id) {
291
+ url += `/${id}`
292
+ }
293
+ return url
294
+ },
295
+
296
+ /**
297
+ * Clear the error state for a type.
298
+ *
299
+ * @param {string} type The type slug
300
+ */
301
+ clearError(type) {
302
+ this.errors = { ...this.errors, [type]: null }
303
+ },
304
+
305
+ /**
306
+ * Set the search term for a type.
307
+ *
308
+ * @param {string} type The type slug
309
+ * @param {string} term The search term
310
+ */
311
+ setSearchTerm(type, term) {
312
+ this.searchTerms = { ...this.searchTerms, [type]: term }
313
+ },
314
+
315
+ /**
316
+ * Clear the search term for a type.
317
+ *
318
+ * @param {string} type The type slug
319
+ */
320
+ clearSearchTerm(type) {
321
+ this.searchTerms = { ...this.searchTerms, [type]: '' }
322
+ },
323
+
324
+ /**
325
+ * Fetch the schema definition for a registered type.
326
+ * Uses cache — only fetches once per type per session.
327
+ *
328
+ * @param {string} type The registered type slug
329
+ * @return {Promise<object|null>} The schema object or null on error
330
+ */
331
+ async fetchSchema(type) {
332
+ const config = this._getTypeConfig(type)
333
+
334
+ if (this.schemas[type]) {
335
+ return this.schemas[type]
336
+ }
337
+
338
+ try {
339
+ const response = await fetch(
340
+ prefixUrl(`/apps/openregister/api/schemas/${config.schema}`),
341
+ { method: 'GET', headers: buildHeaders() },
342
+ )
343
+
344
+ if (!response.ok) return null
345
+
346
+ const schema = await response.json()
347
+ this.schemas = { ...this.schemas, [type]: schema }
348
+ return schema
349
+ } catch {
350
+ return null
351
+ }
352
+ },
353
+
354
+ /**
355
+ * Fetch the register definition for a registered type.
356
+ * Uses cache — only fetches once per type per session.
357
+ *
358
+ * @param {string} type The registered type slug
359
+ * @return {Promise<object|null>} The register object or null on error
360
+ */
361
+ async fetchRegister(type) {
362
+ const config = this._getTypeConfig(type)
363
+
364
+ if (this.registers[type]) {
365
+ return this.registers[type]
366
+ }
367
+
368
+ try {
369
+ const response = await fetch(
370
+ prefixUrl(`/apps/openregister/api/registers/${config.register}`),
371
+ { method: 'GET', headers: buildHeaders() },
372
+ )
373
+
374
+ if (!response.ok) return null
375
+
376
+ const register = await response.json()
377
+ this.registers = { ...this.registers, [type]: register }
378
+ return register
379
+ } catch {
380
+ return null
381
+ }
382
+ },
383
+
384
+ /**
385
+ * Fetch a collection of objects for a registered type.
386
+ *
387
+ * @param {string} type The registered type slug
388
+ * @param {object} [params] Query parameters (_limit, _page, _search, _order, filters)
389
+ * @return {Promise<Array>} The fetched collection (also stored in state)
390
+ */
391
+ async fetchCollection(type, params = {}) {
392
+ this.loading = { ...this.loading, [type]: true }
393
+ this.errors = { ...this.errors, [type]: null }
394
+
395
+ try {
396
+ // Auto-include _facets=extend when schema has facetable properties
397
+ const fetchParams = { ...params }
398
+ if (!fetchParams._facets) {
399
+ const schema = this.schemas[type]
400
+ const hasFacetable = schema
401
+ && schema.properties
402
+ && Object.values(schema.properties).some((p) => p.facetable)
403
+ if (hasFacetable) {
404
+ fetchParams._facets = 'extend'
405
+ }
406
+ }
407
+
408
+ const url = this._buildUrl(type) + buildQueryString(fetchParams)
409
+
410
+ const response = await fetch(url, {
411
+ method: 'GET',
412
+ headers: buildHeaders(),
413
+ })
414
+
415
+ if (!response.ok) {
416
+ this.errors = { ...this.errors, [type]: await parseResponseError(response, type) }
417
+ console.error(`Error fetching ${type} collection:`, this.errors[type])
418
+ return []
419
+ }
420
+
421
+ const data = await response.json()
422
+ const results = data.results || data
423
+
424
+ this.collections = { ...this.collections, [type]: results }
425
+ this.pagination = {
426
+ ...this.pagination,
427
+ [type]: {
428
+ total: data.total || results.length,
429
+ page: data.page || 1,
430
+ pages: data.pages || 1,
431
+ limit: params._limit || 20,
432
+ },
433
+ }
434
+
435
+ // Parse facet data from API response and transform to CnIndexSidebar format
436
+ if (data.facets) {
437
+ const transformed = {}
438
+ for (const [key, facet] of Object.entries(data.facets)) {
439
+ if (facet.buckets || facet.data?.buckets) {
440
+ const buckets = facet.buckets || facet.data.buckets
441
+ transformed[key] = {
442
+ values: buckets.map((b) => ({
443
+ value: b.key ?? b.value,
444
+ count: b.count || 0,
445
+ })),
446
+ }
447
+ }
448
+ }
449
+ this.facets = { ...this.facets, [type]: transformed }
450
+ }
451
+
452
+ return results
453
+ } catch (error) {
454
+ this.errors = {
455
+ ...this.errors,
456
+ [type]: error.name === 'TypeError'
457
+ ? networkError(error)
458
+ : genericError(error),
459
+ }
460
+ console.error(`Error fetching ${type} collection:`, error)
461
+ return []
462
+ } finally {
463
+ this.loading = { ...this.loading, [type]: false }
464
+ }
465
+ },
466
+
467
+ /**
468
+ * Fetch a single object by type and ID.
469
+ *
470
+ * @param {string} type The registered type slug
471
+ * @param {string} id The object ID or UUID
472
+ * @return {Promise<object|null>} The fetched object (also cached in state)
473
+ */
474
+ async fetchObject(type, id) {
475
+ this.loading = { ...this.loading, [type]: true }
476
+ this.errors = { ...this.errors, [type]: null }
477
+
478
+ try {
479
+ const url = this._buildUrl(type, id)
480
+
481
+ const response = await fetch(url, {
482
+ method: 'GET',
483
+ headers: buildHeaders(),
484
+ })
485
+
486
+ if (!response.ok) {
487
+ this.errors = { ...this.errors, [type]: await parseResponseError(response, type) }
488
+ console.error(`Error fetching ${type}/${id}:`, this.errors[type])
489
+ return null
490
+ }
491
+
492
+ const data = await response.json()
493
+
494
+ this.objects = {
495
+ ...this.objects,
496
+ [type]: { ...(this.objects[type] || {}), [id]: data },
497
+ }
498
+
499
+ return data
500
+ } catch (error) {
501
+ this.errors = {
502
+ ...this.errors,
503
+ [type]: error.name === 'TypeError'
504
+ ? networkError(error)
505
+ : genericError(error),
506
+ }
507
+ console.error(`Error fetching ${type}/${id}:`, error)
508
+ return null
509
+ } finally {
510
+ this.loading = { ...this.loading, [type]: false }
511
+ }
512
+ },
513
+
514
+ /**
515
+ * Create or update an object. Uses POST for new objects, PUT for updates.
516
+ *
517
+ * @param {string} type The registered type slug
518
+ * @param {object} objectData The object data (include `id` for updates)
519
+ * @return {Promise<object|null>} The saved object or null on error
520
+ */
521
+ async saveObject(type, objectData) {
522
+ this.loading = { ...this.loading, [type]: true }
523
+ this.errors = { ...this.errors, [type]: null }
524
+
525
+ try {
526
+ const isUpdate = !!objectData.id
527
+ const url = isUpdate
528
+ ? this._buildUrl(type, objectData.id)
529
+ : this._buildUrl(type)
530
+ const method = isUpdate ? 'PUT' : 'POST'
531
+
532
+ const response = await fetch(url, {
533
+ method,
534
+ headers: buildHeaders(),
535
+ body: JSON.stringify(objectData),
536
+ })
537
+
538
+ if (!response.ok) {
539
+ this.errors = { ...this.errors, [type]: await parseResponseError(response, type) }
540
+ console.error(`Error saving ${type}:`, this.errors[type])
541
+ return null
542
+ }
543
+
544
+ const data = await response.json()
545
+ const savedId = data.id || objectData.id
546
+
547
+ this.objects = {
548
+ ...this.objects,
549
+ [type]: { ...(this.objects[type] || {}), [savedId]: data },
550
+ }
551
+
552
+ return data
553
+ } catch (error) {
554
+ this.errors = {
555
+ ...this.errors,
556
+ [type]: error.name === 'TypeError'
557
+ ? networkError(error)
558
+ : genericError(error),
559
+ }
560
+ console.error(`Error saving ${type}:`, error)
561
+ return null
562
+ } finally {
563
+ this.loading = { ...this.loading, [type]: false }
564
+ }
565
+ },
566
+
567
+ /**
568
+ * Delete an object by type and ID.
569
+ *
570
+ * @param {string} type The registered type slug
571
+ * @param {string} id The object ID
572
+ * @return {Promise<boolean>} True if deleted successfully
573
+ */
574
+ async deleteObject(type, id) {
575
+ this.loading = { ...this.loading, [type]: true }
576
+ this.errors = { ...this.errors, [type]: null }
577
+
578
+ try {
579
+ const url = this._buildUrl(type, id)
580
+
581
+ const response = await fetch(url, {
582
+ method: 'DELETE',
583
+ headers: buildHeaders(),
584
+ })
585
+
586
+ if (!response.ok) {
587
+ this.errors = { ...this.errors, [type]: await parseResponseError(response, type) }
588
+ console.error(`Error deleting ${type}/${id}:`, this.errors[type])
589
+ return false
590
+ }
591
+
592
+ if (this.objects[type]) {
593
+ const { [id]: _, ...remaining } = this.objects[type]
594
+ this.objects = { ...this.objects, [type]: remaining }
595
+ }
596
+ if (this.collections[type]) {
597
+ this.collections = {
598
+ ...this.collections,
599
+ [type]: this.collections[type].filter((obj) => obj.id !== id),
600
+ }
601
+ }
602
+
603
+ return true
604
+ } catch (error) {
605
+ this.errors = {
606
+ ...this.errors,
607
+ [type]: error.name === 'TypeError'
608
+ ? networkError(error)
609
+ : genericError(error),
610
+ }
611
+ console.error(`Error deleting ${type}/${id}:`, error)
612
+ return false
613
+ } finally {
614
+ this.loading = { ...this.loading, [type]: false }
615
+ }
616
+ },
617
+
618
+ /**
619
+ * Delete multiple objects by type and IDs in parallel.
620
+ * Each delete is run via Promise.all; partial success is reported so the UI can show which succeeded or failed.
621
+ *
622
+ * @param {string} type The registered type slug
623
+ * @param {string[]} ids Array of object IDs to delete
624
+ * @return {Promise<{ successfulIds: string[], failedIds: string[] }>} Result with successful and failed IDs
625
+ */
626
+ async deleteObjects(type, ids) {
627
+ const result = { successfulIds: [], failedIds: [] }
628
+ if (!ids?.length) return result
629
+
630
+ this.loading = { ...this.loading, [type]: true }
631
+ this.errors = { ...this.errors, [type]: null }
632
+
633
+ try {
634
+ const runOne = async (id) => {
635
+ try {
636
+ const url = this._buildUrl(type, id)
637
+ const response = await fetch(url, {
638
+ method: 'DELETE',
639
+ headers: buildHeaders(),
640
+ })
641
+ return { id, success: response.ok }
642
+ } catch (error) {
643
+ console.error(`Error deleting ${type}/${id}:`, error)
644
+ return { id, success: false }
645
+ }
646
+ }
647
+
648
+ const outcomes = await Promise.all(ids.map(runOne))
649
+ for (const { id, success } of outcomes) {
650
+ if (success) result.successfulIds.push(id)
651
+ else result.failedIds.push(id)
652
+ }
653
+
654
+ if (result.successfulIds.length > 0) {
655
+ const successSet = new Set(result.successfulIds)
656
+ if (this.objects[type]) {
657
+ const remaining = {}
658
+ for (const [k, v] of Object.entries(this.objects[type])) {
659
+ if (!successSet.has(k)) remaining[k] = v
660
+ }
661
+ this.objects = { ...this.objects, [type]: remaining }
662
+ }
663
+ if (this.collections[type]) {
664
+ this.collections = {
665
+ ...this.collections,
666
+ [type]: this.collections[type].filter((obj) => !successSet.has(obj.id)),
667
+ }
668
+ }
669
+ }
670
+
671
+ if (result.failedIds.length > 0) {
672
+ this.errors = {
673
+ ...this.errors,
674
+ [type]: genericError(new Error(`Failed to delete ${result.failedIds.length} item(s)`)),
675
+ }
676
+ }
677
+
678
+ return result
679
+ } finally {
680
+ this.loading = { ...this.loading, [type]: false }
681
+ }
682
+ },
683
+
684
+ /**
685
+ * Batch-resolve references by fetching multiple objects by their IDs.
686
+ * Uses the cache first, only fetches uncached objects.
687
+ *
688
+ * @param {string} type The registered type slug
689
+ * @param {string[]} ids Array of object IDs to resolve
690
+ * @return {Promise<{[key: string]: object}>} Map of id -> object
691
+ */
692
+ async resolveReferences(type, ids) {
693
+ if (!ids || ids.length === 0) return {}
694
+
695
+ const uniqueIds = [...new Set(ids.filter(Boolean))]
696
+ const result = {}
697
+ const toFetch = []
698
+
699
+ for (const id of uniqueIds) {
700
+ const cached = this.objects[type]?.[id]
701
+ if (cached) {
702
+ result[id] = cached
703
+ } else {
704
+ toFetch.push(id)
705
+ }
706
+ }
707
+
708
+ if (toFetch.length > 0) {
709
+ const fetches = toFetch.map(async (id) => {
710
+ try {
711
+ const url = this._buildUrl(type, id)
712
+ const response = await fetch(url, {
713
+ method: 'GET',
714
+ headers: buildHeaders(),
715
+ })
716
+ if (response.ok) {
717
+ const data = await response.json()
718
+ this.objects = {
719
+ ...this.objects,
720
+ [type]: { ...(this.objects[type] || {}), [id]: data },
721
+ }
722
+ result[id] = data
723
+ }
724
+ } catch {
725
+ // Non-blocking — leave unresolved
726
+ }
727
+ })
728
+ await Promise.all(fetches)
729
+ }
730
+
731
+ return result
732
+ },
733
+ }
734
+
735
+ // ── Store factory ───────────────────────────────────────────────────────
736
+
737
+ /**
738
+ * Create the object store definition with a given store ID and optional plugins.
739
+ *
740
+ * Plugins are merged into the store at definition time. Each plugin provides
741
+ * additional state, getters, and actions (e.g. for sub-resources like files,
742
+ * audit trails, relations).
743
+ *
744
+ * @param {string} storeId Pinia store identifier
745
+ * @param {Array} [plugins] Array of plugin definitions
746
+ * @param {string} [baseUrl] Base API URL override
747
+ * @return {Function} Pinia store composable
748
+ */
749
+ function defineObjectStore(storeId, plugins = [], baseUrl = DEFAULT_BASE_URL) {
750
+ const pluginState = mergePluginState(plugins)
751
+ const pluginGetters = mergePluginGetters(plugins)
752
+ const pluginActions = mergePluginActions(plugins)
753
+
754
+ return defineStore(storeId, {
755
+ state: () => ({
756
+ ...baseState(baseUrl),
757
+ ...pluginState,
758
+ }),
759
+
760
+ getters: {
761
+ ...baseGetters,
762
+ ...pluginGetters,
763
+ },
764
+
765
+ actions: {
766
+ ...baseActions,
767
+ ...pluginActions,
768
+
769
+ /**
770
+ * Clear all sub-resource data from active plugins.
771
+ * Calls each plugin's clear method (e.g. clearFiles, clearAuditTrails).
772
+ */
773
+ clearAllSubResources() {
774
+ for (const plugin of plugins) {
775
+ const clearFn = `clear${capitalize(plugin.name)}`
776
+ if (typeof this[clearFn] === 'function') {
777
+ this[clearFn]()
778
+ }
779
+ }
780
+ },
781
+ },
782
+ })
783
+ }
784
+
785
+ /**
786
+ * Default object store instance with ID 'conduction-objects'.
787
+ *
788
+ * @example
789
+ * import { useObjectStore } from '@conduction/nextcloud-vue'
790
+ * const store = useObjectStore()
791
+ */
792
+ export const useObjectStore = defineObjectStore(DEFAULT_STORE_ID, [], prefixUrl(DEFAULT_BASE_URL))
793
+
794
+ /**
795
+ * Factory function to create an object store with a custom Pinia store ID
796
+ * and optional plugins for sub-resources.
797
+ *
798
+ * @param {string} storeId Custom Pinia store identifier
799
+ * @param {object} [options] Configuration options
800
+ * @param {Array} [options.plugins] Array of sub-resource plugins
801
+ * @param {string} [options.baseUrl] Base API URL override
802
+ * @return {Function} Pinia store composable
803
+ *
804
+ * @example
805
+ * // Basic (backwards compatible)
806
+ * const useMyStore = createObjectStore('object')
807
+ *
808
+ * @example
809
+ * // With plugins
810
+ * import { filesPlugin, auditTrailsPlugin } from '@conduction/nextcloud-vue'
811
+ * const useMyStore = createObjectStore('object', {
812
+ * plugins: [filesPlugin(), auditTrailsPlugin()],
813
+ * })
814
+ *
815
+ * @example
816
+ * // With custom baseUrl
817
+ * const useMyStore = createObjectStore('object', {
818
+ * baseUrl: '/apps/myapp/api/objects',
819
+ * })
820
+ */
821
+ export function createObjectStore(storeId, options = {}) {
822
+ return defineObjectStore(storeId, options.plugins || [], options.baseUrl || prefixUrl(DEFAULT_BASE_URL))
823
+ }