@svadmin/core 0.50.0 → 0.53.0
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 +21 -4
- package/src/access-control-contract.ts +250 -0
- package/src/adapters/casbin.ts +20 -14
- package/src/adapters/casl.ts +22 -19
- package/src/admin-tool.ts +7 -7
- package/src/audit-contract.ts +105 -0
- package/src/audit.ts +88 -128
- package/src/auth-hooks.svelte.ts +593 -261
- package/src/auth-mutation-contract.ts +143 -0
- package/src/auth-query-contract.ts +97 -0
- package/src/captured-mutation.svelte.ts +45 -0
- package/src/chatProvider.svelte.ts +1 -1
- package/src/command-contract.ts +195 -0
- package/src/context.svelte.ts +217 -205
- package/src/contract-context.svelte.ts +37 -0
- package/src/contract-form.svelte.ts +566 -0
- package/src/create-contract.ts +56 -0
- package/src/create-hooks.svelte.ts +347 -0
- package/src/create-many-contract.ts +90 -0
- package/src/create-many-hooks.svelte.ts +396 -0
- package/src/data-transfer.svelte.ts +1 -215
- package/src/defined-options.ts +50 -0
- package/src/delete-contract.ts +50 -0
- package/src/delete-hooks.svelte.ts +403 -0
- package/src/delete-many-contract.ts +60 -0
- package/src/delete-many-hooks.svelte.ts +402 -0
- package/src/enterprise.ts +4 -4
- package/src/export-format.ts +16 -14
- package/src/export-hooks.svelte.ts +191 -0
- package/src/feature-gate-contract.ts +53 -0
- package/src/hook-utils.svelte.ts +53 -105
- package/src/hooks.svelte.ts +19 -776
- package/src/http.ts +88 -87
- package/src/i18n.svelte.ts +64 -2
- package/src/import-contract.ts +106 -0
- package/src/import-hooks.svelte.ts +310 -0
- package/src/index.ts +77 -30
- package/src/inferencer-graphql.ts +7 -6
- package/src/inferencer.ts +9 -0
- package/src/infinite-query.svelte.ts +228 -0
- package/src/invalidation-contract.ts +26 -0
- package/src/live-sse.ts +91 -127
- package/src/live-subscription.svelte.ts +130 -0
- package/src/live-transport.ts +127 -0
- package/src/live-websocket.ts +85 -118
- package/src/live.svelte.ts +66 -68
- package/src/notification.svelte.ts +17 -15
- package/src/permission-catalog.ts +121 -0
- package/src/permission-hints-contract.ts +48 -0
- package/src/permissions.svelte.ts +112 -59
- package/src/plain-data.ts +61 -0
- package/src/provider-bundle.ts +85 -79
- package/src/provider-response.ts +35 -0
- package/src/query-hooks.svelte.ts +241 -288
- package/src/query-invalidation.ts +172 -0
- package/src/query-keys.ts +221 -110
- package/src/query-session.svelte.ts +220 -0
- package/src/query-snapshot.ts +144 -0
- package/src/reactive-projection.ts +53 -0
- package/src/record-decoder.ts +63 -0
- package/src/resource-binding.svelte.ts +19 -0
- package/src/resource-contract.ts +595 -0
- package/src/resource-mutation-types.ts +30 -0
- package/src/resource-schemas.ts +371 -0
- package/src/resource-transport-config.ts +156 -0
- package/src/route-parsing.ts +11 -11
- package/src/router-provider.ts +64 -59
- package/src/router.ts +8 -3
- package/src/routing-hooks.svelte.ts +90 -62
- package/src/schema-validation.ts +92 -0
- package/src/schema.ts +13 -0
- package/src/select-options.ts +95 -0
- package/src/select-query.svelte.ts +191 -0
- package/src/session-query.svelte.ts +103 -0
- package/src/strict-command-hooks.svelte.ts +333 -0
- package/src/strict-hooks.svelte.ts +198 -0
- package/src/table-hooks.svelte.ts +180 -174
- package/src/table-state.svelte.ts +117 -117
- package/src/task-contract.ts +87 -0
- package/src/task-hooks.svelte.ts +190 -180
- package/src/task-provider.ts +115 -0
- package/src/task-subscription.ts +132 -0
- package/src/theme.svelte.ts +155 -154
- package/src/toast.svelte.ts +38 -37
- package/src/types.ts +68 -110
- package/src/unsafe.ts +7 -0
- package/src/update-contract.ts +65 -0
- package/src/update-hooks.svelte.ts +347 -0
- package/src/update-many-contract.ts +79 -0
- package/src/update-many-hooks.svelte.ts +373 -0
- package/src/url-sync.ts +11 -10
- package/src/useCan.ts +96 -31
- package/src/useParsed.svelte.ts +18 -14
- package/src/useStepsForm.svelte.ts +123 -63
- package/src/utility-hooks.svelte.ts +101 -158
- package/src/form-hooks.svelte.ts +0 -889
- package/src/mutation-hooks.svelte.ts +0 -561
- package/src/schema-form.ts +0 -31
package/src/http.ts
CHANGED
|
@@ -1,85 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP fetch
|
|
2
|
+
* HTTP fetch utilities with CSRF protection, automatic 401 redirects, and structured error semantics.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { HttpError,
|
|
5
|
+
import { HttpError,type ValidationErrors } from './types';
|
|
6
|
+
import { Type } from '@sinclair/typebox';
|
|
7
|
+
import { Value } from '@sinclair/typebox/value';
|
|
8
|
+
import { definedOptions } from './defined-options';
|
|
6
9
|
|
|
7
10
|
export interface FetchWithInterceptorOptions {
|
|
8
|
-
/** 401
|
|
11
|
+
/** Login path used for 401 redirects. */
|
|
9
12
|
loginPath?: string;
|
|
10
|
-
/** 403
|
|
13
|
+
/** Error message used when a 403 response has no server-provided message. */
|
|
11
14
|
forbiddenMessage?: string;
|
|
12
|
-
/**
|
|
15
|
+
/** Whether to add the X-Requested-With header automatically for CSRF protection. */
|
|
13
16
|
csrfProtection?: boolean;
|
|
14
|
-
/**
|
|
17
|
+
/** Custom fetch implementation for testing or SSR injection. */
|
|
15
18
|
fetchImpl?: FetchWithInterceptor;
|
|
16
|
-
/**
|
|
17
|
-
onUnauthorized?: (loginPath: string,
|
|
19
|
+
/** Custom 401 redirect handler for testing or SSR injection. */
|
|
20
|
+
onUnauthorized?: (loginPath: string,returnTo: string) => void;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
export type FetchWithInterceptor
|
|
23
|
+
export type FetchWithInterceptor=(url: string,init?: RequestInit) => Promise<Response>;
|
|
21
24
|
|
|
22
|
-
const DEFAULT_OPTIONS: Required<Omit<FetchWithInterceptorOptions,
|
|
25
|
+
const DEFAULT_OPTIONS: Required<Omit<FetchWithInterceptorOptions,'fetchImpl'|'onUnauthorized'>>&{
|
|
23
26
|
fetchImpl: FetchWithInterceptor;
|
|
24
|
-
onUnauthorized: (loginPath: string,
|
|
25
|
-
}
|
|
27
|
+
onUnauthorized: (loginPath: string,returnTo: string) => void;
|
|
28
|
+
}={
|
|
26
29
|
loginPath: '/auth/login',
|
|
27
30
|
forbiddenMessage: 'Forbidden: 该操作已被安全策略拒绝',
|
|
28
31
|
csrfProtection: true,
|
|
29
32
|
get fetchImpl() {
|
|
30
|
-
if
|
|
31
|
-
return (url: string,
|
|
33
|
+
if(typeof globalThis!=='undefined'&&typeof globalThis.fetch==='function') {
|
|
34
|
+
return (url: string,init?: RequestInit) => globalThis.fetch(url,init);
|
|
32
35
|
}
|
|
33
36
|
return async () => {
|
|
34
37
|
throw new Error('No fetch implementation found. Pass fetchImpl in options.');
|
|
35
38
|
};
|
|
36
39
|
},
|
|
37
|
-
onUnauthorized: (loginPath,
|
|
38
|
-
if
|
|
39
|
-
window.location.href
|
|
40
|
+
onUnauthorized: (loginPath,returnTo) => {
|
|
41
|
+
if(typeof window!=='undefined') {
|
|
42
|
+
window.location.href=`${loginPath}?returnTo=${returnTo}`;
|
|
40
43
|
}
|
|
41
44
|
},
|
|
42
45
|
};
|
|
43
46
|
|
|
44
|
-
function asRecord(value: unknown): Record<string,
|
|
45
|
-
return
|
|
46
|
-
? value as Record<string, unknown>
|
|
47
|
-
: null;
|
|
47
|
+
function asRecord(value: unknown): Record<string,unknown>|null {
|
|
48
|
+
return Value.Check(Type.Record(Type.String(),Type.Unknown()),value)? value:null;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function asStructuredHttpError(error: unknown): HttpError
|
|
51
|
-
const record
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
function asStructuredHttpError(error: unknown): HttpError|null {
|
|
52
|
+
const record=asRecord(error);
|
|
53
|
+
const status=record?.['statusCode'];
|
|
54
|
+
if(!record||typeof status!=='number'||!Number.isInteger(status)||status<100||status>599) return null;
|
|
55
|
+
const message=typeof record['message']==='string'? record['message']:'HTTP request failed';
|
|
56
|
+
const errors=asValidationErrors(record['errors']);
|
|
57
|
+
return new HttpError(message,status,errors,definedOptions({
|
|
58
|
+
code: typeof record['code']==='string'? record['code']:undefined,
|
|
59
|
+
details: record['details'],
|
|
60
|
+
body: record['body'],
|
|
59
61
|
cause: error,
|
|
60
|
-
});
|
|
62
|
+
}));
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
function firstString(...values: unknown[]): string
|
|
64
|
-
return values.find((value): value is string => typeof value
|
|
65
|
+
function firstString(...values: unknown[]): string|undefined {
|
|
66
|
+
return values.find((value): value is string => typeof value==='string');
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
function asValidationErrors(value: unknown): ValidationErrors
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const valid = Object.values(record).every((entry) => (
|
|
72
|
-
typeof entry === 'string'
|
|
73
|
-
|| (Array.isArray(entry) && entry.every((item) => typeof item === 'string'))
|
|
74
|
-
));
|
|
75
|
-
return valid ? record as ValidationErrors : undefined;
|
|
69
|
+
function asValidationErrors(value: unknown): ValidationErrors|undefined {
|
|
70
|
+
const schema=Type.Record(Type.String(),Type.Union([Type.String(),Type.Array(Type.String())]));
|
|
71
|
+
return Value.Check(schema,value)? value:undefined;
|
|
76
72
|
}
|
|
77
73
|
|
|
78
74
|
async function readErrorBody(response: Response): Promise<unknown> {
|
|
79
|
-
const text
|
|
80
|
-
if
|
|
75
|
+
const text=await response.text();
|
|
76
|
+
if(!text) return undefined;
|
|
81
77
|
try {
|
|
82
|
-
|
|
78
|
+
const parsed: unknown=JSON.parse(text);
|
|
79
|
+
return parsed;
|
|
83
80
|
} catch {
|
|
84
81
|
return text;
|
|
85
82
|
}
|
|
@@ -89,81 +86,85 @@ async function createResponseError(
|
|
|
89
86
|
response: Response,
|
|
90
87
|
forbiddenMessage: string,
|
|
91
88
|
): Promise<HttpError> {
|
|
92
|
-
const body
|
|
93
|
-
const record
|
|
94
|
-
const nestedError
|
|
95
|
-
const code
|
|
89
|
+
const body=await readErrorBody(response);
|
|
90
|
+
const record=asRecord(body);
|
|
91
|
+
const nestedError=asRecord(record?.['error']);
|
|
92
|
+
const code=response.headers.get('X-Svadmin-Auth-Retry')==='exhausted'
|
|
96
93
|
? 'auth_retry_exhausted'
|
|
97
|
-
:
|
|
98
|
-
record?.code,
|
|
99
|
-
record?.error_code,
|
|
100
|
-
nestedError?.code,
|
|
101
|
-
nestedError?.error_code,
|
|
102
|
-
record?.error,
|
|
94
|
+
:firstString(
|
|
95
|
+
record?.['code'],
|
|
96
|
+
record?.['error_code'],
|
|
97
|
+
nestedError?.['code'],
|
|
98
|
+
nestedError?.['error_code'],
|
|
99
|
+
record?.['error'],
|
|
103
100
|
);
|
|
104
|
-
const details
|
|
105
|
-
const message
|
|
106
|
-
record?.message,
|
|
107
|
-
nestedError?.message,
|
|
108
|
-
record?.error_description,
|
|
109
|
-
nestedError?.error_description,
|
|
101
|
+
const details=record?.['details']??nestedError?.['details'];
|
|
102
|
+
const message=firstString(
|
|
103
|
+
record?.['message'],
|
|
104
|
+
nestedError?.['message'],
|
|
105
|
+
record?.['error_description'],
|
|
106
|
+
nestedError?.['error_description'],
|
|
110
107
|
body,
|
|
111
|
-
)
|
|
108
|
+
)??(response.status===401? 'Unauthorized':forbiddenMessage);
|
|
112
109
|
|
|
113
110
|
return new HttpError(
|
|
114
111
|
message,
|
|
115
112
|
response.status,
|
|
116
|
-
asValidationErrors(record?.errors
|
|
117
|
-
{
|
|
113
|
+
asValidationErrors(record?.['errors']??nestedError?.['errors']),
|
|
114
|
+
definedOptions({
|
|
115
|
+
code,
|
|
116
|
+
details,
|
|
117
|
+
body,
|
|
118
|
+
}),
|
|
118
119
|
);
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
/**
|
|
122
|
-
*
|
|
123
|
+
* Fetch with interceptors. Core owns error semantics; the auth provider handles refresh and request replay.
|
|
123
124
|
*/
|
|
124
125
|
export function createFetchWithInterceptor(
|
|
125
|
-
options: FetchWithInterceptorOptions
|
|
126
|
+
options: FetchWithInterceptorOptions={},
|
|
126
127
|
): FetchWithInterceptor {
|
|
127
|
-
const opts
|
|
128
|
+
const opts={ ...DEFAULT_OPTIONS,...options };
|
|
128
129
|
|
|
129
|
-
return async (url: string,
|
|
130
|
-
const headers
|
|
131
|
-
if
|
|
130
|
+
return async (url: string,init: RequestInit={}): Promise<Response> => {
|
|
131
|
+
const headers=new Headers(init.headers||{});
|
|
132
|
+
if(
|
|
132
133
|
opts.csrfProtection
|
|
133
|
-
&&
|
|
134
|
-
&&
|
|
134
|
+
&&init.method
|
|
135
|
+
&&['POST','PUT','DELETE','PATCH'].includes(init.method.toUpperCase())
|
|
135
136
|
) {
|
|
136
|
-
headers.set('X-Requested-With',
|
|
137
|
+
headers.set('X-Requested-With','XMLHttpRequest');
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
let response: Response;
|
|
140
141
|
try {
|
|
141
|
-
response
|
|
142
|
-
} catch
|
|
143
|
-
if
|
|
144
|
-
const structuredError
|
|
145
|
-
if
|
|
142
|
+
response=await opts.fetchImpl(url,{ ...init,headers });
|
|
143
|
+
} catch(error) {
|
|
144
|
+
if(error instanceof HttpError) throw error;
|
|
145
|
+
const structuredError=asStructuredHttpError(error);
|
|
146
|
+
if(structuredError) throw structuredError;
|
|
146
147
|
throw error;
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
if
|
|
150
|
+
if(response.ok||(response.status!==401&&response.status!==403)) {
|
|
150
151
|
return response;
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
const httpError
|
|
154
|
-
if
|
|
155
|
-
const returnTo
|
|
154
|
+
const httpError=await createResponseError(response,opts.forbiddenMessage);
|
|
155
|
+
if(response.status===401) {
|
|
156
|
+
const returnTo=typeof window!=='undefined'
|
|
156
157
|
? encodeURIComponent(window.location.pathname)
|
|
157
|
-
:
|
|
158
|
+
:'';
|
|
158
159
|
try {
|
|
159
|
-
opts.onUnauthorized(opts.loginPath,
|
|
160
|
+
opts.onUnauthorized(opts.loginPath,returnTo);
|
|
160
161
|
} catch {
|
|
161
|
-
//
|
|
162
|
+
// Redirect adapter failures must not replace the server's structured 401 error.
|
|
162
163
|
}
|
|
163
164
|
}
|
|
164
165
|
throw httpError;
|
|
165
166
|
};
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
/**
|
|
169
|
-
export const fetchWithInterceptor
|
|
169
|
+
/** Convenient global instance using the default configuration. */
|
|
170
|
+
export const fetchWithInterceptor=createFetchWithInterceptor();
|
package/src/i18n.svelte.ts
CHANGED
|
@@ -18,9 +18,13 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
18
18
|
'common.moveUp': '上移',
|
|
19
19
|
'common.moveDown': '下移',
|
|
20
20
|
'common.treeSelect': '树形选择',
|
|
21
|
+
'tree.toggleChildren': '展开或折叠子节点',
|
|
22
|
+
'tree.loadFailed': '子节点加载失败',
|
|
21
23
|
'common.cascader': '级联选择',
|
|
22
24
|
'common.transfer': '穿梭框',
|
|
23
25
|
'common.filterBuilder': '高级筛选',
|
|
26
|
+
'filter.incomplete': '请补全筛选条件后再应用。',
|
|
27
|
+
'filter.readonly': '只读筛选条件',
|
|
24
28
|
'common.addRule': '添加筛选条件',
|
|
25
29
|
'common.sourceList': '源列表',
|
|
26
30
|
'common.targetList': '目标列表',
|
|
@@ -53,6 +57,31 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
53
57
|
'common.page': '{current} / {total}',
|
|
54
58
|
'common.export': '导出',
|
|
55
59
|
'common.import': '导入',
|
|
60
|
+
'common.chooseFile': '选择文件',
|
|
61
|
+
'common.noFileChosen': '未选择文件',
|
|
62
|
+
'common.filesSelected': '已选择 {count} 个文件',
|
|
63
|
+
'common.uploadFile': '上传文件',
|
|
64
|
+
'common.mapColumns': '映射列',
|
|
65
|
+
'common.importProgress': '导入与校验',
|
|
66
|
+
'common.dropFileHere': '将 CSV 或 JSON 文件拖放到这里',
|
|
67
|
+
'common.fileFormatHint': '支持 UTF-8 编码的 .csv、.json 文件',
|
|
68
|
+
'common.selectFile': '浏览文件',
|
|
69
|
+
'common.detectedRows': '已检测',
|
|
70
|
+
'common.records': '条记录',
|
|
71
|
+
'common.ignoreColumn': '忽略',
|
|
72
|
+
'common.startImport': '开始导入',
|
|
73
|
+
'common.importing': '导入中...',
|
|
74
|
+
'common.completed': '已完成',
|
|
75
|
+
'common.succeeded': '成功',
|
|
76
|
+
'common.failed': '失败',
|
|
77
|
+
'common.failedRowsHint': '条记录导入失败。',
|
|
78
|
+
'common.downloadErrors': '下载失败 CSV',
|
|
79
|
+
'common.importAnother': '再次导入',
|
|
80
|
+
'common.selectOption': '请选择',
|
|
81
|
+
'common.yesTrue': '是 / 真',
|
|
82
|
+
'common.noFalse': '否 / 假',
|
|
83
|
+
'common.startDate': '开始日期',
|
|
84
|
+
'common.endDate': '结束日期',
|
|
56
85
|
'common.clone': '克隆',
|
|
57
86
|
'common.autoSaving': '自动保存中...',
|
|
58
87
|
'common.autoSaved': '已自动保存',
|
|
@@ -115,6 +144,8 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
115
144
|
'common.saveView': '保存',
|
|
116
145
|
'common.perPage': '每页',
|
|
117
146
|
'common.copyId': '复制 ID',
|
|
147
|
+
'common.moreActions': '更多操作',
|
|
148
|
+
'common.quickEdit': '快速编辑',
|
|
118
149
|
'common.showMore': '展开更多',
|
|
119
150
|
'common.showLess': '收起',
|
|
120
151
|
'common.showPassword': '显示密码',
|
|
@@ -689,9 +720,13 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
689
720
|
'common.moveUp': 'Move Up',
|
|
690
721
|
'common.moveDown': 'Move Down',
|
|
691
722
|
'common.treeSelect': 'Tree Select',
|
|
723
|
+
'tree.toggleChildren': 'Expand or collapse children',
|
|
724
|
+
'tree.loadFailed': 'Failed to load child nodes',
|
|
692
725
|
'common.cascader': 'Cascader',
|
|
693
726
|
'common.transfer': 'Transfer',
|
|
694
727
|
'common.filterBuilder': 'Filter Builder',
|
|
728
|
+
'filter.incomplete': 'Complete the filter conditions before applying.',
|
|
729
|
+
'filter.readonly': 'Read-only filter condition',
|
|
695
730
|
'common.addRule': 'Add Filter Rule',
|
|
696
731
|
'common.sourceList': 'Source',
|
|
697
732
|
'common.targetList': 'Target',
|
|
@@ -724,6 +759,31 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
724
759
|
'common.page': '{current} / {total}',
|
|
725
760
|
'common.export': 'Export',
|
|
726
761
|
'common.import': 'Import',
|
|
762
|
+
'common.chooseFile': 'Choose file',
|
|
763
|
+
'common.noFileChosen': 'No file chosen',
|
|
764
|
+
'common.filesSelected': '{count} files selected',
|
|
765
|
+
'common.uploadFile': 'Upload file',
|
|
766
|
+
'common.mapColumns': 'Map columns',
|
|
767
|
+
'common.importProgress': 'Import & verify',
|
|
768
|
+
'common.dropFileHere': 'Drag and drop CSV or JSON file here',
|
|
769
|
+
'common.fileFormatHint': 'Supports .csv, .json with UTF-8 encoding',
|
|
770
|
+
'common.selectFile': 'Browse file',
|
|
771
|
+
'common.detectedRows': 'Detected',
|
|
772
|
+
'common.records': 'records',
|
|
773
|
+
'common.ignoreColumn': 'Ignore',
|
|
774
|
+
'common.startImport': 'Start import',
|
|
775
|
+
'common.importing': 'Importing...',
|
|
776
|
+
'common.completed': 'Completed',
|
|
777
|
+
'common.succeeded': 'Succeeded',
|
|
778
|
+
'common.failed': 'Failed',
|
|
779
|
+
'common.failedRowsHint': 'records failed to import.',
|
|
780
|
+
'common.downloadErrors': 'Download failed CSV',
|
|
781
|
+
'common.importAnother': 'Import another',
|
|
782
|
+
'common.selectOption': 'Select an option...',
|
|
783
|
+
'common.yesTrue': 'Yes / True',
|
|
784
|
+
'common.noFalse': 'No / False',
|
|
785
|
+
'common.startDate': 'Start date',
|
|
786
|
+
'common.endDate': 'End date',
|
|
727
787
|
'common.clone': 'Clone',
|
|
728
788
|
'common.autoSaving': 'Auto-saving...',
|
|
729
789
|
'common.autoSaved': 'Saved',
|
|
@@ -786,6 +846,8 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
786
846
|
'common.saveView': 'Save',
|
|
787
847
|
'common.perPage': 'per page',
|
|
788
848
|
'common.copyId': 'Copy ID',
|
|
849
|
+
'common.moreActions': 'More actions',
|
|
850
|
+
'common.quickEdit': 'Quick edit',
|
|
789
851
|
'common.showMore': 'Show more',
|
|
790
852
|
'common.showLess': 'Show less',
|
|
791
853
|
'common.showPassword': 'Show password',
|
|
@@ -1355,7 +1417,7 @@ function detectLocale(): string {
|
|
|
1355
1417
|
if (typeof navigator === 'undefined') return 'en';
|
|
1356
1418
|
const browserLang = navigator.language || navigator.languages?.[0] || 'zh-CN';
|
|
1357
1419
|
if (locales[browserLang]) return browserLang;
|
|
1358
|
-
const prefix = browserLang.split('-')
|
|
1420
|
+
const [prefix = browserLang] = browserLang.split('-');
|
|
1359
1421
|
for (const key of Object.keys(locales)) {
|
|
1360
1422
|
if (key === browserLang || key.startsWith(prefix + '-')) return key;
|
|
1361
1423
|
}
|
|
@@ -1407,7 +1469,7 @@ function translateFromTables(
|
|
|
1407
1469
|
params?: Record<string, string | number>,
|
|
1408
1470
|
): string {
|
|
1409
1471
|
const localeTable = locales[locale];
|
|
1410
|
-
let text = localeTable?.[key] ?? locales
|
|
1472
|
+
let text = localeTable?.[key] ?? locales['en']?.[key] ?? key;
|
|
1411
1473
|
|
|
1412
1474
|
if (params) {
|
|
1413
1475
|
for (const [param, value] of Object.entries(params)) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import Papa from 'papaparse';
|
|
2
|
+
import { Type, type Static } from '@sinclair/typebox';
|
|
3
|
+
import { checkExact } from './schema-validation';
|
|
4
|
+
import { snapshotPlainData } from './plain-data';
|
|
5
|
+
import { HttpError } from './types';
|
|
6
|
+
import { parseContractId, parseContractRecord, type ContractSchemas, type ResourceContract } from './resource-contract';
|
|
7
|
+
import { decodeOneResult, decodeManyResult, rejectProviderResponse } from './record-decoder';
|
|
8
|
+
|
|
9
|
+
export type ImportFormat = 'auto' | 'csv' | 'json';
|
|
10
|
+
const progressSchema = Type.Object({
|
|
11
|
+
totalAmount: Type.Integer({ minimum: 0 }),
|
|
12
|
+
attemptedAmount: Type.Integer({ minimum: 0 }),
|
|
13
|
+
processedAmount: Type.Integer({ minimum: 0 }),
|
|
14
|
+
}, { additionalProperties: false });
|
|
15
|
+
export type ImportOperationProgress = Static<typeof progressSchema>;
|
|
16
|
+
|
|
17
|
+
export function newImportProgress(): ImportOperationProgress {
|
|
18
|
+
return { totalAmount: 0, attemptedAmount: 0, processedAmount: 0 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function snapshotImportProgress(value: unknown): ImportOperationProgress {
|
|
22
|
+
const progress = snapshotPlainData(value);
|
|
23
|
+
if (!checkExact(progressSchema, progress) || progress.processedAmount > progress.attemptedAmount ||
|
|
24
|
+
progress.attemptedAmount > progress.totalAmount ||
|
|
25
|
+
!Object.values(progress).every(Number.isSafeInteger)) throw new HttpError('Invalid import progress', 500);
|
|
26
|
+
return progress;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function invalidImportInput(row?: number): never {
|
|
30
|
+
throw new HttpError('Invalid import input', 422, undefined, {
|
|
31
|
+
code: 'INVALID_RESOURCE_INPUT',
|
|
32
|
+
details: { phase: 'input', writeMayHaveSucceeded: false, ...(row === undefined ? {} : { row }) },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Files are the only non-JSON input; do not invoke caller-supplied getters or text methods. */
|
|
37
|
+
export function snapshotImportFile(value: unknown) {
|
|
38
|
+
try {
|
|
39
|
+
if (typeof value !== 'object' || value === null ||
|
|
40
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(value)) ||
|
|
41
|
+
Reflect.ownKeys(value).length !== 1) return invalidImportInput();
|
|
42
|
+
const property = Object.getOwnPropertyDescriptor(value, 'file');
|
|
43
|
+
const file: unknown = property && 'value' in property ? property.value : undefined;
|
|
44
|
+
if (!(file instanceof File)) return invalidImportInput();
|
|
45
|
+
const ownName = Object.getOwnPropertyDescriptor(file, 'name');
|
|
46
|
+
if (ownName && !('value' in ownName)) return invalidImportInput();
|
|
47
|
+
const name: unknown = ownName ? ownName.value : Object.getOwnPropertyDescriptor(File.prototype, 'name')?.get?.call(file);
|
|
48
|
+
if (typeof name !== 'string') return invalidImportInput();
|
|
49
|
+
const read = Blob.prototype.text.bind(file);
|
|
50
|
+
return { file, name, read };
|
|
51
|
+
} catch {
|
|
52
|
+
return invalidImportInput();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const rowsSchema = Type.Array(Type.Record(Type.String(), Type.Unknown()));
|
|
57
|
+
const csvSchema = Type.Array(Type.Array(Type.String()));
|
|
58
|
+
|
|
59
|
+
/** CSV values stay strings; only an explicit mapper may supply business conversions. */
|
|
60
|
+
export function parseImportRows(text: string, name: string, format: ImportFormat): Record<string, unknown>[] {
|
|
61
|
+
try {
|
|
62
|
+
const source = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
|
|
63
|
+
const selected = format === 'auto' ? name.toLowerCase().endsWith('.json') ? 'json'
|
|
64
|
+
: name.toLowerCase().endsWith('.csv') ? 'csv' : undefined : format;
|
|
65
|
+
if (selected === 'json') {
|
|
66
|
+
const data = snapshotPlainData(JSON.parse(source));
|
|
67
|
+
return checkExact(rowsSchema, data) ? data : invalidImportInput();
|
|
68
|
+
}
|
|
69
|
+
if (selected !== 'csv') return invalidImportInput();
|
|
70
|
+
const parsed = Papa.parse<unknown>(source, { delimiter: ',', dynamicTyping: false, skipEmptyLines: true });
|
|
71
|
+
const data = snapshotPlainData(parsed.data);
|
|
72
|
+
if (parsed.errors.length || !checkExact(csvSchema, data)) return invalidImportInput();
|
|
73
|
+
const [headers, ...rows] = data;
|
|
74
|
+
if (!headers) return [];
|
|
75
|
+
if (headers.some(header => header.trim() === '') || new Set(headers).size !== headers.length) return invalidImportInput();
|
|
76
|
+
return rows.map((row, index) => {
|
|
77
|
+
if (row.length !== headers.length) return invalidImportInput(index + 1);
|
|
78
|
+
return Object.fromEntries(headers.map((header, column) => [header, row[column]]));
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
return invalidImportInput();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function decodeImportReceipt<S extends ContractSchemas>(
|
|
86
|
+
contract: ResourceContract<S>, response: unknown, inputs: readonly unknown[],
|
|
87
|
+
batch: boolean, seen: ReadonlySet<string | number>,
|
|
88
|
+
) {
|
|
89
|
+
try {
|
|
90
|
+
const receipt = snapshotPlainData(response);
|
|
91
|
+
const rows = batch
|
|
92
|
+
? decodeManyResult(receipt, value => parseContractRecord(contract, value), true).data
|
|
93
|
+
: [decodeOneResult(receipt, value => parseContractRecord(contract, value), true).data];
|
|
94
|
+
if (rows.length !== inputs.length || new Set(rows.map(row => row.id)).size !== rows.length) rejectProviderResponse(true);
|
|
95
|
+
for (const [index, row] of rows.entries()) {
|
|
96
|
+
const recordId = parseContractId(contract, row.id);
|
|
97
|
+
const submitted = inputs[index];
|
|
98
|
+
const id: unknown = typeof submitted === 'object' && submitted !== null
|
|
99
|
+
? Object.getOwnPropertyDescriptor(submitted, 'id')?.value : undefined;
|
|
100
|
+
if ((id !== undefined && id !== recordId) || seen.has(recordId)) rejectProviderResponse(true);
|
|
101
|
+
}
|
|
102
|
+
return rows;
|
|
103
|
+
} catch {
|
|
104
|
+
return rejectProviderResponse(true);
|
|
105
|
+
}
|
|
106
|
+
}
|