@svadmin/core 0.0.2 → 0.0.6
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/package.json +1 -1
- package/src/auth-hooks.svelte.ts +293 -0
- package/src/context.ts +12 -0
- package/src/data-transfer.test.ts +118 -0
- package/src/data-transfer.ts +139 -0
- package/src/hooks.svelte.ts +110 -5
- package/src/i18n.svelte.ts +70 -4
- package/src/i18n.test.ts +90 -0
- package/src/index.ts +15 -0
- package/src/inferencer.test.ts +189 -0
- package/src/inferencer.ts +247 -0
- package/src/notification.svelte.ts +45 -0
- package/src/permissions.test.ts +55 -0
- package/src/router-provider.test.ts +71 -0
- package/src/router-provider.ts +100 -0
- package/src/types.ts +2 -0
- package/src/useCan.ts +37 -0
- package/src/useParsed.ts +74 -0
package/package.json
CHANGED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Auth Hooks — reactive wrappers around AuthProvider methods
|
|
2
|
+
// Each hook encapsulates the auth call + loading state + error handling + redirect
|
|
3
|
+
// IMPORTANT: getAuthProvider() must be called at hook creation time (during component init),
|
|
4
|
+
// not inside mutate(), because Svelte's getContext() only works during init.
|
|
5
|
+
|
|
6
|
+
import { getAuthProvider } from './context';
|
|
7
|
+
import { navigate } from './router';
|
|
8
|
+
import { toast } from './toast.svelte';
|
|
9
|
+
import { t } from './i18n.svelte';
|
|
10
|
+
import type { AuthActionResult, CheckResult, Identity } from './types';
|
|
11
|
+
|
|
12
|
+
// ─── useLogin ─────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export function useLogin() {
|
|
15
|
+
const provider = getAuthProvider();
|
|
16
|
+
let isLoading = $state(false);
|
|
17
|
+
|
|
18
|
+
async function mutate(params: Record<string, unknown>): Promise<AuthActionResult> {
|
|
19
|
+
if (!provider) throw new Error('AuthProvider not configured');
|
|
20
|
+
isLoading = true;
|
|
21
|
+
try {
|
|
22
|
+
const result = await provider.login(params);
|
|
23
|
+
if (result.success) {
|
|
24
|
+
toast.success(t('common.operationSuccess'));
|
|
25
|
+
if (result.redirectTo) navigate(result.redirectTo);
|
|
26
|
+
} else {
|
|
27
|
+
const msg = result.error?.message ?? t('common.loginFailed');
|
|
28
|
+
toast.error(msg);
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const msg = err instanceof Error ? err.message : t('common.loginFailed');
|
|
33
|
+
toast.error(msg);
|
|
34
|
+
return { success: false, error: { message: msg } };
|
|
35
|
+
} finally {
|
|
36
|
+
isLoading = false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
mutate,
|
|
42
|
+
get isLoading() { return isLoading; },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── useLogout ────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export function useLogout() {
|
|
49
|
+
const provider = getAuthProvider();
|
|
50
|
+
let isLoading = $state(false);
|
|
51
|
+
|
|
52
|
+
async function mutate(params?: Record<string, unknown>): Promise<AuthActionResult> {
|
|
53
|
+
if (!provider) throw new Error('AuthProvider not configured');
|
|
54
|
+
isLoading = true;
|
|
55
|
+
try {
|
|
56
|
+
const result = await provider.logout(params);
|
|
57
|
+
if (result.success) {
|
|
58
|
+
navigate(result.redirectTo ?? '/login');
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const msg = err instanceof Error ? err.message : t('common.operationFailed');
|
|
63
|
+
toast.error(msg);
|
|
64
|
+
return { success: false, error: { message: msg } };
|
|
65
|
+
} finally {
|
|
66
|
+
isLoading = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
mutate,
|
|
72
|
+
get isLoading() { return isLoading; },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── useRegister ──────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export function useRegister() {
|
|
79
|
+
const provider = getAuthProvider();
|
|
80
|
+
let isLoading = $state(false);
|
|
81
|
+
|
|
82
|
+
async function mutate(params: Record<string, unknown>): Promise<AuthActionResult> {
|
|
83
|
+
if (!provider?.register) throw new Error('AuthProvider.register not implemented');
|
|
84
|
+
isLoading = true;
|
|
85
|
+
try {
|
|
86
|
+
const result = await provider.register(params);
|
|
87
|
+
if (result.success) {
|
|
88
|
+
toast.success(t('auth.registerSuccess'));
|
|
89
|
+
if (result.redirectTo) navigate(result.redirectTo);
|
|
90
|
+
} else {
|
|
91
|
+
const msg = result.error?.message ?? t('common.operationFailed');
|
|
92
|
+
toast.error(msg);
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const msg = err instanceof Error ? err.message : t('common.operationFailed');
|
|
97
|
+
toast.error(msg);
|
|
98
|
+
return { success: false, error: { message: msg } };
|
|
99
|
+
} finally {
|
|
100
|
+
isLoading = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
mutate,
|
|
106
|
+
get isLoading() { return isLoading; },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── useForgotPassword ───────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export function useForgotPassword() {
|
|
113
|
+
const provider = getAuthProvider();
|
|
114
|
+
let isLoading = $state(false);
|
|
115
|
+
|
|
116
|
+
async function mutate(params: Record<string, unknown>): Promise<AuthActionResult> {
|
|
117
|
+
if (!provider?.forgotPassword) throw new Error('AuthProvider.forgotPassword not implemented');
|
|
118
|
+
isLoading = true;
|
|
119
|
+
try {
|
|
120
|
+
const result = await provider.forgotPassword(params);
|
|
121
|
+
if (result.success) {
|
|
122
|
+
toast.success(t('auth.resetLinkSent'));
|
|
123
|
+
} else {
|
|
124
|
+
const msg = result.error?.message ?? t('common.operationFailed');
|
|
125
|
+
toast.error(msg);
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
const msg = err instanceof Error ? err.message : t('common.operationFailed');
|
|
130
|
+
toast.error(msg);
|
|
131
|
+
return { success: false, error: { message: msg } };
|
|
132
|
+
} finally {
|
|
133
|
+
isLoading = false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
mutate,
|
|
139
|
+
get isLoading() { return isLoading; },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── useUpdatePassword ───────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export function useUpdatePassword() {
|
|
146
|
+
const provider = getAuthProvider();
|
|
147
|
+
let isLoading = $state(false);
|
|
148
|
+
|
|
149
|
+
async function mutate(params: Record<string, unknown>): Promise<AuthActionResult> {
|
|
150
|
+
if (!provider?.updatePassword) throw new Error('AuthProvider.updatePassword not implemented');
|
|
151
|
+
isLoading = true;
|
|
152
|
+
try {
|
|
153
|
+
const result = await provider.updatePassword(params);
|
|
154
|
+
if (result.success) {
|
|
155
|
+
toast.success(t('common.operationSuccess'));
|
|
156
|
+
if (result.redirectTo) navigate(result.redirectTo);
|
|
157
|
+
} else {
|
|
158
|
+
const msg = result.error?.message ?? t('common.operationFailed');
|
|
159
|
+
toast.error(msg);
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const msg = err instanceof Error ? err.message : t('common.operationFailed');
|
|
164
|
+
toast.error(msg);
|
|
165
|
+
return { success: false, error: { message: msg } };
|
|
166
|
+
} finally {
|
|
167
|
+
isLoading = false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
mutate,
|
|
173
|
+
get isLoading() { return isLoading; },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── useGetIdentity ──────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export function useGetIdentity() {
|
|
180
|
+
const provider = getAuthProvider();
|
|
181
|
+
let data = $state<Identity | null>(null);
|
|
182
|
+
let isLoading = $state(true);
|
|
183
|
+
let error = $state<Error | null>(null);
|
|
184
|
+
|
|
185
|
+
if (provider) {
|
|
186
|
+
provider.getIdentity().then(identity => {
|
|
187
|
+
data = identity;
|
|
188
|
+
isLoading = false;
|
|
189
|
+
}).catch(err => {
|
|
190
|
+
error = err instanceof Error ? err : new Error(String(err));
|
|
191
|
+
isLoading = false;
|
|
192
|
+
console.warn('[svadmin] useGetIdentity failed:', err);
|
|
193
|
+
});
|
|
194
|
+
} else {
|
|
195
|
+
isLoading = false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
get data() { return data; },
|
|
200
|
+
get isLoading() { return isLoading; },
|
|
201
|
+
get error() { return error; },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── useIsAuthenticated ──────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
export function useIsAuthenticated() {
|
|
208
|
+
const provider = getAuthProvider();
|
|
209
|
+
let isAuthenticated = $state(false);
|
|
210
|
+
let isLoading = $state(true);
|
|
211
|
+
|
|
212
|
+
if (provider) {
|
|
213
|
+
provider.check().then((result: CheckResult) => {
|
|
214
|
+
isAuthenticated = result.authenticated;
|
|
215
|
+
isLoading = false;
|
|
216
|
+
}).catch(() => {
|
|
217
|
+
isAuthenticated = false;
|
|
218
|
+
isLoading = false;
|
|
219
|
+
});
|
|
220
|
+
} else {
|
|
221
|
+
// No auth provider — treat as authenticated
|
|
222
|
+
isAuthenticated = true;
|
|
223
|
+
isLoading = false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
get isAuthenticated() { return isAuthenticated; },
|
|
228
|
+
get isLoading() { return isLoading; },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── useOnError ──────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Handles errors from data hooks by calling authProvider.onError().
|
|
236
|
+
* If the provider returns { logout: true }, triggers logout flow.
|
|
237
|
+
* If it returns { redirectTo }, navigates there.
|
|
238
|
+
*/
|
|
239
|
+
export function useOnError() {
|
|
240
|
+
const provider = getAuthProvider();
|
|
241
|
+
|
|
242
|
+
async function mutate(error: unknown) {
|
|
243
|
+
if (!provider?.onError) {
|
|
244
|
+
console.warn('[svadmin] useOnError: authProvider.onError not implemented');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const result = await provider.onError(error);
|
|
249
|
+
if (result.logout) {
|
|
250
|
+
await provider.logout?.();
|
|
251
|
+
navigate(result.redirectTo ?? '/login');
|
|
252
|
+
} else if (result.redirectTo) {
|
|
253
|
+
navigate(result.redirectTo);
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.warn('[svadmin] useOnError failed:', err);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { mutate };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─── usePermissions ──────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Fetches permissions from authProvider.getPermissions().
|
|
267
|
+
* Returns a reactive object with data, isLoading, and error.
|
|
268
|
+
*/
|
|
269
|
+
export function usePermissions<T = unknown>() {
|
|
270
|
+
const provider = getAuthProvider();
|
|
271
|
+
let data = $state<T | null>(null);
|
|
272
|
+
let isLoading = $state(true);
|
|
273
|
+
let error = $state<Error | null>(null);
|
|
274
|
+
|
|
275
|
+
if (provider?.getPermissions) {
|
|
276
|
+
provider.getPermissions().then(permissions => {
|
|
277
|
+
data = permissions as T;
|
|
278
|
+
isLoading = false;
|
|
279
|
+
}).catch(err => {
|
|
280
|
+
error = err instanceof Error ? err : new Error(String(err));
|
|
281
|
+
isLoading = false;
|
|
282
|
+
console.warn('[svadmin] usePermissions failed:', err);
|
|
283
|
+
});
|
|
284
|
+
} else {
|
|
285
|
+
isLoading = false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
get data() { return data; },
|
|
290
|
+
get isLoading() { return isLoading; },
|
|
291
|
+
get error() { return error; },
|
|
292
|
+
};
|
|
293
|
+
}
|
package/src/context.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { getContext, setContext } from 'svelte';
|
|
4
4
|
import type { DataProvider, AuthProvider, ResourceDefinition } from './types';
|
|
5
|
+
import type { RouterProvider } from './router-provider';
|
|
5
6
|
|
|
6
7
|
const DATA_PROVIDER_KEY = Symbol('data-provider');
|
|
7
8
|
const AUTH_PROVIDER_KEY = Symbol('auth-provider');
|
|
8
9
|
const RESOURCES_KEY = Symbol('resources');
|
|
10
|
+
const ROUTER_PROVIDER_KEY = Symbol('router-provider');
|
|
9
11
|
|
|
10
12
|
// ─── Setters (called once in App.svelte) ────────────────────────
|
|
11
13
|
|
|
@@ -47,3 +49,13 @@ export function getResource(name: string): ResourceDefinition {
|
|
|
47
49
|
if (!resource) throw new Error(`Resource "${name}" not found in resource definitions.`);
|
|
48
50
|
return resource;
|
|
49
51
|
}
|
|
52
|
+
|
|
53
|
+
// ─── Router Provider ────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export function setRouterProvider(provider: RouterProvider): void {
|
|
56
|
+
setContext(ROUTER_PROVIDER_KEY, provider);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getRouterProvider(): RouterProvider | undefined {
|
|
60
|
+
return getContext<RouterProvider>(ROUTER_PROVIDER_KEY);
|
|
61
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Unit tests for CSV parsing and data-transfer utilities
|
|
2
|
+
import { describe, test, expect } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
// We test parseCSVLine directly by re-implementing the exported logic
|
|
5
|
+
// since the function is not exported. We test the CSV format contract.
|
|
6
|
+
|
|
7
|
+
function parseCSVLine(line: string): string[] {
|
|
8
|
+
const result: string[] = [];
|
|
9
|
+
let current = '';
|
|
10
|
+
let inQuotes = false;
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < line.length; i++) {
|
|
13
|
+
const ch = line[i];
|
|
14
|
+
if (inQuotes) {
|
|
15
|
+
if (ch === '"' && line[i + 1] === '"') {
|
|
16
|
+
current += '"';
|
|
17
|
+
i++;
|
|
18
|
+
} else if (ch === '"') {
|
|
19
|
+
inQuotes = false;
|
|
20
|
+
} else {
|
|
21
|
+
current += ch;
|
|
22
|
+
}
|
|
23
|
+
} else {
|
|
24
|
+
if (ch === '"') {
|
|
25
|
+
inQuotes = true;
|
|
26
|
+
} else if (ch === ',') {
|
|
27
|
+
result.push(current.trim());
|
|
28
|
+
current = '';
|
|
29
|
+
} else {
|
|
30
|
+
current += ch;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
result.push(current.trim());
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('parseCSVLine', () => {
|
|
39
|
+
test('simple comma-separated values', () => {
|
|
40
|
+
expect(parseCSVLine('a,b,c')).toEqual(['a', 'b', 'c']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('values with whitespace are trimmed', () => {
|
|
44
|
+
expect(parseCSVLine(' hello , world , test ')).toEqual(['hello', 'world', 'test']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('quoted values preserve commas', () => {
|
|
48
|
+
expect(parseCSVLine('"hello, world",b,c')).toEqual(['hello, world', 'b', 'c']);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('escaped quotes inside quoted values', () => {
|
|
52
|
+
expect(parseCSVLine('"say ""hello""",b')).toEqual(['say "hello"', 'b']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('empty values', () => {
|
|
56
|
+
expect(parseCSVLine('a,,c')).toEqual(['a', '', 'c']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('single value', () => {
|
|
60
|
+
expect(parseCSVLine('hello')).toEqual(['hello']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('empty line', () => {
|
|
64
|
+
expect(parseCSVLine('')).toEqual(['']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('quoted value with newline character', () => {
|
|
68
|
+
expect(parseCSVLine('"line1\nline2",b')).toEqual(['line1\nline2', 'b']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('mixed quoted and unquoted', () => {
|
|
72
|
+
expect(parseCSVLine('1,"John Doe","admin@test.com",true')).toEqual([
|
|
73
|
+
'1', 'John Doe', 'admin@test.com', 'true'
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// CSV row formatting (escape logic from useExport)
|
|
79
|
+
function escapeCSVField(val: unknown): string {
|
|
80
|
+
const str = val === null || val === undefined ? '' : String(val);
|
|
81
|
+
return str.includes(',') || str.includes('"') || str.includes('\n')
|
|
82
|
+
? `"${str.replace(/"/g, '""')}"`
|
|
83
|
+
: str;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('escapeCSVField', () => {
|
|
87
|
+
test('simple string passes through', () => {
|
|
88
|
+
expect(escapeCSVField('hello')).toBe('hello');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('null becomes empty string', () => {
|
|
92
|
+
expect(escapeCSVField(null)).toBe('');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('undefined becomes empty string', () => {
|
|
96
|
+
expect(escapeCSVField(undefined)).toBe('');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('string with comma is quoted', () => {
|
|
100
|
+
expect(escapeCSVField('hello, world')).toBe('"hello, world"');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('string with double quote is escaped', () => {
|
|
104
|
+
expect(escapeCSVField('say "hi"')).toBe('"say ""hi"""');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('string with newline is quoted', () => {
|
|
108
|
+
expect(escapeCSVField('line1\nline2')).toBe('"line1\nline2"');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('number is converted to string', () => {
|
|
112
|
+
expect(escapeCSVField(42)).toBe('42');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('boolean is converted to string', () => {
|
|
116
|
+
expect(escapeCSVField(true)).toBe('true');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Data transfer utilities — CSV export/import
|
|
2
|
+
|
|
3
|
+
import { getDataProvider } from './context';
|
|
4
|
+
import type { GetListResult } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* useExport — export all records from a resource as CSV download
|
|
8
|
+
*/
|
|
9
|
+
export function useExport(resource: string) {
|
|
10
|
+
let isExporting = $state(false);
|
|
11
|
+
|
|
12
|
+
async function exportCSV(opts?: { filename?: string; fields?: string[] }) {
|
|
13
|
+
const provider = getDataProvider();
|
|
14
|
+
isExporting = true;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
// Fetch all records (up to 10000)
|
|
18
|
+
const result: GetListResult = await provider.getList({
|
|
19
|
+
resource,
|
|
20
|
+
pagination: { current: 1, pageSize: 10000 },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (result.data.length === 0) return;
|
|
24
|
+
|
|
25
|
+
const records = result.data as Record<string, unknown>[];
|
|
26
|
+
const fields = opts?.fields ?? Object.keys(records[0]);
|
|
27
|
+
|
|
28
|
+
// Build CSV
|
|
29
|
+
const header = fields.join(',');
|
|
30
|
+
const rows = records.map(record =>
|
|
31
|
+
fields.map(f => {
|
|
32
|
+
const val = record[f];
|
|
33
|
+
const str = val === null || val === undefined ? '' : String(val);
|
|
34
|
+
// Escape commas and quotes
|
|
35
|
+
return str.includes(',') || str.includes('"') || str.includes('\n')
|
|
36
|
+
? `"${str.replace(/"/g, '""')}"`
|
|
37
|
+
: str;
|
|
38
|
+
}).join(',')
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const csv = [header, ...rows].join('\n');
|
|
42
|
+
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
|
43
|
+
const url = URL.createObjectURL(blob);
|
|
44
|
+
const a = document.createElement('a');
|
|
45
|
+
a.href = url;
|
|
46
|
+
a.download = opts?.filename ?? `${resource}_export.csv`;
|
|
47
|
+
a.click();
|
|
48
|
+
URL.revokeObjectURL(url);
|
|
49
|
+
} finally {
|
|
50
|
+
isExporting = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
exportCSV,
|
|
56
|
+
get isExporting() { return isExporting; },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* useImport — import records from a CSV file
|
|
62
|
+
*/
|
|
63
|
+
export function useImport(resource: string) {
|
|
64
|
+
let isImporting = $state(false);
|
|
65
|
+
let importResult = $state<{ success: number; failed: number } | null>(null);
|
|
66
|
+
|
|
67
|
+
async function importCSV(file: File): Promise<{ success: number; failed: number }> {
|
|
68
|
+
const provider = getDataProvider();
|
|
69
|
+
isImporting = true;
|
|
70
|
+
let success = 0;
|
|
71
|
+
let failed = 0;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const text = await file.text();
|
|
75
|
+
const lines = text.split('\n').filter(l => l.trim() !== '');
|
|
76
|
+
if (lines.length < 2) return { success: 0, failed: 0 };
|
|
77
|
+
|
|
78
|
+
const headers = parseCSVLine(lines[0]);
|
|
79
|
+
|
|
80
|
+
for (let i = 1; i < lines.length; i++) {
|
|
81
|
+
const values = parseCSVLine(lines[i]);
|
|
82
|
+
const record: Record<string, unknown> = {};
|
|
83
|
+
headers.forEach((h, idx) => {
|
|
84
|
+
record[h] = values[idx] ?? '';
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await provider.create({ resource, variables: record });
|
|
89
|
+
success++;
|
|
90
|
+
} catch {
|
|
91
|
+
failed++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
importResult = { success, failed };
|
|
96
|
+
return { success, failed };
|
|
97
|
+
} finally {
|
|
98
|
+
isImporting = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
importCSV,
|
|
104
|
+
get isImporting() { return isImporting; },
|
|
105
|
+
get importResult() { return importResult; },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parse a single CSV line respecting quoted fields */
|
|
110
|
+
function parseCSVLine(line: string): string[] {
|
|
111
|
+
const result: string[] = [];
|
|
112
|
+
let current = '';
|
|
113
|
+
let inQuotes = false;
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < line.length; i++) {
|
|
116
|
+
const ch = line[i];
|
|
117
|
+
if (inQuotes) {
|
|
118
|
+
if (ch === '"' && line[i + 1] === '"') {
|
|
119
|
+
current += '"';
|
|
120
|
+
i++;
|
|
121
|
+
} else if (ch === '"') {
|
|
122
|
+
inQuotes = false;
|
|
123
|
+
} else {
|
|
124
|
+
current += ch;
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
if (ch === '"') {
|
|
128
|
+
inQuotes = true;
|
|
129
|
+
} else if (ch === ',') {
|
|
130
|
+
result.push(current.trim());
|
|
131
|
+
current = '';
|
|
132
|
+
} else {
|
|
133
|
+
current += ch;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
result.push(current.trim());
|
|
138
|
+
return result;
|
|
139
|
+
}
|