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.
- package/README.md +79 -0
- package/index.d.ts +351 -0
- package/index.js +46 -0
- package/package.json +84 -0
- package/src/components/Browser/Browser.vue +496 -0
- package/src/components/Browser/TreeNav.vue +408 -0
- package/src/components/Browser/ViurBrowser.d.vue.ts +64 -0
- package/src/components/Browser/ViurBrowser.vue +271 -0
- package/src/components/Browser/dragSession.ts +134 -0
- package/src/components/Browser/index.ts +14 -0
- package/src/components/Browser/types.ts +30 -0
- package/src/components/DataTable/ColumnFilterPanel.vue +225 -0
- package/src/components/DataTable/DataTable.vue +3155 -0
- package/src/components/DataTable/ViurDataTable.d.vue.ts +72 -0
- package/src/components/DataTable/ViurDataTable.vue +331 -0
- package/src/components/DataTable/columns.ts +400 -0
- package/src/components/DataTable/index.ts +60 -0
- package/src/components/DataTable/rows.ts +446 -0
- package/src/components/DataTable/types.ts +267 -0
- package/src/components/DataTable/useColumnLayout.ts +294 -0
- package/src/components/DataTable/useGridNavigation.ts +193 -0
- package/src/components/DataTable/useVirtualRows.ts +126 -0
- package/src/components/DataTable/viurColumns.ts +330 -0
- package/src/forms/ViForm.d.vue.ts +106 -0
- package/src/forms/ViForm.vue +592 -0
- package/src/forms/ViFormCategory.vue +57 -0
- package/src/forms/bones/Bone.vue +1023 -0
- package/src/forms/bones/BoneActions.vue +127 -0
- package/src/forms/bones/BoneLabel.vue +122 -0
- package/src/forms/bones/BoneMultipleList.vue +91 -0
- package/src/forms/bones/BoneMultipleWrapper.vue +105 -0
- package/src/forms/bones/BoneNestedWrapper.vue +155 -0
- package/src/forms/bones/BoneSingleEntry.vue +51 -0
- package/src/forms/bones/FilterBone.vue +694 -0
- package/src/forms/bones/actionbar/defaultBar.vue +98 -0
- package/src/forms/bones/actionbar/fileBar.vue +165 -0
- package/src/forms/bones/actionbar/relationalBar.vue +125 -0
- package/src/forms/bones/actions/ActionDialog.vue +70 -0
- package/src/forms/bones/actions/Debug.vue +106 -0
- package/src/forms/bones/actions/Delete.vue +13 -0
- package/src/forms/bones/actions/DescribeImage.vue +201 -0
- package/src/forms/bones/actions/Download.vue +27 -0
- package/src/forms/bones/actions/Placeholder.vue +14 -0
- package/src/forms/bones/actions/Translation.vue +238 -0
- package/src/forms/bones/actions/index.js +280 -0
- package/src/forms/bones/bone.d.ts +45 -0
- package/src/forms/bones/country_information.json +779 -0
- package/src/forms/bones/index.js +225 -0
- package/src/forms/bones/uploadAdapter.js +46 -0
- package/src/forms/bones/utils.js +284 -0
- package/src/forms/bones/view/base_item.vue +35 -0
- package/src/forms/bones/view/boneLogic.js +202 -0
- package/src/forms/bones/view/index.js +5 -0
- package/src/forms/bones/widgets/booleanBone.vue +83 -0
- package/src/forms/bones/widgets/booleanBoneChoose.vue +57 -0
- package/src/forms/bones/widgets/booleanBoneSelect.vue +58 -0
- package/src/forms/bones/widgets/codeBone.vue +214 -0
- package/src/forms/bones/widgets/colorBone.vue +75 -0
- package/src/forms/bones/widgets/dateBone.vue +114 -0
- package/src/forms/bones/widgets/emailBone.vue +100 -0
- package/src/forms/bones/widgets/fileBone.vue +273 -0
- package/src/forms/bones/widgets/jsonBone.vue +63 -0
- package/src/forms/bones/widgets/keyBone.vue +71 -0
- package/src/forms/bones/widgets/numericBone.vue +125 -0
- package/src/forms/bones/widgets/passwordBone.vue +215 -0
- package/src/forms/bones/widgets/phoneBone.vue +173 -0
- package/src/forms/bones/widgets/rawBone.vue +94 -0
- package/src/forms/bones/widgets/recordBone.vue +64 -0
- package/src/forms/bones/widgets/relationalBone.vue +164 -0
- package/src/forms/bones/widgets/relationalBoneSelect.vue +120 -0
- package/src/forms/bones/widgets/selectBone.vue +122 -0
- package/src/forms/bones/widgets/selectBoneChoose.vue +104 -0
- package/src/forms/bones/widgets/spatialBone.vue +111 -0
- package/src/forms/bones/widgets/stringBone.vue +139 -0
- package/src/forms/bones/widgets/textBone.vue +641 -0
- package/src/forms/install.d.ts +4 -0
- package/src/forms/install.js +43 -0
- package/src/forms/layouts/LayloutList2Columns.vue +57 -0
- package/src/forms/layouts/LayoutCategory.vue +38 -0
- package/src/forms/layouts/LayoutList.vue +31 -0
- package/src/forms/utils.js +601 -0
- package/src/public/assets/icons/fontawesome-manifest.json +8 -0
- package/src/public/assets/icons/regular/file.svg +1 -0
- package/src/public/assets/icons/solid/arrows-left-right.svg +1 -0
- package/src/public/assets/icons/solid/bug.svg +1 -0
- package/src/public/assets/icons/solid/check.svg +1 -0
- package/src/public/assets/icons/solid/circle-info.svg +1 -0
- package/src/public/assets/icons/solid/compress.svg +1 -0
- package/src/public/assets/icons/solid/download.svg +1 -0
- package/src/public/assets/icons/solid/expand.svg +1 -0
- package/src/public/assets/icons/solid/grip-vertical.svg +1 -0
- package/src/public/assets/icons/solid/plus.svg +1 -0
- package/src/public/assets/icons/solid/question.svg +1 -0
- package/src/public/assets/icons/solid/triangle-exclamation.svg +1 -0
- package/src/public/assets/icons/solid/upload.svg +1 -0
- package/src/public/assets/icons/solid/xmark.svg +1 -0
- package/src/request/cache.js +183 -0
- package/src/request/configStore.js +516 -0
- package/src/request/envelope.js +211 -0
- package/src/request/index.js +35 -0
- package/src/request/messageStore.js +201 -0
- package/src/request/request.js +752 -0
- package/src/request/skel.js +90 -0
- package/src/theme/colorStore.js +168 -0
- package/src/translations/de.js +178 -0
- package/src/translations/en.js +177 -0
- package/src/user/LoginScreen.d.vue.ts +54 -0
- package/src/user/LoginScreen.vue +627 -0
- package/src/user/google.js +75 -0
- package/src/user/index.js +3 -0
- package/src/user/store.d.ts +59 -0
- package/src/user/store.js +183 -0
- package/src/webawesomeCompat.css +72 -0
- 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" })
|