@raclettejs/core 0.1.31 → 0.1.32-nightly

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 (83) hide show
  1. package/CHANGELOG.md +31 -4
  2. package/dist/cli.js +94 -94
  3. package/dist/cli.js.map +3 -3
  4. package/dist/index.js +3 -3
  5. package/dist/index.js.map +1 -1
  6. package/package.json +4 -4
  7. package/services/backend/.yarn/install-state.gz +0 -0
  8. package/services/backend/dist/core/eventBus/index.js +7 -0
  9. package/services/backend/dist/core/pluginSystem/configGenerator/index.js +346 -0
  10. package/services/backend/dist/core/sockets/index.js +95 -0
  11. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/account/events/index.js +31 -0
  12. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/account/index.js +48 -0
  13. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/account/routes/index.js +15 -0
  14. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/composition/events/index.js +22 -0
  15. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/composition/index.js +51 -0
  16. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/composition/routes/index.js +19 -0
  17. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/interactionLink/events/index.js +22 -0
  18. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/interactionLink/index.js +48 -0
  19. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/interactionLink/routes/index.js +19 -0
  20. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/project/events/index.js +31 -0
  21. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/project/index.js +49 -0
  22. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/project/routes/index.js +16 -0
  23. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/tag/events/index.js +39 -0
  24. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/tag/index.js +50 -0
  25. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/tag/routes/index.js +19 -0
  26. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/user/events/index.js +31 -0
  27. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/user/index.js +51 -0
  28. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/user/routes/index.js +21 -0
  29. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/workSession/events/index.js +31 -0
  30. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/workSession/index.js +48 -0
  31. package/services/backend/dist/corePlugins/raclette__core/backend/datatypes/workSession/routes/index.js +15 -0
  32. package/services/backend/dist/corePlugins/raclette__core/backend/index.js +34 -0
  33. package/services/backend/dist/domains/index.js +11 -0
  34. package/services/backend/dist/domains/system/index.js +11 -0
  35. package/services/backend/dist/domains/system/routes/index.js +17 -0
  36. package/services/backend/dist/helpers/index.js +14 -0
  37. package/services/backend/dist/index.js +3 -0
  38. package/services/backend/dist/modules/authentication/index.js +253 -0
  39. package/services/backend/dist/shared/types/core/index.js +8 -0
  40. package/services/backend/dist/shared/types/dataTypes/index.js +6 -0
  41. package/services/backend/dist/shared/types/index.js +8 -0
  42. package/services/backend/dist/shared/types/plugins/index.js +8 -0
  43. package/services/backend/dist/types/index.js +12 -0
  44. package/services/backend/dist/utils/index.js +2 -0
  45. package/services/backend/src/corePlugins/raclette__core/backend/datatypes/user/routes/route.user.patchOwn.ts +1 -1
  46. package/services/backend/src/corePlugins/raclette__core/backend/datatypes/user/user.service.ts +22 -1
  47. package/services/backend/yarn.lock +1 -1
  48. package/services/frontend/.yarn/install-state.gz +0 -0
  49. package/services/frontend/package.json +2 -2
  50. package/services/frontend/src/core/lib/data/dataApi.ts +1 -2
  51. package/services/frontend/src/orchestrator/ProductOrchestrator.vue +8 -1
  52. package/services/frontend/src/orchestrator/assets/styles/layers.css +1 -4
  53. package/services/frontend/src/orchestrator/assets/styles/tailwindStyles.css +3 -3
  54. package/services/frontend/src/orchestrator/assets/styles/vuetifyStyles.scss +4 -1
  55. package/services/frontend/src/orchestrator/components/dataExport/DataExporter.vue +303 -0
  56. package/services/frontend/src/orchestrator/components/dataTable/BaseDataTable.vue +473 -0
  57. package/services/frontend/src/orchestrator/components/dataTable/FileUpload.vue +83 -0
  58. package/services/frontend/src/orchestrator/components/dataTable/SelectionDialog.vue +169 -0
  59. package/services/frontend/src/orchestrator/components/dataTable/SummaryDialog.vue +211 -0
  60. package/services/frontend/src/orchestrator/components/index.ts +2 -0
  61. package/services/frontend/src/orchestrator/composables/index.ts +1 -0
  62. package/services/frontend/src/orchestrator/composables/useExport/formats/builtins.ts +124 -0
  63. package/services/frontend/src/orchestrator/composables/useExport/index.ts +176 -0
  64. package/services/frontend/src/orchestrator/composables/useExport/types.ts +141 -0
  65. package/services/frontend/src/orchestrator/i18n/de-DE.json +26 -1
  66. package/services/frontend/src/orchestrator/i18n/en-EU.json +26 -1
  67. package/services/frontend/src/orchestrator/i18n/sk.json +26 -1
  68. package/services/frontend/src/orchestrator/i18n/tl-TL.json +14 -0
  69. package/services/frontend/src/orchestrator/setup/vuetify.ts +0 -1
  70. package/services/frontend/yarn.lock +4854 -0
  71. package/types/index.ts +1 -1
  72. package/package/LICENSE.md +0 -699
  73. package/package/README.md +0 -49
  74. package/package/dev/README.md +0 -4
  75. package/package/index.js +0 -11
  76. package/package/raclette.default.config.yaml +0 -60
  77. package/package/services/backend/README.md +0 -172
  78. package/package/services/backend/yarn.lock +0 -5320
  79. package/package/services/frontend/README.md +0 -511
  80. package/package/services/frontend/src/core/lib/eggs/readme.md +0 -75
  81. package/package/services/frontend/yarn.lock +0 -0
  82. package/package/src/README.md +0 -404
  83. package/package/templates/README.md +0 -0
@@ -0,0 +1,169 @@
1
+ <template>
2
+ <v-dialog v-model="dialog" max-width="760" scrollable>
3
+ <v-card>
4
+ <v-card-title class="tw:flex tw:justify-between tw:items-center tw:gap-2">
5
+ <span class="tw:text-lg tw:font-semibold">{{ title }}</span>
6
+ <v-btn icon="mdi-close" size="small" variant="text" @click="close" />
7
+ </v-card-title>
8
+
9
+ <v-divider />
10
+
11
+ <v-card-text class="tw:py-4">
12
+ <div class="tw:flex tw:justify-between tw:items-center tw:mb-4 tw:gap-2">
13
+ <v-checkbox
14
+ :model-value="selectAll"
15
+ @update:model-value="toggleSelectAll"
16
+ :label="$t('core.selectAll')"
17
+ hide-details
18
+ density="compact"
19
+ />
20
+
21
+ <v-chip color="primary" variant="flat" size="small">
22
+ {{ selectedCount }} / {{ items.length }} {{ $t("core.selected") }}
23
+ </v-chip>
24
+ </div>
25
+
26
+ <v-list class="tw:max-h-96 tw:overflow-y-auto tw:border tw:rounded-md tw:py-0">
27
+ <v-list-item
28
+ v-for="item in items"
29
+ :key="item._id"
30
+ @click="toggleSelection(String(item._id))"
31
+ class="tw:cursor-pointer"
32
+ >
33
+ <template #prepend>
34
+ <v-checkbox-btn
35
+ :model-value="selected.includes(String(item._id))"
36
+ @update:model-value="(checked) => setSelection(String(item._id), checked)"
37
+ @click.stop
38
+ />
39
+ </template>
40
+
41
+ <v-list-item-title class="tw:text-sm">
42
+ {{ getDisplayValue(item) }}
43
+ </v-list-item-title>
44
+ </v-list-item>
45
+ </v-list>
46
+
47
+ <v-alert
48
+ v-if="selectedCount === 0"
49
+ type="warning"
50
+ variant="tonal"
51
+ density="compact"
52
+ class="tw:mt-4"
53
+ >
54
+ {{ $t("core.noItemsSelected") }}
55
+ </v-alert>
56
+ </v-card-text>
57
+
58
+ <v-divider />
59
+
60
+ <v-card-actions class="tw:px-4 tw:py-3">
61
+ <v-spacer />
62
+ <v-btn variant="text" @click="close">
63
+ {{ $t("core.cancel") }}
64
+ </v-btn>
65
+
66
+ <v-btn
67
+ v-if="selectedCount"
68
+ @click="confirm"
69
+ :disabled="selectedCount === 0"
70
+ color="primary"
71
+ variant="flat"
72
+ >
73
+ {{ confirmText }}
74
+ </v-btn>
75
+ </v-card-actions>
76
+ </v-card>
77
+ </v-dialog>
78
+ </template>
79
+
80
+ <script setup lang="ts">
81
+ import { ref, computed, watch } from "vue"
82
+
83
+ interface Props {
84
+ modelValue: boolean
85
+ items: any[]
86
+ displayAttribute: string
87
+ title: string
88
+ confirmText: string
89
+ }
90
+
91
+ const props = defineProps<Props>()
92
+
93
+ const emit = defineEmits<{
94
+ "update:modelValue": [value: boolean]
95
+ confirm: [selectedIds: string[]]
96
+ }>()
97
+
98
+ const dialog = computed({
99
+ get: () => props.modelValue,
100
+ set: (value) => emit("update:modelValue", value),
101
+ })
102
+
103
+ const selected = ref<string[]>([])
104
+ const selectAll = ref(false)
105
+
106
+ const selectedCount = computed(() => selected.value.length)
107
+
108
+ const getDisplayValue = (item: any) => {
109
+ return item[props.displayAttribute] || item._id
110
+ }
111
+
112
+ const toggleSelection = (id: string) => {
113
+ const index = selected.value.indexOf(id)
114
+ if (index > -1) {
115
+ selected.value.splice(index, 1)
116
+ } else {
117
+ selected.value.push(id)
118
+ }
119
+ updateSelectAllState()
120
+ }
121
+
122
+ const setSelection = (id: string, checked: boolean | null) => {
123
+ const isChecked = Boolean(checked)
124
+ const exists = selected.value.includes(id)
125
+ if (isChecked && !exists) {
126
+ selected.value.push(id)
127
+ } else if (!isChecked && exists) {
128
+ selected.value = selected.value.filter((selectedId) => selectedId !== id)
129
+ }
130
+ updateSelectAllState()
131
+ }
132
+
133
+ const toggleSelectAll = (value: boolean | null) => {
134
+ const enabled = Boolean(value)
135
+ if (enabled) {
136
+ selected.value = props.items.map((item) => String(item._id))
137
+ } else {
138
+ selected.value = []
139
+ }
140
+ selectAll.value = enabled
141
+ }
142
+
143
+ const updateSelectAllState = () => {
144
+ selectAll.value =
145
+ selected.value.length === props.items.length && props.items.length > 0
146
+ }
147
+
148
+ const close = () => {
149
+ dialog.value = false
150
+ selected.value = []
151
+ selectAll.value = false
152
+ }
153
+
154
+ const confirm = () => {
155
+ if (selectedCount.value > 0) {
156
+ emit("confirm", selected.value)
157
+ close()
158
+ }
159
+ }
160
+
161
+ // Initialize selectAll state when items change
162
+ watch(
163
+ () => props.items,
164
+ () => {
165
+ updateSelectAllState()
166
+ },
167
+ { immediate: true },
168
+ )
169
+ </script>
@@ -0,0 +1,211 @@
1
+ <template>
2
+ <v-dialog v-model="dialog" max-width="800" scrollable>
3
+ <v-card class="tw:overflow-hidden">
4
+ <v-card-title class="tw:flex tw:items-center tw:justify-between tw:gap-2">
5
+ <span class="tw:text-lg tw:font-semibold">{{ title }}</span>
6
+ <v-btn icon="mdi-close" size="small" variant="text" @click="close" />
7
+ </v-card-title>
8
+
9
+ <v-divider />
10
+
11
+ <v-card-text class="tw:py-4">
12
+ <v-alert :type="type" variant="tonal" class="tw:mb-4">
13
+ <div class="tw:text-base tw:font-medium tw:mb-2">
14
+ {{ message }}
15
+ </div>
16
+ <div class="tw:text-sm">
17
+ {{ $t("core.itemsCount") }}:
18
+ <strong>{{ itemCount }}</strong>
19
+ </div>
20
+ </v-alert>
21
+
22
+ <div v-if="showItems && renderedItems.length > 0">
23
+ <div class="tw:text-xs tw:uppercase tw:font-semibold tw:opacity-60 tw:mb-2">
24
+ {{ $t("core.selectedItems") }}
25
+ </div>
26
+
27
+ <v-list density="compact" class="tw:border tw:rounded-md tw:py-0">
28
+ <v-list-item
29
+ v-for="item in renderedItems"
30
+ :key="item.key"
31
+ class="tw:py-2"
32
+ >
33
+ <template #title>
34
+ <span class="tw:text-sm tw:font-medium">{{ item.title }}</span>
35
+ </template>
36
+
37
+ <template #subtitle>
38
+ <pre
39
+ v-if="item.kind === 'pre'"
40
+ class="tw:mt-1 tw:text-xs tw:font-mono tw:p-2 tw:max-h-44 tw:overflow-auto tw:rounded tw:bg-black/5 dark:tw:bg-white/5 tw:whitespace-pre-wrap tw:break-all"
41
+ >{{ item.text }}</pre>
42
+ <div v-else class="tw:mt-1 tw:text-sm tw:opacity-80 tw:break-all">
43
+ {{ item.text }}
44
+ </div>
45
+ </template>
46
+ </v-list-item>
47
+ </v-list>
48
+ </div>
49
+ </v-card-text>
50
+
51
+ <v-divider />
52
+
53
+ <v-card-actions class="tw:px-4 tw:py-3">
54
+ <v-spacer />
55
+ <v-btn variant="text" @click="close">
56
+ {{ cancelText || $t("core.close") }}
57
+ </v-btn>
58
+ <v-btn
59
+ v-if="showConfirm"
60
+ :color="type === 'warning' ? 'warning' : type === 'error' ? 'error' : 'primary'"
61
+ variant="flat"
62
+ @click="confirm"
63
+ >
64
+ {{ confirmText }}
65
+ </v-btn>
66
+ </v-card-actions>
67
+ </v-card>
68
+ </v-dialog>
69
+ </template>
70
+
71
+ <script setup lang="ts">
72
+ import { computed } from "vue"
73
+ import { useI18n } from "vue-i18n"
74
+
75
+ interface Props {
76
+ modelValue: boolean
77
+ items: any[]
78
+ title: string
79
+ message: string
80
+ type?: "info" | "success" | "warning" | "error"
81
+ showItems?: boolean
82
+ showConfirm?: boolean
83
+ displayAttribute: string
84
+ confirmText?: string
85
+ cancelText?: string
86
+ }
87
+
88
+ const props = withDefaults(defineProps<Props>(), {
89
+ type: "info",
90
+ showItems: true,
91
+ showConfirm: false,
92
+ })
93
+
94
+ const emit = defineEmits<{
95
+ "update:modelValue": [value: boolean]
96
+ confirm: []
97
+ }>()
98
+ const { t } = useI18n()
99
+
100
+ const dialog = computed({
101
+ get: () => props.modelValue,
102
+ set: (value) => emit("update:modelValue", value),
103
+ })
104
+
105
+ const itemCount = computed(() => props.items.length)
106
+
107
+ const close = () => {
108
+ dialog.value = false
109
+ }
110
+
111
+ type SummaryRenderKind = "text" | "pre"
112
+
113
+ interface SummaryRenderItem {
114
+ key: string
115
+ title: string
116
+ kind: SummaryRenderKind
117
+ text: string
118
+ }
119
+
120
+ interface SummaryFormatter {
121
+ kind: SummaryRenderKind
122
+ canHandle: (value: unknown) => boolean
123
+ format: (value: unknown) => string
124
+ }
125
+
126
+ const formatAsText = (value: unknown): string => {
127
+ if (value === null || value === undefined) return "-"
128
+ return String(value)
129
+ }
130
+
131
+ const objectJsonFormatter: SummaryFormatter = {
132
+ kind: "pre",
133
+ canHandle: (value) =>
134
+ value !== null && typeof value === "object" && !Array.isArray(value),
135
+ format: (value) => JSON.stringify(value, null, 2),
136
+ }
137
+
138
+ const arrayJsonFormatter: SummaryFormatter = {
139
+ kind: "pre",
140
+ canHandle: (value) => Array.isArray(value),
141
+ format: (value) => JSON.stringify(value, null, 2),
142
+ }
143
+
144
+ const primitiveFormatter: SummaryFormatter = {
145
+ kind: "text",
146
+ canHandle: () => true,
147
+ format: (value) => formatAsText(value),
148
+ }
149
+
150
+ // TODO(summary-formatters): Add XML formatter when import/export surfaces XML payloads.
151
+ // TODO(summary-formatters): Add CSV formatter with row/column-aware preview rendering.
152
+ // TODO(summary-formatters): Add image formatter for URL/blob metadata and thumbnail previews.
153
+ const summaryFormatters: SummaryFormatter[] = [
154
+ objectJsonFormatter,
155
+ arrayJsonFormatter,
156
+ primitiveFormatter,
157
+ ]
158
+
159
+ const resolveFormatter = (value: unknown): SummaryFormatter => {
160
+ return (
161
+ summaryFormatters.find((formatter) => formatter.canHandle(value)) ??
162
+ primitiveFormatter
163
+ )
164
+ }
165
+
166
+ const formatDisplayTitle = (item: unknown, index: number): string => {
167
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
168
+ return `${t("core.selectedItems")} #${index + 1}`
169
+ }
170
+
171
+ const record = item as Record<string, unknown>
172
+ const attributeValue = record[props.displayAttribute]
173
+ if (attributeValue !== undefined && attributeValue !== null) {
174
+ return formatAsText(attributeValue)
175
+ }
176
+ if (record._id !== undefined && record._id !== null) {
177
+ return formatAsText(record._id)
178
+ }
179
+ return `${t("core.selectedItems")} #${index + 1}`
180
+ }
181
+
182
+ const getDisplayPayload = (item: unknown): unknown => {
183
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
184
+ return item
185
+ }
186
+
187
+ const record = item as Record<string, unknown>
188
+ const payload = { ...record }
189
+ delete payload[props.displayAttribute]
190
+ return payload
191
+ }
192
+
193
+ const renderedItems = computed<SummaryRenderItem[]>(() =>
194
+ props.items.map((item, index) => {
195
+ const payload = getDisplayPayload(item)
196
+ const formatter = resolveFormatter(payload)
197
+
198
+ return {
199
+ key: `${index}-${formatDisplayTitle(item, index)}`,
200
+ title: formatDisplayTitle(item, index),
201
+ kind: formatter.kind,
202
+ text: formatter.format(payload),
203
+ }
204
+ }),
205
+ )
206
+
207
+ const confirm = () => {
208
+ emit("confirm")
209
+ close()
210
+ }
211
+ </script>
@@ -14,9 +14,11 @@ import UserChip from "./chip/UserChip.vue"
14
14
  import UserMenu from "./menu/UserMenu.vue"
15
15
  import UserNotifications from "./menu/UserNotifications.vue"
16
16
  import WidgetsLayoutLoader from "./composition/WidgetsLayoutLoader.vue"
17
+ import DataExporter from "./dataExport/DataExporter.vue"
17
18
 
18
19
  export {
19
20
  CompositionOverlay,
21
+ DataExporter,
20
22
  DefaultModal,
21
23
  DraggableElement,
22
24
  DropzoneElement,
@@ -4,3 +4,4 @@ export * from "./useCurrentComposition"
4
4
  export * from "./useWidgets"
5
5
  export * from "./useInteractionLinks"
6
6
  export * from "./useWidgetLifecycle"
7
+ export * from "./useExport"
@@ -0,0 +1,124 @@
1
+ import type { ExportFormat } from "../types"
2
+
3
+ // ─── Optional peer dep: js-yaml ───────────────────────────────────────────────
4
+ // Vite/ESM environments don't support require(). We attempt a static import at
5
+ // module initialisation time. If js-yaml is not installed the import will throw
6
+ // and jsYamlDump stays undefined — the serializer then shows a clear error.
7
+
8
+ type JsYamlDump = (data: unknown, options?: object) => string
9
+
10
+ let jsYamlDump: JsYamlDump | undefined
11
+
12
+ const jsonReplacer = (_key: string, value: unknown) => {
13
+ // JSON doesn't support bigint values; preserve intent as a tagged string.
14
+ if (typeof value === "bigint") {
15
+ return `${value}n`
16
+ }
17
+
18
+ return value
19
+ }
20
+
21
+ try {
22
+ // Static import string must be a literal so bundlers can analyse it.
23
+ // The `@vite-ignore` comment suppresses the "dynamic import" warning.
24
+ const mod = await import(/* @vite-ignore */ "js-yaml")
25
+ jsYamlDump = (mod.dump ?? mod.default?.dump) as JsYamlDump | undefined
26
+ } catch {
27
+ // js-yaml not installed — yamlFormat.serialize() will throw a helpful message
28
+ }
29
+
30
+ // ─── XML ──────────────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Recursively converts a plain JS object / array to XML element strings.
34
+ * - Object keys become element names (invalid XML chars are stripped).
35
+ * - Arrays emit repeated elements using the parent key name (singularised naively).
36
+ * - Primitive values become text content.
37
+ */
38
+ function toXmlElements(value: unknown, tag: string, indent: string): string {
39
+ const pad = indent + " "
40
+
41
+ if (value === null || value === undefined) {
42
+ return `${indent}<${tag}/>`
43
+ }
44
+
45
+ if (Array.isArray(value)) {
46
+ // Emit each item wrapped in a singular version of the tag.
47
+ const child = tag.replace(/s$/, "") || "item"
48
+ return value
49
+ .map((item) => toXmlElements(item, child, indent))
50
+ .join("\n")
51
+ }
52
+
53
+ if (typeof value === "object") {
54
+ const children = Object.entries(value as Record<string, unknown>)
55
+ .map(([k, v]) => toXmlElements(v, sanitiseTag(k), pad))
56
+ .join("\n")
57
+ return `${indent}<${tag}>\n${children}\n${indent}</${tag}>`
58
+ }
59
+
60
+ // Primitive — escape XML special chars.
61
+ const escaped = String(value)
62
+ .replace(/&/g, "&amp;")
63
+ .replace(/</g, "&lt;")
64
+ .replace(/>/g, "&gt;")
65
+ .replace(/"/g, "&quot;")
66
+ .replace(/'/g, "&apos;")
67
+ return `${indent}<${tag}>${escaped}</${tag}>`
68
+ }
69
+
70
+ function sanitiseTag(key: string): string {
71
+ // XML element names must start with a letter or underscore.
72
+ const cleaned = key.replace(/[^a-zA-Z0-9_.-]/g, "_")
73
+ return /^[^a-zA-Z_]/.test(cleaned) ? `_${cleaned}` : cleaned
74
+ }
75
+
76
+ export const xmlFormat: ExportFormat = {
77
+ id: "xml",
78
+ label: "XML",
79
+ extension: "xml",
80
+ mimeType: "application/xml",
81
+ serialize(data) {
82
+ const body = toXmlElements(data, "root", "")
83
+ return `<?xml version="1.0" encoding="UTF-8"?>\n${body}`
84
+ },
85
+ }
86
+
87
+ // ─── JSON ─────────────────────────────────────────────────────────────────────
88
+
89
+ export const jsonFormat: ExportFormat = {
90
+ id: "json",
91
+ label: "JSON",
92
+ extension: "json",
93
+ mimeType: "application/json",
94
+ serialize(data) {
95
+ return JSON.stringify(data, jsonReplacer, 2)
96
+ },
97
+ }
98
+
99
+ // ─── YAML ─────────────────────────────────────────────────────────────────────
100
+
101
+ export const yamlFormat: ExportFormat = {
102
+ id: "yaml",
103
+ label: "YAML",
104
+ extension: "yaml",
105
+ mimeType: "application/yaml",
106
+ serialize(data) {
107
+ if (typeof jsYamlDump !== "function") {
108
+ throw new Error(
109
+ 'The "js-yaml" package is required for YAML export.\n' +
110
+ "Install it with: npm install js-yaml\n" +
111
+ "Types (optional): npm install -D @types/js-yaml",
112
+ )
113
+ }
114
+ return jsYamlDump(data, { indent: 2, lineWidth: 120, noRefs: true })
115
+ },
116
+ }
117
+
118
+ // ─── Registry ─────────────────────────────────────────────────────────────────
119
+
120
+ export const BUILTIN_FORMATS: Record<string, ExportFormat> = {
121
+ json: jsonFormat,
122
+ yaml: yamlFormat,
123
+ xml: xmlFormat,
124
+ }
@@ -0,0 +1,176 @@
1
+ import { computed, ref, watch } from "vue"
2
+ import { useClipboard } from "@vueuse/core"
3
+ import { BUILTIN_FORMATS } from "./formats/builtins"
4
+ import type {
5
+ ExportFormat,
6
+ FormatEntry,
7
+ SerializeResult,
8
+ ExportSuccessPayload,
9
+ ExportErrorPayload,
10
+ } from "./types"
11
+
12
+ export * from "./types"
13
+
14
+ // ─── Types ────────────────────────────────────────────────────────────────────
15
+
16
+ interface UseExportOptions {
17
+ data: () => unknown
18
+ formats: () => FormatEntry[]
19
+ defaultFormat: () => string | undefined
20
+ filename: () => string
21
+ enabled?: () => boolean
22
+ onSuccess: (payload: ExportSuccessPayload) => void
23
+ onError: (payload: ExportErrorPayload) => void
24
+ }
25
+
26
+ // ─── Composable ───────────────────────────────────────────────────────────────
27
+
28
+ export function useExport(options: UseExportOptions) {
29
+ const {
30
+ data,
31
+ formats,
32
+ defaultFormat,
33
+ filename,
34
+ enabled,
35
+ onSuccess,
36
+ onError,
37
+ } = options
38
+
39
+ // ── Resolve format list ────────────────────────────────────────────────────
40
+
41
+ const resolvedFormats = computed<ExportFormat[]>(() => {
42
+ return formats()
43
+ .map((entry): ExportFormat | null => {
44
+ if (typeof entry === "string") {
45
+ return BUILTIN_FORMATS[entry] ?? null
46
+ }
47
+ // Custom format — validate minimum required fields
48
+ if (
49
+ entry &&
50
+ typeof entry.id === "string" &&
51
+ typeof entry.serialize === "function"
52
+ ) {
53
+ return entry as ExportFormat
54
+ }
55
+ console.warn("[RacletteExport] Invalid format entry, skipping:", entry)
56
+ return null
57
+ })
58
+ .filter((f): f is ExportFormat => f !== null)
59
+ })
60
+
61
+ // ── Active format ──────────────────────────────────────────────────────────
62
+
63
+ const activeFormatId = ref<string>("")
64
+
65
+ // Initialize / sync when formats or defaultFormat changes
66
+ watch(
67
+ [resolvedFormats, defaultFormat],
68
+ ([fmts, dflt]) => {
69
+ if (!fmts.length) {
70
+ activeFormatId.value = ""
71
+ return
72
+ }
73
+ const preferred = dflt ?? ""
74
+ const found = fmts.find((f) => f.id === preferred)
75
+ // Keep current selection if still valid, otherwise fall back to first
76
+ const currentStillValid = fmts.some((f) => f.id === activeFormatId.value)
77
+ if (!currentStillValid) {
78
+ activeFormatId.value = found?.id ?? fmts[0].id
79
+ }
80
+ },
81
+ { immediate: true },
82
+ )
83
+
84
+ const activeFormat = computed<ExportFormat | undefined>(() =>
85
+ resolvedFormats.value.find((f) => f.id === activeFormatId.value),
86
+ )
87
+
88
+ // ── Serialization ──────────────────────────────────────────────────────────
89
+
90
+ const serializeResult = computed<SerializeResult>(() => {
91
+ if (enabled && !enabled()) {
92
+ // Defer serialization until explicitly enabled by the caller.
93
+ return { ok: true, value: "" }
94
+ }
95
+
96
+ const fmt = activeFormat.value
97
+ if (!fmt) return { ok: false, error: "No format selected." }
98
+
99
+ try {
100
+ const value = fmt.serialize(data())
101
+ if (typeof value !== "string") {
102
+ throw new TypeError(
103
+ `serialize() must return a string, got ${typeof value}`,
104
+ )
105
+ }
106
+ return { ok: true, value }
107
+ } catch (err) {
108
+ const message = err instanceof Error ? err.message : String(err)
109
+ onError({ formatId: fmt.id, action: "serialize", error: err })
110
+ return { ok: false, error: message }
111
+ }
112
+ })
113
+
114
+ // ── Clipboard ──────────────────────────────────────────────────────────────
115
+
116
+ const { copy, isSupported: clipboardSupported } = useClipboard()
117
+ const copyState = ref<"idle" | "success" | "error">("idle")
118
+ let copyResetTimer: ReturnType<typeof setTimeout> | null = null
119
+
120
+ async function copyToClipboard() {
121
+ const fmt = activeFormat.value
122
+ if (!fmt) return
123
+ if (serializeResult.value.ok === false) return
124
+
125
+ try {
126
+ await copy(serializeResult.value.value)
127
+ copyState.value = "success"
128
+ onSuccess({ formatId: fmt.id, action: "copy" })
129
+ } catch (err) {
130
+ copyState.value = "error"
131
+ onError({ formatId: fmt.id, action: "copy", error: err })
132
+ } finally {
133
+ if (copyResetTimer) clearTimeout(copyResetTimer)
134
+ copyResetTimer = setTimeout(() => {
135
+ copyState.value = "idle"
136
+ }, 2000)
137
+ }
138
+ }
139
+
140
+ // ── Download ───────────────────────────────────────────────────────────────
141
+
142
+ function downloadFile() {
143
+ const fmt = activeFormat.value
144
+ if (!fmt) return
145
+ if (serializeResult.value.ok === false) return
146
+
147
+ try {
148
+ const blob = new Blob([serializeResult.value.value], {
149
+ type: `${fmt.mimeType};charset=utf-8`,
150
+ })
151
+ const url = URL.createObjectURL(blob)
152
+ const anchor = document.createElement("a")
153
+ anchor.href = url
154
+ anchor.download = `${filename() || "export"}.${fmt.extension}`
155
+ anchor.style.display = "none"
156
+ document.body.appendChild(anchor)
157
+ anchor.click()
158
+ document.body.removeChild(anchor)
159
+ URL.revokeObjectURL(url)
160
+ onSuccess({ formatId: fmt.id, action: "download" })
161
+ } catch (err) {
162
+ onError({ formatId: fmt.id, action: "download", error: err })
163
+ }
164
+ }
165
+
166
+ return {
167
+ resolvedFormats,
168
+ activeFormatId,
169
+ activeFormat,
170
+ serializeResult,
171
+ copyToClipboard,
172
+ downloadFile,
173
+ copyState,
174
+ clipboardSupported,
175
+ }
176
+ }