@conduction/nextcloud-vue 0.1.0-beta.3 → 0.1.0-beta.5

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