@raclettejs/workbench 0.1.36-canary.1 → 0.1.37

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/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.37] - 2026-06-25 <a href="https://gitlab.com/raclettejs/core-dev/-/compare/v0.1.37...v0.1.37" target="_blank" rel="noopener"><b>Overview of all changes</b></a>
11
+
12
+
13
+ ### Patch Changes
14
+
15
+ - fix: json importer and exporter fixed for workbench datatypes. Export selection reset after filter reset
16
+ - Updated dependencies
17
+ - @raclettejs/core@0.1.37
18
+
19
+ ## [0.1.37] - 2026-06-25 <a href="https://gitlab.com/raclettejs/core-dev/-/compare/v0.1.36...v0.1.37" target="_blank" rel="noopener"><b>Overview of all changes</b></a>
20
+
21
+ ### Fixed
22
+
23
+ - Importer can now properly import compositions and interactionLinks
24
+
25
+ ## [0.1.36] - 2026-06-22 <a href="https://gitlab.com/raclettejs/core-dev/-/compare/v0.1.35...v0.1.36" target="_blank" rel="noopener"><b>Overview of all changes</b></a>
26
+
27
+ ### Changed
28
+
29
+ - Improved UX of workbenchs "create composition" flow
30
+ - feat: Enhanced basedatatable to provide group by and footer slot abilities
31
+ - WidgetLayout: removed icon as fallback option after image -> only render image or fallback block, never icon in WidgetRenderer
32
+ - feat: added a workbench plugin which let's admins see all valkey entries in a datatable
33
+ - Adjusted package.json to improve monorepo handling
34
+ - Greatly imporoved the docs structure, hardened the code snipped interpolation logic
35
+
36
+ ### Updated
37
+
38
+ - Updated dependencies
39
+ - Updated dependencies
40
+ - Updated dependencies
41
+ - Updated dependencies
42
+ - Updated dependencies
43
+ - Updated dependencies
44
+ - @raclettejs/core@0.1.36
45
+
10
46
  ### Fixed
11
47
 
12
48
  - WidgetLayout: removed icon as fallback option after image -> only render image or fallback block, never icon in WidgetRenderer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raclettejs/workbench",
3
- "version": "0.1.36-canary.1",
3
+ "version": "0.1.37",
4
4
  "license": "MIT",
5
5
  "description": "racletteJS Workbench - used to configure your application, for dev and prod",
6
6
  "repository": "https://gitlab.com/raclettejs/workbench",
@@ -33,7 +33,7 @@
33
33
  ".": "./index.ts"
34
34
  },
35
35
  "dependencies": {
36
- "@raclettejs/core": "0.1.36-canary.1",
36
+ "@raclettejs/core": "0.1.37",
37
37
  "@vueuse/integrations": "14.2.1",
38
38
  "fuse.js": "7.1.0",
39
39
  "three": "0.183.2",
@@ -43,7 +43,7 @@
43
43
  "@emnapi/core": "1.9.2",
44
44
  "@emnapi/runtime": "1.9.2",
45
45
  "@eslint/js": "9.35.0",
46
- "@raclettejs/types": "0.1.36-canary.1",
46
+ "@raclettejs/types": "0.1.37",
47
47
  "@sinclair/typebox": "0.34.48",
48
48
  "@types/ramda": "0.31.1",
49
49
  "@vueuse/core": "14.2.1",
@@ -3,6 +3,8 @@
3
3
  <BaseDataTable
4
4
  :items="parsedCompositions"
5
5
  :exportable-items="exportableCompositions"
6
+ export-row-shape="full-records"
7
+ :export-show-data-scope-switch="false"
6
8
  :loading="compositionsLoading || tagsLoading"
7
9
  :headers="headers"
8
10
  :row-click-handler="handleRowClick"
@@ -108,24 +110,15 @@ import type { Composition } from "@raclettejs/core"
108
110
  import { formatDistance } from "date-fns"
109
111
  import BaseDataTable from "@raclettejs/core/orchestrator/components/dataTable/BaseDataTable.vue"
110
112
  import { useI18n } from "vue-i18n"
111
- import {
112
- mapJsonValues,
113
- parseAndNormalizeWorkbenchImportJson,
114
- resolveWorkbenchImportSelection,
115
- } from "@app/lib/jsonTools"
113
+ import { recordsFromStore } from "@app/lib/workbenchListTransfer"
116
114
  import { useWorkbenchTableActions } from "@app/composables/useWorkbenchTableActions"
115
+ import useWorkbenchListImport from "@app/composables/useWorkbenchListImport"
117
116
  import ImportSelectionDialog from "@raclettejs/core/orchestrator/components/dataImport/ImportSelectionDialog.vue"
118
117
  import ImportSummaryDialog from "@raclettejs/core/orchestrator/components/dataImport/ImportSummaryDialog.vue"
119
118
 
120
119
  const { t } = useI18n()
121
120
  const { goToCreate, goToEditById } = useWorkbenchTableActions()
122
121
  const showDeleted = ref(false)
123
- const showImportDialog = ref(false)
124
- const showImportSummary = ref(false)
125
-
126
- const importableItems = ref<any[]>([])
127
- const importedItems = ref<any[]>([])
128
- const pendingImportById = ref<Record<string, any>>({})
129
122
 
130
123
  const headers = computed<
131
124
  { title: string; key: keyof Composition | "actions" }[]
@@ -160,7 +153,7 @@ const props = defineProps<{
160
153
  uuid: string
161
154
  }>()
162
155
 
163
- const { $data, $socket, $user } = usePluginApi("raclette__core")
156
+ const { $data, $socket } = usePluginApi("raclette__core")
164
157
  const { compositionSlots } = useRouteState()
165
158
 
166
159
  const COMPOSITION_LIST_PATH = "composition-list"
@@ -194,7 +187,20 @@ const { execute: executeRestore } = await $data.composition.updateRestore({
194
187
  const { execute: executeHardDelete } = await $data.composition.deleteHard({
195
188
  options: { cb: () => execute({ isDeleted: showDeleted.value }) },
196
189
  })
197
- const compositionTableRows = (relativeDates: boolean) => {
190
+
191
+ const {
192
+ showImportDialog,
193
+ showImportSummary,
194
+ importableItems,
195
+ importedItems,
196
+ handleFileUpload,
197
+ handleImportConfirm,
198
+ finalizeImport,
199
+ } = useWorkbenchListImport({
200
+ onBulkCreate: (items) => executeBulkCreate(items),
201
+ })
202
+
203
+ const compositionTableRows = () => {
198
204
  if (!compositions.value || !tags.value) {
199
205
  return []
200
206
  }
@@ -209,24 +215,16 @@ const compositionTableRows = (relativeDates: boolean) => {
209
215
  status: composition.status, // TODO: parse status
210
216
  statusIcon: getStatusIcon(composition.status),
211
217
  statusColor: getStatusColor(composition.status),
212
- ...(relativeDates
213
- ? {
214
- createdAt: formatDistance(
215
- new Date(composition.createdAt),
216
- new Date(),
217
- ),
218
- updatedAt: formatDistance(
219
- new Date(composition.updatedAt),
220
- new Date(),
221
- ),
222
- }
223
- : {}),
218
+ createdAt: formatDistance(new Date(composition.createdAt), new Date()),
219
+ updatedAt: formatDistance(new Date(composition.updatedAt), new Date()),
224
220
  }))
225
221
  }
226
222
 
227
- const parsedCompositions = computed(() => compositionTableRows(true))
223
+ const parsedCompositions = computed(() => compositionTableRows())
228
224
 
229
- const exportableCompositions = computed(() => compositionTableRows(false))
225
+ const exportableCompositions = computed(() =>
226
+ recordsFromStore(compositions.value as Record<string, Composition> | undefined),
227
+ )
230
228
 
231
229
  // TODO: replace with composition status type
232
230
  const getStatusIcon = (status: string) => {
@@ -250,51 +248,6 @@ const getStatusColor = (status: string) => {
250
248
  return "#B66218"
251
249
  }
252
250
  }
253
- // Import functions
254
- const handleFileUpload = (content: string | ArrayBuffer) => {
255
- if (typeof content !== "string") {
256
- return
257
- }
258
- try {
259
- const { items, byId } = parseAndNormalizeWorkbenchImportJson(content)
260
- importableItems.value = items
261
- pendingImportById.value = byId
262
- showImportDialog.value = true
263
- } catch (error) {
264
- console.error("Failed to prepare import:", error)
265
- }
266
- }
267
-
268
- const handleImportConfirm = (selectedIds: string[]) => {
269
- importedItems.value = resolveWorkbenchImportSelection(
270
- pendingImportById.value,
271
- selectedIds,
272
- )
273
- showImportSummary.value = true
274
- }
275
-
276
- const finalizeImport = async () => {
277
- const mapping = {
278
- owner: $user.value._id,
279
- lastEditor: $user.value._id,
280
- createdBy: $user.value._id,
281
- }
282
-
283
- const dataToImport = importedItems.value.reduce((acc, item) => {
284
- const mappedItem = mapJsonValues(item, mapping)
285
- acc[item._id] = mappedItem
286
- return acc
287
- }, {} as any)
288
-
289
- await executeBulkCreate(Object.values(dataToImport))
290
- // TODO: Add your actual import logic here
291
- // e.g., call an API endpoint to save the imported compositions
292
-
293
- // Reset states
294
- pendingImportById.value = {}
295
- importableItems.value = []
296
- importedItems.value = []
297
- }
298
251
  const deleteComposition = async (composition: Composition) => {
299
252
  if (showDeleted.value) {
300
253
  await executeHardDelete({ _id: composition._id })
@@ -3,6 +3,8 @@
3
3
  <BaseDataTable
4
4
  :items="parsedInteractionLinks"
5
5
  :exportable-items="exportableInteractionLinks"
6
+ export-row-shape="full-records"
7
+ :export-show-data-scope-switch="false"
6
8
  :loading="interactionLinksLoading || tagsLoading"
7
9
  :headers="headers"
8
10
  :row-click-handler="handleRowClick"
@@ -99,21 +101,13 @@ import { usePluginApi } from "@raclettejs/core/orchestrator/composables"
99
101
  import { formatDistance } from "date-fns"
100
102
  import { computed, ref, watch } from "vue"
101
103
  import { useI18n } from "vue-i18n"
102
- import {
103
- mapJsonValues,
104
- parseAndNormalizeWorkbenchImportJson,
105
- resolveWorkbenchImportSelection,
106
- } from "@app/lib/jsonTools"
104
+ import { recordsFromStore } from "@app/lib/workbenchListTransfer"
105
+ import { useWorkbenchTableActions } from "@app/composables/useWorkbenchTableActions"
106
+ import useWorkbenchListImport from "@app/composables/useWorkbenchListImport"
107
107
  import ImportSelectionDialog from "@raclettejs/core/orchestrator/components/dataImport/ImportSelectionDialog.vue"
108
108
  import ImportSummaryDialog from "@raclettejs/core/orchestrator/components/dataImport/ImportSummaryDialog.vue"
109
- import { useWorkbenchTableActions } from "@app/composables/useWorkbenchTableActions"
110
- const showDeleted = ref(false)
111
- const showImportDialog = ref(false)
112
- const showImportSummary = ref(false)
113
109
 
114
- const importableItems = ref<any[]>([])
115
- const importedItems = ref<any[]>([])
116
- const pendingImportById = ref<Record<string, any>>({})
110
+ const showDeleted = ref(false)
117
111
 
118
112
  const { t } = useI18n()
119
113
  const { goToCreate, goToEditById } = useWorkbenchTableActions()
@@ -155,7 +149,7 @@ const props = defineProps<{
155
149
  uuid: string
156
150
  }>()
157
151
 
158
- const { $data, $socket, $user } = usePluginApi("raclette__core")
152
+ const { $data, $socket } = usePluginApi("raclette__core")
159
153
 
160
154
  const {
161
155
  data: interactionLinks,
@@ -188,7 +182,41 @@ const { execute: executeHardDelete } = await $data.interactionLink.deleteHard({
188
182
  const { execute: executeRestore } = await $data.interactionLink.updateRestore({
189
183
  options: { cb: () => execute({ isDeleted: showDeleted.value }) },
190
184
  })
191
- const interactionLinkTableRows = (relativeDates: boolean) => {
185
+
186
+ const enrichInteractionLinkDescription = (
187
+ link: Record<string, unknown>,
188
+ ): Record<string, unknown> => {
189
+ const enriched = { ...link }
190
+ let linkTitleString = `${enriched.slotType ?? ""}`
191
+ const triggers = enriched.triggers
192
+ if (Array.isArray(triggers)) {
193
+ for (const trigger of triggers) {
194
+ if (trigger && typeof trigger === "object") {
195
+ const settings = (trigger as { settings?: { title?: { default?: string } } })
196
+ .settings
197
+ const type = (trigger as { type?: string }).type ?? ""
198
+ linkTitleString += `${type} - ${settings?.title?.default ?? ""} `
199
+ }
200
+ }
201
+ }
202
+ enriched.description = linkTitleString + (enriched.description ?? "")
203
+ return enriched
204
+ }
205
+
206
+ const {
207
+ showImportDialog,
208
+ showImportSummary,
209
+ importableItems,
210
+ importedItems,
211
+ handleFileUpload,
212
+ handleImportConfirm,
213
+ finalizeImport,
214
+ } = useWorkbenchListImport({
215
+ prepareRecord: enrichInteractionLinkDescription,
216
+ onBulkCreate: (items) => executeBulkCreate(items),
217
+ })
218
+
219
+ const interactionLinkTableRows = () => {
192
220
  if (!interactionLinks.value || !tags.value) {
193
221
  return []
194
222
  }
@@ -202,25 +230,23 @@ const interactionLinkTableRows = (relativeDates: boolean) => {
202
230
  lastEditor: users.value?.[interactionLink.author]?.email,
203
231
  author: users.value?.[interactionLink.author]?.email,
204
232
  triggers: interactionLink.triggers.length,
205
- ...(relativeDates
206
- ? {
207
- createdAt: formatDistance(
208
- new Date(interactionLink.createdAt),
209
- new Date(),
210
- ),
211
- updatedAt: formatDistance(
212
- new Date(interactionLink.updatedAt),
213
- new Date(),
214
- ),
215
- }
216
- : {}),
233
+ createdAt: formatDistance(
234
+ new Date(interactionLink.createdAt),
235
+ new Date(),
236
+ ),
237
+ updatedAt: formatDistance(
238
+ new Date(interactionLink.updatedAt),
239
+ new Date(),
240
+ ),
217
241
  }))
218
242
  }
219
243
 
220
- const parsedInteractionLinks = computed(() => interactionLinkTableRows(true))
244
+ const parsedInteractionLinks = computed(() => interactionLinkTableRows())
221
245
 
222
246
  const exportableInteractionLinks = computed(() =>
223
- interactionLinkTableRows(false),
247
+ recordsFromStore(
248
+ interactionLinks.value as Record<string, InteractionLink> | undefined,
249
+ ),
224
250
  )
225
251
  const restore = async (_id: string) => {
226
252
  executeRestore({ _id })
@@ -240,62 +266,6 @@ const deleteInteractionLink = async (interactionLink: InteractionLink) => {
240
266
  $socket.on("interactionLinkCreated", () =>
241
267
  execute({ isDeleted: showDeleted.value }),
242
268
  )
243
- const createTitleString = (link) => {
244
- let linkTitleString = `${link.slotType}`
245
- if (link.triggers) {
246
- link.triggers.forEach((trigger) => {
247
- linkTitleString += `${trigger.type} - ${trigger.settings?.title?.default} `
248
- })
249
- }
250
- link.description = linkTitleString + link.description
251
- return link
252
- }
253
-
254
- // Import functions
255
- const handleFileUpload = (content: string | ArrayBuffer) => {
256
- if (typeof content !== "string") {
257
- return
258
- }
259
- try {
260
- const { items, byId } = parseAndNormalizeWorkbenchImportJson(content)
261
- importableItems.value = items
262
- pendingImportById.value = byId
263
- showImportDialog.value = true
264
- } catch (error) {
265
- console.error("Failed to prepare import:", error)
266
- }
267
- }
268
-
269
- const handleImportConfirm = (selectedIds: string[]) => {
270
- importedItems.value = resolveWorkbenchImportSelection(
271
- pendingImportById.value,
272
- selectedIds,
273
- )
274
- showImportSummary.value = true
275
- }
276
-
277
- const finalizeImport = async () => {
278
- const mapping = {
279
- owner: $user.value._id,
280
- lastEditor: $user.value._id,
281
- createdBy: $user.value._id,
282
- }
283
-
284
- const dataToImport = importedItems.value.reduce((acc, item) => {
285
- const mappedItem = mapJsonValues(item, mapping)
286
- acc[item._id] = createTitleString(mappedItem)
287
- return acc
288
- }, {} as any)
289
-
290
- await executeBulkCreate(Object.values(dataToImport))
291
- // TODO: Add your actual import logic here
292
- // e.g., call an API endpoint to save the imported compositions
293
-
294
- // Reset states
295
- pendingImportById.value = {}
296
- importableItems.value = []
297
- importedItems.value = []
298
- }
299
269
  watch(
300
270
  showDeleted,
301
271
  () => {
@@ -3,6 +3,8 @@
3
3
  <BaseDataTable
4
4
  :items="parsedTags"
5
5
  :exportable-items="exportableTags"
6
+ export-row-shape="full-records"
7
+ :export-show-data-scope-switch="false"
6
8
  :loading="tagsLoading"
7
9
  :headers="headers"
8
10
  :row-click-handler="handleRowClick"
@@ -92,23 +94,15 @@ import type { Tag } from "@raclettejs/core"
92
94
  import { useI18n } from "vue-i18n"
93
95
  import BaseDataTable from "@raclettejs/core/orchestrator/components/dataTable/BaseDataTable.vue"
94
96
  import { formatDistance } from "date-fns"
95
- import {
96
- mapJsonValues,
97
- parseAndNormalizeWorkbenchImportJson,
98
- resolveWorkbenchImportSelection,
99
- } from "@app/lib/jsonTools"
97
+ import { recordsFromStore } from "@app/lib/workbenchListTransfer"
98
+ import { useWorkbenchTableActions } from "@app/composables/useWorkbenchTableActions"
99
+ import useWorkbenchListImport from "@app/composables/useWorkbenchListImport"
100
100
  import ImportSelectionDialog from "@raclettejs/core/orchestrator/components/dataImport/ImportSelectionDialog.vue"
101
101
  import ImportSummaryDialog from "@raclettejs/core/orchestrator/components/dataImport/ImportSummaryDialog.vue"
102
- import { useWorkbenchTableActions } from "@app/composables/useWorkbenchTableActions"
102
+
103
103
  const showDeleted = ref(false)
104
104
  const { t } = useI18n()
105
105
  const { goToCreate, goToEditById } = useWorkbenchTableActions()
106
- const showImportDialog = ref(false)
107
- const showImportSummary = ref(false)
108
-
109
- const importableItems = ref<any[]>([])
110
- const importedItems = ref<any[]>([])
111
- const pendingImportById = ref<Record<string, any>>({})
112
106
  const headers = computed<{ title: string; key: keyof Tag }[]>(() => [
113
107
  {
114
108
  title: t("workbench.tagList.tableHeaders.title"),
@@ -132,7 +126,7 @@ const props = defineProps<{
132
126
  uuid: string
133
127
  }>()
134
128
 
135
- const { $data, $socket, $user } = usePluginApi("raclette__core")
129
+ const { $data, $socket } = usePluginApi("raclette__core")
136
130
 
137
131
  const {
138
132
  data: tags,
@@ -155,7 +149,20 @@ const { execute: executeHardDelete } = await $data.tag.deleteHard({
155
149
  const { execute: executeRestore } = await $data.tag.updateRestore({
156
150
  options: { cb: () => execute({ isDeleted: showDeleted.value }) },
157
151
  })
158
- const tagTableRows = (relativeUpdatedAt: boolean) => {
152
+
153
+ const {
154
+ showImportDialog,
155
+ showImportSummary,
156
+ importableItems,
157
+ importedItems,
158
+ handleFileUpload,
159
+ handleImportConfirm,
160
+ finalizeImport,
161
+ } = useWorkbenchListImport({
162
+ onBulkCreate: (items) => executeBulkCreate(items),
163
+ })
164
+
165
+ const tagTableRows = () => {
159
166
  if (!tags.value) {
160
167
  return []
161
168
  }
@@ -163,15 +170,15 @@ const tagTableRows = (relativeUpdatedAt: boolean) => {
163
170
  return Object.values(tags.value as { [key: string]: Tag }).map((tag) => ({
164
171
  ...tag,
165
172
  tags: tag?.tags?.map((t) => tags.value?.[t]) ?? [],
166
- ...(relativeUpdatedAt
167
- ? { updatedAt: formatDistance(new Date(tag.updatedAt), new Date()) }
168
- : {}),
173
+ updatedAt: formatDistance(new Date(tag.updatedAt), new Date()),
169
174
  }))
170
175
  }
171
176
 
172
- const parsedTags = computed(() => tagTableRows(true))
177
+ const parsedTags = computed(() => tagTableRows())
173
178
 
174
- const exportableTags = computed(() => tagTableRows(false))
179
+ const exportableTags = computed(() =>
180
+ recordsFromStore(tags.value as Record<string, Tag> | undefined),
181
+ )
175
182
 
176
183
  const deleteTag = async (tag: Tag) => {
177
184
  if (showDeleted.value) {
@@ -180,51 +187,6 @@ const deleteTag = async (tag: Tag) => {
180
187
  await executeDelete({ _id: tag._id })
181
188
  }
182
189
  }
183
- // Import functions
184
- const handleFileUpload = (content: string | ArrayBuffer) => {
185
- if (typeof content !== "string") {
186
- return
187
- }
188
- try {
189
- const { items, byId } = parseAndNormalizeWorkbenchImportJson(content)
190
- importableItems.value = items
191
- pendingImportById.value = byId
192
- showImportDialog.value = true
193
- } catch (error) {
194
- console.error("Failed to prepare import:", error)
195
- }
196
- }
197
-
198
- const handleImportConfirm = (selectedIds: string[]) => {
199
- importedItems.value = resolveWorkbenchImportSelection(
200
- pendingImportById.value,
201
- selectedIds,
202
- )
203
- showImportSummary.value = true
204
- }
205
-
206
- const finalizeImport = async () => {
207
- const mapping = {
208
- owner: $user.value._id,
209
- lastEditor: $user.value._id,
210
- createdBy: $user.value._id,
211
- }
212
-
213
- const dataToImport = importedItems.value.reduce((acc, item) => {
214
- const mappedItem = mapJsonValues(item, mapping)
215
- acc[item._id] = mappedItem
216
- return acc
217
- }, {} as any)
218
-
219
- await executeBulkCreate(Object.values(dataToImport))
220
- // TODO: Add your actual import logic here
221
- // e.g., call an API endpoint to save the imported compositions
222
-
223
- // Reset states
224
- pendingImportById.value = {}
225
- importableItems.value = []
226
- importedItems.value = []
227
- }
228
190
  const restore = async (_id: string) => {
229
191
  executeRestore({ _id })
230
192
  }
@@ -3,6 +3,8 @@
3
3
  <BaseDataTable
4
4
  :items="parsedUsers"
5
5
  :exportable-items="exportableUsers"
6
+ export-row-shape="full-records"
7
+ :export-show-data-scope-switch="false"
6
8
  :loading="usersLoading || tagsLoading"
7
9
  :headers="headers"
8
10
  :row-click-handler="handleRowClick"
@@ -74,6 +76,7 @@ import type { User } from "@raclettejs/core"
74
76
  import { formatDistance } from "date-fns"
75
77
  import { useI18n } from "vue-i18n"
76
78
  import BaseDataTable from "@raclettejs/core/orchestrator/components/dataTable/BaseDataTable.vue"
79
+ import { recordsFromStore } from "@app/lib/workbenchListTransfer"
77
80
  import { useWorkbenchTableActions } from "@app/composables/useWorkbenchTableActions"
78
81
  const showDeleted = ref(false)
79
82
 
@@ -123,7 +126,7 @@ const { execute: executeHardDelete } = await $data.user.deleteHard({
123
126
  const { execute: executeRestore } = await $data.user.updateRestore({
124
127
  options: { cb: () => execute({ isDeleted: showDeleted.value }) },
125
128
  })
126
- const userTableRows = (relativeUpdatedAt: boolean) => {
129
+ const userTableRows = () => {
127
130
  if (!users.value || !tags.value) {
128
131
  return []
129
132
  }
@@ -131,15 +134,15 @@ const userTableRows = (relativeUpdatedAt: boolean) => {
131
134
  return Object.values(users.value as { [key: string]: User }).map((user) => ({
132
135
  ...user,
133
136
  tags: user.tags.map((tag) => tags.value?.[tag]),
134
- ...(relativeUpdatedAt
135
- ? { updatedAt: formatDistance(new Date(user.updatedAt), new Date()) }
136
- : {}),
137
+ updatedAt: formatDistance(new Date(user.updatedAt), new Date()),
137
138
  }))
138
139
  }
139
140
 
140
- const parsedUsers = computed(() => userTableRows(true))
141
+ const parsedUsers = computed(() => userTableRows())
141
142
 
142
- const exportableUsers = computed(() => userTableRows(false))
143
+ const exportableUsers = computed(() =>
144
+ recordsFromStore(users.value as Record<string, User> | undefined),
145
+ )
143
146
  const deleteUser = async (user: User) => {
144
147
  if (showDeleted.value) {
145
148
  await executeHardDelete({ _id: user._id })
@@ -11,6 +11,8 @@ export type CacheEntrySource = {
11
11
  valuePreview?: string
12
12
  truncated: boolean
13
13
  valueParsed?: unknown
14
+ /** True when `valueParsed` was built from the full stored value (not the transport slice). */
15
+ valueParsedFromFullValue?: boolean
14
16
  ttl: number
15
17
  ttlLabel: string
16
18
  }
@@ -51,6 +53,7 @@ export const buildCacheEntrySource = (
51
53
  const sanitized = sanitizeCacheEntryFields(truncatedFields)
52
54
 
53
55
  let valueParsed = sanitized.valueParsed
56
+ let valueParsedFromFullValue = false
54
57
 
55
58
  if (attachFullParsed && truncatedFields.truncated) {
56
59
  const fullSanitized = sanitizeCacheEntryFields({
@@ -59,6 +62,7 @@ export const buildCacheEntrySource = (
59
62
  })
60
63
  if (fullSanitized.valueParsed !== undefined) {
61
64
  valueParsed = fullSanitized.valueParsed
65
+ valueParsedFromFullValue = true
62
66
  }
63
67
  }
64
68
 
@@ -69,6 +73,7 @@ export const buildCacheEntrySource = (
69
73
  valuePreview: sanitized.valuePreview,
70
74
  truncated: sanitized.truncated,
71
75
  valueParsed,
76
+ valueParsedFromFullValue: valueParsedFromFullValue || undefined,
72
77
  ttl,
73
78
  ttlLabel: formatTtlLabel(ttl),
74
79
  }
@@ -0,0 +1,191 @@
1
+ /** Injected into repaired JSON so viewers know the payload is incomplete. */
2
+ export const TRUNCATION_MARKER = "__raclette_truncated__"
3
+
4
+ const TRUNCATION_NOTICE =
5
+ "Value was cut for transport; remainder omitted. Do not treat as complete source of truth."
6
+
7
+ type CacheEntryDisplaySource = {
8
+ value: string
9
+ truncated: boolean
10
+ valueParsed?: unknown
11
+ valueParsedFromFullValue?: boolean
12
+ }
13
+
14
+ const prettyJson = (value: unknown): string => JSON.stringify(value, null, 2)
15
+
16
+ const appendTruncationNotice = (body: string): string =>
17
+ `${body}\n\n/* ${TRUNCATION_NOTICE} */`
18
+
19
+ /**
20
+ * Close open strings/arrays/objects after an abrupt cut so JSON.parse may succeed
21
+ * on a prefix of the original document.
22
+ */
23
+ export const repairTruncatedJson = (raw: string): string => {
24
+ const text = raw.trimEnd()
25
+ if (!text.length) {
26
+ return text
27
+ }
28
+
29
+ try {
30
+ JSON.parse(text)
31
+ return text
32
+ } catch {
33
+ // continue with repair
34
+ }
35
+
36
+ const stack: ("{" | "[")[] = []
37
+ let inString = false
38
+ let escaped = false
39
+ let lastCompleteTopLevel = 0
40
+
41
+ for (let i = 0; i < text.length; i++) {
42
+ const char = text[i]
43
+
44
+ if (inString) {
45
+ if (escaped) {
46
+ escaped = false
47
+ } else if (char === "\\") {
48
+ escaped = true
49
+ } else if (char === '"') {
50
+ inString = false
51
+ }
52
+ continue
53
+ }
54
+
55
+ if (char === '"') {
56
+ inString = true
57
+ continue
58
+ }
59
+
60
+ if (char === "{") {
61
+ stack.push("{")
62
+ continue
63
+ }
64
+
65
+ if (char === "[") {
66
+ stack.push("[")
67
+ continue
68
+ }
69
+
70
+ if (char === "}") {
71
+ if (stack.at(-1) === "{") {
72
+ stack.pop()
73
+ if (stack.length === 0) {
74
+ lastCompleteTopLevel = i + 1
75
+ }
76
+ }
77
+ continue
78
+ }
79
+
80
+ if (char === "]") {
81
+ if (stack.at(-1) === "[") {
82
+ stack.pop()
83
+ if (stack.length === 0) {
84
+ lastCompleteTopLevel = i + 1
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ let candidate = text
91
+ if (inString || stack.length > 0) {
92
+ if (lastCompleteTopLevel > 0) {
93
+ candidate = text.slice(0, lastCompleteTopLevel)
94
+ }
95
+ }
96
+
97
+ candidate = candidate.replace(/,\s*$/, "")
98
+
99
+ const openStack: ("{" | "[")[] = []
100
+ inString = false
101
+ escaped = false
102
+
103
+ for (let i = 0; i < candidate.length; i++) {
104
+ const char = candidate[i]
105
+ if (inString) {
106
+ if (escaped) escaped = false
107
+ else if (char === "\\") escaped = true
108
+ else if (char === '"') inString = false
109
+ continue
110
+ }
111
+ if (char === '"') {
112
+ inString = true
113
+ continue
114
+ }
115
+ if (char === "{") openStack.push("{")
116
+ else if (char === "[") openStack.push("[")
117
+ else if (char === "}" && openStack.at(-1) === "{") openStack.pop()
118
+ else if (char === "]" && openStack.at(-1) === "[") openStack.pop()
119
+ }
120
+
121
+ if (inString) {
122
+ const lastQuote = candidate.lastIndexOf('"')
123
+ candidate = lastQuote > 0 ? candidate.slice(0, lastQuote) : candidate
124
+ candidate = candidate.replace(/,\s*"[^"]*$/, "")
125
+ candidate = candidate.replace(/:\s*$/, "")
126
+ }
127
+
128
+ while (openStack.length > 0) {
129
+ const open = openStack.pop()
130
+ candidate += open === "{" ? "}" : "]"
131
+ }
132
+
133
+ candidate = candidate.replace(/,\s*([}\]])/g, "$1")
134
+
135
+ return candidate
136
+ }
137
+
138
+ const withTruncationMarker = (parsed: unknown): unknown => {
139
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
140
+ return {
141
+ ...parsed,
142
+ [TRUNCATION_MARKER]: TRUNCATION_NOTICE,
143
+ }
144
+ }
145
+
146
+ return {
147
+ [TRUNCATION_MARKER]: TRUNCATION_NOTICE,
148
+ value: parsed,
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Pretty-print a cache entry for <pre> display.
154
+ * Recovers structure from truncated JSON when possible and always signals truncation.
155
+ */
156
+ export const formatCacheEntryDisplay = (entry: CacheEntryDisplaySource): string => {
157
+ if (!entry.truncated) {
158
+ if (entry.valueParsed !== undefined) {
159
+ return prettyJson(entry.valueParsed)
160
+ }
161
+ try {
162
+ return prettyJson(JSON.parse(entry.value))
163
+ } catch {
164
+ return entry.value
165
+ }
166
+ }
167
+
168
+ if (
169
+ entry.valueParsed !== undefined &&
170
+ entry.valueParsedFromFullValue
171
+ ) {
172
+ return prettyJson(entry.valueParsed)
173
+ }
174
+
175
+ if (entry.valueParsed !== undefined) {
176
+ return appendTruncationNotice(prettyJson(entry.valueParsed))
177
+ }
178
+
179
+ try {
180
+ return appendTruncationNotice(prettyJson(JSON.parse(entry.value)))
181
+ } catch {
182
+ const repaired = repairTruncatedJson(entry.value)
183
+ try {
184
+ return appendTruncationNotice(
185
+ prettyJson(withTruncationMarker(JSON.parse(repaired))),
186
+ )
187
+ } catch {
188
+ return `${entry.value}\n\n… [${TRUNCATION_NOTICE}]`
189
+ }
190
+ }
191
+ }
@@ -257,7 +257,11 @@
257
257
  </v-card-subtitle>
258
258
  <v-card-text>
259
259
  <v-alert
260
- v-if="selectedEntry.truncated"
260
+ v-if="
261
+ selectedEntry.truncated &&
262
+ !selectedEntry.valueParsedFromFullValue &&
263
+ selectedEntry.valueParsed === undefined
264
+ "
261
265
  type="warning"
262
266
  density="compact"
263
267
  class="tw:mb-3"
@@ -294,6 +298,7 @@ import {
294
298
  isScalarFallbackResult,
295
299
  type ValueTableHeader,
296
300
  } from "../utils/cacheValueTableRows"
301
+ import { formatCacheEntryDisplay } from "../utils/formatCacheEntryDisplay"
297
302
  import { computed, onMounted, ref } from "vue"
298
303
  import { useI18n } from "vue-i18n"
299
304
 
@@ -305,6 +310,7 @@ type CacheEntry = {
305
310
  valuePreview?: string
306
311
  truncated: boolean
307
312
  valueParsed?: unknown
313
+ valueParsedFromFullValue?: boolean
308
314
  ttl: number
309
315
  ttlLabel: string
310
316
  }
@@ -440,7 +446,7 @@ const showValueTruncatedWarning = computed(() => {
440
446
  if (!isValueView.value || !entry?.truncated) {
441
447
  return false
442
448
  }
443
- return entry.valueParsed === undefined
449
+ return !entry.valueParsedFromFullValue && entry.valueParsed === undefined
444
450
  })
445
451
 
446
452
  const valueTableDataName = computed(() => {
@@ -530,6 +536,7 @@ const toTableRow = (entry: Partial<CacheEntry> & { key?: string }): CacheEntry =
530
536
  valuePreview: entry.valuePreview,
531
537
  truncated: entry.truncated ?? false,
532
538
  valueParsed: entry.valueParsed,
539
+ valueParsedFromFullValue: entry.valueParsedFromFullValue,
533
540
  ttl: entry.ttl ?? -2,
534
541
  ttlLabel: entry.ttlLabel ?? "",
535
542
  }
@@ -638,12 +645,7 @@ const displayValue = (item: CacheEntry) => {
638
645
  return item.value
639
646
  }
640
647
 
641
- const formatDetailValue = (item: CacheEntry) => {
642
- if (item.valueParsed !== undefined) {
643
- return JSON.stringify(item.valueParsed, null, 2)
644
- }
645
- return item.value
646
- }
648
+ const formatDetailValue = (item: CacheEntry) => formatCacheEntryDisplay(item)
647
649
 
648
650
  const formatValueTableCell = (value: string | undefined) => value ?? ""
649
651
 
@@ -0,0 +1,74 @@
1
+ import { ref } from "vue"
2
+ import {
3
+ indexRecordsById,
4
+ parseWorkbenchListImport,
5
+ resolveSelectedRecords,
6
+ toBulkCreateBody,
7
+ type WorkbenchListRecord,
8
+ } from "@app/lib/workbenchListTransfer"
9
+
10
+ export interface UseWorkbenchListImportOptions {
11
+ onBulkCreate: (items: WorkbenchListRecord[]) => Promise<void>
12
+ /** Optional per-datatype tweak after server-field omission (e.g. interaction-link description). */
13
+ prepareRecord?: (record: WorkbenchListRecord) => WorkbenchListRecord
14
+ }
15
+
16
+ /**
17
+ * File upload → selection dialog → summary dialog → `createBulk`.
18
+ */
19
+ export const useWorkbenchListImport = (
20
+ options: UseWorkbenchListImportOptions,
21
+ ) => {
22
+ const showImportDialog = ref(false)
23
+ const showImportSummary = ref(false)
24
+ const importableItems = ref<WorkbenchListRecord[]>([])
25
+ const importedItems = ref<WorkbenchListRecord[]>([])
26
+ const pendingImportById = ref<Record<string, WorkbenchListRecord>>({})
27
+
28
+ const resetImportState = () => {
29
+ pendingImportById.value = {}
30
+ importableItems.value = []
31
+ importedItems.value = []
32
+ }
33
+
34
+ const handleFileUpload = (content: string | ArrayBuffer) => {
35
+ if (typeof content !== "string") {
36
+ return
37
+ }
38
+ try {
39
+ const records = parseWorkbenchListImport(content)
40
+ importableItems.value = records
41
+ pendingImportById.value = indexRecordsById(records)
42
+ showImportDialog.value = true
43
+ } catch (error) {
44
+ console.error("Failed to prepare import:", error)
45
+ }
46
+ }
47
+
48
+ const handleImportConfirm = (selectedIds: string[]) => {
49
+ importedItems.value = resolveSelectedRecords(
50
+ pendingImportById.value,
51
+ selectedIds,
52
+ )
53
+ showImportSummary.value = true
54
+ }
55
+
56
+ const finalizeImport = async () => {
57
+ await options.onBulkCreate(
58
+ toBulkCreateBody(importedItems.value, options.prepareRecord),
59
+ )
60
+ resetImportState()
61
+ }
62
+
63
+ return {
64
+ showImportDialog,
65
+ showImportSummary,
66
+ importableItems,
67
+ importedItems,
68
+ handleFileUpload,
69
+ handleImportConfirm,
70
+ finalizeImport,
71
+ }
72
+ }
73
+
74
+ export default useWorkbenchListImport
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Workbench list JSON transfer format (export and import).
3
+ *
4
+ * ```json
5
+ * [
6
+ * { "_id": "uuid", "title": "…", … }
7
+ * ]
8
+ * ```
9
+ *
10
+ * - Export: raw store records via `recordsFromStore`, matched to table selection in
11
+ * `BaseDataTable` (`export-row-shape="full-records"`).
12
+ * - Import: same array shape; server-owned fields are omitted before `createBulk`.
13
+ */
14
+
15
+ export type WorkbenchListRecord = Record<string, unknown>
16
+
17
+ /** Fields present on store documents but not accepted by bulk-create APIs. */
18
+ const BULK_CREATE_OMIT_KEYS = new Set([
19
+ "createdAt",
20
+ "updatedAt",
21
+ "isDeleted",
22
+ "author",
23
+ "lastEditor",
24
+ "owner",
25
+ "createdBy",
26
+ ])
27
+
28
+ const isRecord = (value: unknown): value is WorkbenchListRecord =>
29
+ value != null && typeof value === "object" && !Array.isArray(value)
30
+
31
+ /** Store map (`{ [_id]: T }`) → array for `exportable-items`. */
32
+ export const recordsFromStore = <T extends WorkbenchListRecord>(
33
+ store: Record<string, T> | null | undefined,
34
+ ): T[] => Object.values(store ?? {}) as T[]
35
+
36
+ /** Parse an exported workbench list JSON file. */
37
+ export const parseWorkbenchListImport = (
38
+ content: string,
39
+ ): WorkbenchListRecord[] => {
40
+ let parsed: unknown
41
+ try {
42
+ parsed = JSON.parse(content)
43
+ } catch {
44
+ throw new Error("Invalid JSON file.")
45
+ }
46
+
47
+ if (!Array.isArray(parsed)) {
48
+ throw new Error("Import file must be a JSON array of records.")
49
+ }
50
+
51
+ const records = parsed.filter(isRecord)
52
+ if (!records.length) {
53
+ throw new Error("Import file must contain at least one record.")
54
+ }
55
+
56
+ for (const record of records) {
57
+ if (record._id == null || record._id === "") {
58
+ throw new Error("Every record must have an _id.")
59
+ }
60
+ }
61
+
62
+ return records
63
+ }
64
+
65
+ export const indexRecordsById = (
66
+ records: WorkbenchListRecord[],
67
+ ): Record<string, WorkbenchListRecord> =>
68
+ Object.fromEntries(records.map((record) => [String(record._id), record]))
69
+
70
+ export const resolveSelectedRecords = (
71
+ byId: Record<string, WorkbenchListRecord>,
72
+ selectedIds: string[],
73
+ ): WorkbenchListRecord[] =>
74
+ selectedIds
75
+ .map((id) => byId[id])
76
+ .filter((record): record is WorkbenchListRecord => record != null)
77
+
78
+ /** Drop server-owned fields before POSTing to `createBulk`. */
79
+ export const toBulkCreateBody = (
80
+ records: WorkbenchListRecord[],
81
+ prepare?: (record: WorkbenchListRecord) => WorkbenchListRecord,
82
+ ): WorkbenchListRecord[] =>
83
+ records.map((record) => {
84
+ const body: WorkbenchListRecord = { ...record }
85
+ for (const key of BULK_CREATE_OMIT_KEYS) {
86
+ delete body[key]
87
+ }
88
+ return prepare ? prepare(body) : body
89
+ })
@@ -1,147 +0,0 @@
1
- /**
2
- * Parsed workbench list import: rows for {@link SelectionDialog} and a map by `_id` for confirm.
3
- */
4
- export interface WorkbenchImportNormalization {
5
- items: Record<string, unknown>[]
6
- byId: Record<string, Record<string, unknown>>
7
- }
8
-
9
- /**
10
- * Parses JSON from a table export (array of records with `_id`) into dialog state.
11
- * Rejects non-arrays so imports match the current export format only.
12
- */
13
- export const parseAndNormalizeWorkbenchImportJson = (
14
- content: string,
15
- ): WorkbenchImportNormalization => {
16
- let parsed: unknown
17
- try {
18
- parsed = JSON.parse(content)
19
- } catch {
20
- throw new Error("Invalid JSON file.")
21
- }
22
-
23
- if (!Array.isArray(parsed)) {
24
- throw new Error(
25
- "Imported JSON must be an array of records (export from the data table).",
26
- )
27
- }
28
-
29
- const byId: Record<string, Record<string, unknown>> = {}
30
- const items: Record<string, unknown>[] = []
31
-
32
- for (const entry of parsed) {
33
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
34
- continue
35
- }
36
- const row = entry as Record<string, unknown>
37
- const id = row._id
38
- if (id === undefined || id === null || id === "") {
39
- continue
40
- }
41
- const sid = String(id)
42
- byId[sid] = row
43
- items.push(row)
44
- }
45
-
46
- if (!items.length) {
47
- throw new Error(
48
- "No importable records found. Each entry must be an object with an _id.",
49
- )
50
- }
51
-
52
- return { items, byId }
53
- }
54
-
55
- /** Resolve selected `_id`s using the `byId` map from {@link parseAndNormalizeWorkbenchImportJson}. */
56
- export const resolveWorkbenchImportSelection = <
57
- T extends Record<string, unknown>,
58
- >(
59
- byId: Record<string, T>,
60
- selectedIds: string[],
61
- ): T[] =>
62
- selectedIds
63
- .map((id) => byId[id])
64
- .filter((item): item is T => item != null)
65
-
66
- /**
67
- * Downloads a JSON object as a .json file
68
- * @param {Object|Array} data - The data to be downloaded as JSON
69
- * @param {string} filename - The name of the file (without .json extension)
70
- * @throws {Error} If data cannot be serialized to JSON
71
- */
72
- export const downloadJson = (data, filename = "download") => {
73
- try {
74
- // Convert data to JSON string with pretty formatting
75
- const jsonString = JSON.stringify(data, null, 2)
76
-
77
- // Create blob from JSON string
78
- const blob = new Blob([jsonString], { type: "application/json" })
79
-
80
- // Create download link
81
- const url = URL.createObjectURL(blob)
82
- const link = document.createElement("a")
83
-
84
- // Ensure filename has .json extension
85
- const normalizedFilename = filename.endsWith(".json")
86
- ? filename
87
- : `${filename}.json`
88
-
89
- link.href = url
90
- link.download = normalizedFilename
91
-
92
- // Trigger download
93
- document.body.appendChild(link)
94
- link.click()
95
-
96
- // Cleanup
97
- document.body.removeChild(link)
98
- URL.revokeObjectURL(url)
99
- } catch (error) {
100
- console.error("Failed to download JSON:", error)
101
- const message = error instanceof Error ? error.message : String(error)
102
- throw new Error(`Unable to download JSON file: ${message}`)
103
- }
104
- }
105
- /**
106
- * Recursively maps values in a JSON object based on a mapping configuration
107
- * @param {Object|Array} data - The source JSON data to transform
108
- * @param {Object} mapping - Mapping object where keys are attribute names and values are replacement values
109
- * @returns {Object|Array} New object with mapped values
110
- * @throws {Error} If data cannot be processed
111
- *
112
- * @example
113
- * const data = { owner: 'user123', name: 'Project A', lastEditor: 'user123' };
114
- * const mapping = { owner: 'user456', lastEditor: 'user789' };
115
- * const result = mapJsonValues(data, mapping);
116
- * // Result: { owner: 'user456', name: 'Project A', lastEditor: 'user789' }
117
- */
118
- export const mapJsonValues = (data, mapping = {}) => {
119
- if (!data || typeof data !== "object") {
120
- return data
121
- }
122
-
123
- // Handle arrays
124
- if (Array.isArray(data)) {
125
- return data.map((item) => mapJsonValues(item, mapping))
126
- }
127
-
128
- // Handle objects
129
- const result = {}
130
-
131
- for (const [key, value] of Object.entries(data)) {
132
- // If this key exists in mapping, use the mapped value
133
- if (key in mapping) {
134
- result[key] = mapping[key]
135
- }
136
- // If value is an object or array, recursively process it
137
- else if (value && typeof value === "object") {
138
- result[key] = mapJsonValues(value, mapping)
139
- }
140
- // Otherwise keep the original value
141
- else {
142
- result[key] = value
143
- }
144
- }
145
-
146
- return result
147
- }