@protobase/ui 0.19.3 → 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.
Files changed (66) hide show
  1. package/dist/account/backup-codes.js +2 -9
  2. package/dist/app/displays/list-displays.js +2 -1
  3. package/dist/app/displays/map-display.js +3 -1
  4. package/dist/app/filled-fields.js +5 -0
  5. package/dist/app/gallery/details-open-storage.js +10 -0
  6. package/dist/app/gallery/gallery-layout.js +3 -1
  7. package/dist/app/gallery/lightbox.js +107 -61
  8. package/dist/app/import/convert.js +151 -0
  9. package/dist/app/import/csv-parser.js +119 -0
  10. package/dist/app/import/failed-rows.js +21 -0
  11. package/dist/app/import/import-button.js +31 -0
  12. package/dist/app/import/import-check-step.js +123 -0
  13. package/dist/app/import/import-counts.js +19 -0
  14. package/dist/app/import/import-dialog.js +152 -0
  15. package/dist/app/import/import-file-step.js +65 -0
  16. package/dist/app/import/import-mapping-step.js +195 -0
  17. package/dist/app/import/import-result-step.js +86 -0
  18. package/dist/app/import/import-select.js +35 -0
  19. package/dist/app/import/locale-formats.js +15 -0
  20. package/dist/app/import/lookups.js +48 -0
  21. package/dist/app/import/mapping.js +78 -0
  22. package/dist/app/import/parse-worker.d.ts +1 -0
  23. package/dist/app/import/parse-worker.js +39 -0
  24. package/dist/app/import/prepare-rows.js +129 -0
  25. package/dist/app/import/run-import.js +130 -0
  26. package/dist/app/import/sheet-cell.js +17 -0
  27. package/dist/app/import/sheet-file.js +58 -0
  28. package/dist/app/import/sheet-source.js +72 -0
  29. package/dist/app/import/use-import-flow.js +225 -0
  30. package/dist/app/import/xlsx-reader.js +43 -0
  31. package/dist/app/import/xlsx-xml.js +169 -0
  32. package/dist/app/import/zip-reader.js +72 -0
  33. package/dist/app/list-page.js +8 -1
  34. package/dist/app/map/map-layer-choice.js +11 -0
  35. package/dist/app/map/map-layer-switch.js +55 -0
  36. package/dist/app/map/map-section.js +3 -1
  37. package/dist/app/map/position-areas.js +4 -2
  38. package/dist/app/map/record-map.js +16 -11
  39. package/dist/app/map/records-map.js +7 -2
  40. package/dist/app/named-icon.js +9 -1
  41. package/dist/app/record-page.js +11 -2
  42. package/dist/auth/sign-in-prompt.js +1 -1
  43. package/dist/data/api-provider.js +8 -2
  44. package/dist/data/meta-data.d.ts +3 -2
  45. package/dist/lib/save-file.js +12 -0
  46. package/dist/lib/use-focus-trap.js +7 -1
  47. package/dist/package.js +1 -1
  48. package/dist/primitives/sheet-swipe.js +5 -0
  49. package/dist/primitives/sheet.js +40 -12
  50. package/dist/record-view/key-facts.js +29 -0
  51. package/dist/shell/assets/app-CZ4drn7G.js +67 -0
  52. package/dist/shell/assets/index-BoeKjvo8.css +2 -0
  53. package/dist/shell/assets/{index-5frCs7fQ.js → index-BzCCIfII.js} +1 -1
  54. package/dist/shell/assets/maplibre-BOsAtlAo.js +814 -0
  55. package/dist/shell/assets/parse-worker-BtcY0dQw.js +5 -0
  56. package/dist/shell/assets/record-map-BK_02Lmk.js +1 -0
  57. package/dist/shell/assets/records-map-Dn72A_l-.js +1 -0
  58. package/dist/shell/assets/{shared-protobase-ui-Ceamz_7l.js → shared-protobase-ui-B6lrj7eA.js} +1 -1
  59. package/dist/shell/index.html +4 -4
  60. package/dist/shell/protobase.shell.json +1 -1
  61. package/package.json +4 -4
  62. package/dist/shell/assets/app-ChEqQ9ca.js +0 -66
  63. package/dist/shell/assets/index-D-4IHhqX.css +0 -2
  64. package/dist/shell/assets/maplibre-D8plsjP2.js +0 -814
  65. package/dist/shell/assets/record-map-BDJTOwZo.js +0 -1
  66. package/dist/shell/assets/records-map-C-XKw6m1.js +0 -1
@@ -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: () => saveText(text, "backup-codes.txt"),
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) => meta.filter((name) => row.record[name] !== null && row.record[name] !== void 0 && row.record[name] !== "");
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`. */
@@ -25,7 +25,8 @@ const arrow = "flex size-8 items-center justify-center rounded-full border borde
25
25
  * elsewhere clears it. A marker and a card whose record's position is approximate say so.
26
26
  */
27
27
  const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove, footer = true, flush = false }) => {
28
- const style = useAdminMeta().maps?.style;
28
+ const maps = useAdminMeta().maps;
29
+ const style = maps?.style;
29
30
  const [chosen, setChosen] = useState();
30
31
  const [area, setArea] = useState();
31
32
  const settled = useDebounced(area, settleMs);
@@ -70,6 +71,7 @@ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove,
70
71
  fallback: null,
71
72
  children: /* @__PURE__ */ jsx(RecordsMap, {
72
73
  style,
74
+ layers: maps?.layers,
73
75
  initial: at,
74
76
  points,
75
77
  buckets,
@@ -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 { keyStep, steppedPhoto, swipeStep } from "./gallery-layout.js";
6
- import { useRef } from "react";
7
- import { ChevronLeft, ChevronRight, X } from "lucide-react";
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, and, for a photo with details the user may
14
- * read, those details in a sidebar beside it, or below it on a mobile screen, where the dialog scrolls down to them. The
15
- * page does not scroll behind it, and focus returns to what opened it.
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: [/* @__PURE__ */ jsxs("div", {
57
- className: cn("flex shrink-0 flex-col md:sticky md:top-0 md:h-dvh md:min-w-0 md:flex-1", shownDetails === void 0 ? "h-dvh" : "h-[85svh]"),
58
- children: [/* @__PURE__ */ jsxs("div", {
59
- className: "flex shrink-0 items-center justify-between gap-2 p-3",
60
- children: [/* @__PURE__ */ jsx("span", {
61
- className: "rounded-full bg-background/85 px-3 py-1 text-[13px] font-medium tabular-nums text-foreground",
62
- "aria-live": "polite",
63
- children: `${index + 1} / ${count}`
64
- }), /* @__PURE__ */ jsx("button", {
65
- type: "button",
66
- className: control,
67
- "aria-label": "Close",
68
- onClick: onClose,
69
- children: /* @__PURE__ */ jsx(X, {
70
- className: "size-4",
71
- "aria-hidden": true
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
- }), /* @__PURE__ */ jsxs("div", {
75
- className: "relative flex min-h-0 flex-1 touch-pan-y items-center justify-center px-3 pb-3 md:px-16",
76
- onPointerDown: down,
77
- onPointerUp: up,
78
- onPointerCancel: () => start.current = void 0,
79
- children: [image && /* @__PURE__ */ jsx(FileImage, {
80
- image,
81
- className: "max-h-full max-w-full select-none object-contain"
82
- }, image.url), count > 1 && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
83
- type: "button",
84
- className: cn(control, "absolute left-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
85
- "aria-label": "Previous photo",
86
- "aria-disabled": index === 0,
87
- onClick: () => step(-1),
88
- children: /* @__PURE__ */ jsx(ChevronLeft, {
89
- className: "size-5",
90
- "aria-hidden": true
91
- })
92
- }), /* @__PURE__ */ jsx("button", {
93
- type: "button",
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 };