From 198720ca38ba1429c8f6fffa74b8898fec03a82f Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 17:01:53 +0200 Subject: [PATCH] feat(shared-stores): add generic data export/import utilities exportToJSON, exportToCSV (with BOM for Excel), importFromJSON, downloadFile, timestampedFilename. Works with any Dexie table. Supports filtering, column selection, custom formatters, ID regeneration, and transform functions. 16 tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../shared-stores/src/data-export.test.ts | 162 ++++++++++++++++++ packages/shared-stores/src/data-export.ts | 151 ++++++++++++++++ packages/shared-stores/src/index.ts | 10 ++ 3 files changed, 323 insertions(+) create mode 100644 packages/shared-stores/src/data-export.test.ts create mode 100644 packages/shared-stores/src/data-export.ts diff --git a/packages/shared-stores/src/data-export.test.ts b/packages/shared-stores/src/data-export.test.ts new file mode 100644 index 000000000..d0335940e --- /dev/null +++ b/packages/shared-stores/src/data-export.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi } from 'vitest'; +import { exportToJSON, exportToCSV, importFromJSON, timestampedFilename } from './data-export'; + +function createMockTable(records: Record[]) { + return { + toArray: vi.fn(async () => [...records]), + bulkAdd: vi.fn(async () => undefined), + clear: vi.fn(async () => undefined), + }; +} + +describe('exportToJSON', () => { + it('exports all records as JSON string', async () => { + const table = createMockTable([ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ]); + const json = await exportToJSON(table as never); + const parsed = JSON.parse(json); + expect(parsed).toHaveLength(2); + expect(parsed[0].name).toBe('Alice'); + }); + + it('applies filter before export', async () => { + const table = createMockTable([ + { id: '1', name: 'Alice', deletedAt: null }, + { id: '2', name: 'Bob', deletedAt: '2024-01-01' }, + ]); + const json = await exportToJSON(table as never, { + filter: (r) => !r.deletedAt, + }); + const parsed = JSON.parse(json); + expect(parsed).toHaveLength(1); + expect(parsed[0].name).toBe('Alice'); + }); + + it('supports compact output', async () => { + const table = createMockTable([{ id: '1' }]); + const json = await exportToJSON(table as never, { pretty: false }); + expect(json).not.toContain('\n'); + }); +}); + +describe('exportToCSV', () => { + it('exports records as CSV with headers', async () => { + const table = createMockTable([ + { id: '1', name: 'Alice', email: 'alice@test.com' }, + { id: '2', name: 'Bob', email: 'bob@test.com' }, + ]); + const csv = await exportToCSV(table as never, { + columns: ['name', 'email'], + headers: ['Name', 'E-Mail'], + }); + const lines = csv.replace('\uFEFF', '').split('\n'); + expect(lines[0]).toBe('Name;E-Mail'); + expect(lines[1]).toBe('Alice;alice@test.com'); + expect(lines[2]).toBe('Bob;bob@test.com'); + }); + + it('auto-quotes values with separator', async () => { + const table = createMockTable([{ id: '1', desc: 'foo;bar' }]); + const csv = await exportToCSV(table as never, { columns: ['desc'] }); + expect(csv).toContain('"foo;bar"'); + }); + + it('returns empty string for no records', async () => { + const table = createMockTable([]); + const csv = await exportToCSV(table as never); + expect(csv).toBe(''); + }); + + it('uses all keys from first record as default columns', async () => { + const table = createMockTable([{ a: 1, b: 2, c: 3 }]); + const csv = await exportToCSV(table as never); + const header = csv.replace('\uFEFF', '').split('\n')[0]; + expect(header).toBe('a;b;c'); + }); + + it('applies filter', async () => { + const table = createMockTable([ + { id: '1', name: 'Keep' }, + { id: '2', name: 'Skip', deletedAt: '2024' }, + ]); + const csv = await exportToCSV(table as never, { + columns: ['name'], + filter: (r) => !r.deletedAt, + }); + const lines = csv.replace('\uFEFF', '').split('\n'); + expect(lines).toHaveLength(2); // header + 1 row + }); + + it('includes BOM for Excel', async () => { + const table = createMockTable([{ id: '1' }]); + const csv = await exportToCSV(table as never); + expect(csv.startsWith('\uFEFF')).toBe(true); + }); +}); + +describe('importFromJSON', () => { + it('imports records from JSON file', async () => { + const table = createMockTable([]); + const file = new File( + [ + JSON.stringify([ + { id: 'x', name: 'Alice' }, + { id: 'y', name: 'Bob' }, + ]), + ], + 'test.json', + { type: 'application/json' } + ); + const count = await importFromJSON(table as never, file); + expect(count).toBe(2); + expect(table.bulkAdd).toHaveBeenCalledOnce(); + }); + + it('generates new IDs by default', async () => { + const table = createMockTable([]); + const file = new File([JSON.stringify([{ id: 'old-id', name: 'Test' }])], 'test.json'); + await importFromJSON(table as never, file); + const added = table.bulkAdd.mock.calls[0][0] as Record[]; + expect(added[0].id).not.toBe('old-id'); + }); + + it('keeps original IDs when newIds=false', async () => { + const table = createMockTable([]); + const file = new File([JSON.stringify([{ id: 'keep-me', name: 'Test' }])], 'test.json'); + await importFromJSON(table as never, file, { newIds: false }); + const added = table.bulkAdd.mock.calls[0][0] as Record[]; + expect(added[0].id).toBe('keep-me'); + }); + + it('applies transform', async () => { + const table = createMockTable([]); + const file = new File([JSON.stringify([{ name: 'test' }])], 'test.json'); + await importFromJSON(table as never, file, { + transform: (r) => ({ ...r, imported: true }), + }); + const added = table.bulkAdd.mock.calls[0][0] as Record[]; + expect(added[0].imported).toBe(true); + }); + + it('clears table when clearFirst=true', async () => { + const table = createMockTable([]); + const file = new File([JSON.stringify([])], 'test.json'); + await importFromJSON(table as never, file, { clearFirst: true }); + expect(table.clear).toHaveBeenCalledOnce(); + }); + + it('throws on non-array JSON', async () => { + const table = createMockTable([]); + const file = new File([JSON.stringify({ not: 'array' })], 'test.json'); + await expect(importFromJSON(table as never, file)).rejects.toThrow('array'); + }); +}); + +describe('timestampedFilename', () => { + it('generates filename with date', () => { + const name = timestampedFilename('contacts', 'json'); + expect(name).toMatch(/^contacts-\d{4}-\d{2}-\d{2}\.json$/); + }); +}); diff --git a/packages/shared-stores/src/data-export.ts b/packages/shared-stores/src/data-export.ts new file mode 100644 index 000000000..d3bbec952 --- /dev/null +++ b/packages/shared-stores/src/data-export.ts @@ -0,0 +1,151 @@ +/** + * Data Export/Import Utilities + * + * Generic functions for exporting IndexedDB data to JSON/CSV + * and importing from JSON. Works with any Dexie table. + * + * @example + * ```typescript + * import { exportToJSON, exportToCSV, importFromJSON, downloadFile } from '@manacore/shared-stores'; + * import { db } from '$lib/data/database'; + * + * // Export contacts as JSON + * const data = await exportToJSON(db.table('contacts')); + * downloadFile(data, 'contacts.json', 'application/json'); + * + * // Export time entries as CSV + * const csv = await exportToCSV(db.table('timeEntries'), { + * columns: ['date', 'description', 'duration'], + * headers: ['Datum', 'Beschreibung', 'Dauer'], + * }); + * downloadFile(csv, 'entries.csv', 'text/csv'); + * + * // Import from JSON file + * const file = inputElement.files[0]; + * const count = await importFromJSON(db.table('contacts'), file); + * ``` + */ + +import type { Table } from 'dexie'; + +// ─── Export to JSON ─────────────────────────────────────── + +export interface ExportJSONOptions { + /** Filter records before export (e.g., exclude deleted) */ + filter?: (record: Record) => boolean; + /** Pretty-print with indentation (default: true) */ + pretty?: boolean; +} + +/** Export all records from a Dexie table as a JSON string. */ +export async function exportToJSON(table: Table, options?: ExportJSONOptions): Promise { + let records = await table.toArray(); + if (options?.filter) { + records = records.filter(options.filter); + } + return JSON.stringify(records, null, options?.pretty !== false ? 2 : undefined); +} + +// ─── Export to CSV ──────────────────────────────────────── + +export interface ExportCSVOptions { + /** Column keys to include (default: all keys from first record) */ + columns?: string[]; + /** Display headers (default: same as columns) */ + headers?: string[]; + /** Filter records before export */ + filter?: (record: Record) => boolean; + /** Column separator (default: ';' for Excel compat) */ + separator?: string; + /** Format a cell value (default: auto-quote strings with separator) */ + formatCell?: (value: unknown, key: string) => string; +} + +/** Export all records from a Dexie table as a CSV string. */ +export async function exportToCSV(table: Table, options?: ExportCSVOptions): Promise { + let records: Record[] = await table.toArray(); + if (options?.filter) { + records = records.filter(options.filter); + } + if (records.length === 0) return ''; + + const sep = options?.separator ?? ';'; + const columns = options?.columns ?? Object.keys(records[0]); + const headers = options?.headers ?? columns; + + const formatCell = + options?.formatCell ?? + ((value: unknown, _key: string) => { + if (value === null || value === undefined) return ''; + const str = String(value); + if (str.includes(sep) || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }); + + const rows = records.map((record) => + columns.map((col) => formatCell(record[col], col)).join(sep) + ); + + const BOM = '\uFEFF'; // UTF-8 BOM for Excel compatibility + return BOM + [headers.join(sep), ...rows].join('\n'); +} + +// ─── Import from JSON ───────────────────────────────────── + +export interface ImportJSONOptions { + /** Transform each record before inserting */ + transform?: (record: Record) => Record; + /** Generate new IDs for imported records (default: true) */ + newIds?: boolean; + /** Clear table before import (default: false) */ + clearFirst?: boolean; +} + +/** Import records from a JSON file into a Dexie table. Returns count of imported records. */ +export async function importFromJSON( + table: Table, + file: File, + options?: ImportJSONOptions +): Promise { + const text = await file.text(); + let records: Record[] = JSON.parse(text); + + if (!Array.isArray(records)) { + throw new Error('JSON must contain an array of records'); + } + + if (options?.transform) { + records = records.map(options.transform); + } + + if (options?.newIds !== false) { + records = records.map((r) => ({ ...r, id: crypto.randomUUID() })); + } + + if (options?.clearFirst) { + await table.clear(); + } + + await table.bulkAdd(records); + return records.length; +} + +// ─── File Download Trigger ──────────────────────────────── + +/** Trigger a browser file download from a string. */ +export function downloadFile(content: string, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: `${mimeType};charset=utf-8` }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +/** Generate a timestamped filename. */ +export function timestampedFilename(prefix: string, ext: string): string { + return `${prefix}-${new Date().toISOString().split('T')[0]}.${ext}`; +} diff --git a/packages/shared-stores/src/index.ts b/packages/shared-stores/src/index.ts index 80d886f8a..6f2ef155b 100644 --- a/packages/shared-stores/src/index.ts +++ b/packages/shared-stores/src/index.ts @@ -64,6 +64,16 @@ export { type ReminderSource, type DueReminder, } from './reminder-scheduler'; +export { + exportToJSON, + exportToCSV, + importFromJSON, + downloadFile, + timestampedFilename, + type ExportJSONOptions, + type ExportCSVOptions, + type ImportJSONOptions, +} from './data-export'; export { createGuestMode,