@protobase/ui 0.19.4 → 0.19.5
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/account/backup-codes.js +2 -9
- package/dist/app/displays/list-displays.js +2 -1
- package/dist/app/filled-fields.js +5 -0
- package/dist/app/gallery/details-open-storage.js +10 -0
- package/dist/app/gallery/gallery-layout.js +3 -1
- package/dist/app/gallery/lightbox.js +107 -61
- package/dist/app/import/convert.js +151 -0
- package/dist/app/import/csv-parser.js +119 -0
- package/dist/app/import/failed-rows.js +21 -0
- package/dist/app/import/import-button.js +31 -0
- package/dist/app/import/import-check-step.js +123 -0
- package/dist/app/import/import-counts.js +19 -0
- package/dist/app/import/import-dialog.js +152 -0
- package/dist/app/import/import-file-step.js +65 -0
- package/dist/app/import/import-mapping-step.js +195 -0
- package/dist/app/import/import-result-step.js +86 -0
- package/dist/app/import/import-select.js +35 -0
- package/dist/app/import/locale-formats.js +15 -0
- package/dist/app/import/lookups.js +48 -0
- package/dist/app/import/mapping.js +78 -0
- package/dist/app/import/parse-worker.d.ts +1 -0
- package/dist/app/import/parse-worker.js +39 -0
- package/dist/app/import/prepare-rows.js +129 -0
- package/dist/app/import/run-import.js +130 -0
- package/dist/app/import/sheet-cell.js +17 -0
- package/dist/app/import/sheet-file.js +58 -0
- package/dist/app/import/sheet-source.js +72 -0
- package/dist/app/import/use-import-flow.js +225 -0
- package/dist/app/import/xlsx-reader.js +43 -0
- package/dist/app/import/xlsx-xml.js +169 -0
- package/dist/app/import/zip-reader.js +72 -0
- package/dist/app/list-page.js +8 -1
- package/dist/app/named-icon.js +9 -1
- package/dist/app/record-page.js +11 -2
- package/dist/auth/sign-in-prompt.js +1 -1
- package/dist/data/api-provider.js +8 -2
- package/dist/lib/save-file.js +12 -0
- package/dist/lib/use-focus-trap.js +7 -1
- package/dist/package.js +1 -1
- package/dist/primitives/sheet-swipe.js +5 -0
- package/dist/primitives/sheet.js +40 -12
- package/dist/record-view/key-facts.js +29 -0
- package/dist/shell/assets/app-CZ4drn7G.js +67 -0
- package/dist/shell/assets/index-BoeKjvo8.css +2 -0
- package/dist/shell/assets/{index-CEvfMgFJ.js → index-BzCCIfII.js} +1 -1
- package/dist/shell/assets/{maplibre-D6zfLfTV.js → maplibre-BOsAtlAo.js} +1 -1
- package/dist/shell/assets/parse-worker-BtcY0dQw.js +5 -0
- package/dist/shell/assets/{record-map-B6_rbCcG.js → record-map-BK_02Lmk.js} +1 -1
- package/dist/shell/assets/{records-map-bQbuIc13.js → records-map-Dn72A_l-.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-8pJgwjfc.js → shared-protobase-ui-B6lrj7eA.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-BYFiTXK6.js +0 -66
- package/dist/shell/assets/index-CiGP6kle.css +0 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Button } from "../primitives/button.js";
|
|
2
|
+
import { saveFile } from "../lib/save-file.js";
|
|
2
3
|
import { useState } from "react";
|
|
3
4
|
import { Check, Copy, Download } from "lucide-react";
|
|
4
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -26,19 +27,11 @@ const BackupCodes = ({ codes }) => {
|
|
|
26
27
|
children: [copied ? /* @__PURE__ */ jsx(Check, { className: "size-3.5 text-success" }) : /* @__PURE__ */ jsx(Copy, { className: "size-3.5" }), copied ? "Copied" : "Copy"]
|
|
27
28
|
}), /* @__PURE__ */ jsxs(Button, {
|
|
28
29
|
size: "sm",
|
|
29
|
-
onClick: () =>
|
|
30
|
+
onClick: () => saveFile(text, "backup-codes.txt"),
|
|
30
31
|
children: [/* @__PURE__ */ jsx(Download, { className: "size-3.5" }), "Download"]
|
|
31
32
|
})]
|
|
32
33
|
})]
|
|
33
34
|
});
|
|
34
35
|
};
|
|
35
|
-
const saveText = (text, name) => {
|
|
36
|
-
const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
|
|
37
|
-
Object.assign(document.createElement("a"), {
|
|
38
|
-
href: url,
|
|
39
|
-
download: name
|
|
40
|
-
}).click();
|
|
41
|
-
URL.revokeObjectURL(url);
|
|
42
|
-
};
|
|
43
36
|
//#endregion
|
|
44
37
|
export { BackupCodes };
|
|
@@ -2,6 +2,7 @@ import { recordKey, recordTitleField } from "../../live/model-helpers.js";
|
|
|
2
2
|
import { humanize } from "../../live/naming.js";
|
|
3
3
|
import { displayValue } from "../../live/display-value.js";
|
|
4
4
|
import { photosKey } from "../../live/row-photos.js";
|
|
5
|
+
import { filledFields } from "../filled-fields.js";
|
|
5
6
|
import { encodeKey, tableDisplay } from "@protobase/schema/internal";
|
|
6
7
|
//#region src/app/displays/list-displays.ts
|
|
7
8
|
const displaysOf = (view) => view?.list?.displays ?? [];
|
|
@@ -47,7 +48,7 @@ const cardFields = (model, view, display) => ({
|
|
|
47
48
|
flags: display.flags ?? []
|
|
48
49
|
});
|
|
49
50
|
/** The meta fields a card shows for a row: those with a value, so a listing for rent only shows no purchase price. */
|
|
50
|
-
const filledMeta = (row, meta) =>
|
|
51
|
+
const filledMeta = (row, meta) => filledFields(row.record, meta);
|
|
51
52
|
/** The amount field a card shows for a row: the first with a value, so a listing for rent only shows its rent. */
|
|
52
53
|
const filledAmount = (row, amount) => filledMeta(row, amount)[0];
|
|
53
54
|
/** A card's amount split for drawing: the number with its prefix, such as `€1,450`, apart from its suffix, such as ` /month`. */
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
//#region src/app/filled-fields.ts
|
|
2
|
+
/** The fields of `names` with a value in `record`: an empty one, null or an empty text, is left out, never shown as `—`. */
|
|
3
|
+
const filledFields = (record, names) => names.filter((name) => record[name] !== null && record[name] !== void 0 && record[name] !== "");
|
|
4
|
+
//#endregion
|
|
5
|
+
export { filledFields };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { guarded } from "../../lib/guarded.js";
|
|
2
|
+
//#region src/app/gallery/details-open-storage.ts
|
|
3
|
+
/** Where this browser remembers whether the lightbox opens with a photo's details shown. */
|
|
4
|
+
const detailsOpenStorageKey = "protobase:lightbox-details";
|
|
5
|
+
/** Whether the lightbox's details were left open in this browser; closed until someone opens them. */
|
|
6
|
+
const readDetailsOpen = (storage) => guarded(() => storage.getItem(detailsOpenStorageKey) === "true", false);
|
|
7
|
+
/** Remembers only open details, so the default (closed) costs no storage. */
|
|
8
|
+
const writeDetailsOpen = (storage, open) => guarded(() => open ? storage.setItem(detailsOpenStorageKey, "true") : storage.removeItem(detailsOpenStorageKey), void 0);
|
|
9
|
+
//#endregion
|
|
10
|
+
export { detailsOpenStorageKey, readDetailsOpen, writeDetailsOpen };
|
|
@@ -15,5 +15,7 @@ const swipeStep = (dx, dy) => Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)
|
|
|
15
15
|
const keyStep = (key) => key === "ArrowRight" ? 1 : key === "ArrowLeft" ? -1 : 0;
|
|
16
16
|
/** The photo a step from `index` lands on among `count`: it stops at the first and the last rather than wrapping around. */
|
|
17
17
|
const steppedPhoto = (index, by, count) => Math.min(Math.max(index + by, 0), Math.max(count - 1, 0));
|
|
18
|
+
/** Whether a key press toggles a photo's details: `i` alone, without Ctrl, Cmd or Alt, which belong to the browser. */
|
|
19
|
+
const togglesDetails = (event) => event.key === "i" && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
18
20
|
//#endregion
|
|
19
|
-
export { galleryTiles, keyStep, steppedPhoto, swipeStep };
|
|
21
|
+
export { galleryTiles, keyStep, steppedPhoto, swipeStep, togglesDetails };
|
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import { cn } from "../../lib/cn.js";
|
|
2
|
+
import { useIsDesktop } from "../../lib/use-media-query.js";
|
|
2
3
|
import { useFocusTrap } from "../../lib/use-focus-trap.js";
|
|
3
4
|
import { shownImage } from "../../live/file-values.js";
|
|
4
5
|
import { FileImage } from "../../live/file-image.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { Sheet } from "../../primitives/sheet.js";
|
|
7
|
+
import { keyStep, steppedPhoto, swipeStep, togglesDetails } from "./gallery-layout.js";
|
|
8
|
+
import { readDetailsOpen, writeDetailsOpen } from "./details-open-storage.js";
|
|
9
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
10
|
+
import { ChevronLeft, ChevronRight, Info, X } from "lucide-react";
|
|
8
11
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
9
12
|
//#region src/app/gallery/lightbox.tsx
|
|
10
13
|
const control = "flex size-10 shrink-0 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm transition-colors hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40";
|
|
11
14
|
/**
|
|
12
15
|
* Photos one at a time at full size over the page: a modal dialog with a counter, arrows and keys (left, right, Escape)
|
|
13
|
-
* that step through them, stopping at the first and the last, a sideways swipe on a touch screen
|
|
14
|
-
* read,
|
|
15
|
-
*
|
|
16
|
+
* that step through them, stopping at the first and the last, and a sideways swipe on a touch screen. For a photo with
|
|
17
|
+
* details the user may read, an info button, or the `i` key, shows them in a sidebar beside it, or in a sheet over the
|
|
18
|
+
* lower part of a mobile screen; this browser remembers whether they were left shown. The page does not scroll behind
|
|
19
|
+
* it, and focus returns to what opened it.
|
|
16
20
|
*/
|
|
17
21
|
const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsTitle = "Details" }) => {
|
|
18
22
|
const panel = useRef(null);
|
|
19
23
|
const start = useRef(void 0);
|
|
24
|
+
const sidebarId = useId();
|
|
25
|
+
const desktop = useIsDesktop();
|
|
26
|
+
const [detailsOpen, setDetailsOpen] = useState(() => readDetailsOpen(localStorage));
|
|
27
|
+
const [trapped, setTrapped] = useState(false);
|
|
20
28
|
useFocusTrap(panel, true, onClose);
|
|
29
|
+
useEffect(() => setTrapped(true), []);
|
|
21
30
|
const count = photos.length;
|
|
22
31
|
const step = (by) => {
|
|
23
32
|
const next = steppedPhoto(index, by, count);
|
|
@@ -28,7 +37,16 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsTitl
|
|
|
28
37
|
height: 1200
|
|
29
38
|
});
|
|
30
39
|
const shownDetails = details?.(index);
|
|
40
|
+
const hasDetails = shownDetails !== void 0;
|
|
41
|
+
const toggleDetails = (open) => {
|
|
42
|
+
setDetailsOpen(open);
|
|
43
|
+
writeDetailsOpen(localStorage, open);
|
|
44
|
+
};
|
|
31
45
|
const key = (event) => {
|
|
46
|
+
if (hasDetails && togglesDetails(event)) {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
return toggleDetails(!detailsOpen);
|
|
49
|
+
}
|
|
32
50
|
const by = keyStep(event.key);
|
|
33
51
|
if (by === 0) return;
|
|
34
52
|
event.preventDefault();
|
|
@@ -53,62 +71,90 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsTitl
|
|
|
53
71
|
"aria-label": title,
|
|
54
72
|
onKeyDown: key,
|
|
55
73
|
className: "fixed inset-0 z-[90] flex flex-col overflow-y-auto overscroll-contain bg-backdrop outline-none md:flex-row",
|
|
56
|
-
children: [
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
children: [
|
|
75
|
+
/* @__PURE__ */ jsxs("div", {
|
|
76
|
+
className: "flex h-dvh shrink-0 flex-col md:sticky md:top-0 md:min-w-0 md:flex-1",
|
|
77
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
78
|
+
className: "flex shrink-0 items-center justify-between gap-2 p-3",
|
|
79
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
80
|
+
className: "rounded-full bg-background/85 px-3 py-1 text-[13px] font-medium tabular-nums text-foreground",
|
|
81
|
+
"aria-live": "polite",
|
|
82
|
+
children: `${index + 1} / ${count}`
|
|
83
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
84
|
+
className: "flex items-center gap-2",
|
|
85
|
+
children: [hasDetails && /* @__PURE__ */ jsx("button", {
|
|
86
|
+
type: "button",
|
|
87
|
+
className: cn(control, "aria-expanded:bg-foreground aria-expanded:text-background"),
|
|
88
|
+
"aria-label": detailsTitle,
|
|
89
|
+
"aria-expanded": detailsOpen,
|
|
90
|
+
"aria-controls": desktop ? sidebarId : void 0,
|
|
91
|
+
"aria-keyshortcuts": "i",
|
|
92
|
+
onClick: () => toggleDetails(!detailsOpen),
|
|
93
|
+
children: /* @__PURE__ */ jsx(Info, {
|
|
94
|
+
className: "size-4",
|
|
95
|
+
"aria-hidden": true
|
|
96
|
+
})
|
|
97
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
98
|
+
type: "button",
|
|
99
|
+
className: control,
|
|
100
|
+
"aria-label": "Close",
|
|
101
|
+
onClick: onClose,
|
|
102
|
+
children: /* @__PURE__ */ jsx(X, {
|
|
103
|
+
className: "size-4",
|
|
104
|
+
"aria-hidden": true
|
|
105
|
+
})
|
|
106
|
+
})]
|
|
107
|
+
})]
|
|
108
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
109
|
+
className: "relative flex min-h-0 flex-1 touch-pan-y items-center justify-center px-3 pb-3 md:px-16",
|
|
110
|
+
onPointerDown: down,
|
|
111
|
+
onPointerUp: up,
|
|
112
|
+
onPointerCancel: () => start.current = void 0,
|
|
113
|
+
children: [image && /* @__PURE__ */ jsx(FileImage, {
|
|
114
|
+
image,
|
|
115
|
+
className: "max-h-full max-w-full select-none object-contain"
|
|
116
|
+
}, image.url), count > 1 && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
|
|
117
|
+
type: "button",
|
|
118
|
+
className: cn(control, "absolute left-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
|
|
119
|
+
"aria-label": "Previous photo",
|
|
120
|
+
"aria-disabled": index === 0,
|
|
121
|
+
onClick: () => step(-1),
|
|
122
|
+
children: /* @__PURE__ */ jsx(ChevronLeft, {
|
|
123
|
+
className: "size-5",
|
|
124
|
+
"aria-hidden": true
|
|
125
|
+
})
|
|
126
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
127
|
+
type: "button",
|
|
128
|
+
className: cn(control, "absolute right-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
|
|
129
|
+
"aria-label": "Next photo",
|
|
130
|
+
"aria-disabled": index === count - 1,
|
|
131
|
+
onClick: () => step(1),
|
|
132
|
+
children: /* @__PURE__ */ jsx(ChevronRight, {
|
|
133
|
+
className: "size-5",
|
|
134
|
+
"aria-hidden": true
|
|
135
|
+
})
|
|
136
|
+
})] })]
|
|
73
137
|
})]
|
|
74
|
-
}),
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
children: [
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
className: cn(control, "absolute right-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
|
|
95
|
-
"aria-label": "Next photo",
|
|
96
|
-
"aria-disabled": index === count - 1,
|
|
97
|
-
onClick: () => step(1),
|
|
98
|
-
children: /* @__PURE__ */ jsx(ChevronRight, {
|
|
99
|
-
className: "size-5",
|
|
100
|
-
"aria-hidden": true
|
|
101
|
-
})
|
|
102
|
-
})] })]
|
|
103
|
-
})]
|
|
104
|
-
}), shownDetails !== void 0 && /* @__PURE__ */ jsxs("aside", {
|
|
105
|
-
"aria-label": detailsTitle,
|
|
106
|
-
className: "shrink-0 border-t border-border bg-background p-5 text-foreground md:min-h-dvh md:w-[360px] md:border-l md:border-t-0",
|
|
107
|
-
children: [/* @__PURE__ */ jsx("h2", {
|
|
108
|
-
className: "mb-3 text-[15px] font-semibold",
|
|
109
|
-
children: detailsTitle
|
|
110
|
-
}), shownDetails]
|
|
111
|
-
})]
|
|
138
|
+
}),
|
|
139
|
+
hasDetails && detailsOpen && desktop && /* @__PURE__ */ jsxs("aside", {
|
|
140
|
+
id: sidebarId,
|
|
141
|
+
"aria-label": detailsTitle,
|
|
142
|
+
className: "min-h-dvh w-[360px] shrink-0 border-l border-border bg-background p-5 text-foreground",
|
|
143
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
144
|
+
className: "mb-3 text-[15px] font-semibold",
|
|
145
|
+
children: detailsTitle
|
|
146
|
+
}), shownDetails]
|
|
147
|
+
}),
|
|
148
|
+
/* @__PURE__ */ jsx(Sheet, {
|
|
149
|
+
title: detailsTitle,
|
|
150
|
+
open: hasDetails && detailsOpen && !desktop && trapped,
|
|
151
|
+
onClose: () => toggleDetails(false),
|
|
152
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
153
|
+
className: "pt-3",
|
|
154
|
+
children: shownDetails
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
]
|
|
112
158
|
});
|
|
113
159
|
};
|
|
114
160
|
//#endregion
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { cellText, isEmptyCell, isSheetDate } from "./sheet-cell.js";
|
|
2
|
+
import { enumLabel } from "@protobase/schema";
|
|
3
|
+
//#region src/app/import/convert.ts
|
|
4
|
+
const ok = (value) => ({
|
|
5
|
+
ok: true,
|
|
6
|
+
value
|
|
7
|
+
});
|
|
8
|
+
const problem = (text) => ({
|
|
9
|
+
ok: false,
|
|
10
|
+
problem: text
|
|
11
|
+
});
|
|
12
|
+
const quoted = (cell) => `“${cellText(cell)}”`;
|
|
13
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
+
/**
|
|
15
|
+
* A number written as text, as a plain decimal (`-1234.5`): digits grouped by thousands with the other separator or
|
|
16
|
+
* spaces, and the `decimal` separator before the fraction. Undefined when the text is no such number.
|
|
17
|
+
*/
|
|
18
|
+
const parseNumberText = (text, decimal) => {
|
|
19
|
+
const group = `[${escapeRegExp(decimal === "." ? "," : ".")}\\s\\u00a0\\u202f']`;
|
|
20
|
+
const match = new RegExp(`^([-+]?)(\\d{1,3}(?:${group}\\d{3})+|\\d+)(?:${escapeRegExp(decimal)}(\\d+))?$`).exec(text.trim());
|
|
21
|
+
if (match) return `${match[1] === "-" ? "-" : ""}${match[2].replace(/\D/g, "")}${match[3] ? `.${match[3]}` : ""}`;
|
|
22
|
+
if (decimal === "." && /^[-+]?\d+(\.\d+)?e[-+]?\d+$/i.test(text.trim())) return String(Number(text.trim()));
|
|
23
|
+
};
|
|
24
|
+
const toNumberText = (cell, options) => {
|
|
25
|
+
if (typeof cell === "number") return String(cell);
|
|
26
|
+
if (typeof cell === "string") return parseNumberText(cell, options.decimal);
|
|
27
|
+
};
|
|
28
|
+
const integerValue = (field, cell, options) => {
|
|
29
|
+
const text = toNumberText(cell, options);
|
|
30
|
+
if (text === void 0) return problem(`${quoted(cell)} is not a number`);
|
|
31
|
+
const [whole, fraction = ""] = text.split(".");
|
|
32
|
+
if (/[1-9]/.test(fraction) || /e/i.test(text)) return problem(`${quoted(cell)} is not a whole number`);
|
|
33
|
+
if (field.type === "bigint") return ok(String(BigInt(whole)));
|
|
34
|
+
const value = Number(whole);
|
|
35
|
+
return Number.isSafeInteger(value) ? ok(value) : problem(`${quoted(cell)} is too large`);
|
|
36
|
+
};
|
|
37
|
+
const decimalValue = (cell, options) => {
|
|
38
|
+
const text = toNumberText(cell, options);
|
|
39
|
+
return text === void 0 ? problem(`${quoted(cell)} is not a number`) : ok(text);
|
|
40
|
+
};
|
|
41
|
+
const trueWords = /* @__PURE__ */ new Set([
|
|
42
|
+
"true",
|
|
43
|
+
"yes",
|
|
44
|
+
"1"
|
|
45
|
+
]);
|
|
46
|
+
const falseWords = /* @__PURE__ */ new Set([
|
|
47
|
+
"false",
|
|
48
|
+
"no",
|
|
49
|
+
"0"
|
|
50
|
+
]);
|
|
51
|
+
const booleanValue = (cell) => {
|
|
52
|
+
if (typeof cell === "boolean") return ok(cell);
|
|
53
|
+
const text = typeof cell === "string" || typeof cell === "number" ? String(cell).trim().toLowerCase() : "";
|
|
54
|
+
if (trueWords.has(text)) return ok(true);
|
|
55
|
+
if (falseWords.has(text)) return ok(false);
|
|
56
|
+
return problem(`${quoted(cell)} is not true or false (true, false, yes, no, 1 or 0)`);
|
|
57
|
+
};
|
|
58
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
59
|
+
const calendarDate = (year, month, day) => {
|
|
60
|
+
const at = new Date(Date.UTC(year, month - 1, day));
|
|
61
|
+
if (at.getUTCFullYear() !== year || at.getUTCMonth() !== month - 1 || at.getUTCDate() !== day) return void 0;
|
|
62
|
+
return `${year}-${pad(month)}-${pad(day)}`;
|
|
63
|
+
};
|
|
64
|
+
const timePattern = "(\\d{1,2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?";
|
|
65
|
+
const readWallTime = (text, order) => {
|
|
66
|
+
const iso = new RegExp(`^(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})(?:[T ]${timePattern}(Z|[+-]\\d{2}:?\\d{2})?)?$`).exec(text.trim());
|
|
67
|
+
const local = new RegExp(`^(\\d{1,2})[./-](\\d{1,2})[./-](\\d{4})(?: ${timePattern})?$`).exec(text.trim());
|
|
68
|
+
if (!iso && !local) return void 0;
|
|
69
|
+
const [year, month, day] = iso ? [
|
|
70
|
+
iso[1],
|
|
71
|
+
iso[2],
|
|
72
|
+
iso[3]
|
|
73
|
+
].map(Number) : order === "dmy" ? [
|
|
74
|
+
local[3],
|
|
75
|
+
local[2],
|
|
76
|
+
local[1]
|
|
77
|
+
].map(Number) : [
|
|
78
|
+
local[3],
|
|
79
|
+
local[1],
|
|
80
|
+
local[2]
|
|
81
|
+
].map(Number);
|
|
82
|
+
const date = calendarDate(year, month, day);
|
|
83
|
+
if (!date) return void 0;
|
|
84
|
+
const [hours, minutes, seconds = "0"] = iso ? iso.slice(4, 7) : local.slice(4, 7);
|
|
85
|
+
if (hours === void 0) return { date };
|
|
86
|
+
if (Number(hours) > 23 || Number(minutes) > 59 || Number(seconds) > 59) return void 0;
|
|
87
|
+
return {
|
|
88
|
+
date,
|
|
89
|
+
time: `${pad(Number(hours))}:${minutes}:${pad(Number(seconds))}`,
|
|
90
|
+
zone: iso?.[7]
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
const wallTimeOf = (cell, options) => {
|
|
94
|
+
if (isSheetDate(cell)) {
|
|
95
|
+
const [date, time] = cell.date.split("T");
|
|
96
|
+
return {
|
|
97
|
+
date,
|
|
98
|
+
time
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return typeof cell === "string" ? readWallTime(cell, options.dateOrder) : void 0;
|
|
102
|
+
};
|
|
103
|
+
const notDate = (cell, options) => problem(`${quoted(cell)} is not a date (2026-04-03 or ${options.dateOrder === "dmy" ? "day/month/year" : "month/day/year"})`);
|
|
104
|
+
const dateValue = (cell, options) => {
|
|
105
|
+
const at = wallTimeOf(cell, options);
|
|
106
|
+
if (!at) return notDate(cell, options);
|
|
107
|
+
if (at.time && at.time !== "00:00:00") return problem(`${quoted(cell)} has a time of day; the field holds a date`);
|
|
108
|
+
return ok(at.date);
|
|
109
|
+
};
|
|
110
|
+
const timestampValue = (cell, options) => {
|
|
111
|
+
const at = wallTimeOf(cell, options);
|
|
112
|
+
if (!at) return notDate(cell, options);
|
|
113
|
+
if (at.zone) return ok((/* @__PURE__ */ new Date(`${at.date}T${at.time}${at.zone === "Z" ? "Z" : at.zone.replace(/^([+-]\d{2})(\d{2})$/, "$1:$2")}`)).toISOString());
|
|
114
|
+
const [year, month, day] = at.date.split("-").map(Number);
|
|
115
|
+
const [hours, minutes, seconds] = (at.time ?? "00:00:00").split(":").map(Number);
|
|
116
|
+
return ok(new Date(year, month - 1, day, hours, minutes, seconds).toISOString());
|
|
117
|
+
};
|
|
118
|
+
const normalized = (text) => text.trim().toLowerCase().replace(/\s+/g, " ");
|
|
119
|
+
const enumValue = (field, hints, cell) => {
|
|
120
|
+
const values = field.enumValues ?? [];
|
|
121
|
+
const text = normalized(cellText(cell));
|
|
122
|
+
const matches = values.filter((value) => normalized(value) === text || normalized(enumLabel(value, hints?.valueLabels)) === text);
|
|
123
|
+
if (matches.length === 1) return ok(matches[0]);
|
|
124
|
+
if (matches.length > 1) return problem(`${quoted(cell)} matches more than one value: ${matches.map((value) => enumLabel(value, hints?.valueLabels)).join(", ")}`);
|
|
125
|
+
return problem(`${quoted(cell)} is not one of ${values.map((value) => enumLabel(value, hints?.valueLabels)).join(", ")}`);
|
|
126
|
+
};
|
|
127
|
+
const textValue = (cell) => typeof cell === "boolean" ? problem(`${quoted(cell)} is true or false, not text`) : ok(cellText(cell));
|
|
128
|
+
/**
|
|
129
|
+
* The value a cell gives a field, as the API takes it, or the problem that keeps it from being one: never a guess, and an
|
|
130
|
+
* empty cell is null, never a default. A relation's cell is the value of the field its records are found by, so it
|
|
131
|
+
* converts as that field (`convertCell(targetField, ...)`).
|
|
132
|
+
*/
|
|
133
|
+
const convertCell = (field, hints, cell, options) => {
|
|
134
|
+
if (isEmptyCell(cell)) return field.nullable ? ok(null) : problem("is empty, and it is required");
|
|
135
|
+
switch (field.type) {
|
|
136
|
+
case "integer":
|
|
137
|
+
case "bigint": return integerValue(field, cell, options);
|
|
138
|
+
case "decimal": return decimalValue(cell, options);
|
|
139
|
+
case "boolean": return booleanValue(cell);
|
|
140
|
+
case "date": return dateValue(cell, options);
|
|
141
|
+
case "timestamp": return timestampValue(cell, options);
|
|
142
|
+
case "enum": return enumValue(field, hints, cell);
|
|
143
|
+
case "text":
|
|
144
|
+
case "uuid":
|
|
145
|
+
case "currency":
|
|
146
|
+
case "country": return textValue(cell);
|
|
147
|
+
default: throw new Error(`Not implemented: importing ${field.type} fields, which importableFields leaves out`);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
export { convertCell, parseNumberText };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
//#region src/app/import/csv-parser.ts
|
|
2
|
+
const delimiters = [
|
|
3
|
+
",",
|
|
4
|
+
";",
|
|
5
|
+
" "
|
|
6
|
+
];
|
|
7
|
+
/**
|
|
8
|
+
* The delimiter of a CSV's header line: whichever of comma, semicolon and tab it holds most often outside quotes, a
|
|
9
|
+
* comma when it holds none (a file of one column).
|
|
10
|
+
*/
|
|
11
|
+
const detectDelimiter = (headerLine) => {
|
|
12
|
+
const counts = new Map(delimiters.map((delimiter) => [delimiter, 0]));
|
|
13
|
+
let quoted = false;
|
|
14
|
+
for (const char of headerLine) if (char === "\"") quoted = !quoted;
|
|
15
|
+
else if (!quoted && counts.has(char)) counts.set(char, counts.get(char) + 1);
|
|
16
|
+
return [...counts].reduce((best, entry) => entry[1] > best[1] ? entry : best, [",", 0])[0];
|
|
17
|
+
};
|
|
18
|
+
const firstLine = (text) => {
|
|
19
|
+
let quoted = false;
|
|
20
|
+
for (let index = 0; index < text.length; index++) {
|
|
21
|
+
const char = text[index];
|
|
22
|
+
if (char === "\"") quoted = !quoted;
|
|
23
|
+
else if (!quoted && (char === "\n" || char === "\r")) return text.slice(0, index);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* A CSV parser fed text a chunk at a time (RFC 4180: quoted fields with `""` for a quote and line breaks inside, CRLF,
|
|
28
|
+
* LF or CR between rows), so a large file is read without holding it whole. `push` returns the rows completed by the
|
|
29
|
+
* chunk, `end` the last one. The delimiter is the header line's (`detectDelimiter`) unless given; a byte order mark at
|
|
30
|
+
* the start is dropped.
|
|
31
|
+
*/
|
|
32
|
+
const createCsvParser = (given) => {
|
|
33
|
+
let delimiter = given;
|
|
34
|
+
let pending = "";
|
|
35
|
+
let started = false;
|
|
36
|
+
let field = "";
|
|
37
|
+
let row = [];
|
|
38
|
+
let quoted = false;
|
|
39
|
+
let quoteSeen = false;
|
|
40
|
+
let crSeen = false;
|
|
41
|
+
let rowStarted = false;
|
|
42
|
+
const endField = () => {
|
|
43
|
+
row.push(field);
|
|
44
|
+
field = "";
|
|
45
|
+
};
|
|
46
|
+
const endRow = (rows) => {
|
|
47
|
+
endField();
|
|
48
|
+
rows.push(row);
|
|
49
|
+
row = [];
|
|
50
|
+
rowStarted = false;
|
|
51
|
+
};
|
|
52
|
+
const consume = (text) => {
|
|
53
|
+
const rows = [];
|
|
54
|
+
for (const char of text) {
|
|
55
|
+
if (crSeen) {
|
|
56
|
+
crSeen = false;
|
|
57
|
+
if (char === "\n") continue;
|
|
58
|
+
}
|
|
59
|
+
if (quoted) {
|
|
60
|
+
if (quoteSeen) {
|
|
61
|
+
quoteSeen = false;
|
|
62
|
+
if (char === "\"") {
|
|
63
|
+
field += "\"";
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
quoted = false;
|
|
67
|
+
} else if (char === "\"") {
|
|
68
|
+
quoteSeen = true;
|
|
69
|
+
continue;
|
|
70
|
+
} else {
|
|
71
|
+
field += char;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (char === "\"" && field === "") {
|
|
76
|
+
quoted = true;
|
|
77
|
+
rowStarted = true;
|
|
78
|
+
} else if (char === delimiter) {
|
|
79
|
+
endField();
|
|
80
|
+
rowStarted = true;
|
|
81
|
+
} else if (char === "\n" || char === "\r") {
|
|
82
|
+
crSeen = char === "\r";
|
|
83
|
+
endRow(rows);
|
|
84
|
+
} else {
|
|
85
|
+
field += char;
|
|
86
|
+
rowStarted = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return rows;
|
|
90
|
+
};
|
|
91
|
+
const push = (chunk) => {
|
|
92
|
+
if (started) return consume(chunk);
|
|
93
|
+
pending += chunk;
|
|
94
|
+
if (pending.charCodeAt(0) === 65279) pending = pending.slice(1);
|
|
95
|
+
const header = firstLine(pending);
|
|
96
|
+
if (header === void 0) return [];
|
|
97
|
+
started = true;
|
|
98
|
+
delimiter ??= detectDelimiter(header);
|
|
99
|
+
const text = pending;
|
|
100
|
+
pending = "";
|
|
101
|
+
return consume(text);
|
|
102
|
+
};
|
|
103
|
+
const end = () => {
|
|
104
|
+
const rows = started ? [] : push("\n");
|
|
105
|
+
if (quoteSeen) {
|
|
106
|
+
quoteSeen = false;
|
|
107
|
+
quoted = false;
|
|
108
|
+
}
|
|
109
|
+
if (rowStarted || field !== "") endRow(rows);
|
|
110
|
+
return rows;
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
push,
|
|
114
|
+
end,
|
|
115
|
+
delimiter: () => delimiter
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
//#endregion
|
|
119
|
+
export { createCsvParser, detectDelimiter };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { cellText } from "./sheet-cell.js";
|
|
2
|
+
//#region src/app/import/failed-rows.ts
|
|
3
|
+
const csvField = (text) => /[",\r\n]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
|
|
4
|
+
/**
|
|
5
|
+
* The rows an import did not write as a CSV file to fix and import again: the file's own columns, then the row's line in
|
|
6
|
+
* the file and why it was not written. Sorted by line, with a byte order mark so spreadsheet programs read it as UTF-8.
|
|
7
|
+
*/
|
|
8
|
+
const failedRowsCsv = (headers, rows) => {
|
|
9
|
+
const lines = [...rows].sort((a, b) => a.line - b.line).map((row) => [
|
|
10
|
+
...headers.map((_, column) => cellText(row.cells[column] ?? null)),
|
|
11
|
+
String(row.line),
|
|
12
|
+
row.problems.join("; ")
|
|
13
|
+
]);
|
|
14
|
+
return `${[[
|
|
15
|
+
...headers,
|
|
16
|
+
"Line",
|
|
17
|
+
"Problems"
|
|
18
|
+
], ...lines].map((fields) => fields.map(csvField).join(",")).join("\r\n")}\r\n`;
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
export { failedRowsCsv };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
|
+
import { useAdminMeta, usePermissions } from "../meta-gate.js";
|
|
3
|
+
import { can } from "../permissions.js";
|
|
4
|
+
import { ImportDialog } from "./import-dialog.js";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { Upload } from "lucide-react";
|
|
7
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
//#region src/app/import/import-button.tsx
|
|
9
|
+
/**
|
|
10
|
+
* The list page's Import button, for a user who may create records of a resource whose view does not turn it off
|
|
11
|
+
* (`.import(false)`), and the import dialog it opens. The server checks every write as it would a single one.
|
|
12
|
+
*/
|
|
13
|
+
const ImportButton = ({ model }) => {
|
|
14
|
+
const { resources, views } = useAdminMeta();
|
|
15
|
+
const permissions = usePermissions(model.name);
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
if (views[model.name]?.import === false || !can(permissions, "create")) return null;
|
|
18
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
|
|
19
|
+
onClick: () => setOpen(true),
|
|
20
|
+
"aria-haspopup": "dialog",
|
|
21
|
+
children: [/* @__PURE__ */ jsx(Upload, { className: "size-3.5" }), "Import"]
|
|
22
|
+
}), open && /* @__PURE__ */ jsx(ImportDialog, {
|
|
23
|
+
model,
|
|
24
|
+
resources,
|
|
25
|
+
views,
|
|
26
|
+
canUpdate: can(permissions, "update"),
|
|
27
|
+
onClose: () => setOpen(false)
|
|
28
|
+
})] });
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { ImportButton };
|