@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,15 @@
1
+ //#region src/app/import/locale-formats.ts
2
+ /**
3
+ * How the browser's locale writes numbers and dates, as the import's starting choice: its decimal separator, and whether
4
+ * its dates put the day or the month first. The user sees both and can change them.
5
+ */
6
+ const localeFormats = (locale) => {
7
+ const decimal = new Intl.NumberFormat(locale).formatToParts(1.5).find((part) => part.type === "decimal")?.value === "," ? "," : ".";
8
+ const parts = new Intl.DateTimeFormat(locale).formatToParts(new Date(2026, 3, 3)).map((part) => part.type);
9
+ return {
10
+ decimal,
11
+ dateOrder: parts.indexOf("month") < parts.indexOf("day") ? "mdy" : "dmy"
12
+ };
13
+ };
14
+ //#endregion
15
+ export { localeFormats };
@@ -0,0 +1,48 @@
1
+ import { createWhere, printFilter } from "@protobase/schema";
2
+ //#region src/app/import/lookups.ts
3
+ const where = createWhere();
4
+ const valuesPerRequest = 100;
5
+ /** A value as the lookups compare it: the API answers numbers as numbers and the rest as text. */
6
+ const lookupKey = (value) => String(value);
7
+ /**
8
+ * Finds records by the value of a field, for the relations of an import's rows and the records it updates: each value
9
+ * is asked for once per import, a hundred at a time, and remembered.
10
+ */
11
+ const createLookups = (clientFor) => {
12
+ const known = /* @__PURE__ */ new Map();
13
+ const foundOf = (resource, field) => {
14
+ const id = `${resource}.${field}`;
15
+ if (!known.has(id)) known.set(id, /* @__PURE__ */ new Map());
16
+ return known.get(id);
17
+ };
18
+ /** Asks the API for the values not asked for yet; the user reads only the records they may list. */
19
+ const load = async (model, field, values) => {
20
+ const found = foundOf(model.name, field);
21
+ const missing = [...new Map(values.filter((value) => !found.has(lookupKey(value))).map((value) => [lookupKey(value), value])).values()];
22
+ const key = model.primaryKey[0];
23
+ for (let at = 0; at < missing.length; at += valuesPerRequest) {
24
+ const asked = missing.slice(at, at + valuesPerRequest);
25
+ for (const value of asked) found.set(lookupKey(value), []);
26
+ const filter = printFilter(where.in(field, asked));
27
+ let pageToken;
28
+ do {
29
+ const page = await clientFor(model.name).list(model.name, {
30
+ filter,
31
+ fields: [.../* @__PURE__ */ new Set([key, field])],
32
+ pageSize: 1e3,
33
+ pageToken
34
+ });
35
+ for (const record of page.items) found.get(lookupKey(record[field]))?.push(record[key]);
36
+ pageToken = page.nextPageToken || void 0;
37
+ } while (pageToken);
38
+ }
39
+ };
40
+ /** The keys of the records found by the value, once `load` asked for it. */
41
+ const keysOf = (resource, field, value) => foundOf(resource, field).get(lookupKey(value)) ?? [];
42
+ return {
43
+ load,
44
+ keysOf
45
+ };
46
+ };
47
+ //#endregion
48
+ export { createLookups, lookupKey };
@@ -0,0 +1,78 @@
1
+ import { humanize } from "../../live/naming.js";
2
+ //#region src/app/import/mapping.ts
3
+ /** How a field reads in the import: its view's label, as everywhere else in the app. */
4
+ const fieldLabel = (view, name) => view?.fields[name]?.label ?? humanize(name);
5
+ const importableTypes = /* @__PURE__ */ new Set([
6
+ "text",
7
+ "integer",
8
+ "bigint",
9
+ "decimal",
10
+ "boolean",
11
+ "date",
12
+ "timestamp",
13
+ "uuid",
14
+ "enum",
15
+ "relation",
16
+ "currency",
17
+ "country"
18
+ ]);
19
+ const relationTarget = (field, resources) => {
20
+ const target = field.relation ? resources[field.relation.resource] : void 0;
21
+ return target?.primaryKey.length === 1 ? target : void 0;
22
+ };
23
+ /** The fields an import may write: not read-only, not the tenant, and of a type a cell can hold. */
24
+ const importableFields = (model, resources) => Object.values(model.fields).filter((field) => {
25
+ if (field.readOnly || field.name === model.tenant || !importableTypes.has(field.type)) return false;
26
+ return field.type !== "relation" || relationTarget(field, resources) !== void 0;
27
+ });
28
+ const matchableTypes = /* @__PURE__ */ new Set([
29
+ "text",
30
+ "integer",
31
+ "bigint",
32
+ "uuid",
33
+ "date",
34
+ "currency",
35
+ "country",
36
+ "enum"
37
+ ]);
38
+ /**
39
+ * The fields records of `model` can be found by: its key when it is one field, then the filterable fields whose values
40
+ * single records out, such as an email or an order number. The user says which is unique; a value that matches several
41
+ * records is an error on its row.
42
+ */
43
+ const matchFields = (model) => {
44
+ const key = model.primaryKey.length === 1 ? [model.fields[model.primaryKey[0]]] : [];
45
+ const others = Object.values(model.fields).filter((field) => field.filterable && !field.sensitive && matchableTypes.has(field.type) && !model.primaryKey.includes(field.name));
46
+ return [...key, ...others];
47
+ };
48
+ /** The resource a relation field points at, when an import can fill it. */
49
+ const relationModel = (field, resources) => relationTarget(field, resources);
50
+ /** Whether a new record needs the field: it can be neither empty nor filled in by a default. */
51
+ const requiredOnCreate = (field) => !field.nullable && !field.default;
52
+ const normalized = (text) => text.toLowerCase().replace(/[\s_-]+/g, "");
53
+ /**
54
+ * Each column whose header is a field's label or name, ignoring case and spacing, mapped to that field; a field takes
55
+ * the first column that matches it, and a column that matches none is skipped.
56
+ */
57
+ const autoMapping = (headers, fields, view) => {
58
+ const taken = /* @__PURE__ */ new Set();
59
+ return headers.flatMap((header, column) => {
60
+ const text = normalized(header);
61
+ if (text === "") return [];
62
+ const field = fields.find((candidate) => !taken.has(candidate.name) && (normalized(fieldLabel(view, candidate.name)) === text || normalized(candidate.name) === text));
63
+ if (!field) return [];
64
+ taken.add(field.name);
65
+ return [{
66
+ column,
67
+ field: field.name
68
+ }];
69
+ });
70
+ };
71
+ /** The fields a new record needs that no column fills, which a create, or a create-or-update, must have. */
72
+ const unmappedRequired = (mode, fields, mapping) => {
73
+ if (mode === "update") return [];
74
+ const mapped = new Set(mapping.map((entry) => entry.field));
75
+ return fields.filter((field) => requiredOnCreate(field) && !mapped.has(field.name));
76
+ };
77
+ //#endregion
78
+ export { autoMapping, fieldLabel, importableFields, matchFields, relationModel, requiredOnCreate, unmappedRequired };
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,39 @@
1
+ import { sheetNames, sheetRows } from "./sheet-file.js";
2
+ //#region src/app/import/parse-worker.ts
3
+ const scope = self;
4
+ const reply = (message) => scope.postMessage(message);
5
+ let next;
6
+ const read = async (request) => {
7
+ let chunk = [];
8
+ for await (const row of sheetRows(request.file, request.sheet)) {
9
+ chunk.push(row);
10
+ if (chunk.length < request.chunk) continue;
11
+ const asked = new Promise((resolve) => next = resolve);
12
+ reply({
13
+ type: "rows",
14
+ rows: chunk
15
+ });
16
+ chunk = [];
17
+ await asked;
18
+ }
19
+ reply({
20
+ type: "rows",
21
+ rows: chunk
22
+ });
23
+ reply({ type: "end" });
24
+ };
25
+ const handle = async (request) => {
26
+ if (request.type === "next") return next?.();
27
+ if (request.type === "sheets") return reply({
28
+ type: "sheets",
29
+ sheets: await sheetNames(request.file)
30
+ });
31
+ await read(request);
32
+ };
33
+ scope.onmessage = (event) => {
34
+ handle(event.data).catch((error) => reply({
35
+ type: "error",
36
+ message: error instanceof Error ? error.message : String(error)
37
+ }));
38
+ };
39
+ //#endregion
@@ -0,0 +1,129 @@
1
+ import { humanize } from "../../live/naming.js";
2
+ import { cellText, isEmptyCell } from "./sheet-cell.js";
3
+ import { fieldLabel, relationModel } from "./mapping.js";
4
+ import { convertCell } from "./convert.js";
5
+ //#region src/app/import/prepare-rows.ts
6
+ const singularOf = (views, resource) => views[resource]?.names?.singular ?? humanize(resource);
7
+ const pluralOf = (views, resource) => views[resource]?.names?.plural ?? humanize(resource);
8
+ const convertRow = (row, plan, context) => {
9
+ const { model, resources, views } = context;
10
+ const view = views[model.name];
11
+ const values = {};
12
+ const data = {};
13
+ const problems = [];
14
+ const relations = [];
15
+ for (const { column, field: name, matchBy } of plan.columns) {
16
+ const field = model.fields[name];
17
+ const cell = row.cells[column] ?? null;
18
+ const label = fieldLabel(view, name);
19
+ const target = relationModel(field, resources);
20
+ if (target && !isEmptyCell(cell)) {
21
+ const by = matchBy ?? target.primaryKey[0];
22
+ const converted = convertCell(target.fields[by], views[target.name]?.fields[by], cell, plan.options);
23
+ if (!converted.ok) problems.push(`${label}: ${converted.problem}`);
24
+ else relations.push({
25
+ field: name,
26
+ finding: {
27
+ model: target,
28
+ field: by,
29
+ value: converted.value
30
+ }
31
+ });
32
+ values[name] = cellText(cell);
33
+ continue;
34
+ }
35
+ const converted = convertCell(field, view?.fields[name], cell, plan.options);
36
+ if (!converted.ok) problems.push(`${label}: ${converted.problem}`);
37
+ else data[name] = values[name] = converted.value;
38
+ }
39
+ let match;
40
+ if (plan.match && plan.mode !== "create") {
41
+ const field = model.fields[plan.match.field];
42
+ const converted = convertCell({
43
+ ...field,
44
+ nullable: false
45
+ }, view?.fields[field.name], row.cells[plan.match.column] ?? null, plan.options);
46
+ if (!converted.ok) problems.push(`${fieldLabel(view, field.name)}: ${converted.problem}`);
47
+ else match = {
48
+ model,
49
+ field: field.name,
50
+ value: converted.value
51
+ };
52
+ }
53
+ return {
54
+ values,
55
+ data,
56
+ problems,
57
+ relations,
58
+ match
59
+ };
60
+ };
61
+ const foundOne = (context, finding) => {
62
+ const keys = context.lookups.keysOf(finding.model.name, finding.field, finding.value);
63
+ const named = `${fieldLabel(context.views[finding.model.name], finding.field).toLowerCase()} “${String(finding.value)}”`;
64
+ if (keys.length === 1) return { key: keys[0] };
65
+ if (keys.length === 0) return { none: `no ${singularOf(context.views, finding.model.name).toLowerCase()} has ${named}` };
66
+ return { several: `${keys.length} ${pluralOf(context.views, finding.model.name).toLowerCase()} have ${named}` };
67
+ };
68
+ const isBlank = (row) => row.cells.every(isEmptyCell);
69
+ /**
70
+ * The rows of a chunk as writes: each mapped cell converted, related records and the records to update found (asked
71
+ * for together), and the write each row makes, or the problems that keep it out. Rows with no value at all are left
72
+ * out.
73
+ */
74
+ const prepareRows = async (rows, plan, context) => {
75
+ const { model, views } = context;
76
+ const converted = rows.filter((row) => !isBlank(row)).map((row) => ({
77
+ row,
78
+ ...convertRow(row, plan, context)
79
+ }));
80
+ const wanted = /* @__PURE__ */ new Map();
81
+ for (const finding of converted.flatMap((entry) => [...entry.relations.map((relation) => relation.finding), ...entry.match ? [entry.match] : []])) {
82
+ const id = `${finding.model.name}.${finding.field}`;
83
+ if (!wanted.has(id)) wanted.set(id, {
84
+ model: finding.model,
85
+ field: finding.field,
86
+ values: []
87
+ });
88
+ wanted.get(id).values.push(finding.value);
89
+ }
90
+ for (const { model: target, field, values } of wanted.values()) await context.lookups.load(target, field, values);
91
+ return converted.map(({ row, values, data, problems, relations, match }) => {
92
+ const written = { ...data };
93
+ for (const { field, finding } of relations) {
94
+ const found = foundOne(context, finding);
95
+ if ("key" in found) written[field] = found.key;
96
+ else problems.push(`${fieldLabel(views[model.name], field)}: ${found.none ?? found.several}`);
97
+ }
98
+ const existing = match && foundOne(context, match);
99
+ if (existing?.several) problems.push(`Not one record: ${existing.several}`);
100
+ if (existing?.none && plan.mode === "update") problems.push(`Nothing to update: ${existing.none}`);
101
+ const prepared = {
102
+ line: row.line,
103
+ cells: row.cells,
104
+ values,
105
+ problems
106
+ };
107
+ if (problems.length > 0) return prepared;
108
+ if (existing && "key" in existing) return {
109
+ ...prepared,
110
+ op: {
111
+ op: "update",
112
+ resource: model.name,
113
+ key: existing.key,
114
+ etag: "*",
115
+ data: written
116
+ }
117
+ };
118
+ return {
119
+ ...prepared,
120
+ op: {
121
+ op: "create",
122
+ resource: model.name,
123
+ data: written
124
+ }
125
+ };
126
+ });
127
+ };
128
+ //#endregion
129
+ export { prepareRows };
@@ -0,0 +1,130 @@
1
+ import { cellText } from "./sheet-cell.js";
2
+ import { fieldLabel } from "./mapping.js";
3
+ import { prepareRows } from "./prepare-rows.js";
4
+ import { isApiError } from "@protobase/client";
5
+ //#region src/app/import/run-import.ts
6
+ /** How many data rows the mapping step shows under each column. */
7
+ const sampleRows = 5;
8
+ /** How many rows of the check the preview shows. */
9
+ const previewRows = 5;
10
+ /** The header and the first rows of a sheet, reading no further than its first chunk. */
11
+ const readHead = async (source, sheet) => {
12
+ const chunks = source.rows(sheet);
13
+ const { value: first = [] } = await chunks.next();
14
+ await chunks.return(void 0);
15
+ const [header, ...rows] = first;
16
+ return {
17
+ headers: (header?.cells ?? []).map(cellText),
18
+ sample: rows.slice(0, sampleRows)
19
+ };
20
+ };
21
+ async function* dataRows(source, sheet) {
22
+ let header = true;
23
+ for await (const chunk of source.rows(sheet)) {
24
+ yield header ? chunk.slice(1) : chunk;
25
+ header = false;
26
+ }
27
+ }
28
+ /** Reads the whole file and prepares every row as the import would, writing nothing; `onProgress` gets the rows read so far. */
29
+ const checkImport = async (source, sheet, plan, context, onProgress, signal) => {
30
+ const check = {
31
+ rows: 0,
32
+ creates: 0,
33
+ updates: 0,
34
+ problems: [],
35
+ preview: []
36
+ };
37
+ for await (const chunk of dataRows(source, sheet)) {
38
+ signal?.throwIfAborted();
39
+ for (const row of await prepareRows(chunk, plan, context)) {
40
+ check.rows++;
41
+ if (check.preview.length < previewRows) check.preview.push(row);
42
+ if (row.op?.op === "create") check.creates++;
43
+ else if (row.op?.op === "update") check.updates++;
44
+ else check.problems.push({
45
+ line: row.line,
46
+ cells: row.cells,
47
+ problems: row.problems
48
+ });
49
+ }
50
+ onProgress(check.rows);
51
+ }
52
+ return check;
53
+ };
54
+ const refusal = (error, context) => {
55
+ if (!isApiError(error)) return [error.message];
56
+ const fields = error.errors.filter((entry) => entry.field);
57
+ if (fields.length === 0) return [error.message];
58
+ return fields.map((entry) => `${fieldLabel(context.views[context.model.name], entry.field)}: ${entry.message}`);
59
+ };
60
+ /**
61
+ * Writes the file's rows in batches of `batchRows`, each batch one transaction through `POST :batchWrite`, with the
62
+ * same validation and access as single writes. A row the server refuses is set aside with the reason, and the rest of
63
+ * its batch is sent again, so every row that can be written is. Rows with problems are skipped. Answers `cancelled`
64
+ * when stopped between batches; an error other than a refused row stops the import and is thrown.
65
+ */
66
+ const runImport = async (source, sheet, plan, context, { client, onProgress, cancelled }) => {
67
+ const progress = {
68
+ created: 0,
69
+ updated: 0,
70
+ skipped: 0,
71
+ failed: 0,
72
+ notWritten: []
73
+ };
74
+ const write = async (rows) => {
75
+ const pending = [...rows];
76
+ while (pending.length > 0) {
77
+ const ops = pending.map((row) => row.op);
78
+ try {
79
+ const { results } = await client.batchWrite(ops);
80
+ for (const result of results) if (result.op === "create") progress.created++;
81
+ else if (result.op === "update") progress.updated++;
82
+ return;
83
+ } catch (error) {
84
+ const index = isApiError(error) ? error.operation : void 0;
85
+ if (index === void 0 || !pending[index]) throw error;
86
+ const [refused] = pending.splice(index, 1);
87
+ progress.failed++;
88
+ progress.notWritten.push({
89
+ line: refused.line,
90
+ cells: refused.cells,
91
+ problems: refusal(error, context)
92
+ });
93
+ }
94
+ }
95
+ };
96
+ let queue = [];
97
+ const flush = async (all) => {
98
+ while (queue.length >= 100 || all && queue.length > 0) {
99
+ if (cancelled()) return false;
100
+ await write(queue.splice(0, 100));
101
+ onProgress({ ...progress });
102
+ }
103
+ return true;
104
+ };
105
+ for await (const chunk of dataRows(source, sheet)) {
106
+ for (const row of await prepareRows(chunk, plan, context)) if (row.op) queue.push(row);
107
+ else {
108
+ progress.skipped++;
109
+ progress.notWritten.push({
110
+ line: row.line,
111
+ cells: row.cells,
112
+ problems: row.problems
113
+ });
114
+ }
115
+ if (!await flush(false)) return {
116
+ ...progress,
117
+ cancelled: true
118
+ };
119
+ }
120
+ if (!await flush(true)) return {
121
+ ...progress,
122
+ cancelled: true
123
+ };
124
+ return {
125
+ ...progress,
126
+ cancelled: false
127
+ };
128
+ };
129
+ //#endregion
130
+ export { checkImport, readHead, runImport };
@@ -0,0 +1,17 @@
1
+ //#region src/app/import/sheet-cell.ts
2
+ const isSheetDate = (cell) => typeof cell === "object" && cell !== null;
3
+ /** Whether the cell holds nothing: no value, or only spaces. */
4
+ const isEmptyCell = (cell) => cell === null || typeof cell === "string" && cell.trim() === "";
5
+ /** The cell as the file shows it, for a header, a preview or the file of failed rows. */
6
+ const cellText = (cell) => {
7
+ if (cell === null) return "";
8
+ if (isSheetDate(cell)) return cell.date.replace("T", " ");
9
+ if (typeof cell === "boolean") return cell ? "TRUE" : "FALSE";
10
+ return String(cell);
11
+ };
12
+ /** A column's letters as a spreadsheet names it: 0 is A, 27 is AB. */
13
+ const columnLetters = (index) => index < 26 ? String.fromCharCode(65 + index) : columnLetters(Math.floor(index / 26) - 1) + columnLetters(index % 26);
14
+ /** How the import names a column: its header, or "Column C" when the header cell is empty. */
15
+ const columnName = (headers, index) => headers[index]?.trim() ? headers[index] : `Column ${columnLetters(index)}`;
16
+ //#endregion
17
+ export { cellText, columnLetters, columnName, isEmptyCell, isSheetDate };
@@ -0,0 +1,58 @@
1
+ import { createCsvParser } from "./csv-parser.js";
2
+ import { openWorkbook } from "./xlsx-reader.js";
3
+ //#region src/app/import/sheet-file.ts
4
+ const startsAsZip = async (file) => {
5
+ const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
6
+ return head[0] === 80 && head[1] === 75;
7
+ };
8
+ /** Whether the file is a workbook or CSV text: by its name's extension, else by whether it is a ZIP archive. */
9
+ const sheetFormat = async (file) => {
10
+ const extension = /\.([^.]+)$/.exec(file.name)?.[1]?.toLowerCase();
11
+ if (extension === "xlsx") return "xlsx";
12
+ if (extension === "csv" || extension === "tsv" || extension === "txt") return "csv";
13
+ return await startsAsZip(file) ? "xlsx" : "csv";
14
+ };
15
+ /** The workbook's sheet names, in order; a CSV has none. */
16
+ const sheetNames = async (file) => await sheetFormat(file) === "xlsx" ? (await openWorkbook(file)).sheets : [];
17
+ const notText = () => /* @__PURE__ */ new Error("The file is not UTF-8 text. Save it as \"CSV UTF-8\" and choose it again.");
18
+ async function* csvRows(file) {
19
+ const parser = createCsvParser();
20
+ const decoder = new TextDecoder("utf-8", { fatal: true });
21
+ const decode = (bytes) => {
22
+ try {
23
+ return bytes ? decoder.decode(bytes, { stream: true }) : decoder.decode();
24
+ } catch (error) {
25
+ if (error instanceof TypeError) throw notText();
26
+ throw error;
27
+ }
28
+ };
29
+ const reader = file.stream().getReader();
30
+ let line = 0;
31
+ const numbered = (rows) => rows.map((cells) => ({
32
+ line: ++line,
33
+ cells
34
+ }));
35
+ try {
36
+ for (;;) {
37
+ const { done, value } = await reader.read();
38
+ if (done) break;
39
+ yield* numbered(parser.push(decode(value)));
40
+ }
41
+ yield* numbered([...parser.push(decode()), ...parser.end()]);
42
+ } finally {
43
+ await reader.cancel();
44
+ }
45
+ }
46
+ /**
47
+ * The rows of the file, the header first, read as the file streams in: a CSV's (UTF-8, with its delimiter found from
48
+ * the header line), or the named sheet of a workbook.
49
+ */
50
+ async function* sheetRows(file, sheet) {
51
+ if (await sheetFormat(file) === "csv") return yield* csvRows(file);
52
+ const workbook = await openWorkbook(file);
53
+ const name = sheet ?? workbook.sheets[0];
54
+ if (name === void 0) throw new Error("The workbook has no sheets");
55
+ yield* workbook.rows(name);
56
+ }
57
+ //#endregion
58
+ export { sheetFormat, sheetNames, sheetRows };
@@ -0,0 +1,72 @@
1
+ //#region src/app/import/sheet-source.ts
2
+ /** How many rows the worker sends at a time. */
3
+ const chunkRows = 500;
4
+ const startWorker = () => {
5
+ const worker = new Worker(new URL("./parse-worker.js", import.meta.url), { type: "module" });
6
+ const replies = [];
7
+ let waiting;
8
+ worker.onmessage = (event) => {
9
+ if (waiting) {
10
+ const resolve = waiting;
11
+ waiting = void 0;
12
+ resolve(event.data);
13
+ } else replies.push(event.data);
14
+ };
15
+ worker.onerror = (event) => {
16
+ event.preventDefault();
17
+ const failed = {
18
+ type: "error",
19
+ message: "The file could not be read"
20
+ };
21
+ if (waiting) waiting(failed);
22
+ else replies.push(failed);
23
+ };
24
+ const send = (request) => worker.postMessage(request);
25
+ const receive = () => replies.length > 0 ? Promise.resolve(replies.shift()) : new Promise((resolve) => waiting = resolve);
26
+ return {
27
+ send,
28
+ receive,
29
+ stop: () => worker.terminate()
30
+ };
31
+ };
32
+ /** The file read by a Web Worker, so parsing never holds up the page; each read has a worker of its own. */
33
+ const workerSheetSource = (file) => ({
34
+ sheets: async () => {
35
+ const worker = startWorker();
36
+ try {
37
+ worker.send({
38
+ type: "sheets",
39
+ file
40
+ });
41
+ const reply = await worker.receive();
42
+ if (reply.type === "error") throw new Error(reply.message);
43
+ if (reply.type !== "sheets") throw new Error(`The parse worker answered ${reply.type} to a request for sheets`);
44
+ return reply.sheets;
45
+ } finally {
46
+ worker.stop();
47
+ }
48
+ },
49
+ rows: async function* (sheet) {
50
+ const worker = startWorker();
51
+ try {
52
+ worker.send({
53
+ type: "read",
54
+ file,
55
+ sheet,
56
+ chunk: chunkRows
57
+ });
58
+ for (;;) {
59
+ const reply = await worker.receive();
60
+ if (reply.type === "error") throw new Error(reply.message);
61
+ if (reply.type === "end") return;
62
+ if (reply.type !== "rows") throw new Error(`The parse worker answered ${reply.type} to a read`);
63
+ yield reply.rows;
64
+ worker.send({ type: "next" });
65
+ }
66
+ } finally {
67
+ worker.stop();
68
+ }
69
+ }
70
+ });
71
+ //#endregion
72
+ export { workerSheetSource };