@morscherlab/mint-sdk 1.0.13 → 1.0.15
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/dist/__tests__/composables/useCommandHistory.test.d.ts +1 -0
- package/dist/__tests__/composables/useFileImport.test.d.ts +1 -0
- package/dist/__tests__/composables/useOptimisticMutation.test.d.ts +1 -0
- package/dist/__tests__/composables/useResourceCrud.test.d.ts +1 -0
- package/dist/__tests__/composables/useWellPainting.test.d.ts +1 -0
- package/dist/__tests__/composables/useWellPlateAdapter.test.d.ts +1 -0
- package/dist/__tests__/composables/useWellPlateValidation.test.d.ts +1 -0
- package/dist/components/index.js +1 -1
- package/dist/{components-CzFtQExv.js → components-Cyk8QEyL.js} +81 -75
- package/dist/components-Cyk8QEyL.js.map +1 -0
- package/dist/composables/index.d.ts +7 -0
- package/dist/composables/index.js +2 -2
- package/dist/composables/useCommandHistory.d.ts +29 -0
- package/dist/composables/useFileImport.d.ts +35 -0
- package/dist/composables/useOptimisticMutation.d.ts +28 -0
- package/dist/composables/useResourceCrud.d.ts +40 -0
- package/dist/composables/useWellPainting.d.ts +35 -0
- package/dist/composables/useWellPlateAdapter.d.ts +36 -0
- package/dist/composables/useWellPlateValidation.d.ts +27 -0
- package/dist/{composables-BuG5yAb7.js → composables-D9mexHSW.js} +666 -3
- package/dist/composables-D9mexHSW.js.map +1 -0
- package/dist/index.js +3 -3
- package/dist/install.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/components/SampleSelector.test.ts +6 -3
- package/src/__tests__/components/SampleSelectorSampleRow.test.ts +5 -2
- package/src/__tests__/composables/useCommandHistory.test.ts +43 -0
- package/src/__tests__/composables/useFileImport.test.ts +44 -0
- package/src/__tests__/composables/useOptimisticMutation.test.ts +40 -0
- package/src/__tests__/composables/useResourceCrud.test.ts +56 -0
- package/src/__tests__/composables/useWellPainting.test.ts +52 -0
- package/src/__tests__/composables/useWellPlateAdapter.test.ts +50 -0
- package/src/__tests__/composables/useWellPlateValidation.test.ts +42 -0
- package/src/components/PluginWorkspaceView.vue +3 -3
- package/src/components/SampleSelector.vue +2 -0
- package/src/components/SampleSelectorSampleRow.vue +2 -0
- package/src/composables/index.ts +67 -0
- package/src/composables/useCommandHistory.ts +113 -0
- package/src/composables/useFileImport.ts +231 -0
- package/src/composables/useOptimisticMutation.ts +107 -0
- package/src/composables/useResourceCrud.ts +245 -0
- package/src/composables/useWellPainting.ts +187 -0
- package/src/composables/useWellPlateAdapter.ts +147 -0
- package/src/composables/useWellPlateValidation.ts +85 -0
- package/dist/components-CzFtQExv.js.map +0 -1
- package/dist/composables-BuG5yAb7.js.map +0 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { useResourceCrud } from '../../composables/useResourceCrud'
|
|
4
|
+
|
|
5
|
+
interface Item {
|
|
6
|
+
id: string
|
|
7
|
+
name: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('useResourceCrud', () => {
|
|
11
|
+
it('loads and mutates local resource state through an adapter', async () => {
|
|
12
|
+
const remote: Item[] = [{ id: 'a', name: 'Alpha' }]
|
|
13
|
+
const resource = useResourceCrud<Item, Pick<Item, 'name'>, Partial<Item>, string>({
|
|
14
|
+
adapter: {
|
|
15
|
+
list: async () => [...remote],
|
|
16
|
+
create: async payload => {
|
|
17
|
+
const item = { id: payload.name.toLowerCase(), name: payload.name }
|
|
18
|
+
remote.push(item)
|
|
19
|
+
return item
|
|
20
|
+
},
|
|
21
|
+
update: async (id, payload) => {
|
|
22
|
+
const item = { id, name: payload.name ?? 'Untitled' }
|
|
23
|
+
remote.splice(remote.findIndex(candidate => candidate.id === id), 1, item)
|
|
24
|
+
return item
|
|
25
|
+
},
|
|
26
|
+
remove: async id => {
|
|
27
|
+
remote.splice(remote.findIndex(candidate => candidate.id === id), 1)
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
await resource.load()
|
|
33
|
+
expect(resource.items.value).toEqual([{ id: 'a', name: 'Alpha' }])
|
|
34
|
+
|
|
35
|
+
await resource.createItem({ name: 'Beta' })
|
|
36
|
+
expect(resource.items.value.map(item => item.name)).toEqual(['Alpha', 'Beta'])
|
|
37
|
+
|
|
38
|
+
await resource.updateItem('beta', { name: 'Bravo' })
|
|
39
|
+
expect(resource.items.value.find(item => item.id === 'beta')?.name).toBe('Bravo')
|
|
40
|
+
|
|
41
|
+
await resource.deleteItem('a')
|
|
42
|
+
expect(resource.items.value.map(item => item.id)).toEqual(['beta'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('normalizes adapter errors', async () => {
|
|
46
|
+
const resource = useResourceCrud<Item>({
|
|
47
|
+
adapter: {
|
|
48
|
+
list: async () => { throw new Error('nope') },
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
await expect(resource.load()).rejects.toThrow('nope')
|
|
53
|
+
expect(resource.error.value).toBe('nope')
|
|
54
|
+
expect(resource.loading.value).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
coordinateToWellId,
|
|
5
|
+
useWellPainting,
|
|
6
|
+
wellIdToCoordinate,
|
|
7
|
+
wellIdsInRectangle,
|
|
8
|
+
} from '../../composables/useWellPainting'
|
|
9
|
+
|
|
10
|
+
describe('useWellPainting', () => {
|
|
11
|
+
it('converts well coordinates and computes rectangle selections', () => {
|
|
12
|
+
expect(wellIdToCoordinate('B2')).toEqual({ row: 1, col: 1 })
|
|
13
|
+
expect(coordinateToWellId({ row: 0, col: 0 })).toBe('A1')
|
|
14
|
+
expect(wellIdsInRectangle({ row: 0, col: 0 }, { row: 1, col: 1 }, 96)).toEqual([
|
|
15
|
+
'A1',
|
|
16
|
+
'A2',
|
|
17
|
+
'B1',
|
|
18
|
+
'B2',
|
|
19
|
+
])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('emits rectangle selections in select mode', () => {
|
|
23
|
+
const completed: string[][] = []
|
|
24
|
+
const painting = useWellPainting({
|
|
25
|
+
format: 96,
|
|
26
|
+
initialMode: 'select',
|
|
27
|
+
onComplete: result => completed.push(result.wellIds),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
painting.onWellMouseDown('A1')
|
|
31
|
+
painting.onWellMouseMove('B2')
|
|
32
|
+
expect(painting.previewWellIds.value).toEqual(['A1', 'A2', 'B1', 'B2'])
|
|
33
|
+
painting.onWellMouseUp()
|
|
34
|
+
|
|
35
|
+
expect(completed).toEqual([['A1', 'A2', 'B1', 'B2']])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('tracks paint trails for non-select modes', () => {
|
|
39
|
+
const completed: { mode: string; wells: string[] }[] = []
|
|
40
|
+
const painting = useWellPainting({
|
|
41
|
+
format: 96,
|
|
42
|
+
initialMode: 'paint',
|
|
43
|
+
onComplete: result => completed.push({ mode: result.mode, wells: result.wellIds }),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
painting.onWellMouseDown('A1')
|
|
47
|
+
painting.onWellMouseMove('A3')
|
|
48
|
+
painting.onWellMouseUp()
|
|
49
|
+
|
|
50
|
+
expect(completed).toEqual([{ mode: 'paint', wells: ['A1', 'A3'] }])
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createColumnConditions,
|
|
5
|
+
createRowConditions,
|
|
6
|
+
createWellPlateWells,
|
|
7
|
+
} from '../../composables/useWellPlateAdapter'
|
|
8
|
+
|
|
9
|
+
describe('well plate adapter helpers', () => {
|
|
10
|
+
it('maps domain entries to WellPlate wells', () => {
|
|
11
|
+
const wells = createWellPlateWells(
|
|
12
|
+
[{ id: 'B3', type: 'sample', signal: 42 }],
|
|
13
|
+
{
|
|
14
|
+
getWellId: entry => entry.id,
|
|
15
|
+
getSampleType: entry => entry.type,
|
|
16
|
+
getValue: entry => entry.signal,
|
|
17
|
+
getMetadata: () => ({ source: 'analysis' }),
|
|
18
|
+
},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
expect(wells.B3).toEqual({
|
|
22
|
+
id: 'B3',
|
|
23
|
+
row: 1,
|
|
24
|
+
col: 2,
|
|
25
|
+
state: 'filled',
|
|
26
|
+
sampleType: 'sample',
|
|
27
|
+
value: 42,
|
|
28
|
+
metadata: { source: 'analysis' },
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('builds row and column condition spans from axis entries', () => {
|
|
33
|
+
const entry = {
|
|
34
|
+
label: 'Drug A',
|
|
35
|
+
color: '#123456',
|
|
36
|
+
start: 2,
|
|
37
|
+
unit: 'uM',
|
|
38
|
+
concentrations: [{ value: 1, replicates: 2 }, { value: 10 }],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
expect(createColumnConditions([entry])[0]).toMatchObject({
|
|
42
|
+
cols: [2, 3, 4],
|
|
43
|
+
concentrations: [1, 1, 10],
|
|
44
|
+
})
|
|
45
|
+
expect(createRowConditions([{ ...entry, start: 'B' }])[0]).toMatchObject({
|
|
46
|
+
rows: ['B', 'C', 'D'],
|
|
47
|
+
concentrations: [1, 1, 10],
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { runWellPlateValidation, useWellPlateValidation } from '../../composables/useWellPlateValidation'
|
|
4
|
+
|
|
5
|
+
describe('well plate validation helpers', () => {
|
|
6
|
+
it('runs rule-driven validation', () => {
|
|
7
|
+
const warnings = runWellPlateValidation(
|
|
8
|
+
{ assigned: 0 },
|
|
9
|
+
[
|
|
10
|
+
{
|
|
11
|
+
type: 'empty',
|
|
12
|
+
severity: 'warning',
|
|
13
|
+
validate: context => context.assigned === 0 && 'No wells assigned',
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
expect(warnings).toEqual([
|
|
19
|
+
{ type: 'empty', severity: 'warning', message: 'No wells assigned' },
|
|
20
|
+
])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('derives blocking state and counts', () => {
|
|
24
|
+
const validation = useWellPlateValidation({
|
|
25
|
+
context: { overlaps: ['A1'] },
|
|
26
|
+
rules: [
|
|
27
|
+
{
|
|
28
|
+
type: 'overlap',
|
|
29
|
+
severity: 'error',
|
|
30
|
+
validate: context => ({
|
|
31
|
+
message: `${context.overlaps.length} overlap`,
|
|
32
|
+
affectedWells: context.overlaps,
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(validation.hasBlockingIssues.value).toBe(true)
|
|
39
|
+
expect(validation.errorCount.value).toBe(1)
|
|
40
|
+
expect(validation.warningCount.value).toBe(1)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -37,10 +37,10 @@ const props = withDefaults(defineProps<PluginWorkspaceViewProps>(), {
|
|
|
37
37
|
pluginSwitcher: undefined,
|
|
38
38
|
pillNav: undefined,
|
|
39
39
|
currentPillId: undefined,
|
|
40
|
-
showThemeToggle:
|
|
41
|
-
showSettings:
|
|
40
|
+
showThemeToggle: true,
|
|
41
|
+
showSettings: true,
|
|
42
42
|
settingsConfig: undefined,
|
|
43
|
-
showStandaloneLabel:
|
|
43
|
+
showStandaloneLabel: false,
|
|
44
44
|
standaloneLabel: 'Standalone',
|
|
45
45
|
accountMenu: undefined,
|
|
46
46
|
showNotifications: false,
|
|
@@ -36,6 +36,8 @@ const checkboxClasses = computed(() => [
|
|
|
36
36
|
'mint-sample-selector__sample',
|
|
37
37
|
dragging ? 'mint-sample-selector__sample--dragging' : '',
|
|
38
38
|
]"
|
|
39
|
+
:title="sample"
|
|
40
|
+
:aria-label="`Sample: ${sample}`"
|
|
39
41
|
draggable="true"
|
|
40
42
|
>
|
|
41
43
|
<svg class="mint-sample-selector__drag-handle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
package/src/composables/index.ts
CHANGED
|
@@ -191,6 +191,41 @@ export {
|
|
|
191
191
|
type RequestSyncSuccessKind,
|
|
192
192
|
type UseRequestSyncStateReturn,
|
|
193
193
|
} from './useRequestSyncState'
|
|
194
|
+
export {
|
|
195
|
+
useCommandHistory,
|
|
196
|
+
type CommandHistoryCommand,
|
|
197
|
+
type CommandHistoryExecuteOptions,
|
|
198
|
+
type CommandHistoryResult,
|
|
199
|
+
type UseCommandHistoryOptions,
|
|
200
|
+
type UseCommandHistoryReturn,
|
|
201
|
+
} from './useCommandHistory'
|
|
202
|
+
export {
|
|
203
|
+
useOptimisticMutation,
|
|
204
|
+
type OptimisticMutationContext,
|
|
205
|
+
type UseOptimisticMutationOptions,
|
|
206
|
+
type UseOptimisticMutationReturn,
|
|
207
|
+
} from './useOptimisticMutation'
|
|
208
|
+
export {
|
|
209
|
+
createPluginResourceClient,
|
|
210
|
+
useResourceCrud,
|
|
211
|
+
type PluginResourceClientOptions,
|
|
212
|
+
type ResourceCrudAdapter,
|
|
213
|
+
type ResourceKey,
|
|
214
|
+
type UseResourceCrudOptions,
|
|
215
|
+
type UseResourceCrudReturn,
|
|
216
|
+
} from './useResourceCrud'
|
|
217
|
+
export {
|
|
218
|
+
detectDelimiter,
|
|
219
|
+
parseDelimitedText,
|
|
220
|
+
readFileAsText,
|
|
221
|
+
useFileImport,
|
|
222
|
+
validateImportFile,
|
|
223
|
+
type DelimitedTable,
|
|
224
|
+
type FileImportOptions,
|
|
225
|
+
type FileImportValidationOptions,
|
|
226
|
+
type ParseDelimitedTextOptions,
|
|
227
|
+
type UseFileImportReturn,
|
|
228
|
+
} from './useFileImport'
|
|
194
229
|
export {
|
|
195
230
|
useSelectionLimit,
|
|
196
231
|
type SelectionLimitSource,
|
|
@@ -248,6 +283,38 @@ export {
|
|
|
248
283
|
type UseTextSearchOptions,
|
|
249
284
|
type UseTextSearchReturn,
|
|
250
285
|
} from './useTextSearch'
|
|
286
|
+
export {
|
|
287
|
+
coordinateToWellId,
|
|
288
|
+
useWellPainting,
|
|
289
|
+
wellIdToCoordinate,
|
|
290
|
+
wellIdsInRectangle,
|
|
291
|
+
type UseWellPaintingOptions,
|
|
292
|
+
type UseWellPaintingReturn,
|
|
293
|
+
type WellCoordinate,
|
|
294
|
+
type WellPaintResult,
|
|
295
|
+
type WellPlateSource,
|
|
296
|
+
} from './useWellPainting'
|
|
297
|
+
export {
|
|
298
|
+
createColumnConditions,
|
|
299
|
+
createRowConditions,
|
|
300
|
+
createWellPlateWells,
|
|
301
|
+
useWellPlateAdapter,
|
|
302
|
+
type AxisConditionEntry,
|
|
303
|
+
type UseWellPlateAdapterReturn,
|
|
304
|
+
type WellPlateAdapterOptions,
|
|
305
|
+
type WellPlateAdapterSource,
|
|
306
|
+
type WellPlateEntry,
|
|
307
|
+
} from './useWellPlateAdapter'
|
|
308
|
+
export {
|
|
309
|
+
runWellPlateValidation,
|
|
310
|
+
useWellPlateValidation,
|
|
311
|
+
type UseWellPlateValidationOptions,
|
|
312
|
+
type UseWellPlateValidationReturn,
|
|
313
|
+
type WellPlateValidationRule,
|
|
314
|
+
type WellPlateValidationSeverity,
|
|
315
|
+
type WellPlateValidationSource,
|
|
316
|
+
type WellPlateValidationWarning,
|
|
317
|
+
} from './useWellPlateValidation'
|
|
251
318
|
export {
|
|
252
319
|
controlsToFormSchema,
|
|
253
320
|
controlsToSectionFormSchema,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export type CommandHistoryResult = void | Promise<void>
|
|
4
|
+
|
|
5
|
+
export interface CommandHistoryCommand {
|
|
6
|
+
execute: () => CommandHistoryResult
|
|
7
|
+
undo: () => CommandHistoryResult
|
|
8
|
+
redo?: () => CommandHistoryResult
|
|
9
|
+
description?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CommandHistoryExecuteOptions {
|
|
13
|
+
skipExecute?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseCommandHistoryOptions {
|
|
17
|
+
maxDepth?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UseCommandHistoryReturn<TCommand extends CommandHistoryCommand = CommandHistoryCommand> {
|
|
21
|
+
undoStack: Ref<TCommand[]>
|
|
22
|
+
redoStack: Ref<TCommand[]>
|
|
23
|
+
canUndo: ComputedRef<boolean>
|
|
24
|
+
canRedo: ComputedRef<boolean>
|
|
25
|
+
undoDescription: ComputedRef<string>
|
|
26
|
+
redoDescription: ComputedRef<string>
|
|
27
|
+
execute: (command: TCommand, options?: CommandHistoryExecuteOptions) => CommandHistoryResult
|
|
28
|
+
push: (command: TCommand) => void
|
|
29
|
+
undo: () => CommandHistoryResult
|
|
30
|
+
redo: () => CommandHistoryResult
|
|
31
|
+
clear: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Generic command-stack state for plugin editors with undo/redo controls. */
|
|
35
|
+
export function useCommandHistory<TCommand extends CommandHistoryCommand = CommandHistoryCommand>(
|
|
36
|
+
options: UseCommandHistoryOptions = {},
|
|
37
|
+
): UseCommandHistoryReturn<TCommand> {
|
|
38
|
+
const maxDepth = Math.max(1, options.maxDepth ?? 50)
|
|
39
|
+
const undoStack = ref<TCommand[]>([]) as Ref<TCommand[]>
|
|
40
|
+
const redoStack = ref<TCommand[]>([]) as Ref<TCommand[]>
|
|
41
|
+
|
|
42
|
+
const canUndo = computed(() => undoStack.value.length > 0)
|
|
43
|
+
const canRedo = computed(() => redoStack.value.length > 0)
|
|
44
|
+
const undoDescription = computed(() => undoStack.value[undoStack.value.length - 1]?.description ?? '')
|
|
45
|
+
const redoDescription = computed(() => redoStack.value[redoStack.value.length - 1]?.description ?? '')
|
|
46
|
+
|
|
47
|
+
function push(command: TCommand): void {
|
|
48
|
+
undoStack.value = [...undoStack.value.slice(-(maxDepth - 1)), command]
|
|
49
|
+
redoStack.value = []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function execute(
|
|
53
|
+
command: TCommand,
|
|
54
|
+
executeOptions: CommandHistoryExecuteOptions = {},
|
|
55
|
+
): CommandHistoryResult {
|
|
56
|
+
if (executeOptions.skipExecute) {
|
|
57
|
+
push(command)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return afterMaybePromise(command.execute(), () => push(command))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function undo(): CommandHistoryResult {
|
|
65
|
+
const command = undoStack.value[undoStack.value.length - 1]
|
|
66
|
+
if (!command) return
|
|
67
|
+
|
|
68
|
+
return afterMaybePromise(command.undo(), () => {
|
|
69
|
+
undoStack.value = undoStack.value.slice(0, -1)
|
|
70
|
+
redoStack.value = [...redoStack.value, command]
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function redo(): CommandHistoryResult {
|
|
75
|
+
const command = redoStack.value[redoStack.value.length - 1]
|
|
76
|
+
if (!command) return
|
|
77
|
+
|
|
78
|
+
return afterMaybePromise((command.redo ?? command.execute)(), () => {
|
|
79
|
+
redoStack.value = redoStack.value.slice(0, -1)
|
|
80
|
+
undoStack.value = [...undoStack.value.slice(-(maxDepth - 1)), command]
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function clear(): void {
|
|
85
|
+
undoStack.value = []
|
|
86
|
+
redoStack.value = []
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
undoStack,
|
|
91
|
+
redoStack,
|
|
92
|
+
canUndo,
|
|
93
|
+
canRedo,
|
|
94
|
+
undoDescription,
|
|
95
|
+
redoDescription,
|
|
96
|
+
execute,
|
|
97
|
+
push,
|
|
98
|
+
undo,
|
|
99
|
+
redo,
|
|
100
|
+
clear,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function afterMaybePromise(result: CommandHistoryResult, callback: () => void): CommandHistoryResult {
|
|
105
|
+
if (isPromiseLike(result)) {
|
|
106
|
+
return result.then(callback)
|
|
107
|
+
}
|
|
108
|
+
callback()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isPromiseLike(value: unknown): value is Promise<void> {
|
|
112
|
+
return !!value && typeof (value as { then?: unknown }).then === 'function'
|
|
113
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { ref, shallowRef, type Ref, type ShallowRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface DelimitedTable {
|
|
4
|
+
columns: string[]
|
|
5
|
+
rows: Record<string, string>[]
|
|
6
|
+
rawRows: string[][]
|
|
7
|
+
delimiter: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ParseDelimitedTextOptions {
|
|
11
|
+
delimiter?: string
|
|
12
|
+
headers?: readonly string[]
|
|
13
|
+
hasHeaderRow?: boolean
|
|
14
|
+
trim?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FileImportValidationOptions {
|
|
18
|
+
accept?: string | readonly string[]
|
|
19
|
+
maxSizeBytes?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FileImportOptions<TResult = string> extends FileImportValidationOptions {
|
|
23
|
+
encoding?: string
|
|
24
|
+
parse?: (text: string, file: File) => TResult | Promise<TResult>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UseFileImportReturn<TResult> {
|
|
28
|
+
loading: ShallowRef<boolean>
|
|
29
|
+
error: Ref<string | null>
|
|
30
|
+
fileName: Ref<string>
|
|
31
|
+
result: Ref<TResult | null>
|
|
32
|
+
importFile: (file: File) => Promise<TResult>
|
|
33
|
+
clear: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Stateful file reader for plugin import flows with shared validation and error handling. */
|
|
37
|
+
export function useFileImport<TResult = string>(
|
|
38
|
+
options: FileImportOptions<TResult> = {},
|
|
39
|
+
): UseFileImportReturn<TResult> {
|
|
40
|
+
const loading = shallowRef(false)
|
|
41
|
+
const error = ref<string | null>(null)
|
|
42
|
+
const fileName = ref('')
|
|
43
|
+
const result = ref<TResult | null>(null) as Ref<TResult | null>
|
|
44
|
+
|
|
45
|
+
function clear(): void {
|
|
46
|
+
loading.value = false
|
|
47
|
+
error.value = null
|
|
48
|
+
fileName.value = ''
|
|
49
|
+
result.value = null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function importFile(file: File): Promise<TResult> {
|
|
53
|
+
loading.value = true
|
|
54
|
+
error.value = null
|
|
55
|
+
fileName.value = file.name
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
validateImportFile(file, options)
|
|
59
|
+
const text = await readFileAsText(file, options.encoding)
|
|
60
|
+
const parsed = options.parse
|
|
61
|
+
? await options.parse(text, file)
|
|
62
|
+
: text as TResult
|
|
63
|
+
result.value = parsed
|
|
64
|
+
return parsed
|
|
65
|
+
} catch (err) {
|
|
66
|
+
const message = readErrorMessage(err)
|
|
67
|
+
error.value = message
|
|
68
|
+
throw err
|
|
69
|
+
} finally {
|
|
70
|
+
loading.value = false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
loading,
|
|
76
|
+
error,
|
|
77
|
+
fileName,
|
|
78
|
+
result,
|
|
79
|
+
importFile,
|
|
80
|
+
clear,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function validateImportFile(file: File, options: FileImportValidationOptions = {}): void {
|
|
85
|
+
if (options.maxSizeBytes !== undefined && file.size > options.maxSizeBytes) {
|
|
86
|
+
throw new Error(`File is too large. Maximum size is ${formatBytes(options.maxSizeBytes)}.`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const accept = normalizeAccept(options.accept)
|
|
90
|
+
if (accept.length === 0) return
|
|
91
|
+
|
|
92
|
+
const name = file.name.toLowerCase()
|
|
93
|
+
const type = file.type.toLowerCase()
|
|
94
|
+
const matches = accept.some((entry) => {
|
|
95
|
+
if (entry.startsWith('.')) return name.endsWith(entry)
|
|
96
|
+
if (entry.endsWith('/*')) return type.startsWith(entry.slice(0, -1))
|
|
97
|
+
return type === entry
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (!matches) {
|
|
101
|
+
throw new Error(`Unsupported file type. Expected ${accept.join(', ')}.`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function readFileAsText(file: File, encoding?: string): Promise<string> {
|
|
106
|
+
if (typeof FileReader === 'undefined' && typeof file.text === 'function') {
|
|
107
|
+
return file.text()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const reader = new FileReader()
|
|
112
|
+
reader.onload = () => resolve(String(reader.result ?? ''))
|
|
113
|
+
reader.onerror = () => reject(reader.error ?? new Error('Failed to read file.'))
|
|
114
|
+
reader.readAsText(file, encoding)
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function parseDelimitedText(
|
|
119
|
+
text: string,
|
|
120
|
+
options: ParseDelimitedTextOptions = {},
|
|
121
|
+
): DelimitedTable {
|
|
122
|
+
const trim = options.trim ?? true
|
|
123
|
+
const delimiter = options.delimiter ?? detectDelimiter(text)
|
|
124
|
+
const rawRows = parseDelimitedRows(text, delimiter)
|
|
125
|
+
.filter(row => row.some(cell => cell.trim().length > 0))
|
|
126
|
+
.map(row => trim ? row.map(cell => cell.trim()) : row)
|
|
127
|
+
|
|
128
|
+
const hasHeaderRow = options.hasHeaderRow ?? !options.headers
|
|
129
|
+
const columns = options.headers
|
|
130
|
+
? [...options.headers]
|
|
131
|
+
: hasHeaderRow
|
|
132
|
+
? rawRows[0] ?? []
|
|
133
|
+
: createColumnNames(maxRowLength(rawRows))
|
|
134
|
+
|
|
135
|
+
const dataRows = hasHeaderRow && !options.headers ? rawRows.slice(1) : rawRows
|
|
136
|
+
const rows = dataRows.map(row => Object.fromEntries(
|
|
137
|
+
columns.map((column, index) => [column, row[index] ?? '']),
|
|
138
|
+
))
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
columns,
|
|
142
|
+
rows,
|
|
143
|
+
rawRows,
|
|
144
|
+
delimiter,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function detectDelimiter(text: string): string {
|
|
149
|
+
const firstLine = text.split(/\r?\n/, 1)[0] ?? ''
|
|
150
|
+
const commaCount = countChar(firstLine, ',')
|
|
151
|
+
const tabCount = countChar(firstLine, '\t')
|
|
152
|
+
const semicolonCount = countChar(firstLine, ';')
|
|
153
|
+
if (tabCount > commaCount && tabCount >= semicolonCount) return '\t'
|
|
154
|
+
if (semicolonCount > commaCount) return ';'
|
|
155
|
+
return ','
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseDelimitedRows(text: string, delimiter: string): string[][] {
|
|
159
|
+
const rows: string[][] = []
|
|
160
|
+
let row: string[] = []
|
|
161
|
+
let field = ''
|
|
162
|
+
let quoted = false
|
|
163
|
+
|
|
164
|
+
for (let index = 0; index < text.length; index++) {
|
|
165
|
+
const char = text[index]
|
|
166
|
+
const next = text[index + 1]
|
|
167
|
+
|
|
168
|
+
if (char === '"') {
|
|
169
|
+
if (quoted && next === '"') {
|
|
170
|
+
field += '"'
|
|
171
|
+
index++
|
|
172
|
+
} else {
|
|
173
|
+
quoted = !quoted
|
|
174
|
+
}
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!quoted && char === delimiter) {
|
|
179
|
+
row.push(field)
|
|
180
|
+
field = ''
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!quoted && (char === '\n' || char === '\r')) {
|
|
185
|
+
if (char === '\r' && next === '\n') index++
|
|
186
|
+
row.push(field)
|
|
187
|
+
rows.push(row)
|
|
188
|
+
row = []
|
|
189
|
+
field = ''
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
field += char
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
row.push(field)
|
|
197
|
+
rows.push(row)
|
|
198
|
+
return rows
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeAccept(accept: string | readonly string[] | undefined): string[] {
|
|
202
|
+
if (!accept) return []
|
|
203
|
+
const entries = typeof accept === 'string' ? accept.split(',') : [...accept]
|
|
204
|
+
return entries
|
|
205
|
+
.map((entry: string) => entry.trim().toLowerCase())
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function createColumnNames(count: number): string[] {
|
|
210
|
+
return Array.from({ length: count }, (_, index) => `column_${index + 1}`)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function maxRowLength(rows: readonly string[][]): number {
|
|
214
|
+
return rows.reduce((max, row) => Math.max(max, row.length), 0)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function countChar(value: string, char: string): number {
|
|
218
|
+
return [...value].filter(candidate => candidate === char).length
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function formatBytes(bytes: number): string {
|
|
222
|
+
if (bytes < 1024) return `${bytes} B`
|
|
223
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
|
224
|
+
return `${Math.round(bytes / (1024 * 1024))} MB`
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function readErrorMessage(value: unknown): string {
|
|
228
|
+
if (value instanceof Error && value.message) return value.message
|
|
229
|
+
if (typeof value === 'string' && value.trim()) return value
|
|
230
|
+
return 'Failed to import file.'
|
|
231
|
+
}
|