@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.
- package/dist/account/backup-codes.js +2 -9
- package/dist/app/displays/list-displays.js +2 -1
- package/dist/app/displays/map-display.js +3 -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/map/map-layer-choice.js +11 -0
- package/dist/app/map/map-layer-switch.js +55 -0
- package/dist/app/map/map-section.js +3 -1
- package/dist/app/map/position-areas.js +4 -2
- package/dist/app/map/record-map.js +16 -11
- package/dist/app/map/records-map.js +7 -2
- 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/data/meta-data.d.ts +3 -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-5frCs7fQ.js → index-BzCCIfII.js} +1 -1
- package/dist/shell/assets/maplibre-BOsAtlAo.js +814 -0
- package/dist/shell/assets/parse-worker-BtcY0dQw.js +5 -0
- package/dist/shell/assets/record-map-BK_02Lmk.js +1 -0
- package/dist/shell/assets/records-map-Dn72A_l-.js +1 -0
- package/dist/shell/assets/{shared-protobase-ui-Ceamz_7l.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-ChEqQ9ca.js +0 -66
- package/dist/shell/assets/index-D-4IHhqX.css +0 -2
- package/dist/shell/assets/maplibre-D8plsjP2.js +0 -814
- package/dist/shell/assets/record-map-BDJTOwZo.js +0 -1
- 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 };
|
package/dist/app/list-page.js
CHANGED
|
@@ -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: [
|
|
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
|
|
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:
|
|
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 };
|