viur-ui 0.0.1

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 (114) hide show
  1. package/README.md +79 -0
  2. package/index.d.ts +351 -0
  3. package/index.js +46 -0
  4. package/package.json +84 -0
  5. package/src/components/Browser/Browser.vue +496 -0
  6. package/src/components/Browser/TreeNav.vue +408 -0
  7. package/src/components/Browser/ViurBrowser.d.vue.ts +64 -0
  8. package/src/components/Browser/ViurBrowser.vue +271 -0
  9. package/src/components/Browser/dragSession.ts +134 -0
  10. package/src/components/Browser/index.ts +14 -0
  11. package/src/components/Browser/types.ts +30 -0
  12. package/src/components/DataTable/ColumnFilterPanel.vue +225 -0
  13. package/src/components/DataTable/DataTable.vue +3155 -0
  14. package/src/components/DataTable/ViurDataTable.d.vue.ts +72 -0
  15. package/src/components/DataTable/ViurDataTable.vue +331 -0
  16. package/src/components/DataTable/columns.ts +400 -0
  17. package/src/components/DataTable/index.ts +60 -0
  18. package/src/components/DataTable/rows.ts +446 -0
  19. package/src/components/DataTable/types.ts +267 -0
  20. package/src/components/DataTable/useColumnLayout.ts +294 -0
  21. package/src/components/DataTable/useGridNavigation.ts +193 -0
  22. package/src/components/DataTable/useVirtualRows.ts +126 -0
  23. package/src/components/DataTable/viurColumns.ts +330 -0
  24. package/src/forms/ViForm.d.vue.ts +106 -0
  25. package/src/forms/ViForm.vue +592 -0
  26. package/src/forms/ViFormCategory.vue +57 -0
  27. package/src/forms/bones/Bone.vue +1023 -0
  28. package/src/forms/bones/BoneActions.vue +127 -0
  29. package/src/forms/bones/BoneLabel.vue +122 -0
  30. package/src/forms/bones/BoneMultipleList.vue +91 -0
  31. package/src/forms/bones/BoneMultipleWrapper.vue +105 -0
  32. package/src/forms/bones/BoneNestedWrapper.vue +155 -0
  33. package/src/forms/bones/BoneSingleEntry.vue +51 -0
  34. package/src/forms/bones/FilterBone.vue +694 -0
  35. package/src/forms/bones/actionbar/defaultBar.vue +98 -0
  36. package/src/forms/bones/actionbar/fileBar.vue +165 -0
  37. package/src/forms/bones/actionbar/relationalBar.vue +125 -0
  38. package/src/forms/bones/actions/ActionDialog.vue +70 -0
  39. package/src/forms/bones/actions/Debug.vue +106 -0
  40. package/src/forms/bones/actions/Delete.vue +13 -0
  41. package/src/forms/bones/actions/DescribeImage.vue +201 -0
  42. package/src/forms/bones/actions/Download.vue +27 -0
  43. package/src/forms/bones/actions/Placeholder.vue +14 -0
  44. package/src/forms/bones/actions/Translation.vue +238 -0
  45. package/src/forms/bones/actions/index.js +280 -0
  46. package/src/forms/bones/bone.d.ts +45 -0
  47. package/src/forms/bones/country_information.json +779 -0
  48. package/src/forms/bones/index.js +225 -0
  49. package/src/forms/bones/uploadAdapter.js +46 -0
  50. package/src/forms/bones/utils.js +284 -0
  51. package/src/forms/bones/view/base_item.vue +35 -0
  52. package/src/forms/bones/view/boneLogic.js +202 -0
  53. package/src/forms/bones/view/index.js +5 -0
  54. package/src/forms/bones/widgets/booleanBone.vue +83 -0
  55. package/src/forms/bones/widgets/booleanBoneChoose.vue +57 -0
  56. package/src/forms/bones/widgets/booleanBoneSelect.vue +58 -0
  57. package/src/forms/bones/widgets/codeBone.vue +214 -0
  58. package/src/forms/bones/widgets/colorBone.vue +75 -0
  59. package/src/forms/bones/widgets/dateBone.vue +114 -0
  60. package/src/forms/bones/widgets/emailBone.vue +100 -0
  61. package/src/forms/bones/widgets/fileBone.vue +273 -0
  62. package/src/forms/bones/widgets/jsonBone.vue +63 -0
  63. package/src/forms/bones/widgets/keyBone.vue +71 -0
  64. package/src/forms/bones/widgets/numericBone.vue +125 -0
  65. package/src/forms/bones/widgets/passwordBone.vue +215 -0
  66. package/src/forms/bones/widgets/phoneBone.vue +173 -0
  67. package/src/forms/bones/widgets/rawBone.vue +94 -0
  68. package/src/forms/bones/widgets/recordBone.vue +64 -0
  69. package/src/forms/bones/widgets/relationalBone.vue +164 -0
  70. package/src/forms/bones/widgets/relationalBoneSelect.vue +120 -0
  71. package/src/forms/bones/widgets/selectBone.vue +122 -0
  72. package/src/forms/bones/widgets/selectBoneChoose.vue +104 -0
  73. package/src/forms/bones/widgets/spatialBone.vue +111 -0
  74. package/src/forms/bones/widgets/stringBone.vue +139 -0
  75. package/src/forms/bones/widgets/textBone.vue +641 -0
  76. package/src/forms/install.d.ts +4 -0
  77. package/src/forms/install.js +43 -0
  78. package/src/forms/layouts/LayloutList2Columns.vue +57 -0
  79. package/src/forms/layouts/LayoutCategory.vue +38 -0
  80. package/src/forms/layouts/LayoutList.vue +31 -0
  81. package/src/forms/utils.js +601 -0
  82. package/src/public/assets/icons/fontawesome-manifest.json +8 -0
  83. package/src/public/assets/icons/regular/file.svg +1 -0
  84. package/src/public/assets/icons/solid/arrows-left-right.svg +1 -0
  85. package/src/public/assets/icons/solid/bug.svg +1 -0
  86. package/src/public/assets/icons/solid/check.svg +1 -0
  87. package/src/public/assets/icons/solid/circle-info.svg +1 -0
  88. package/src/public/assets/icons/solid/compress.svg +1 -0
  89. package/src/public/assets/icons/solid/download.svg +1 -0
  90. package/src/public/assets/icons/solid/expand.svg +1 -0
  91. package/src/public/assets/icons/solid/grip-vertical.svg +1 -0
  92. package/src/public/assets/icons/solid/plus.svg +1 -0
  93. package/src/public/assets/icons/solid/question.svg +1 -0
  94. package/src/public/assets/icons/solid/triangle-exclamation.svg +1 -0
  95. package/src/public/assets/icons/solid/upload.svg +1 -0
  96. package/src/public/assets/icons/solid/xmark.svg +1 -0
  97. package/src/request/cache.js +183 -0
  98. package/src/request/configStore.js +516 -0
  99. package/src/request/envelope.js +211 -0
  100. package/src/request/index.js +35 -0
  101. package/src/request/messageStore.js +201 -0
  102. package/src/request/request.js +752 -0
  103. package/src/request/skel.js +90 -0
  104. package/src/theme/colorStore.js +168 -0
  105. package/src/translations/de.js +178 -0
  106. package/src/translations/en.js +177 -0
  107. package/src/user/LoginScreen.d.vue.ts +54 -0
  108. package/src/user/LoginScreen.vue +627 -0
  109. package/src/user/google.js +75 -0
  110. package/src/user/index.js +3 -0
  111. package/src/user/store.d.ts +59 -0
  112. package/src/user/store.js +183 -0
  113. package/src/webawesomeCompat.css +72 -0
  114. package/src/webawesomeConfig.js +61 -0
@@ -0,0 +1,752 @@
1
+ import { ref, computed, watch } from "vue"
2
+ import { createFetch } from "@vueuse/core"
3
+ import { getCached, setCached, invalidateByKey, clearCache } from "./cache.js"
4
+ import { normalizeStructure } from "./skel.js"
5
+ import { normalizeEnvelope } from "./envelope.js"
6
+ import { notify, requestMessage } from "./messageStore.js"
7
+
8
+ const controllerPath = (controller) => (Array.isArray(controller) ? controller.join(".") : controller)
9
+
10
+ export class HTTPError extends Error {
11
+ constructor(statusCode, statusText, message, response = null) {
12
+ super(message || statusText)
13
+ this.statusCode = statusCode
14
+ this.statusText = statusText
15
+ this.response = response
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Build a ViUR request URL: `[/lang]/<renderer>[/<version>]/<controller>/<endpoint>`.
21
+ *
22
+ * `version` is optional and orthogonal to `renderer`. Omit it to hit the
23
+ * unversioned endpoint (e.g. `/json/…`), which the server aliases to the newest
24
+ * wire format (envelope v2). Pass `2`/`"v2"` or `1`/`"v1"` to pin a version
25
+ * (`/json/v2/…`, `/json/v1/…`). A numeric version is rendered as `v<n>`.
26
+ */
27
+ function create_url({ controller, endpoint, renderer = "json", version = null, lang = null } = {}) {
28
+ if (!controller) {
29
+ throw new Error("Parameter 'controller' is required")
30
+ }
31
+ if (!endpoint) {
32
+ throw new Error("Parameter 'endpoint' is required")
33
+ }
34
+
35
+ let url = ""
36
+
37
+ if (lang) url += `/${lang}`
38
+
39
+ url += `/${renderer}`
40
+
41
+ const versionSegment = version == null ? null : typeof version === "number" ? `v${version}` : version
42
+ if (versionSegment) url += `/${versionSegment}`
43
+
44
+ if (Array.isArray(controller)) {
45
+ url += "/" + controller.join("/")
46
+ } else {
47
+ url += `/${controller}`
48
+ }
49
+
50
+ url += `/${endpoint}`
51
+
52
+ if (url && !(url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//"))) {
53
+ url = (import.meta.env.VITE_API_URL ? import.meta.env.VITE_API_URL : window.location.origin) + url
54
+ }
55
+
56
+ return url
57
+ }
58
+
59
+ const SKEY_BATCH_SIZE = 10
60
+ let _skeyCache = []
61
+ let _skeyPending = null
62
+
63
+ async function _refillSkeys(renderer) {
64
+ const base = import.meta.env.VITE_API_URL ?? window.location.origin
65
+ let response = await fetch(`${base}/${renderer}/skey?amount=${SKEY_BATCH_SIZE}`, { credentials: "include" })
66
+ if (!response.ok) {
67
+ response = await fetch(`${base}/${renderer}/skey`, { credentials: "include" })
68
+ if (!response.ok) throw new Error("Failed to fetch skey token")
69
+ }
70
+ let data = await response.json()
71
+ if (!Array.isArray(data)) data = [data]
72
+ _skeyCache.push(...data)
73
+ _skeyPending = null
74
+ }
75
+
76
+ async function fetchSkeyToken(renderer) {
77
+ if (_skeyCache.length === 0) {
78
+ if (!_skeyPending) _skeyPending = _refillSkeys(renderer)
79
+ await _skeyPending
80
+ }
81
+ return _skeyCache.shift()
82
+ }
83
+
84
+ async function executeAndThrow(fetchResult, executeImpl) {
85
+ await executeImpl()
86
+ if (fetchResult.error.value) {
87
+ throw fetchResult.error.value
88
+ }
89
+ return fetchResult
90
+ }
91
+
92
+ function createControllerListFetch({ all = false, secure = false } = {}) {
93
+ return (
94
+ controller,
95
+ {
96
+ params: initialParams = {},
97
+ group = null,
98
+ url = null,
99
+ renderer = import.meta.env.VITE_DEFAULT_RENDERER ?? "json",
100
+ version = null,
101
+ immediate = true,
102
+ cached = false,
103
+ ttl = 0,
104
+ // push failures to the message store (when one exists)
105
+ messages = true,
106
+ // extra request headers for the LIST requests (not the structure fetch):
107
+ // object or function returning one — e.g. X-VIUR-BONELIST, evaluated per
108
+ // request so the caller can react to state changes.
109
+ headers = null,
110
+ } = {}
111
+ ) => {
112
+ const data = ref([]) // skellist
113
+ const structure = ref({})
114
+ const cursor = ref("")
115
+ const orders = ref([])
116
+ const params = ref({ ...initialParams })
117
+ let _isNext = false
118
+
119
+ function _buildUrl() {
120
+ const base = url ?? create_url({ controller, endpoint: group ? `list/${group}` : "list", renderer, version })
121
+ const qs = new URLSearchParams()
122
+ for (const [k, v] of Object.entries(params.value)) {
123
+ Array.isArray(v) ? v.forEach((item) => qs.append(k, item)) : qs.append(k, v)
124
+ }
125
+ return qs.toString() ? `${base}?${qs}` : base
126
+ }
127
+
128
+ /**
129
+ * Struktur der Liste holen.
130
+ *
131
+ * Zwei Antwortformen sind unterwegs:
132
+ * • aktuell: `/<modul>/structure` rendert wie jede andere Aktion, also
133
+ * `{ action: "structure.view", values, structure }`
134
+ * • historisch (getStructure): `{ viewSkel: {…}, editSkel: {…} }`
135
+ * Zusätzlich kann die Struktur eine PAAR-LISTE sein
136
+ * (`json.bone.structure.keytuples`) — normalizeStructure glättet das.
137
+ */
138
+ async function _fetchStructure() {
139
+ const skeltype =
140
+ params.value.skelType === "node"
141
+ ? "viewNodeSkel"
142
+ : params.value.skelType === "leaf"
143
+ ? "viewLeafSkel"
144
+ : "viewSkel"
145
+ let url = create_url({ controller, endpoint: "structure", renderer, version })
146
+ if (params.value.skelType) {
147
+ url += `${url.includes("?") ? "&" : "?"}skelType=${encodeURIComponent(params.value.skelType)}`
148
+ }
149
+ const resp = await fetch(url, { credentials: "include", headers: { Accept: "application/json" } })
150
+ if (!resp.ok) return
151
+ const body = normalizeEnvelope(await resp.json())
152
+ const raw = body?.["structure"] ?? body?.[skeltype]
153
+ if (raw) structure.value = normalizeStructure(raw)
154
+ }
155
+
156
+ const listUrl = ref(_buildUrl())
157
+
158
+ const useViurFetch = createFetch({
159
+ baseUrl: "",
160
+ fetchOptions: {
161
+ credentials: "include",
162
+ headers: { Accept: "application/json" },
163
+ },
164
+ options: {
165
+ async beforeFetch({ options, url, cancel }) {
166
+ const extraHeaders = typeof headers === "function" ? headers() : headers
167
+ if (extraHeaders) options.headers = { ...options.headers, ...extraHeaders }
168
+ if (cached && !_isNext) {
169
+ const hit = await getCached(url, ttl)
170
+ if (hit) {
171
+ data.value = hit.data ?? hit.skellist ?? hit
172
+ cursor.value = hit.meta?.cursor ?? hit.cursor ?? ""
173
+ orders.value = hit.meta?.orders ?? hit.orders ?? []
174
+ cancel()
175
+ return { options, url }
176
+ }
177
+ }
178
+ if (!secure) return { options, url }
179
+ const skey = await fetchSkeyToken(renderer)
180
+ const separator = url.includes("?") ? "&" : "?"
181
+ return { url: `${url}${separator}skey=${skey}`, options }
182
+ },
183
+ async afterFetch(ctx) {
184
+ // Normalize v1/v2 up to the canonical v2 envelope, then read the list
185
+ // payload from `data` and pagination from `meta`.
186
+ const responseData = normalizeEnvelope(ctx.data)
187
+ if (!_isNext) {
188
+ await _fetchStructure()
189
+ }
190
+ const list = responseData.data ?? []
191
+ // Manche Installationen liefern die Struktur in der Liste mit
192
+ // (Kompatibilitäts-Flag `json.bone.structure.inlists`) — dann ist der
193
+ // separate Aufruf oben umsonst gewesen, aber diese hier ist aktueller.
194
+ if (responseData.structure) {
195
+ structure.value = normalizeStructure(responseData.structure)
196
+ }
197
+ cursor.value = responseData.meta?.cursor ?? ""
198
+ orders.value = responseData.meta?.orders ?? []
199
+ data.value = _isNext ? data.value.concat(list) : list
200
+ if (cached && !_isNext) {
201
+ const entityKeys = list.map((s) => s.key).filter(Boolean)
202
+ await setCached(ctx.url ?? listUrl.value, responseData, entityKeys)
203
+ }
204
+ ctx.data = data.value
205
+ return ctx
206
+ },
207
+ onFetchError(ctx) {
208
+ ctx.error = new HTTPError(ctx.response?.status, ctx.response?.statusText, ctx.error?.message, ctx.response)
209
+ if (messages) {
210
+ notify(
211
+ requestMessage({ method: "GET", endpoint: "list", module: controllerPath(controller), error: ctx.error })
212
+ )
213
+ }
214
+ return ctx
215
+ },
216
+ },
217
+ })
218
+
219
+ const { isFetching, isFinished, error, execute: _execute, abort } = useViurFetch(listUrl, { immediate }).json()
220
+
221
+ /** Alles geladen: letzte Antwort da und kein Cursor mehr offen. Ab hier
222
+ * dürfen Sortierung/Filter lokal auf dem Datensatz laufen. */
223
+ const complete = computed(() => isFinished.value && !cursor.value)
224
+
225
+ const fetchResult = {
226
+ data,
227
+ structure,
228
+ orders,
229
+ cursor,
230
+ params,
231
+ complete,
232
+ isFetching,
233
+ isFinished,
234
+ error,
235
+ execute,
236
+ next,
237
+ nextAll,
238
+ filter,
239
+ reset,
240
+ }
241
+
242
+ // when all=true: automatically fetch next pages until cursor is empty
243
+ watch(isFinished, async (finished) => {
244
+ if (finished && all && cursor.value) {
245
+ params.value = { ...params.value, cursor: cursor.value }
246
+ _isNext = true
247
+ listUrl.value = _buildUrl()
248
+ await executeAndThrow(fetchResult, _execute)
249
+ }
250
+ })
251
+
252
+ function execute() {
253
+ _isNext = false
254
+ abort()
255
+ data.value = []
256
+ structure.value = {}
257
+ cursor.value = ""
258
+ orders.value = []
259
+ const { cursor: _, ...rest } = params.value
260
+ params.value = rest
261
+ listUrl.value = _buildUrl()
262
+ return executeAndThrow(fetchResult, _execute)
263
+ }
264
+
265
+ /** Eine Seite nachladen; erkennt das ViUR-Ende (leere Seite mit derselben
266
+ * Cursor-Marke) und löscht dann den Cursor. Liefert true bei Fortschritt. */
267
+ async function _advance() {
268
+ if (!cursor.value) return false
269
+ const before = data.value.length
270
+ const cursorBefore = cursor.value
271
+ params.value = { ...params.value, cursor: cursor.value }
272
+ _isNext = true
273
+ listUrl.value = _buildUrl()
274
+ await executeAndThrow(fetchResult, _execute)
275
+ if (data.value.length === before && cursor.value === cursorBefore) {
276
+ cursor.value = ""
277
+ return false
278
+ }
279
+ return true
280
+ }
281
+
282
+ /** Die nächsten `pages` Seiten laden (Standard: eine) — z. B. direkt als
283
+ * Handler der DataTable-Cursor-Leiste: `@load-more="({ pages }) =>
284
+ * pages === 'all' ? list.nextAll() : list.next(pages)"`. */
285
+ async function next(pages = 1) {
286
+ let remaining = pages
287
+ while (remaining-- > 0) {
288
+ if (!(await _advance())) return
289
+ }
290
+ }
291
+
292
+ /** Alle restlichen Seiten laden ("Alles laden"). */
293
+ async function nextAll() {
294
+ while (await _advance()) {
295
+ /* bis zum Ende */
296
+ }
297
+ }
298
+
299
+ function filter(newParams) {
300
+ params.value = newParams
301
+ return execute()
302
+ }
303
+
304
+ function reset() {
305
+ abort()
306
+ data.value = []
307
+ structure.value = {}
308
+ cursor.value = ""
309
+ orders.value = []
310
+ const { cursor: _, ...rest } = params.value
311
+ params.value = rest
312
+ }
313
+
314
+ return fetchResult
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Convenience fetch for Tree prototypes: loads nodes and leaves of one parent
320
+ * node together. Internally composes two list fetches (`skelType=node` and
321
+ * `skelType=leaf`) that share the same params, so `filter()`/`execute()`/
322
+ * `reset()` apply to both.
323
+ *
324
+ * - `nodes` / `leaves`: the individual skellists
325
+ * - `data`: nodes followed by leaves, each item enriched with `skelType`
326
+ * - `structure`: `{ node, leaf }` structures
327
+ * - `next()`: paginates nodes first, then leaves; `hasMore` tells if there is more
328
+ * - `open(parententry)`: shortcut for `filter({ ...params, parententry })`
329
+ *
330
+ * @param {string|string[]} controller
331
+ * @param {object} [options] same as `useListFetch` (minus `group`/`url`), plus
332
+ * `parententry` (shortcut for `params.parententry`) and `all` (fetch every
333
+ * page of both lists automatically)
334
+ */
335
+ export function useTreeFetch(
336
+ controller,
337
+ { params: initialParams = {}, parententry = null, all = false, ...listOptions } = {}
338
+ ) {
339
+ const baseParams = { ...initialParams }
340
+ if (parententry) baseParams.parententry = parententry
341
+
342
+ const listFetch = all ? useListFetchAll : useListFetch
343
+ const nodes = listFetch(controller, { ...listOptions, params: { ...baseParams, skelType: "node" } })
344
+ const leaves = listFetch(controller, { ...listOptions, params: { ...baseParams, skelType: "leaf" } })
345
+
346
+ const data = computed(() => [
347
+ ...nodes.data.value.map((skel) => ({ ...skel, skelType: "node" })),
348
+ ...leaves.data.value.map((skel) => ({ ...skel, skelType: "leaf" })),
349
+ ])
350
+ const structure = computed(() => ({ node: nodes.structure.value, leaf: leaves.structure.value }))
351
+ const isFetching = computed(() => nodes.isFetching.value || leaves.isFetching.value)
352
+ const isFinished = computed(() => nodes.isFinished.value && leaves.isFinished.value)
353
+ const error = computed(() => nodes.error.value ?? leaves.error.value)
354
+ const hasMore = computed(() => !!(nodes.cursor.value || leaves.cursor.value))
355
+ /** Beide Listen vollständig geladen — Sortierung/Filter dürfen lokal laufen. */
356
+ const complete = computed(() => isFinished.value && !hasMore.value)
357
+
358
+ function _both(fn) {
359
+ return Promise.all([fn(nodes), fn(leaves)])
360
+ }
361
+
362
+ function execute() {
363
+ return _both((l) => l.execute())
364
+ }
365
+
366
+ // Eine Seite nachladen: erst Nodes zu Ende, dann Leaves. Das ViUR-Ende
367
+ // (leere Seite mit derselben Cursor-Marke) erkennt useListFetch.next()
368
+ // selbst und löscht dann seinen Cursor — hier zählt nur der Fortschritt.
369
+ async function _nextOf(list) {
370
+ const before = list.data.value.length
371
+ await list.next()
372
+ return list.data.value.length > before
373
+ }
374
+
375
+ async function next() {
376
+ if (nodes.cursor.value && (await _nextOf(nodes))) return
377
+ if (leaves.cursor.value) await _nextOf(leaves)
378
+ }
379
+
380
+ /** Alle restlichen Seiten beider Listen laden ("Alles laden"). */
381
+ async function nextAll() {
382
+ while (nodes.cursor.value) {
383
+ if (!(await _nextOf(nodes))) break
384
+ }
385
+ while (leaves.cursor.value) {
386
+ if (!(await _nextOf(leaves))) break
387
+ }
388
+ }
389
+
390
+ function filter(newParams) {
391
+ const { skelType: _, ...rest } = newParams
392
+ return _both((l) => l.filter({ ...rest, skelType: l === nodes ? "node" : "leaf" }))
393
+ }
394
+
395
+ function open(key) {
396
+ const { skelType: _, cursor: __, ...rest } = nodes.params.value
397
+ return filter({ ...rest, parententry: key })
398
+ }
399
+
400
+ function reset() {
401
+ nodes.reset()
402
+ leaves.reset()
403
+ }
404
+
405
+ return {
406
+ nodes,
407
+ leaves,
408
+ data,
409
+ structure,
410
+ isFetching,
411
+ isFinished,
412
+ error,
413
+ hasMore,
414
+ complete,
415
+ execute,
416
+ next,
417
+ nextAll,
418
+ filter,
419
+ open,
420
+ reset,
421
+ }
422
+ }
423
+
424
+ export function createControllerFetch({
425
+ endpoint,
426
+ method = "GET",
427
+ secure = false,
428
+ invalidates = false,
429
+ invalidatesAll = false,
430
+ } = {}) {
431
+ return (controller, args = "/", data = {}, ctx = {}) => {
432
+ const renderer = ctx.renderer ?? import.meta.env.VITE_DEFAULT_RENDERER ?? "json"
433
+ const {
434
+ version = null,
435
+ lang = null,
436
+ headers = {},
437
+ mode = null,
438
+ skey: ctxSkey = null,
439
+ cached = false,
440
+ ttl = 0,
441
+ immediate = true,
442
+ // extra URL query string/params, appended to the request URL for GET and
443
+ // POST alike (e.g. query carried on a server-provided `follow` URL)
444
+ query = null,
445
+ // feed the message store (when one exists): success/rejected on POST,
446
+ // errors on every method. `false` keeps this request silent.
447
+ messages = true,
448
+ } = ctx
449
+
450
+ // Stable cache key: base URL + data params, skey excluded so it stays consistent across requests
451
+ let _cacheKey = null
452
+ let _cachedHit = null
453
+
454
+ const fetchReturn = createFetch({
455
+ baseUrl: "",
456
+ fetchOptions: {
457
+ credentials: "include",
458
+ headers: { Accept: "application/json" },
459
+ },
460
+ options: {
461
+ async beforeFetch({ options, url, cancel }) {
462
+ const skeyToken = secure ? ctxSkey || (await fetchSkeyToken(renderer)) : null
463
+ const base = create_url({ controller, endpoint, renderer, version, lang })
464
+ const extraQs = query ? (typeof query === "string" ? query : new URLSearchParams(query).toString()) : ""
465
+
466
+ options.method = method
467
+ options.headers = { ...options.headers, ...headers }
468
+ if (mode) options.mode = mode
469
+
470
+ if (method.toUpperCase() === "GET") {
471
+ const dataQs = new URLSearchParams(data).toString()
472
+ const qs = [dataQs, extraQs].filter(Boolean).join("&")
473
+ _cacheKey = qs ? `${base}?${qs}` : base
474
+
475
+ if (cached) {
476
+ const hit = await getCached(_cacheKey, ttl)
477
+ if (hit) {
478
+ _cachedHit = hit
479
+ cancel()
480
+ return { url, options }
481
+ }
482
+ }
483
+
484
+ const finalUrl = skeyToken
485
+ ? `${_cacheKey}${_cacheKey.includes("?") ? "&" : "?"}skey=${skeyToken}`
486
+ : _cacheKey
487
+ return { url: finalUrl, options }
488
+ }
489
+
490
+ const formData =
491
+ data instanceof FormData
492
+ ? data
493
+ : (() => {
494
+ const fd = new FormData()
495
+ for (const [key, value] of Object.entries(data)) {
496
+ if (Array.isArray(value)) {
497
+ value.forEach((item) => {
498
+ if (item !== null && item !== undefined) fd.append(key, item)
499
+ })
500
+ } else if (value !== null && value !== undefined) {
501
+ fd.append(key, value)
502
+ }
503
+ }
504
+ return fd
505
+ })()
506
+ if (skeyToken) formData.append("skey", skeyToken)
507
+ options.body = formData
508
+ const postUrl = extraQs ? `${base}${base.includes("?") ? "&" : "?"}${extraQs}` : base
509
+ return { url: postUrl, options }
510
+ },
511
+ onFetchError(ctx) {
512
+ ctx.error = new HTTPError(ctx.response?.status, ctx.response?.statusText, ctx.error?.message, ctx.response)
513
+ if (messages) {
514
+ notify(requestMessage({ method, endpoint, module: controllerPath(controller), error: ctx.error }))
515
+ }
516
+ return ctx
517
+ },
518
+ async afterFetch(ctx) {
519
+ // Normalize v1/v2 up to the canonical v2 envelope so downstream
520
+ // consumers (and cache/invalidation below) read the entity from `data`.
521
+ ctx.data = normalizeEnvelope(ctx.data)
522
+ if (messages) {
523
+ const message = requestMessage({ method, endpoint, module: controllerPath(controller), body: ctx.data })
524
+ if (message) notify(message)
525
+ }
526
+ if (cached && method.toUpperCase() === "GET" && _cacheKey) {
527
+ const entityKey = ctx.data?.data?.key
528
+ await setCached(_cacheKey, ctx.data, entityKey ? [entityKey] : [])
529
+ }
530
+ if (invalidates) {
531
+ const keys = []
532
+ if (data.key) keys.push(data.key)
533
+ if (ctx.data?.data?.key) keys.push(ctx.data.data.key)
534
+ if (keys.length) await invalidateByKey(keys)
535
+ }
536
+ if (invalidatesAll) {
537
+ const base = import.meta.env.VITE_API_URL ?? window.location.origin
538
+ const controllerPath = Array.isArray(controller) ? controller.join("/") : controller
539
+ await clearCache(`${base}/${renderer}/${controllerPath}/`)
540
+ }
541
+ return ctx
542
+ },
543
+ },
544
+ })(args, { immediate }).json()
545
+
546
+ const _execute = fetchReturn.execute
547
+ fetchReturn.execute = () => executeAndThrow(fetchReturn, _execute)
548
+
549
+ // When a cache hit cancelled the fetch, isFinished fires but data is null.
550
+ // Apply the cached value once isFinished becomes true.
551
+ const stop = watch(fetchReturn.isFinished, (finished) => {
552
+ if (finished && _cachedHit !== null) {
553
+ fetchReturn.data.value = _cachedHit
554
+ _cachedHit = null
555
+ stop()
556
+ }
557
+ })
558
+
559
+ return fetchReturn
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Upload a file to ViUR.
565
+ * 1. POST /file/getUploadURL (secure) — get signed upload URL + uploadKey
566
+ * 2. POST uploadUrl (no-cors) — push raw file bytes to storage
567
+ * 3. POST /file/add (secure) — register the file in ViUR
568
+ *
569
+ * @param {File} file
570
+ * @param {{ target?: string, publicUpload?: boolean, renderer?: string }} [options]
571
+ * @returns {Promise<object>} values of the newly created file skel
572
+ */
573
+ function _toFormData(obj) {
574
+ const fd = new FormData()
575
+ for (const [key, value] of Object.entries(obj)) {
576
+ if (Array.isArray(value)) {
577
+ value.forEach((item) => fd.append(key, item))
578
+ } else if (value !== undefined && value !== null) {
579
+ fd.append(key, value)
580
+ }
581
+ }
582
+ return fd
583
+ }
584
+
585
+ export async function uploadFile(
586
+ file,
587
+ { target = undefined, publicUpload = false, renderer = null, version = null } = {}
588
+ ) {
589
+ const _renderer = renderer ?? import.meta.env.VITE_DEFAULT_RENDERER ?? "json"
590
+ const fetchOpts = { credentials: "include", headers: { Accept: "application/json" }, method: "POST" }
591
+
592
+ // Step 1: get upload URL
593
+ const filedata = {
594
+ fileName: file.name,
595
+ mimeType: file.type || "application/octet-stream",
596
+ size: file.size.toString(),
597
+ }
598
+ if (target) filedata.node = target
599
+ if (publicUpload) filedata.public = true
600
+ const fd1 = _toFormData(filedata)
601
+ fd1.append("skey", await fetchSkeyToken(_renderer))
602
+
603
+ const getUrlResp = await fetch(
604
+ create_url({ controller: "file", endpoint: "getUploadURL", renderer: _renderer, version }),
605
+ {
606
+ ...fetchOpts,
607
+ body: fd1,
608
+ }
609
+ )
610
+ if (!getUrlResp.ok) throw new Error(`getUploadURL failed: ${getUrlResp.status}`)
611
+ // Envelope-agnostic: v2 carries the payload in `data`, v1 in `values`.
612
+ const getUrlData = normalizeEnvelope(await getUrlResp.json())
613
+ const { uploadUrl, uploadKey } = getUrlData.data ?? getUrlData.values ?? {}
614
+
615
+ // Step 2: upload raw bytes to external storage (no-cors, no credentials)
616
+ await fetch(uploadUrl, { method: "POST", body: file, mode: "no-cors" })
617
+
618
+ // Step 3: register file in ViUR
619
+ const fd3 = _toFormData({ key: uploadKey, skelType: "leaf" })
620
+ fd3.append("skey", await fetchSkeyToken(_renderer))
621
+
622
+ const addResp = await fetch(create_url({ controller: "file", endpoint: "add", renderer: _renderer, version }), {
623
+ ...fetchOpts,
624
+ body: fd3,
625
+ })
626
+ if (!addResp.ok) throw new Error(`file/add failed: ${addResp.status}`)
627
+ const addData = normalizeEnvelope(await addResp.json())
628
+ if (addData.status !== "success") throw new Error(`Upload failed: ${JSON.stringify(addData)}`)
629
+ return addData.data ?? addData.values
630
+ }
631
+
632
+ function _absolute(url) {
633
+ if (
634
+ url &&
635
+ !(url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//") || url.startsWith("data:"))
636
+ ) {
637
+ return (import.meta.env.VITE_API_URL ?? window.location.origin) + url
638
+ }
639
+ return url
640
+ }
641
+
642
+ /**
643
+ * Build a download URL for a file bone value.
644
+ * @param {any} bone - file bone value (object with dest, or raw URL string)
645
+ * @param {boolean} [thumbnail=false] - use thumbnail URL if available
646
+ * @param {string} [renderer]
647
+ * @returns {string|null}
648
+ */
649
+ export function downloadUrlFor(bone, thumbnail = false, renderer = import.meta.env.VITE_DEFAULT_RENDERER ?? "json") {
650
+ if (bone && typeof bone === "object" && "dest" in bone) {
651
+ let url
652
+ if (thumbnail && "thumbnail" in bone["dest"]) {
653
+ url = bone["dest"]["thumbnail"]
654
+ } else if ("downloadUrl" in bone["dest"]) {
655
+ url = bone["dest"]["downloadUrl"]
656
+ } else {
657
+ return null
658
+ }
659
+ return url.startsWith("data:") ? url : _absolute(`/${renderer}${url}`)
660
+ }
661
+ return _absolute(bone)
662
+ }
663
+
664
+ /**
665
+ * Build a serving URL for a Google Cloud Storage file.
666
+ * Converts https://<host>.googleusercontent.com/<key> → /file/serve/<host>/<key>[/<size>][/<filename>][?options&download]
667
+ * @param {string} servingurl
668
+ * @param {number|null} [size]
669
+ * @param {string|null} [filename]
670
+ * @param {string} [options=""]
671
+ * @param {boolean} [download=false]
672
+ * @returns {string}
673
+ */
674
+ export function serveUrlFor(servingurl, size = null, filename = null, options = "", download = false) {
675
+ const match = servingurl?.match(/^https:\/\/(.*?)\.googleusercontent\.com\/(.*?)$/)
676
+ if (!match) return _absolute(servingurl)
677
+
678
+ let url = `/file/serve/${match[1]}/${match[2]}`
679
+ if (size) url += `/${size}`
680
+ if (filename) url += `/${filename}`
681
+
682
+ const qs = Object.entries({ options, download })
683
+ .filter(([, v]) => v)
684
+ .map(([k, v]) => `${k}=${v}`)
685
+ if (qs.length) url += `?${qs.join("&")}`
686
+
687
+ return _absolute(url)
688
+ }
689
+
690
+ export function resetState() {
691
+ _skeyCache = []
692
+ _skeyPending = null
693
+ return clearCache()
694
+ }
695
+
696
+ export const useStructureFetch = createControllerFetch({ endpoint: "structure" })
697
+ export const useListFetch = createControllerListFetch()
698
+ export const useListFetchAll = createControllerListFetch({ all: true })
699
+ export const useViewFetch = createControllerFetch({ endpoint: "view" })
700
+ export const useEditFetch = createControllerFetch({ endpoint: "edit", method: "POST", secure: true, invalidates: true })
701
+ export const useDeleteFetch = createControllerFetch({
702
+ endpoint: "delete",
703
+ method: "POST",
704
+ secure: true,
705
+ invalidates: true,
706
+ })
707
+ export const useAddFetch = createControllerFetch({
708
+ endpoint: "add",
709
+ method: "POST",
710
+ secure: true,
711
+ invalidatesAll: true,
712
+ })
713
+
714
+ /**
715
+ * Clone an entry. Works for both prototypes:
716
+ * - List: `useCloneFetch("items", "/", { key, ...values })`
717
+ * - Tree: `useCloneFetch("files", "/", { skelType: "node", key, parententry, ...values })`
718
+ * Pass `bounce: true` to validate without writing (returns the clone form).
719
+ */
720
+ export const useCloneFetch = createControllerFetch({
721
+ endpoint: "clone",
722
+ method: "POST",
723
+ secure: true,
724
+ invalidatesAll: true,
725
+ })
726
+
727
+ /**
728
+ * Preview an entry without writing it (List prototype only).
729
+ * `usePreviewFetch("items", "/", { ...values })`
730
+ */
731
+ export const usePreviewFetch = createControllerFetch({ endpoint: "preview", method: "POST", secure: true })
732
+
733
+ /**
734
+ * Move a node or leaf to another node (Tree prototype only).
735
+ * `useMoveFetch("files", "/", { skelType: "leaf", key, parentNode, sortindex })`
736
+ * Invalidates the moved entity and all cached lists of the controller, since
737
+ * both the source and destination node listings change.
738
+ */
739
+ export const useMoveFetch = createControllerFetch({
740
+ endpoint: "move",
741
+ method: "POST",
742
+ secure: true,
743
+ invalidates: true,
744
+ invalidatesAll: true,
745
+ })
746
+
747
+ /**
748
+ * List the root nodes (repositories) available to the current user (Tree
749
+ * prototype only). The response is a plain array of `{ name, key }`.
750
+ * `useListRootNodesFetch("files")`
751
+ */
752
+ export const useListRootNodesFetch = createControllerFetch({ endpoint: "listRootNodes" })