mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
4fa096147c
commit
198720ca38
3 changed files with 323 additions and 0 deletions
162
packages/shared-stores/src/data-export.test.ts
Normal file
162
packages/shared-stores/src/data-export.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { exportToJSON, exportToCSV, importFromJSON, timestampedFilename } from './data-export';
|
||||
|
||||
function createMockTable(records: Record<string, unknown>[]) {
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
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$/);
|
||||
});
|
||||
});
|
||||
151
packages/shared-stores/src/data-export.ts
Normal file
151
packages/shared-stores/src/data-export.ts
Normal file
|
|
@ -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<string, unknown>) => 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<string> {
|
||||
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<string, unknown>) => 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<string> {
|
||||
let records: Record<string, unknown>[] = 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<string, unknown>) => Record<string, unknown>;
|
||||
/** 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<number> {
|
||||
const text = await file.text();
|
||||
let records: Record<string, unknown>[] = 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}`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue