@rebasepro/admin 0.13.1-canary.gef9608c → 0.14.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/dist/{CollectionEditorDialog-7b-kSLno.js → CollectionEditorDialog-CSheud_E.js} +19 -9
- package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
- package/dist/{PropertyEditView-C-31uvpG.js → PropertyEditView-CWZlC1gq.js} +5 -5
- package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
- package/dist/{RouterCollectionsStudioView-BJylABq3.js → RouterCollectionsStudioView-CwTpdzWF.js} +9 -13
- package/dist/RouterCollectionsStudioView-CwTpdzWF.js.map +1 -0
- package/dist/collection_editor/serializable_types.d.ts +1 -0
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/ClearFilterSortButton.d.ts +7 -1
- package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
- package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
- package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +101 -0
- package/dist/components/CollectionViewBinding/SlotValue.d.ts +26 -0
- package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
- package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -6
- package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +1 -1
- package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +26 -1
- package/dist/components/DetailViewBinding.d.ts +7 -0
- package/dist/components/DrawerNavigationGroup.d.ts +4 -2
- package/dist/components/DrawerNavigationItem.d.ts +13 -5
- package/dist/components/EditViewBinding.d.ts +22 -7
- package/dist/components/EntityIdentityBar.d.ts +24 -1
- package/dist/components/RelationSelector.d.ts +13 -0
- package/dist/data_export/export/export.d.ts +7 -0
- package/dist/data_export/export/fetch_export_data.d.ts +48 -0
- package/dist/data_export/export/index.d.ts +1 -0
- package/dist/data_import/utils/csv.d.ts +30 -0
- package/dist/data_import/utils/index.d.ts +2 -0
- package/dist/data_import/utils/save_entities.d.ts +38 -0
- package/dist/data_import/utils/transforms.d.ts +9 -0
- package/dist/editor.js +3 -0
- package/dist/editor.js.map +1 -1
- package/dist/{export-C-XG3aRh.js → export-L53WektO.js} +175 -39
- package/dist/export-L53WektO.js.map +1 -0
- package/dist/form/form_utils.d.ts +38 -1
- package/dist/{history-DfMuvPfr.js → history-D5SKygpJ.js} +2 -2
- package/dist/{history-DfMuvPfr.js.map → history-D5SKygpJ.js.map} +1 -1
- package/dist/{import-B0GyllB0.js → import-C_4edkoD.js} +5 -4
- package/dist/import-C_4edkoD.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +96 -80
- package/dist/index.js.map +1 -1
- package/dist/preview/components/date_fns_locales.d.ts +9 -0
- package/dist/util/view_constants.d.ts +24 -0
- package/dist/util/view_mode.d.ts +18 -0
- package/dist/{util-DHNXodBo.js → util-C-D5yD2n.js} +2173 -673
- package/dist/util-C-D5yD2n.js.map +1 -0
- package/package.json +10 -9
- package/src/collection_editor/serializable_types.ts +1 -0
- package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
- package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
- package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
- package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
- package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
- package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
- package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
- package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
- package/src/components/ClearFilterSortButton.tsx +16 -7
- package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +2 -2
- package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
- package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
- package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
- package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
- package/src/components/CollectionViewBinding/BoardCardBinding.tsx +18 -1
- package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
- package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
- package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +816 -335
- package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +26 -6
- package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
- package/src/components/CollectionViewBinding/EntityCardBinding.tsx +45 -10
- package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
- package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
- package/src/components/CollectionViewBinding/SlotValue.tsx +96 -0
- package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
- package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -9
- package/src/components/CollectionViewBinding/SplitListShowButton.tsx +3 -3
- package/src/components/CollectionViewBinding/SplitListView.tsx +36 -9
- package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
- package/src/components/CollectionViewBinding/useBoardDataController.tsx +11 -3
- package/src/components/CollectionViewBinding/usePreviewSlots.ts +76 -24
- package/src/components/CollectionViewBinding/utils.ts +7 -4
- package/src/components/DefaultDrawer.tsx +7 -0
- package/src/components/DetailViewBinding.tsx +33 -9
- package/src/components/DrawerNavigationGroup.tsx +12 -32
- package/src/components/DrawerNavigationItem.tsx +33 -12
- package/src/components/EditViewBinding.tsx +175 -28
- package/src/components/EntityIdentityBar.tsx +109 -17
- package/src/components/EntityInspector.tsx +24 -6
- package/src/components/EntityViewBinding.tsx +4 -2
- package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
- package/src/components/RelationSelector.tsx +61 -11
- package/src/components/SearchIconsView.tsx +14 -33
- package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
- package/src/components/SidePanelBinding.tsx +36 -25
- package/src/components/UserSelector.tsx +52 -9
- package/src/components/history/LastEditedByIndicator.tsx +7 -12
- package/src/data_export/export/ExportCollectionAction.tsx +101 -48
- package/src/data_export/export/export.ts +63 -16
- package/src/data_export/export/fetch_export_data.ts +90 -0
- package/src/data_export/export/index.ts +1 -0
- package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
- package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
- package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
- package/src/data_import/import/ImportCollectionAction.tsx +13 -2
- package/src/data_import/utils/csv.ts +153 -0
- package/src/data_import/utils/data.ts +16 -2
- package/src/data_import/utils/file_to_json.ts +77 -6
- package/src/data_import/utils/index.ts +2 -0
- package/src/data_import/utils/save_entities.ts +138 -0
- package/src/data_import/utils/transforms.ts +23 -0
- package/src/editor/components/image-bubble.tsx +2 -0
- package/src/editor/selectors/link-selector.tsx +1 -0
- package/src/form/EntityForm.tsx +12 -3
- package/src/form/EntityFormBinding.tsx +8 -12
- package/src/form/PropertyFieldBinding.tsx +2 -2
- package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
- package/src/form/field_bindings/BinaryFieldBinding.tsx +1 -0
- package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
- package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
- package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
- package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
- package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +15 -2
- package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
- package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
- package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
- package/src/form/field_bindings/VectorFieldBinding.tsx +1 -0
- package/src/form/form_utils.ts +78 -0
- package/src/index.ts +9 -1
- package/src/preview/PropertyPreview.tsx +7 -3
- package/src/preview/components/DatePreview.tsx +15 -2
- package/src/preview/components/ReferencePreview.tsx +5 -8
- package/src/preview/components/RelationPreview.tsx +8 -7
- package/src/preview/components/date_fns_locales.ts +125 -0
- package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
- package/src/util/navigation_utils.ts +4 -2
- package/src/util/property_utils.tsx +6 -1
- package/src/util/view_constants.ts +27 -0
- package/src/util/view_mode.ts +36 -0
- package/dist/CollectionEditorDialog-7b-kSLno.js.map +0 -1
- package/dist/PropertyEditView-C-31uvpG.js.map +0 -1
- package/dist/RouterCollectionsStudioView-BJylABq3.js.map +0 -1
- package/dist/export-C-XG3aRh.js.map +0 -1
- package/dist/import-B0GyllB0.js.map +0 -1
- package/dist/util-DHNXodBo.js.map +0 -1
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
Typography
|
|
18
18
|
} from "@rebasepro/ui";
|
|
19
19
|
import { getIn, setIn } from "@rebasepro/forms";
|
|
20
|
+
import { useTranslation } from "@rebasepro/app";
|
|
20
21
|
import { ImportConfig } from "../types";
|
|
21
22
|
|
|
22
23
|
export interface DataPropertyMappingProps {
|
|
@@ -36,6 +37,7 @@ export function DataNewPropertiesMapping({
|
|
|
36
37
|
buildPropertyView
|
|
37
38
|
}: DataPropertyMappingProps) {
|
|
38
39
|
|
|
40
|
+
const { t } = useTranslation();
|
|
39
41
|
const headersMapping = importConfig.headersMapping;
|
|
40
42
|
const headingsOrder = importConfig.headingsOrder;
|
|
41
43
|
const idColumn = importConfig.idColumn;
|
|
@@ -117,7 +119,7 @@ export function DataNewPropertiesMapping({
|
|
|
117
119
|
<TableCell header={true}>
|
|
118
120
|
</TableCell>
|
|
119
121
|
<TableCell header={true} style={{ width: "65%" }}>
|
|
120
|
-
|
|
122
|
+
{t("default_value")}
|
|
121
123
|
</TableCell>
|
|
122
124
|
</TableHeader>
|
|
123
125
|
<TableBody>
|
|
@@ -205,16 +207,31 @@ function DefaultValuesField({
|
|
|
205
207
|
onValueChange,
|
|
206
208
|
defaultValue
|
|
207
209
|
}: { property: Property, onValueChange: (value: any) => void, defaultValue?: any }) {
|
|
210
|
+
const { t } = useTranslation();
|
|
211
|
+
const defaultValueLabel = t("default_value");
|
|
212
|
+
// Named fields stay distinguishable from each other — several of these sit
|
|
213
|
+
// in one table, and an aria-label repeated verbatim tells a screen reader
|
|
214
|
+
// nothing about which row it is on.
|
|
215
|
+
//
|
|
216
|
+
// Composed with a separator rather than a phrase: there is no key for the
|
|
217
|
+
// possessive form, and `t` returns the key itself for one that does not
|
|
218
|
+
// exist, so inventing `default_value_for` here would read out the literal
|
|
219
|
+
// string "default_value_for" in every locale, English included.
|
|
220
|
+
const ariaLabel = property.name
|
|
221
|
+
? `${defaultValueLabel}: ${property.name}`
|
|
222
|
+
: defaultValueLabel;
|
|
208
223
|
if (property.type === "string") {
|
|
209
224
|
return <TextField size={"medium"}
|
|
210
|
-
|
|
225
|
+
aria-label={ariaLabel}
|
|
226
|
+
placeholder={defaultValueLabel}
|
|
211
227
|
value={defaultValue ?? ""}
|
|
212
228
|
onChange={(event) => onValueChange(event.target.value)}/>;
|
|
213
229
|
} else if (property.type === "number") {
|
|
214
230
|
return <TextField size={"medium"}
|
|
215
231
|
type={"number"}
|
|
232
|
+
aria-label={ariaLabel}
|
|
216
233
|
value={defaultValue ?? ""}
|
|
217
|
-
placeholder={
|
|
234
|
+
placeholder={defaultValueLabel}
|
|
218
235
|
onChange={(event) => onValueChange(event.target.value)}/>;
|
|
219
236
|
} else if (property.type === "boolean") {
|
|
220
237
|
return <BooleanSwitchWithLabel
|
|
@@ -38,6 +38,7 @@ export function ImportNewPropertyFieldPreview({
|
|
|
38
38
|
{includeName &&
|
|
39
39
|
<TextField
|
|
40
40
|
size={"medium"}
|
|
41
|
+
aria-label={propertyKey ? `Name of property ${propertyKey}` : "Property name"}
|
|
41
42
|
className={"text-base grow"}
|
|
42
43
|
value={property?.name ?? ""}
|
|
43
44
|
onChange={(e) => {
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { useData } from "@rebasepro/app";
|
|
2
|
-
import { Entity } from "@rebasepro/types";
|
|
3
|
-
import { RebaseData } from "@rebasepro/types";
|
|
4
2
|
import { Button, CenteredView, CircularProgress, Typography } from "@rebasepro/ui";
|
|
5
3
|
import { useEffect, useRef, useState } from "react";
|
|
6
4
|
import { ImportConfig } from "../types";
|
|
7
5
|
import type { AdminCollection } from "@rebasepro/admin-types";
|
|
6
|
+
import { IMPORT_BATCH_SIZE, ImportSaveError, saveImportedEntities } from "../utils/save_entities";
|
|
8
7
|
|
|
9
8
|
export function ImportSaveInProgress<C extends AdminCollection<any>>
|
|
10
9
|
({
|
|
@@ -20,11 +19,16 @@ export function ImportSaveInProgress<C extends AdminCollection<any>>
|
|
|
20
19
|
onImportSuccess: (collection: C) => void
|
|
21
20
|
}) {
|
|
22
21
|
|
|
23
|
-
const [errorSaving, setErrorSaving] = useState<
|
|
22
|
+
const [errorSaving, setErrorSaving] = useState<ImportSaveError | undefined>(undefined);
|
|
24
23
|
const dataClient = useData();
|
|
25
24
|
|
|
26
25
|
const savingRef = useRef<boolean>(false);
|
|
27
26
|
|
|
27
|
+
// Rows confirmed as written. A retry resumes here rather than re-sending
|
|
28
|
+
// rows that now exist and conflicting on its first batch, forever.
|
|
29
|
+
const committedRef = useRef<number>(0);
|
|
30
|
+
const bulkUnsupportedRef = useRef<{ current: boolean }>({ current: false });
|
|
31
|
+
|
|
28
32
|
const [processedEntities, setProcessedEntities] = useState<number>(0);
|
|
29
33
|
|
|
30
34
|
function save() {
|
|
@@ -33,20 +37,29 @@ export function ImportSaveInProgress<C extends AdminCollection<any>>
|
|
|
33
37
|
return;
|
|
34
38
|
|
|
35
39
|
savingRef.current = true;
|
|
40
|
+
setErrorSaving(undefined);
|
|
36
41
|
|
|
37
|
-
|
|
42
|
+
saveImportedEntities(
|
|
38
43
|
dataClient,
|
|
39
|
-
collection,
|
|
40
44
|
path,
|
|
41
45
|
importConfig.entities,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
{
|
|
47
|
+
offset: committedRef.current,
|
|
48
|
+
batchSize: IMPORT_BATCH_SIZE,
|
|
49
|
+
onBatchCommitted: (written) => {
|
|
50
|
+
committedRef.current = written;
|
|
51
|
+
setProcessedEntities(written);
|
|
52
|
+
},
|
|
53
|
+
bulkUnsupported: bulkUnsupportedRef.current
|
|
54
|
+
}
|
|
45
55
|
).then(() => {
|
|
46
56
|
onImportSuccess(collection);
|
|
47
57
|
savingRef.current = false;
|
|
48
58
|
}).catch((e) => {
|
|
49
|
-
|
|
59
|
+
const error = e as ImportSaveError;
|
|
60
|
+
committedRef.current = error.committed ?? committedRef.current;
|
|
61
|
+
setProcessedEntities(committedRef.current);
|
|
62
|
+
setErrorSaving(error);
|
|
50
63
|
savingRef.current = false;
|
|
51
64
|
});
|
|
52
65
|
}
|
|
@@ -56,6 +69,10 @@ export function ImportSaveInProgress<C extends AdminCollection<any>>
|
|
|
56
69
|
}, []);
|
|
57
70
|
|
|
58
71
|
if (errorSaving) {
|
|
72
|
+
const total = importConfig.entities.length;
|
|
73
|
+
const failedRows = errorSaving.failedTo - errorSaving.failedFrom > 1
|
|
74
|
+
? `rows ${errorSaving.failedFrom + 1}–${errorSaving.failedTo}`
|
|
75
|
+
: `row ${errorSaving.failedFrom + 1}`;
|
|
59
76
|
return (
|
|
60
77
|
<CenteredView className={"flex flex-col gap-4 items-center"}>
|
|
61
78
|
<Typography variant={"h6"}>
|
|
@@ -65,10 +82,18 @@ export function ImportSaveInProgress<C extends AdminCollection<any>>
|
|
|
65
82
|
<Typography variant={"body2"} color={"error"}>
|
|
66
83
|
{errorSaving.message}
|
|
67
84
|
</Typography>
|
|
85
|
+
|
|
86
|
+
<Typography variant={"body2"}>
|
|
87
|
+
{errorSaving.committed} of {total} rows were imported.
|
|
88
|
+
The import stopped on {failedRows}, which {errorSaving.failedTo - errorSaving.failedFrom > 1 ? "were" : "was"} not written.
|
|
89
|
+
</Typography>
|
|
90
|
+
|
|
68
91
|
<Button
|
|
69
92
|
onClick={save}
|
|
70
93
|
>
|
|
71
|
-
|
|
94
|
+
{errorSaving.committed > 0
|
|
95
|
+
? `Retry from row ${errorSaving.committed + 1}`
|
|
96
|
+
: "Retry"}
|
|
72
97
|
</Button>
|
|
73
98
|
</CenteredView>
|
|
74
99
|
);
|
|
@@ -94,29 +119,3 @@ export function ImportSaveInProgress<C extends AdminCollection<any>>
|
|
|
94
119
|
);
|
|
95
120
|
|
|
96
121
|
}
|
|
97
|
-
|
|
98
|
-
function saveDataBatch(dataClient: RebaseData,
|
|
99
|
-
collection: AdminCollection,
|
|
100
|
-
path: string,
|
|
101
|
-
data: Partial<Entity<any>>[],
|
|
102
|
-
offset = 0,
|
|
103
|
-
batchSize = 25,
|
|
104
|
-
onProgressUpdate: (progress: number) => void): Promise<void> {
|
|
105
|
-
|
|
106
|
-
console.debug("Saving imported data", offset, batchSize);
|
|
107
|
-
|
|
108
|
-
const batch = data.slice(offset, offset + batchSize);
|
|
109
|
-
return Promise.all(batch.map(d =>
|
|
110
|
-
dataClient.collection(path).create(
|
|
111
|
-
d.values,
|
|
112
|
-
d.id
|
|
113
|
-
)))
|
|
114
|
-
.then(() => {
|
|
115
|
-
if (offset + batchSize < data.length) {
|
|
116
|
-
onProgressUpdate(offset + batchSize);
|
|
117
|
-
return saveDataBatch(dataClient, collection, path, data, offset + batchSize, batchSize, onProgressUpdate);
|
|
118
|
-
}
|
|
119
|
-
onProgressUpdate(data.length);
|
|
120
|
-
return Promise.resolve();
|
|
121
|
-
});
|
|
122
|
-
}
|
|
@@ -35,7 +35,7 @@ import { DataNewPropertiesMapping } from "../components/DataNewPropertiesMapping
|
|
|
35
35
|
import { ImportFileUpload } from "../components/ImportFileUpload";
|
|
36
36
|
import { ImportSaveInProgress } from "../components/ImportSaveInProgress";
|
|
37
37
|
import { ImportConfig } from "../types";
|
|
38
|
-
import { slugify } from "@rebasepro/utils";
|
|
38
|
+
import { isPrototypePollutingKey, slugify } from "@rebasepro/utils";
|
|
39
39
|
|
|
40
40
|
type ImportState = "initial" | "mapping" | "preview" | "import_data_saving";
|
|
41
41
|
|
|
@@ -400,7 +400,15 @@ export function ImportDataPreview<M extends Record<string, unknown>>({
|
|
|
400
400
|
return <CollectionTableBinding
|
|
401
401
|
title={<div>
|
|
402
402
|
<Typography variant={"subtitle2"}>Imported data preview</Typography>
|
|
403
|
-
|
|
403
|
+
{/* Conditional because it is only true when a column was chosen as
|
|
404
|
+
the id: without one, every row is created and nothing can be
|
|
405
|
+
overwritten. With one, the import asks for an upsert — which it
|
|
406
|
+
did not, back when this sentence was unconditional and false. */}
|
|
407
|
+
<Typography variant={"caption"}>
|
|
408
|
+
{importConfig.idColumn
|
|
409
|
+
? "Entities with the same id will be overwritten"
|
|
410
|
+
: "All rows will be imported as new entities"}
|
|
411
|
+
</Typography>
|
|
404
412
|
</div>}
|
|
405
413
|
tableController={{
|
|
406
414
|
data: importConfig.entities,
|
|
@@ -421,6 +429,9 @@ function buildHeadersMappingFromData(objArr: object[], properties?: Properties)
|
|
|
421
429
|
const headersMapping: Record<string, string> = {};
|
|
422
430
|
objArr.filter(Boolean).forEach((obj) => {
|
|
423
431
|
Object.keys(obj).forEach((key) => {
|
|
432
|
+
// The keys are the uploaded file's header row; `headersMapping[key] = …`
|
|
433
|
+
// is the same setter the import pipeline refuses elsewhere.
|
|
434
|
+
if (isPrototypePollutingKey(key)) return;
|
|
424
435
|
const child = (obj as Record<string, unknown>)[key];
|
|
425
436
|
if (child != null && typeof child === "object" && !Array.isArray(child)) {
|
|
426
437
|
const childProperty = properties?.[key];
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { isPrototypePollutingKey } from "@rebasepro/utils";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Delimiters sniffed from the header row, most common first.
|
|
5
|
+
*
|
|
6
|
+
* A spreadsheet saved as "CSV" in a locale that uses the comma as a decimal
|
|
7
|
+
* separator is semicolon-delimited, and a tab-separated file saved as `.csv`
|
|
8
|
+
* happens often enough to be worth reading.
|
|
9
|
+
*/
|
|
10
|
+
const CANDIDATE_DELIMITERS = [",", ";", "\t"];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Split CSV text into rows of raw cells, RFC 4180 style.
|
|
14
|
+
*
|
|
15
|
+
* Quoted fields may contain the delimiter, newlines and doubled quotes; this is
|
|
16
|
+
* the exact inverse of what the export's `entryToCSVRow` writes, so a file this
|
|
17
|
+
* admin produced reads back into the cells it was built from.
|
|
18
|
+
*/
|
|
19
|
+
export function parseCsvRows(text: string, delimiter = ","): string[][] {
|
|
20
|
+
const rows: string[][] = [];
|
|
21
|
+
let row: string[] = [];
|
|
22
|
+
let field = "";
|
|
23
|
+
let quoted = false;
|
|
24
|
+
let inQuotes = false;
|
|
25
|
+
let i = 0;
|
|
26
|
+
|
|
27
|
+
const endField = () => {
|
|
28
|
+
row.push(field);
|
|
29
|
+
field = "";
|
|
30
|
+
quoted = false;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
while (i < text.length) {
|
|
34
|
+
const char = text[i];
|
|
35
|
+
|
|
36
|
+
if (inQuotes) {
|
|
37
|
+
if (char === "\"") {
|
|
38
|
+
// A doubled quote is a literal one; a single quote closes the field.
|
|
39
|
+
if (text[i + 1] === "\"") {
|
|
40
|
+
field += "\"";
|
|
41
|
+
i += 2;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
inQuotes = false;
|
|
45
|
+
i++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
field += char;
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (char === "\"" && field === "") {
|
|
54
|
+
inQuotes = true;
|
|
55
|
+
quoted = true;
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (char === delimiter) {
|
|
60
|
+
endField();
|
|
61
|
+
i++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (char === "\r") {
|
|
65
|
+
// CRLF: the line ends on the \n. A lone CR outside quotes is dropped.
|
|
66
|
+
i++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (char === "\n") {
|
|
70
|
+
endField();
|
|
71
|
+
rows.push(row);
|
|
72
|
+
row = [];
|
|
73
|
+
i++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
field += char;
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A file that does not end in a newline still has a last row.
|
|
81
|
+
if (field !== "" || quoted || row.length > 0) {
|
|
82
|
+
endField();
|
|
83
|
+
rows.push(row);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Pick the delimiter that splits the header row into the most cells. A
|
|
91
|
+
* single-column file matches nothing and stays on the comma.
|
|
92
|
+
*/
|
|
93
|
+
export function detectCsvDelimiter(text: string): string {
|
|
94
|
+
const sample = text.slice(0, 64 * 1024);
|
|
95
|
+
let best = CANDIDATE_DELIMITERS[0];
|
|
96
|
+
let bestCount = 1;
|
|
97
|
+
for (const candidate of CANDIDATE_DELIMITERS) {
|
|
98
|
+
const count = parseCsvRows(sample, candidate)[0]?.length ?? 0;
|
|
99
|
+
if (count > bestCount) {
|
|
100
|
+
best = candidate;
|
|
101
|
+
bestCount = count;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return best;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ParsedCsv {
|
|
108
|
+
headers: string[];
|
|
109
|
+
data: Record<string, string>[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse CSV text into one object per row, keyed by the header row.
|
|
114
|
+
*
|
|
115
|
+
* The picker accepts `.csv` and the dialog promises "Upload a CSV, Excel or JSON
|
|
116
|
+
* file", but every non-JSON file used to be handed to ExcelJS'
|
|
117
|
+
* `workbook.xlsx.load`, which is a zip reader — so every CSV failed with
|
|
118
|
+
* "Can't find end of central directory : is this a zip file ?". The export
|
|
119
|
+
* writes CSV by default, so the round trip could not close.
|
|
120
|
+
*
|
|
121
|
+
* A blank header keeps its column rather than collapsing it, so the cells to its
|
|
122
|
+
* right stay under their own names.
|
|
123
|
+
*/
|
|
124
|
+
export function parseCsvToObjects(text: string): ParsedCsv {
|
|
125
|
+
// A BOM is one character of the first header, and Excel writes one.
|
|
126
|
+
const cleaned = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
|
|
127
|
+
const delimiter = detectCsvDelimiter(cleaned);
|
|
128
|
+
const rows = parseCsvRows(cleaned, delimiter)
|
|
129
|
+
.filter(row => row.some(cell => cell.trim() !== ""));
|
|
130
|
+
|
|
131
|
+
if (rows.length === 0) {
|
|
132
|
+
return { headers: [],
|
|
133
|
+
data: [] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const headers = rows[0].map((header, index) => header.trim() || `Column${index + 1}`);
|
|
137
|
+
|
|
138
|
+
const data = rows.slice(1).map((cells) => {
|
|
139
|
+
const obj: Record<string, string> = {};
|
|
140
|
+
headers.forEach((header, index) => {
|
|
141
|
+
// The header row is uploaded data: `obj["__proto__"] = …` is the
|
|
142
|
+
// prototype setter, not a column. Refused, as elsewhere in import.
|
|
143
|
+
if (isPrototypePollutingKey(header)) return;
|
|
144
|
+
const cell = cells[index];
|
|
145
|
+
if (cell === undefined) return;
|
|
146
|
+
obj[header] = cell;
|
|
147
|
+
});
|
|
148
|
+
return obj;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return { headers,
|
|
152
|
+
data };
|
|
153
|
+
}
|
|
@@ -5,7 +5,7 @@ import { isPropertyBuilder } from "@rebasepro/common";
|
|
|
5
5
|
import { unflattenObject } from "./file_to_json";
|
|
6
6
|
import { getIn } from "@rebasepro/forms";
|
|
7
7
|
import { inferTypeFromValue } from "@rebasepro/inference";
|
|
8
|
-
import { mergeDeep } from "@rebasepro/utils";
|
|
8
|
+
import { isPrototypePollutingKey, mergeDeep } from "@rebasepro/utils";
|
|
9
9
|
|
|
10
10
|
export function convertDataToEntity(authController: AuthController,
|
|
11
11
|
navigation: CollectionRegistryController,
|
|
@@ -57,6 +57,12 @@ export function convertDataToEntity(authController: AuthController,
|
|
|
57
57
|
|
|
58
58
|
export function flattenEntry(obj: Record<string, unknown>, parent = ""): Record<string, unknown> {
|
|
59
59
|
return Object.keys(obj).reduce<Record<string, unknown>>((acc, key) => {
|
|
60
|
+
// Keys come from the uploaded file, so `acc[key] = …` is the class-22
|
|
61
|
+
// primitive when the key is `__proto__`; refuse the column instead.
|
|
62
|
+
if (isPrototypePollutingKey(key)) {
|
|
63
|
+
console.warn(`Skipping column "${key}": a header may not reach the prototype chain`);
|
|
64
|
+
return acc;
|
|
65
|
+
}
|
|
60
66
|
const prefixedKey = parent ? `${parent}.${key}` : key;
|
|
61
67
|
|
|
62
68
|
if (typeof obj[key] === "object" && !(obj[key] instanceof Date) && obj[key] !== null && !Array.isArray(obj[key])) {
|
|
@@ -100,7 +106,15 @@ export function processValueMapping(authController: AuthController, value: any,
|
|
|
100
106
|
if (from === "array" && to === "array" && Array.isArray(value) && usedProperty.of && !Array.isArray(usedProperty.of) && !isPropertyBuilder(usedProperty.of)) {
|
|
101
107
|
return value.map(v => processValueMapping(authController, v, navigation, usedProperty.of as Property));
|
|
102
108
|
} else if (from === "string" && to === "number" && typeof value === "string") {
|
|
103
|
-
|
|
109
|
+
// `Number("")` is 0, so a blank cell used to import as a real zero — a
|
|
110
|
+
// price of nothing rather than a price nobody filled in — and `Number`
|
|
111
|
+
// of anything unreadable imported as NaN. Both are absent values, and
|
|
112
|
+
// saying so lets the importer's own validation see them. The `vector`
|
|
113
|
+
// branch above already draws the same distinction.
|
|
114
|
+
const trimmed = value.trim();
|
|
115
|
+
if (trimmed === "") return null;
|
|
116
|
+
const num = Number(trimmed);
|
|
117
|
+
return Number.isNaN(num) ? null : num;
|
|
104
118
|
} else if (from === "string" && to === "array" && typeof value === "string" && usedProperty.of && !Array.isArray(usedProperty.of) && !isPropertyBuilder(usedProperty.of)) {
|
|
105
119
|
return value.split(",").map((v: string) => processValueMapping(authController, v, navigation, usedProperty.of as Property));
|
|
106
120
|
} else if (from === "string" && to === "boolean") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import ExcelJS from "exceljs";
|
|
2
1
|
import { getWorksheetHeaders } from "./file_headers";
|
|
3
2
|
import { mapJsonParse, unflattenObject } from "./transforms";
|
|
3
|
+
import { parseCsvToObjects } from "./csv";
|
|
4
|
+
import { isPrototypePollutingKey } from "@rebasepro/utils";
|
|
4
5
|
|
|
5
6
|
// Re-export for backwards compat (tests import from this file)
|
|
6
7
|
export { unflattenObject } from "./transforms";
|
|
@@ -10,9 +11,78 @@ type ConversionResult = {
|
|
|
10
11
|
propertiesOrder: string[]
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* ExcelJS, fetched the first time somebody actually opens a workbook.
|
|
16
|
+
*
|
|
17
|
+
* It was a top-level `import ExcelJS from "exceljs"`, and that one line put
|
|
18
|
+
* 940 kB of spreadsheet reader into the admin's entry chunk — preloaded on the
|
|
19
|
+
* login screen, before anyone has authenticated, let alone clicked Import.
|
|
20
|
+
* `CollectionViewActions` lazy-loads the import and export actions and says so
|
|
21
|
+
* in a comment, but the package's own barrel (`src/index.ts` re-exports
|
|
22
|
+
* `./data_import`) puts this module back in the entry's static graph, so the
|
|
23
|
+
* `lazy()` bought nothing. A static import inside a module the barrel reaches
|
|
24
|
+
* is eager no matter what the component above it does; the only thing that
|
|
25
|
+
* makes a dependency lazy is importing it lazily, here.
|
|
26
|
+
*/
|
|
27
|
+
type ExcelJsModule = typeof import("exceljs");
|
|
28
|
+
|
|
29
|
+
let excelJsModule: Promise<ExcelJsModule> | undefined;
|
|
30
|
+
|
|
31
|
+
function loadExcelJs(): Promise<ExcelJsModule> {
|
|
32
|
+
// exceljs is CommonJS, so the namespace a bundler hands back wraps the real
|
|
33
|
+
// module under `default`. Native ESM (and the type declarations) put the
|
|
34
|
+
// members at the top level. Accept either.
|
|
35
|
+
excelJsModule ??= import("exceljs").then(mod => (mod as { default?: ExcelJsModule }).default ?? mod);
|
|
36
|
+
return excelJsModule;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether this file is delimited text rather than a workbook.
|
|
41
|
+
*
|
|
42
|
+
* Browsers report `text/csv`, `application/csv` or nothing at all for the same
|
|
43
|
+
* `.csv`, so the extension is the reliable half of the test.
|
|
44
|
+
*/
|
|
45
|
+
function isCsvFile(file: File): boolean {
|
|
46
|
+
const name = (file.name ?? "").toLowerCase();
|
|
47
|
+
if (name.endsWith(".csv") || name.endsWith(".tsv")) return true;
|
|
48
|
+
return file.type === "text/csv" || file.type === "application/csv";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Turn the parsed cells of one sheet/file into the entity-shaped rows the import maps. */
|
|
52
|
+
function toImportRows(rows: Array<Record<string, unknown>>): object[] {
|
|
53
|
+
return rows.map(mapJsonParse).map(unflattenObject);
|
|
54
|
+
}
|
|
55
|
+
|
|
13
56
|
export function convertFileToJson(file: File): Promise<ConversionResult> {
|
|
14
57
|
return new Promise((resolve, reject) => {
|
|
15
|
-
if (file
|
|
58
|
+
if (isCsvFile(file)) {
|
|
59
|
+
// Not the ExcelJS branch: `workbook.xlsx.load` is a zip reader, and
|
|
60
|
+
// every CSV — the format this admin exports by default — died on it
|
|
61
|
+
// with "Can't find end of central directory : is this a zip file ?".
|
|
62
|
+
console.debug("Converting CSV file to JSON", file.name);
|
|
63
|
+
const reader = new FileReader();
|
|
64
|
+
reader.onload = function (e) {
|
|
65
|
+
try {
|
|
66
|
+
const { headers, data } = parseCsvToObjects(e.target?.result as string);
|
|
67
|
+
if (headers.length === 0) {
|
|
68
|
+
reject(new Error("The CSV file is empty"));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
resolve({
|
|
72
|
+
data: toImportRows(data),
|
|
73
|
+
propertiesOrder: headers
|
|
74
|
+
});
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error("Error parsing CSV file", err);
|
|
77
|
+
reject(err);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
reader.onerror = () => reject(reader.error ?? new Error("Could not read the file"));
|
|
81
|
+
// Explicit UTF-8: the pre-ExcelJS implementation passed
|
|
82
|
+
// `codepage: 65001` for the same reason, and the browser's default
|
|
83
|
+
// guess mangles accented and CJK text.
|
|
84
|
+
reader.readAsText(file, "utf-8");
|
|
85
|
+
} else if (file.type === "application/json") {
|
|
16
86
|
console.debug("Converting JSON file to JSON", file.name);
|
|
17
87
|
const reader = new FileReader();
|
|
18
88
|
reader.onload = function (e) {
|
|
@@ -40,6 +110,7 @@ export function convertFileToJson(file: File): Promise<ConversionResult> {
|
|
|
40
110
|
reader.onload = async function (e) {
|
|
41
111
|
try {
|
|
42
112
|
const buffer = e.target?.result as ArrayBuffer;
|
|
113
|
+
const ExcelJS = await loadExcelJs();
|
|
43
114
|
const workbook = new ExcelJS.Workbook();
|
|
44
115
|
await workbook.xlsx.load(buffer);
|
|
45
116
|
const worksheet = workbook.worksheets[0];
|
|
@@ -57,17 +128,17 @@ export function convertFileToJson(file: File): Promise<ConversionResult> {
|
|
|
57
128
|
const obj: Record<string, any> = {};
|
|
58
129
|
row.eachCell({ includeEmpty: false }, (cell, colNumber) => {
|
|
59
130
|
const header = headers[colNumber - 1];
|
|
60
|
-
|
|
131
|
+
// A `__proto__` header would be the prototype setter
|
|
132
|
+
// here rather than a column; refused, as in `csv.ts`.
|
|
133
|
+
if (header && !isPrototypePollutingKey(header)) {
|
|
61
134
|
obj[header] = cell.value;
|
|
62
135
|
}
|
|
63
136
|
});
|
|
64
137
|
parsedData.push(obj);
|
|
65
138
|
});
|
|
66
139
|
|
|
67
|
-
const cleanedData = parsedData.map(mapJsonParse);
|
|
68
|
-
const jsonData = cleanedData.map(unflattenObject);
|
|
69
140
|
resolve({
|
|
70
|
-
data:
|
|
141
|
+
data: toImportRows(parsedData),
|
|
71
142
|
propertiesOrder: headers
|
|
72
143
|
});
|
|
73
144
|
} catch (err) {
|