@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
@@ -0,0 +1,225 @@
1
+ import { useResourceClients } from "../../data/api-provider.js";
2
+ import { autoMapping, importableFields, matchFields } from "./mapping.js";
3
+ import { checkImport, readHead, runImport } from "./run-import.js";
4
+ import { localeFormats } from "./locale-formats.js";
5
+ import { createLookups } from "./lookups.js";
6
+ import { workerSheetSource } from "./sheet-source.js";
7
+ import { useMemo, useRef, useState } from "react";
8
+ import { useQueryClient } from "@tanstack/react-query";
9
+ //#region src/app/import/use-import-flow.ts
10
+ const emptyProgress = {
11
+ created: 0,
12
+ updated: 0,
13
+ skipped: 0,
14
+ failed: 0,
15
+ notWritten: []
16
+ };
17
+ /** The state of an import, from choosing the file to its summary, and the moves between its steps. */
18
+ const useImportFlow = ({ model, resources, views, sourceFor = workerSheetSource }) => {
19
+ const clientFor = useResourceClients();
20
+ const queryClient = useQueryClient();
21
+ const view = views[model.name];
22
+ const fields = useMemo(() => importableFields(model, resources), [model, resources]);
23
+ const [step, setStep] = useState({
24
+ kind: "file",
25
+ reading: false
26
+ });
27
+ const [error, setError] = useState();
28
+ const [file, setFile] = useState();
29
+ const [sheet, setSheet] = useState();
30
+ const [head, setHead] = useState();
31
+ const [mode, setMode] = useState("create");
32
+ const [columns, setColumns] = useState([]);
33
+ const [match, setMatch] = useState();
34
+ const [options, setOptions] = useState(() => localeFormats());
35
+ const cancelled = useRef(false);
36
+ const checking = useRef(void 0);
37
+ const fail = (next) => (cause) => {
38
+ setError(cause);
39
+ setStep(next);
40
+ };
41
+ /** Reads the chosen file's sheet names; a CSV, or a workbook of one sheet, goes on to its columns. */
42
+ const choose = (chosen) => {
43
+ setError(void 0);
44
+ setStep({
45
+ kind: "file",
46
+ reading: true
47
+ });
48
+ const source = sourceFor(chosen);
49
+ source.sheets().then((sheets) => {
50
+ setFile({
51
+ file: chosen,
52
+ source,
53
+ sheets
54
+ });
55
+ setSheet(sheets[0]);
56
+ if (sheets.length <= 1) return open(source, sheets[0]);
57
+ setStep({
58
+ kind: "file",
59
+ reading: false
60
+ });
61
+ }).catch(fail({
62
+ kind: "file",
63
+ reading: false
64
+ }));
65
+ };
66
+ const open = async (source, name) => {
67
+ setStep({
68
+ kind: "file",
69
+ reading: true
70
+ });
71
+ const read = await readHead(source, name);
72
+ setHead(read);
73
+ setColumns(autoMapping(read.headers, fields, view));
74
+ const key = matchFields(model)[0];
75
+ const keyColumn = key ? autoMapping(read.headers, [key], view)[0] : void 0;
76
+ setMatch(key && keyColumn ? {
77
+ field: key.name,
78
+ column: keyColumn.column
79
+ } : void 0);
80
+ setStep({ kind: "map" });
81
+ };
82
+ const openSheet = () => {
83
+ if (!file) return;
84
+ setError(void 0);
85
+ open(file.source, sheet).catch(fail({
86
+ kind: "file",
87
+ reading: false
88
+ }));
89
+ };
90
+ /** Maps the column to the field, or skips it without one; a field fills one column, so another that had it is skipped. */
91
+ const mapColumn = (column, field) => setColumns((current) => {
92
+ const others = current.filter((entry) => entry.column !== column && entry.field !== field);
93
+ return field ? [...others, {
94
+ column,
95
+ field
96
+ }].sort((a, b) => a.column - b.column) : others;
97
+ });
98
+ /** The field of the related record a relation column is found by. */
99
+ const matchColumnBy = (column, matchBy) => setColumns((current) => current.map((entry) => entry.column === column ? {
100
+ ...entry,
101
+ matchBy
102
+ } : entry));
103
+ const plan = () => ({
104
+ mode,
105
+ columns,
106
+ match: mode === "create" ? void 0 : match,
107
+ options
108
+ });
109
+ const context = () => ({
110
+ model,
111
+ resources,
112
+ views,
113
+ lookups: createLookups(clientFor)
114
+ });
115
+ /** Reads the whole file and prepares each row as the import would, writing nothing. */
116
+ const check = () => {
117
+ if (!file) return;
118
+ setError(void 0);
119
+ const controller = new AbortController();
120
+ checking.current = controller;
121
+ setStep({
122
+ kind: "checking",
123
+ rows: 0
124
+ });
125
+ checkImport(file.source, sheet, plan(), context(), (rows) => setStep({
126
+ kind: "checking",
127
+ rows
128
+ }), controller.signal).then((result) => {
129
+ if (!controller.signal.aborted) setStep({
130
+ kind: "checked",
131
+ check: result
132
+ });
133
+ }).catch((cause) => {
134
+ if (!controller.signal.aborted) fail({ kind: "map" })(cause);
135
+ });
136
+ };
137
+ const back = () => {
138
+ checking.current?.abort();
139
+ setError(void 0);
140
+ setStep({ kind: "map" });
141
+ };
142
+ /** Writes the rows in batches; Cancel stops it between two. */
143
+ const start = (total) => {
144
+ if (!file) return;
145
+ setError(void 0);
146
+ cancelled.current = false;
147
+ setStep({
148
+ kind: "importing",
149
+ progress: emptyProgress,
150
+ total,
151
+ cancelling: false
152
+ });
153
+ let latest = emptyProgress;
154
+ const refresh = () => {
155
+ for (const prefix of [
156
+ "page",
157
+ "list",
158
+ "first-page",
159
+ "map",
160
+ "facets",
161
+ "series"
162
+ ]) queryClient.invalidateQueries({ queryKey: [prefix, model.name] });
163
+ };
164
+ runImport(file.source, sheet, plan(), context(), {
165
+ client: clientFor(model.name),
166
+ onProgress: (progress) => {
167
+ latest = progress;
168
+ setStep({
169
+ kind: "importing",
170
+ progress,
171
+ total,
172
+ cancelling: cancelled.current
173
+ });
174
+ },
175
+ cancelled: () => cancelled.current
176
+ }).then((result) => setStep({
177
+ kind: "done",
178
+ result
179
+ })).catch((cause) => setStep({
180
+ kind: "done",
181
+ result: {
182
+ ...latest,
183
+ cancelled: false
184
+ },
185
+ stopped: cause
186
+ })).finally(refresh);
187
+ };
188
+ const cancel = () => {
189
+ cancelled.current = true;
190
+ setStep((current) => current.kind === "importing" ? {
191
+ ...current,
192
+ cancelling: true
193
+ } : current);
194
+ };
195
+ /** Stops a check that is still reading, when the dialog closes. */
196
+ const stop = () => checking.current?.abort();
197
+ return {
198
+ step,
199
+ error,
200
+ file: file?.file,
201
+ sheets: file?.sheets ?? [],
202
+ sheet,
203
+ setSheet,
204
+ head,
205
+ fields,
206
+ mode,
207
+ setMode,
208
+ columns,
209
+ mapColumn,
210
+ matchColumnBy,
211
+ match,
212
+ setMatch,
213
+ options,
214
+ setOptions,
215
+ choose,
216
+ openSheet,
217
+ check,
218
+ back,
219
+ start,
220
+ cancel,
221
+ stop
222
+ };
223
+ };
224
+ //#endregion
225
+ export { useImportFlow };
@@ -0,0 +1,43 @@
1
+ import { createRowScanner, dateStyles, parseRow, sharedStrings, workbookSheets } from "./xlsx-xml.js";
2
+ import { zipEntries, zipText, zipTextChunks } from "./zip-reader.js";
3
+ //#region src/app/import/xlsx-reader.ts
4
+ const part = (entries, name) => {
5
+ const entry = entries.get(name);
6
+ if (!entry) throw new Error(`The file is not an Excel workbook (.xlsx): it has no ${name}`);
7
+ return entry;
8
+ };
9
+ /**
10
+ * An .xlsx workbook: the names of its sheets, and each sheet's rows read as its XML streams in. The shared strings and
11
+ * the styles are read whole, since cells point into them; the sheets are not.
12
+ */
13
+ const openWorkbook = async (blob) => {
14
+ const entries = await zipEntries(blob);
15
+ const { sheets, date1904 } = workbookSheets(await zipText(blob, part(entries, "xl/workbook.xml")), await zipText(blob, part(entries, "xl/_rels/workbook.xml.rels")));
16
+ let context;
17
+ const sheetContext = async () => {
18
+ context = {
19
+ strings: entries.has("xl/sharedStrings.xml") ? sharedStrings(await zipText(blob, entries.get("xl/sharedStrings.xml"))) : [],
20
+ dates: entries.has("xl/styles.xml") ? dateStyles(await zipText(blob, entries.get("xl/styles.xml"))) : /* @__PURE__ */ new Set(),
21
+ date1904
22
+ };
23
+ return context;
24
+ };
25
+ async function* rows(name) {
26
+ const sheet = sheets.find((candidate) => candidate.name === name);
27
+ if (!sheet) throw new Error(`The workbook has no sheet called ${name}`);
28
+ const shared = context ?? await sheetContext();
29
+ const scan = createRowScanner();
30
+ let line = 0;
31
+ for await (const text of zipTextChunks(blob, part(entries, sheet.path))) for (const xml of scan(text)) {
32
+ const row = parseRow(xml, shared, line);
33
+ line = row.line;
34
+ yield row;
35
+ }
36
+ }
37
+ return {
38
+ sheets: sheets.map((sheet) => sheet.name),
39
+ rows
40
+ };
41
+ };
42
+ //#endregion
43
+ export { openWorkbook };
@@ -0,0 +1,169 @@
1
+ //#region src/app/import/xlsx-xml.ts
2
+ const entities = {
3
+ amp: "&",
4
+ lt: "<",
5
+ gt: ">",
6
+ quot: "\"",
7
+ apos: "'"
8
+ };
9
+ /** The text with XML's entities and character references replaced by what they stand for. */
10
+ const decodeXml = (text) => text.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (whole, name) => {
11
+ if (name[0] !== "#") return entities[name.toLowerCase()] ?? whole;
12
+ return String.fromCodePoint(name[1] === "x" || name[1] === "X" ? parseInt(name.slice(2), 16) : Number(name.slice(1)));
13
+ });
14
+ /** An element's attributes, by name without their namespace prefix. */
15
+ const attributes = (text) => {
16
+ const found = {};
17
+ for (const match of text.matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
18
+ const name = match[1].slice(match[1].indexOf(":") + 1);
19
+ found[name] = decodeXml(match[2] ?? match[3] ?? "");
20
+ }
21
+ return found;
22
+ };
23
+ const runsText = (xml) => xml.replace(/<rPh\b[\s\S]*?<\/rPh>/g, "").match(/<t\b[^>]*>[\s\S]*?<\/t>/g)?.map((run) => decodeXml(run.replace(/^<t\b[^>]*>|<\/t>$/g, ""))).join("") ?? "";
24
+ /** The workbook's shared strings (xl/sharedStrings.xml), which text cells point at by index. */
25
+ const sharedStrings = (xml) => [...xml.matchAll(/<si\b[^>]*?(?:\/>|>([\s\S]*?)<\/si>)/g)].map((match) => runsText(match[1] ?? ""));
26
+ /**
27
+ * The workbook's sheets in order, each with the path of its part in the archive, from xl/workbook.xml and its
28
+ * relationships (xl/_rels/workbook.xml.rels); `date1904` when its dates count from 1904, as old Mac workbooks do.
29
+ */
30
+ const workbookSheets = (workbookXml, relsXml) => {
31
+ const targets = new Map([...relsXml.matchAll(/<Relationship\b([^>]*)>/g)].map((match) => {
32
+ const { Id, Target } = attributes(match[1]);
33
+ return [Id, Target.startsWith("/") ? Target.slice(1) : `xl/${Target}`];
34
+ }));
35
+ const sheets = [...workbookXml.matchAll(/<sheet\b([^>]*)>/g)].map((match) => {
36
+ const { name, id } = attributes(match[1]);
37
+ const path = targets.get(id);
38
+ if (!name || !path) throw new Error("The workbook names a sheet it does not hold");
39
+ return {
40
+ name,
41
+ path
42
+ };
43
+ });
44
+ const pr = /<workbookPr\b([^>]*)>/.exec(workbookXml);
45
+ return {
46
+ sheets,
47
+ date1904: pr ? ["1", "true"].includes(attributes(pr[1]).date1904 ?? "") : false
48
+ };
49
+ };
50
+ const builtInDates = /* @__PURE__ */ new Set([
51
+ 14,
52
+ 15,
53
+ 16,
54
+ 17,
55
+ 18,
56
+ 19,
57
+ 20,
58
+ 21,
59
+ 22,
60
+ 27,
61
+ 28,
62
+ 29,
63
+ 30,
64
+ 31,
65
+ 32,
66
+ 33,
67
+ 34,
68
+ 35,
69
+ 36,
70
+ 45,
71
+ 46,
72
+ 47,
73
+ 50,
74
+ 51,
75
+ 52,
76
+ 53,
77
+ 54,
78
+ 55,
79
+ 56,
80
+ 57,
81
+ 58
82
+ ]);
83
+ /** Whether a number format shows a date or time: it has d, m, y, h or s outside quoted text, colours and escapes. */
84
+ const isDateFormat = (code) => /[dmyhs]/i.test(code.replace(/"[^"]*"|\[[^\]]*\]|\\.|_.|\*./g, ""));
85
+ /** The indexes of the cell styles (xl/styles.xml's cellXfs) whose number format is a date or time. */
86
+ const dateStyles = (xml) => {
87
+ const custom = new Map([...xml.matchAll(/<numFmt\b([^>]*)>/g)].map((match) => {
88
+ const { numFmtId, formatCode } = attributes(match[1]);
89
+ return [Number(numFmtId), formatCode ?? ""];
90
+ }));
91
+ const cellXfs = /<cellXfs\b[^>]*>([\s\S]*?)<\/cellXfs>/.exec(xml)?.[1] ?? "";
92
+ const styles = /* @__PURE__ */ new Set();
93
+ [...cellXfs.matchAll(/<xf\b([^>]*)>/g)].forEach((match, index) => {
94
+ const id = Number(attributes(match[1]).numFmtId ?? 0);
95
+ if (builtInDates.has(id) || custom.has(id) && isDateFormat(custom.get(id))) styles.add(index);
96
+ });
97
+ return styles;
98
+ };
99
+ const pad = (value) => String(value).padStart(2, "0");
100
+ /**
101
+ * A date cell's wall-clock time from its serial number, the days since the workbook's epoch: a date when it has no time
102
+ * of day (`2026-03-01`), else to the second (`2026-03-01T14:30:00`). Excel's 1900 calendar counts a 29 February 1900
103
+ * that never was, so serials before it are a day ahead.
104
+ */
105
+ const serialDate = (serial, date1904 = false) => {
106
+ const days = date1904 ? serial + 1462 : serial < 60 ? serial + 1 : serial;
107
+ const at = new Date(Date.UTC(1899, 11, 30) + Math.round(days * 86400) * 1e3);
108
+ const date = `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())}`;
109
+ return at.getUTCHours() * 3600 + at.getUTCMinutes() * 60 + at.getUTCSeconds() === 0 ? date : `${date}T${pad(at.getUTCHours())}:${pad(at.getUTCMinutes())}:${pad(at.getUTCSeconds())}`;
110
+ };
111
+ /** The 0-based column of a cell reference: `A1` is 0, `AB7` is 27. */
112
+ const columnIndex = (ref) => {
113
+ let index = 0;
114
+ for (const char of ref.toUpperCase()) {
115
+ const code = char.charCodeAt(0);
116
+ if (code < 65 || code > 90) break;
117
+ index = index * 26 + (code - 64);
118
+ }
119
+ return index - 1;
120
+ };
121
+ const cellValue = (attrs, body, context) => {
122
+ const type = attrs.t ?? "n";
123
+ if (type === "inlineStr") return runsText(body);
124
+ const raw = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(body)?.[1];
125
+ if (raw === void 0) return null;
126
+ const value = decodeXml(raw);
127
+ if (type === "s") return context.strings[Number(value)] ?? null;
128
+ if (type === "b") return value === "1";
129
+ if (type === "str" || type === "e") return value;
130
+ if (type === "d") return { date: value.replace(/Z$/, "") };
131
+ const number = Number(value);
132
+ return context.dates.has(Number(attrs.s ?? 0)) ? { date: serialDate(number, context.date1904) } : number;
133
+ };
134
+ /** One `<row>` element as a row of cells, each at its column; a row without a number follows `previous`. */
135
+ const parseRow = (xml, context, previous) => {
136
+ const open = /^<row\b([^>]*)>/.exec(xml);
137
+ const line = Number(attributes(open?.[1] ?? "").r ?? previous + 1);
138
+ const cells = [];
139
+ for (const match of xml.matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
140
+ const attrs = attributes(match[1]);
141
+ const column = attrs.r ? columnIndex(attrs.r) : cells.length;
142
+ while (cells.length < column) cells.push(null);
143
+ cells[column] = cellValue(attrs, match[2] ?? "", context);
144
+ }
145
+ return {
146
+ line,
147
+ cells
148
+ };
149
+ };
150
+ /**
151
+ * A scanner fed a sheet's XML a chunk at a time, which returns the `<row>` elements each chunk completes, so a large
152
+ * sheet is read without holding its text whole.
153
+ */
154
+ const createRowScanner = () => {
155
+ let buffer = "";
156
+ return (chunk) => {
157
+ buffer += chunk;
158
+ const rows = [];
159
+ let end = 0;
160
+ for (const match of buffer.matchAll(/<row\b[^>]*?(?:\/>|>[\s\S]*?<\/row>)/g)) {
161
+ rows.push(match[0]);
162
+ end = match.index + match[0].length;
163
+ }
164
+ buffer = buffer.slice(end);
165
+ return rows;
166
+ };
167
+ };
168
+ //#endregion
169
+ export { attributes, columnIndex, createRowScanner, dateStyles, decodeXml, isDateFormat, parseRow, serialDate, sharedStrings, workbookSheets };
@@ -0,0 +1,72 @@
1
+ //#region src/app/import/zip-reader.ts
2
+ const endSignature = 101010256;
3
+ const entrySignature = 33639248;
4
+ const localSignature = 67324752;
5
+ const endSearch = 65557;
6
+ const bytesOf = async (blob, start, end) => new DataView(await blob.slice(start, end).arrayBuffer());
7
+ const notZip = () => /* @__PURE__ */ new Error("The file is not an Excel workbook (.xlsx): it is no ZIP archive");
8
+ const endRecordAt = (view) => {
9
+ for (let at = view.byteLength - 22; at >= 0; at--) if (view.getUint32(at, true) === endSignature) return at;
10
+ return -1;
11
+ };
12
+ /** The archive's entries by name, from its central directory. A ZIP64 archive (over 4 GB) is refused. */
13
+ const zipEntries = async (blob) => {
14
+ const tailStart = Math.max(0, blob.size - endSearch);
15
+ const tail = await bytesOf(blob, tailStart, blob.size);
16
+ const end = endRecordAt(tail);
17
+ if (end < 0) throw notZip();
18
+ const count = tail.getUint16(end + 10, true);
19
+ const size = tail.getUint32(end + 12, true);
20
+ const start = tail.getUint32(end + 16, true);
21
+ if (count === 65535 || size === 4294967295 || start === 4294967295) throw new Error("The workbook is too large to read: it is a ZIP64 archive");
22
+ const directory = await bytesOf(blob, start, start + size);
23
+ const decoder = new TextDecoder();
24
+ const entries = /* @__PURE__ */ new Map();
25
+ let at = 0;
26
+ for (let index = 0; index < count; index++) {
27
+ if (directory.getUint32(at, true) !== entrySignature) throw notZip();
28
+ const nameLength = directory.getUint16(at + 28, true);
29
+ const extraLength = directory.getUint16(at + 30, true);
30
+ const commentLength = directory.getUint16(at + 32, true);
31
+ const name = decoder.decode(new Uint8Array(directory.buffer, directory.byteOffset + at + 46, nameLength));
32
+ entries.set(name, {
33
+ name,
34
+ method: directory.getUint16(at + 10, true),
35
+ size: directory.getUint32(at + 20, true),
36
+ offset: directory.getUint32(at + 42, true)
37
+ });
38
+ at += 46 + nameLength + extraLength + commentLength;
39
+ }
40
+ return entries;
41
+ };
42
+ /** The entry's content as a stream of bytes: stored as it is, or inflated (deflate). */
43
+ const zipEntryStream = async (blob, entry) => {
44
+ const local = await bytesOf(blob, entry.offset, entry.offset + 30);
45
+ if (local.getUint32(0, true) !== localSignature) throw notZip();
46
+ const dataStart = entry.offset + 30 + local.getUint16(26, true) + local.getUint16(28, true);
47
+ const raw = blob.slice(dataStart, dataStart + entry.size).stream();
48
+ if (entry.method === 0) return raw;
49
+ if (entry.method === 8) return raw.pipeThrough(new DecompressionStream("deflate-raw"));
50
+ throw new Error(`The workbook's part ${entry.name} is compressed in a way that cannot be read (method ${entry.method})`);
51
+ };
52
+ /** The entry's content as text, decoded as UTF-8 a chunk at a time. */
53
+ async function* zipTextChunks(blob, entry) {
54
+ const reader = (await zipEntryStream(blob, entry)).pipeThrough(new TextDecoderStream()).getReader();
55
+ try {
56
+ for (;;) {
57
+ const { done, value } = await reader.read();
58
+ if (done) return;
59
+ yield value;
60
+ }
61
+ } finally {
62
+ await reader.cancel();
63
+ }
64
+ }
65
+ /** The entry's whole content as text, for the small parts of a workbook. */
66
+ const zipText = async (blob, entry) => {
67
+ const parts = [];
68
+ for await (const text of zipTextChunks(blob, entry)) parts.push(text);
69
+ return parts.join("");
70
+ };
71
+ //#endregion
72
+ export { zipEntries, zipEntryStream, zipText, zipTextChunks };
@@ -28,6 +28,7 @@ import { useLiveRows } from "../live/use-live-rows.js";
28
28
  import { Sheet } from "../primitives/sheet.js";
29
29
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
30
30
  import { useRestore } from "./delete/use-restore.js";
31
+ import { ImportButton } from "./import/import-button.js";
31
32
  import { SignInPrompt } from "../auth/sign-in-prompt.js";
32
33
  import { ListCards } from "./displays/list-cards.js";
33
34
  import { MapDisplay } from "./displays/map-display.js";
@@ -270,6 +271,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
270
271
  filter: filterText,
271
272
  chips: filterChips(model, view, filterText, facetNames(config))
272
273
  });
274
+ const importButton = !scope && /* @__PURE__ */ jsx(ImportButton, { model });
273
275
  const displaySwitch = (menu) => /* @__PURE__ */ jsx(DisplaySwitch, {
274
276
  view,
275
277
  value: display?.name ?? tableDisplay,
@@ -349,8 +351,13 @@ const LoadedList = ({ model, address, scope, heading }) => {
349
351
  actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
350
352
  displaySwitch(false),
351
353
  saveSearch,
354
+ importButton,
352
355
  createButton
353
- ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [saveSearch, createButton] })
356
+ ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
357
+ saveSearch,
358
+ importButton,
359
+ createButton
360
+ ] })
354
361
  }),
355
362
  !parsed.ok && /* @__PURE__ */ jsx(ErrorBanner, {
356
363
  title: "The filter in the address could not be read",
@@ -0,0 +1,11 @@
1
+ import { guarded } from "../../lib/guarded.js";
2
+ //#region src/app/map/map-layer-choice.ts
3
+ const mapLayerStorageKey = "protobase:map-layer";
4
+ /** The layer the browser last chose, if the app still offers it: else the style's own map, which is the default. */
5
+ const chosenMapLayer = (layers, id) => layers.find((layer) => layer.id === id);
6
+ /** The id of the layer chosen in this browser, if any; unavailable storage reads as the default map. */
7
+ const readMapLayer = (storage) => guarded(() => storage.getItem(mapLayerStorageKey), null);
8
+ /** Remembers a layer by its id, and the default map by forgetting the choice, so the default costs no storage. */
9
+ const writeMapLayer = (storage, id) => guarded(() => id === void 0 ? storage.removeItem(mapLayerStorageKey) : storage.setItem(mapLayerStorageKey, id), void 0);
10
+ //#endregion
11
+ export { chosenMapLayer, mapLayerStorageKey, readMapLayer, writeMapLayer };
@@ -0,0 +1,55 @@
1
+ import { Segmented } from "../../primitives/segmented.js";
2
+ import { chosenMapLayer, readMapLayer, writeMapLayer } from "./map-layer-choice.js";
3
+ import { useState } from "react";
4
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
+ import { Layer, Source, useMap } from "react-map-gl/maplibre";
6
+ //#region src/app/map/map-layer-switch.tsx
7
+ const styleValue = "";
8
+ /**
9
+ * Inside a map, a switch in its top left corner between the style's map and each of `layers`, and the chosen layer's
10
+ * tiles drawn over the style's, under `below` when the map has that layer, such as the areas around records. The
11
+ * layer's credit joins the map's attribution while it shows. Outside its bounds the style's map shows through. The
12
+ * choice is remembered in this browser, for every map. Nothing without layers.
13
+ */
14
+ const MapLayerSwitch = ({ layers, below }) => {
15
+ const [chosenId, setChosenId] = useState(() => readMapLayer(localStorage));
16
+ const { current: map } = useMap();
17
+ if (!layers || layers.length === 0) return null;
18
+ const chosen = chosenMapLayer(layers, chosenId);
19
+ const choose = (value) => {
20
+ const id = value === styleValue ? void 0 : value;
21
+ writeMapLayer(localStorage, id);
22
+ setChosenId(id ?? null);
23
+ };
24
+ const beforeId = below && map?.getLayer(below) ? below : void 0;
25
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [chosen && /* @__PURE__ */ jsx(Source, {
26
+ id: `map-layer-${chosen.id}`,
27
+ type: "raster",
28
+ tiles: [chosen.tiles],
29
+ tileSize: 256,
30
+ attribution: chosen.attribution,
31
+ maxzoom: chosen.maxzoom ?? 22,
32
+ ...chosen.bounds && { bounds: chosen.bounds },
33
+ children: /* @__PURE__ */ jsx(Layer, {
34
+ id: `map-layer-${chosen.id}`,
35
+ type: "raster",
36
+ ...beforeId && { beforeId }
37
+ })
38
+ }, chosen.id), /* @__PURE__ */ jsx("div", {
39
+ className: "absolute left-2.5 top-2.5 z-10 rounded-md shadow",
40
+ children: /* @__PURE__ */ jsx(Segmented, {
41
+ label: "Map layer",
42
+ value: chosen?.id ?? styleValue,
43
+ onChange: choose,
44
+ options: [{
45
+ value: styleValue,
46
+ label: "Map"
47
+ }, ...layers.map((layer) => ({
48
+ value: layer.id,
49
+ label: layer.label
50
+ }))]
51
+ })
52
+ })] });
53
+ };
54
+ //#endregion
55
+ export { MapLayerSwitch };
@@ -12,7 +12,8 @@ const defaultZoom = 15;
12
12
  * `/meta` names. Without a style, or for a record without a position, there is nothing to draw and no section.
13
13
  */
14
14
  const MapSection = ({ item, record }) => {
15
- const style = useAdminMeta().maps?.style;
15
+ const maps = useAdminMeta().maps;
16
+ const style = maps?.style;
16
17
  const position = recordPosition(record, item);
17
18
  if (!style || !position) return null;
18
19
  const map = /* @__PURE__ */ jsx("div", {
@@ -21,6 +22,7 @@ const MapSection = ({ item, record }) => {
21
22
  fallback: null,
22
23
  children: /* @__PURE__ */ jsx(RecordMap, {
23
24
  style,
25
+ layers: maps?.layers,
24
26
  position,
25
27
  zoom: item.zoom ?? defaultZoom
26
28
  }, `${position.latitude},${position.longitude}`)
@@ -2,6 +2,8 @@ import { areaFeatures, fadeIn } from "./area-features.js";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { Layer, Source } from "react-map-gl/maplibre";
4
4
  //#region src/app/map/position-areas.tsx
5
+ /** The areas' lowest layer, which a layer of tiles goes under. */
6
+ const areaLayerId = "position-area-fills";
5
7
  const polygons = [
6
8
  "==",
7
9
  ["get", "kind"],
@@ -38,7 +40,7 @@ const PositionAreas = ({ points, selected, colors }) => {
38
40
  data,
39
41
  children: [
40
42
  /* @__PURE__ */ jsx(Layer, {
41
- id: "position-area-fills",
43
+ id: areaLayerId,
42
44
  type: "fill",
43
45
  filter: polygons,
44
46
  paint: {
@@ -75,4 +77,4 @@ const PositionAreas = ({ points, selected, colors }) => {
75
77
  });
76
78
  };
77
79
  //#endregion
78
- export { PositionAreas };
80
+ export { PositionAreas, areaLayerId };