@protobase/ui 0.19.4 → 0.19.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) 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/filled-fields.js +5 -0
  4. package/dist/app/gallery/details-open-storage.js +10 -0
  5. package/dist/app/gallery/gallery-layout.js +3 -1
  6. package/dist/app/gallery/lightbox.js +107 -61
  7. package/dist/app/import/convert.js +151 -0
  8. package/dist/app/import/csv-parser.js +119 -0
  9. package/dist/app/import/failed-rows.js +21 -0
  10. package/dist/app/import/import-button.js +31 -0
  11. package/dist/app/import/import-check-step.js +123 -0
  12. package/dist/app/import/import-counts.js +19 -0
  13. package/dist/app/import/import-dialog.js +152 -0
  14. package/dist/app/import/import-file-step.js +65 -0
  15. package/dist/app/import/import-mapping-step.js +195 -0
  16. package/dist/app/import/import-result-step.js +86 -0
  17. package/dist/app/import/import-select.js +35 -0
  18. package/dist/app/import/locale-formats.js +15 -0
  19. package/dist/app/import/lookups.js +48 -0
  20. package/dist/app/import/mapping.js +78 -0
  21. package/dist/app/import/parse-worker.d.ts +1 -0
  22. package/dist/app/import/parse-worker.js +39 -0
  23. package/dist/app/import/prepare-rows.js +129 -0
  24. package/dist/app/import/run-import.js +130 -0
  25. package/dist/app/import/sheet-cell.js +17 -0
  26. package/dist/app/import/sheet-file.js +58 -0
  27. package/dist/app/import/sheet-source.js +72 -0
  28. package/dist/app/import/use-import-flow.js +225 -0
  29. package/dist/app/import/xlsx-reader.js +43 -0
  30. package/dist/app/import/xlsx-xml.js +169 -0
  31. package/dist/app/import/zip-reader.js +72 -0
  32. package/dist/app/list-page.js +8 -1
  33. package/dist/app/named-icon.js +9 -1
  34. package/dist/app/record-page.js +11 -2
  35. package/dist/auth/sign-in-prompt.js +1 -1
  36. package/dist/data/api-provider.js +8 -2
  37. package/dist/lib/save-file.js +12 -0
  38. package/dist/lib/use-focus-trap.js +7 -1
  39. package/dist/package.js +1 -1
  40. package/dist/primitives/sheet-swipe.js +5 -0
  41. package/dist/primitives/sheet.js +40 -12
  42. package/dist/record-view/key-facts.js +29 -0
  43. package/dist/shell/assets/app-CZ4drn7G.js +67 -0
  44. package/dist/shell/assets/index-BoeKjvo8.css +2 -0
  45. package/dist/shell/assets/{index-CEvfMgFJ.js → index-BzCCIfII.js} +1 -1
  46. package/dist/shell/assets/{maplibre-D6zfLfTV.js → maplibre-BOsAtlAo.js} +1 -1
  47. package/dist/shell/assets/parse-worker-BtcY0dQw.js +5 -0
  48. package/dist/shell/assets/{record-map-B6_rbCcG.js → record-map-BK_02Lmk.js} +1 -1
  49. package/dist/shell/assets/{records-map-bQbuIc13.js → records-map-Dn72A_l-.js} +1 -1
  50. package/dist/shell/assets/{shared-protobase-ui-8pJgwjfc.js → shared-protobase-ui-B6lrj7eA.js} +1 -1
  51. package/dist/shell/index.html +4 -4
  52. package/dist/shell/protobase.shell.json +1 -1
  53. package/package.json +4 -4
  54. package/dist/shell/assets/app-BYFiTXK6.js +0 -66
  55. package/dist/shell/assets/index-CiGP6kle.css +0 -2
@@ -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",
@@ -1,4 +1,4 @@
1
- import { Bed, Bell, Bookmark, ExternalLink, FileText, Heart, RulerDimensionLine, Send, Wallet } from "lucide-react";
1
+ import { Bath, Bed, Bell, Bookmark, Calendar, DoorOpen, ExternalLink, FileText, Heart, House, KeyRound, LandPlot, Leaf, RulerDimensionLine, Send, Tag, Wallet } from "lucide-react";
2
2
  //#region src/app/named-icon.ts
3
3
  /** The icons an action's, a field's or a sign-in prompt's `icon` may name. */
4
4
  const byName = {
@@ -7,6 +7,14 @@ const byName = {
7
7
  send: Send,
8
8
  ruler: RulerDimensionLine,
9
9
  bed: Bed,
10
+ bath: Bath,
11
+ house: House,
12
+ "door-open": DoorOpen,
13
+ "land-plot": LandPlot,
14
+ leaf: Leaf,
15
+ tag: Tag,
16
+ key: KeyRound,
17
+ calendar: Calendar,
10
18
  heart: Heart,
11
19
  bell: Bell,
12
20
  bookmark: Bookmark,
@@ -10,6 +10,7 @@ import { recordTitle } from "../live/model-helpers.js";
10
10
  import { humanize } from "../live/naming.js";
11
11
  import { useProjectUi } from "./pages/project-ui.js";
12
12
  import { displayValue } from "../live/display-value.js";
13
+ import { filledFields } from "./filled-fields.js";
13
14
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
14
15
  import { AppLink, useAppAddress } from "./use-app-address.js";
15
16
  import { ErrorBanner } from "./error-banner.js";
@@ -22,9 +23,11 @@ import { Section } from "../record-view/section.js";
22
23
  import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record-sidebar.js";
23
24
  import { RecordHeader } from "../record-view/record-header.js";
24
25
  import { SpecTable } from "../record-view/spec-table.js";
26
+ import { KeyFacts } from "../record-view/key-facts.js";
25
27
  import { toneFor } from "../live/columns-from-model.js";
26
28
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
27
29
  import { useRestore } from "./delete/use-restore.js";
30
+ import { namedIcon } from "./named-icon.js";
28
31
  import { ValueBadge } from "./value-badge.js";
29
32
  import { FieldValue } from "./pages/field-value.js";
30
33
  import { auditField } from "../live/audit-fields.js";
@@ -375,12 +378,18 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
375
378
  recordKey
376
379
  }, `gallery:${index}`)];
377
380
  if (item.kind !== "section") return [];
378
- const fields = item.fields.filter((name) => model.fields[name] && visible(name));
381
+ const visibleFields = item.fields.filter((name) => model.fields[name] && visible(name));
382
+ const facts = shown === "read" && item.presentation === "facts";
383
+ const fields = facts ? filledFields(stored.record, visibleFields) : visibleFields;
379
384
  if (fields.length === 0) return [];
380
385
  return [/* @__PURE__ */ jsx(Section, {
381
386
  title: item.title,
382
387
  help: helpShown(item.helpWhen, view, editing) ? item.help : void 0,
383
- children: shown === "read" ? /* @__PURE__ */ jsx(SpecTable, { rows: fields.map(readRow) }) : /* @__PURE__ */ jsx("div", {
388
+ children: facts ? /* @__PURE__ */ jsx(KeyFacts, { facts: fields.map((name) => ({
389
+ label: readRow(name).label,
390
+ value: readRow(name).value,
391
+ icon: namedIcon(hint(name)?.icon)
392
+ })) }) : shown === "read" ? /* @__PURE__ */ jsx(SpecTable, { rows: fields.map(readRow) }) : /* @__PURE__ */ jsx("div", {
384
393
  className: "grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2",
385
394
  children: fields.map(fieldView)
386
395
  })
@@ -2,8 +2,8 @@ import { RouteLink } from "../primitives/route-link.js";
2
2
  import { useAuth } from "./auth-provider.js";
3
3
  import { Button, buttonClass } from "../primitives/button.js";
4
4
  import { useSignInAddress } from "./use-sign-in-address.js";
5
- import { namedIcon } from "../app/named-icon.js";
6
5
  import { ResponsiveDialog } from "../primitives/responsive-dialog.js";
6
+ import { namedIcon } from "../app/named-icon.js";
7
7
  import { useId } from "react";
8
8
  import { Check, LogIn } from "lucide-react";
9
9
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -1,5 +1,5 @@
1
1
  import { keys } from "./query-keys.js";
2
- import { createContext, useContext, useMemo, useRef, useState } from "react";
2
+ import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
3
3
  import { createClient } from "@protobase/client";
4
4
  import { jsx } from "react/jsx-runtime";
5
5
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -21,6 +21,12 @@ const useResourceClient = (resource) => {
21
21
  const own = useClient();
22
22
  return useContext(ResourceClientContext)?.(resource) ?? own;
23
23
  };
24
+ /** `useResourceClient` for any resource: the client of the API that serves each, for code that reaches several. */
25
+ const useResourceClients = () => {
26
+ const own = useClient();
27
+ const resourceClient = useContext(ResourceClientContext);
28
+ return useCallback((resource) => resourceClient?.(resource) ?? own, [own, resourceClient]);
29
+ };
24
30
  /** The client of a source's API, one per URL, with the signed-in user's token; for `MetaGate`. */
25
31
  const useSourceClients = () => {
26
32
  const clientOf = useContext(SourceClientsContext);
@@ -104,4 +110,4 @@ const ApiProvider = ({ baseUrl, client: given, sources, fetch, queryClient: give
104
110
  });
105
111
  };
106
112
  //#endregion
107
- export { ApiProvider, ClientProvider, useClient, useResourceClient, useSourceClients };
113
+ export { ApiProvider, ClientProvider, useClient, useResourceClient, useResourceClients, useSourceClients };
@@ -0,0 +1,12 @@
1
+ //#region src/lib/save-file.ts
2
+ /** Hands the browser a file to save under `name`, made from `text`. */
3
+ const saveFile = (text, name, type = "text/plain") => {
4
+ const url = URL.createObjectURL(new Blob([text], { type }));
5
+ Object.assign(document.createElement("a"), {
6
+ href: url,
7
+ download: name
8
+ }).click();
9
+ URL.revokeObjectURL(url);
10
+ };
11
+ //#endregion
12
+ export { saveFile };