@protobase/ui 0.19.6 → 0.19.7

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.
@@ -0,0 +1,54 @@
1
+ import { createExportBuilder } from "./builder.js";
2
+ //#region src/app/export/builder-port.ts
3
+ const joined = (chunks) => {
4
+ const bytes = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.length, 0));
5
+ let offset = 0;
6
+ for (const chunk of chunks) {
7
+ bytes.set(chunk, offset);
8
+ offset += chunk.length;
9
+ }
10
+ return bytes;
11
+ };
12
+ /** Handles one message at a time, as the worker does, returning the bytes it made; used by the worker and by tests. */
13
+ const createBuilderHandler = () => {
14
+ let builder;
15
+ let chunks = [];
16
+ return (message) => {
17
+ if (message.kind === "start") builder = createExportBuilder(message.plan, (chunk) => chunks.push(chunk));
18
+ else if (!builder) throw new Error("The export was not started");
19
+ else if (message.kind === "page") builder.page(message.records, message.labels);
20
+ else builder.finish();
21
+ const bytes = joined(chunks);
22
+ chunks = [];
23
+ return bytes;
24
+ };
25
+ };
26
+ /**
27
+ * A port to a Web Worker that builds the file, so compressing it never holds up the page. The worker answers messages
28
+ * in the order they came; an error in it fails every message still waiting.
29
+ */
30
+ const createWorkerPort = () => {
31
+ const worker = new Worker(new URL("./export-worker.js", import.meta.url), {
32
+ type: "module",
33
+ name: "protobase-export"
34
+ });
35
+ const waiting = [];
36
+ worker.onmessage = (event) => waiting.shift()?.resolve(event.data.bytes);
37
+ worker.onerror = (event) => {
38
+ event.preventDefault();
39
+ const error = /* @__PURE__ */ new Error(`The export could not be built: ${event.message}`);
40
+ for (const entry of waiting.splice(0)) entry.reject(error);
41
+ };
42
+ return {
43
+ send: (message) => new Promise((resolve, reject) => {
44
+ waiting.push({
45
+ resolve,
46
+ reject
47
+ });
48
+ worker.postMessage(message);
49
+ }),
50
+ close: () => worker.terminate()
51
+ };
52
+ };
53
+ //#endregion
54
+ export { createBuilderHandler, createWorkerPort };
@@ -0,0 +1,61 @@
1
+ import { createXlsxWriter } from "./xlsx.js";
2
+ import { csvText, xlsxCell } from "./cells.js";
3
+ import { csvField, csvLine, csvTextField, utf8Bom } from "./csv.js";
4
+ //#region src/app/export/builder.ts
5
+ const typedText = /* @__PURE__ */ new Set([
6
+ "text",
7
+ "uuid",
8
+ "enum",
9
+ "relation",
10
+ "file",
11
+ "json",
12
+ "currency",
13
+ "country"
14
+ ]);
15
+ const minWidth = 8;
16
+ const maxWidth = 60;
17
+ /** Each column's width in characters, room for its header and the longest value of the first page as shown. */
18
+ const columnWidths = (columns, records, labels) => columns.map((column) => {
19
+ const longest = Math.max(column.header.length, ...records.map((record) => csvText(column, record[column.name], labels, "shown").length));
20
+ return Math.min(maxWidth, Math.max(minWidth, longest + 2));
21
+ });
22
+ const createCsvBuilder = (plan, onChunk) => {
23
+ const encoder = new TextEncoder();
24
+ onChunk(utf8Bom);
25
+ onChunk(encoder.encode(csvLine(plan.columns.map((column) => csvField(column.header)))));
26
+ const field = (column, record, labels) => {
27
+ const text = csvText(column, record[column.name], labels, plan.mode);
28
+ return typedText.has(column.field.type) ? csvTextField(text) : csvField(text);
29
+ };
30
+ return {
31
+ page: (records, labels) => {
32
+ onChunk(encoder.encode(records.map((record) => csvLine(plan.columns.map((column) => field(column, record, labels)))).join("")));
33
+ },
34
+ finish: () => void 0
35
+ };
36
+ };
37
+ const createXlsxBuilder = (plan, onChunk) => {
38
+ let writer;
39
+ const start = (records, labels) => createXlsxWriter({
40
+ name: plan.name,
41
+ headers: plan.columns.map((column) => column.header),
42
+ widths: columnWidths(plan.columns, records, labels)
43
+ }, onChunk);
44
+ return {
45
+ page: (records, labels) => {
46
+ writer ??= start(records, labels);
47
+ writer.rows(records.map((record) => plan.columns.map((column) => xlsxCell(column, record[column.name], labels, plan.mode))));
48
+ },
49
+ finish: () => {
50
+ writer ??= start([], {});
51
+ writer.finish();
52
+ }
53
+ };
54
+ };
55
+ /**
56
+ * Builds an export file page by page: `page` with each page of records and the names of the records their relations
57
+ * point at, then `finish` once. Each piece of the file goes to `onChunk` as soon as it is made.
58
+ */
59
+ const createExportBuilder = (plan, onChunk) => plan.format === "csv" ? createCsvBuilder(plan, onChunk) : createXlsxBuilder(plan, onChunk);
60
+ //#endregion
61
+ export { columnWidths, createExportBuilder };
@@ -0,0 +1,93 @@
1
+ import { fileShown, isStoredFile } from "../../live/file-values.js";
2
+ import { displayValue } from "../../live/display-value.js";
3
+ //#region src/app/export/cells.ts
4
+ const empty = { kind: "empty" };
5
+ const text = (value) => ({
6
+ kind: "text",
7
+ value
8
+ });
9
+ const isEmpty = (value) => value === null || value === void 0 || value === "";
10
+ const relationLabel = (column, value, labels) => labels[column.name]?.[String(value)] ?? String(value);
11
+ const fileText = (value, mode) => {
12
+ if (!isStoredFile(value)) return String(value);
13
+ return mode === "raw" ? value.uri : fileShown(value)?.name ?? value.uri;
14
+ };
15
+ /** A value as one CSV field: as the list shows it, or as the API sends it, empty for no value. */
16
+ const csvText = (column, value, labels, mode) => {
17
+ if (isEmpty(value)) return "";
18
+ const { field, hints } = column;
19
+ if (field.type === "file") return fileText(value, mode);
20
+ if (field.type === "relation") return mode === "shown" ? relationLabel(column, value, labels) : String(value);
21
+ if (mode === "shown") return displayValue(field, value, hints);
22
+ if (typeof value === "object") return JSON.stringify(value);
23
+ return String(value);
24
+ };
25
+ const excelEpochDays = 25569;
26
+ const msPerDay = 864e5;
27
+ const excelSerial = (ms) => ms / msPerDay + excelEpochDays;
28
+ const dateFormats = {
29
+ shown: {
30
+ date: "d mmm yyyy",
31
+ timestamp: "d mmm yyyy, hh:mm"
32
+ },
33
+ raw: {
34
+ date: "yyyy-mm-dd",
35
+ timestamp: "yyyy-mm-dd hh:mm:ss"
36
+ }
37
+ };
38
+ const literal = (affix) => [...affix ?? ""].map((char) => `\\${char}`).join("");
39
+ /** The Excel number format that shows a number as the list does: its decimals, prefix and suffix, or a percent sign. */
40
+ const numberFormat = (hints) => {
41
+ if (hints.format === "percent") return "General\\%";
42
+ const digits = hints.decimals === void 0 ? "General" : hints.decimals > 0 ? `#,##0.${"0".repeat(hints.decimals)}` : "#,##0";
43
+ const code = `${literal(hints.prefix)}${digits}${literal(hints.suffix)}`;
44
+ return code === "General" ? void 0 : code;
45
+ };
46
+ const numberCell = (column, value, mode) => {
47
+ const number = Number(value);
48
+ if (!Number.isFinite(number)) return text(String(value));
49
+ if (column.field.type === "bigint" && !Number.isSafeInteger(number)) return text(String(value));
50
+ const format = mode === "shown" ? numberFormat(column.hints) : void 0;
51
+ return format ? {
52
+ kind: "number",
53
+ value: number,
54
+ format
55
+ } : {
56
+ kind: "number",
57
+ value: number
58
+ };
59
+ };
60
+ const dateCell = (type, value, mode) => {
61
+ const ms = Date.parse(String(value));
62
+ if (Number.isNaN(ms)) return text(String(value));
63
+ return {
64
+ kind: "number",
65
+ value: excelSerial(ms),
66
+ format: dateFormats[mode][type]
67
+ };
68
+ };
69
+ /** A value as one typed Excel cell: numbers, dates and booleans as such, labels or stored values as text. */
70
+ const xlsxCell = (column, value, labels, mode) => {
71
+ if (isEmpty(value)) return empty;
72
+ const { field } = column;
73
+ switch (field.type) {
74
+ case "boolean": return {
75
+ kind: "boolean",
76
+ value: Boolean(value)
77
+ };
78
+ case "integer":
79
+ case "bigint":
80
+ case "decimal": return numberCell(column, value, mode);
81
+ case "date":
82
+ case "timestamp": return dateCell(field.type, value, mode);
83
+ case "relation":
84
+ if (mode === "shown") return text(relationLabel(column, value, labels));
85
+ return typeof value === "number" ? {
86
+ kind: "number",
87
+ value
88
+ } : text(String(value));
89
+ default: return text(csvText(column, value, labels, mode));
90
+ }
91
+ };
92
+ //#endregion
93
+ export { csvText, numberFormat, xlsxCell };
@@ -0,0 +1,17 @@
1
+ //#region src/app/export/csv.ts
2
+ /** The UTF-8 byte order mark, which makes Excel read a CSV file as UTF-8 instead of the system's code page. */
3
+ const utf8Bom = new Uint8Array([
4
+ 239,
5
+ 187,
6
+ 191
7
+ ]);
8
+ const needsQuotes = /[",\r\n]/;
9
+ const formulaStart = /^[=+\-@\t\r]/;
10
+ /** One field as RFC 4180 writes it: in double quotes, its quotes doubled, when it holds a comma, quote or line break. */
11
+ const csvField = (value) => needsQuotes.test(value) ? `"${value.replaceAll("\"", "\"\"")}"` : value;
12
+ /** Text from a text field, kept from running as a formula in a spreadsheet. */
13
+ const csvTextField = (value) => csvField(formulaStart.test(value) ? `'${value}` : value);
14
+ /** One record, CRLF-terminated as RFC 4180 has it. */
15
+ const csvLine = (fields) => `${fields.join(",")}\r\n`;
16
+ //#endregion
17
+ export { csvField, csvLine, csvTextField, utf8Bom };
@@ -0,0 +1,235 @@
1
+ import { Segmented } from "../../primitives/segmented.js";
2
+ import { formatApproxRows, formatInt } from "../../format/number.js";
3
+ import { Button } from "../../primitives/button.js";
4
+ import { Checkbox } from "../../primitives/checkbox.js";
5
+ import { ResponsiveDialog } from "../../primitives/responsive-dialog.js";
6
+ import { exportLimit } from "./export-options.js";
7
+ import { useState } from "react";
8
+ import { ArrowDown, ArrowUp } from "lucide-react";
9
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
10
+ //#region src/app/export/export-dialog.tsx
11
+ const formatOptions = [{
12
+ value: "xlsx",
13
+ label: "Excel (.xlsx)"
14
+ }, {
15
+ value: "csv",
16
+ label: "CSV"
17
+ }];
18
+ const modeOptions = [{
19
+ value: "shown",
20
+ label: "As shown"
21
+ }, {
22
+ value: "raw",
23
+ label: "Raw values"
24
+ }];
25
+ const modeHelp = {
26
+ shown: "Labels, dates and numbers as the list shows them.",
27
+ raw: "Values as they are stored, such as keys and codes."
28
+ };
29
+ const countText = (count) => {
30
+ if (count.kind === "counting") return "Counting rows…";
31
+ if (count.kind === "exact") return formatApproxRows(count.rows);
32
+ return count.rows > 1e4 ? formatApproxRows(count.rows) : "more than 10,000 rows";
33
+ };
34
+ /** The fewest rows the list finds, as far as the count tells: an estimate stands only above 10,000. */
35
+ const countedRows = (count) => {
36
+ if (count.kind === "counting") return void 0;
37
+ return count.kind === "exact" ? count.rows : Math.max(count.rows, 10001);
38
+ };
39
+ const Setting = ({ label, help, children }) => /* @__PURE__ */ jsxs("div", {
40
+ className: "flex flex-col items-start gap-1.5",
41
+ children: [
42
+ /* @__PURE__ */ jsx("span", {
43
+ className: "text-[13px] font-medium",
44
+ children: label
45
+ }),
46
+ children,
47
+ help && /* @__PURE__ */ jsx("span", {
48
+ className: "text-xs text-muted-foreground",
49
+ children: help
50
+ })
51
+ ]
52
+ });
53
+ const move = (columns, name, by) => {
54
+ const index = columns.indexOf(name);
55
+ const next = [...columns];
56
+ next.splice(index, 1);
57
+ next.splice(index + by, 0, name);
58
+ return next;
59
+ };
60
+ /**
61
+ * The columns a file holds, in order: the chosen ones first, each with buttons that move it up or down, then the
62
+ * fields left out, to add. The dialog scrolls when the list is long, so it has no scroll box of its own.
63
+ */
64
+ const ColumnPicker = ({ fields, columns, onChange }) => {
65
+ const labels = Object.fromEntries(fields.map((field) => [field.name, field.label]));
66
+ const ordered = [...columns, ...fields.map((field) => field.name).filter((name) => !columns.includes(name))];
67
+ return /* @__PURE__ */ jsx("ul", {
68
+ className: "divide-y self-stretch rounded-md border",
69
+ "aria-label": "Columns",
70
+ children: ordered.map((name) => {
71
+ const chosen = columns.includes(name);
72
+ const index = columns.indexOf(name);
73
+ return /* @__PURE__ */ jsxs("li", {
74
+ className: "flex min-h-10 items-center gap-2 px-3 py-1",
75
+ children: [/* @__PURE__ */ jsxs("label", {
76
+ className: "flex min-w-0 flex-1 cursor-pointer items-center gap-2.5",
77
+ children: [/* @__PURE__ */ jsx(Checkbox, {
78
+ checked: chosen,
79
+ label: labels[name],
80
+ onChange: (checked) => onChange(checked ? [...columns, name] : columns.filter((column) => column !== name))
81
+ }), /* @__PURE__ */ jsx("span", {
82
+ className: chosen ? "truncate" : "truncate text-muted-foreground",
83
+ children: labels[name]
84
+ })]
85
+ }), chosen && /* @__PURE__ */ jsxs("span", {
86
+ className: "flex shrink-0 gap-1",
87
+ children: [/* @__PURE__ */ jsx(Button, {
88
+ size: "sm",
89
+ variant: "ghost",
90
+ className: "min-h-8 px-2",
91
+ "aria-label": `Move ${labels[name]} up`,
92
+ disabled: index === 0,
93
+ onClick: () => onChange(move(columns, name, -1)),
94
+ children: /* @__PURE__ */ jsx(ArrowUp, { className: "size-3.5" })
95
+ }), /* @__PURE__ */ jsx(Button, {
96
+ size: "sm",
97
+ variant: "ghost",
98
+ className: "min-h-8 px-2",
99
+ "aria-label": `Move ${labels[name]} down`,
100
+ disabled: index === columns.length - 1,
101
+ onClick: () => onChange(move(columns, name, 1)),
102
+ children: /* @__PURE__ */ jsx(ArrowDown, { className: "size-3.5" })
103
+ })]
104
+ })]
105
+ }, name);
106
+ })
107
+ });
108
+ };
109
+ /**
110
+ * The Export dialog: the file's format, which rows (the list's, or the selected ones), which columns in which order, and
111
+ * whether values are exported as shown or raw, with the number of rows before it starts.
112
+ */
113
+ const ExportDialog = ({ plural, view, fields, initialColumns, count, selected, open, onExport, onClose }) => {
114
+ const [format, setFormat] = useState("xlsx");
115
+ const [scope, setScope] = useState(selected ? "selected" : "list");
116
+ const [columns, setColumns] = useState(initialColumns);
117
+ const [mode, setMode] = useState("shown");
118
+ const limit = exportLimit(view, format);
119
+ const rows = scope === "selected" ? `${formatInt(selected ?? 0)} selected ${selected === 1 ? "row" : "rows"}` : countText(count);
120
+ const overLimit = (scope === "selected" ? selected ?? 0 : countedRows(count) ?? 0) > limit;
121
+ const scopeOptions = [{
122
+ value: "list",
123
+ label: "Rows in the list"
124
+ }, {
125
+ value: "selected",
126
+ label: `Selected (${formatInt(selected ?? 0)})`
127
+ }];
128
+ return /* @__PURE__ */ jsx(ResponsiveDialog, {
129
+ title: `Export ${plural.toLowerCase()}`,
130
+ open,
131
+ onClose,
132
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
133
+ onClick: onClose,
134
+ children: "Cancel"
135
+ }), /* @__PURE__ */ jsx(Button, {
136
+ variant: "primary",
137
+ disabled: columns.length === 0,
138
+ onClick: () => onExport({
139
+ format,
140
+ scope,
141
+ columns,
142
+ mode
143
+ }),
144
+ children: "Export"
145
+ })] }),
146
+ children: /* @__PURE__ */ jsxs("div", {
147
+ className: "flex flex-col gap-4 text-[13px]",
148
+ children: [
149
+ /* @__PURE__ */ jsx(Setting, {
150
+ label: "Format",
151
+ children: /* @__PURE__ */ jsx(Segmented, {
152
+ size: "md",
153
+ label: "Format",
154
+ value: format,
155
+ options: formatOptions,
156
+ onChange: setFormat
157
+ })
158
+ }),
159
+ selected !== void 0 && selected > 0 && /* @__PURE__ */ jsx(Setting, {
160
+ label: "Rows",
161
+ help: scope === "list" ? "With the list’s filters, search and sort." : void 0,
162
+ children: /* @__PURE__ */ jsx(Segmented, {
163
+ size: "md",
164
+ label: "Rows",
165
+ value: scope,
166
+ options: scopeOptions,
167
+ onChange: setScope
168
+ })
169
+ }),
170
+ /* @__PURE__ */ jsx(Setting, {
171
+ label: "Values",
172
+ help: modeHelp[mode],
173
+ children: /* @__PURE__ */ jsx(Segmented, {
174
+ size: "md",
175
+ label: "Values",
176
+ value: mode,
177
+ options: modeOptions,
178
+ onChange: setMode
179
+ })
180
+ }),
181
+ /* @__PURE__ */ jsx(Setting, {
182
+ label: "Columns",
183
+ children: /* @__PURE__ */ jsx(ColumnPicker, {
184
+ fields,
185
+ columns,
186
+ onChange: setColumns
187
+ })
188
+ }),
189
+ /* @__PURE__ */ jsxs("p", {
190
+ role: "status",
191
+ className: "text-muted-foreground",
192
+ children: [
193
+ "Exports ",
194
+ rows,
195
+ ".",
196
+ overLimit && ` A file holds at most ${formatInt(limit)} rows; the rest are left out.`
197
+ ]
198
+ })
199
+ ]
200
+ })
201
+ });
202
+ };
203
+ /** While the file is built: how many rows are in it so far, out of how many, and a way to stop. */
204
+ const ExportProgressDialog = ({ plural, done, total, onCancel }) => {
205
+ const share = total ? Math.min(1, done / total) : void 0;
206
+ return /* @__PURE__ */ jsx(ResponsiveDialog, {
207
+ title: `Exporting ${plural.toLowerCase()}`,
208
+ open: true,
209
+ onClose: onCancel,
210
+ actions: /* @__PURE__ */ jsx(Button, {
211
+ onClick: onCancel,
212
+ children: "Cancel"
213
+ }),
214
+ children: /* @__PURE__ */ jsxs("div", {
215
+ className: "flex flex-col gap-2 text-[13px]",
216
+ children: [/* @__PURE__ */ jsx("div", {
217
+ role: "progressbar",
218
+ "aria-label": "Rows exported",
219
+ "aria-valuemin": 0,
220
+ "aria-valuemax": total,
221
+ "aria-valuenow": done,
222
+ className: "h-1.5 overflow-hidden rounded-full bg-muted",
223
+ children: /* @__PURE__ */ jsx("div", {
224
+ className: "h-full rounded-full bg-primary transition-[width]",
225
+ style: { width: `${(share ?? 0) * 100}%` }
226
+ })
227
+ }), /* @__PURE__ */ jsx("p", {
228
+ className: "text-muted-foreground",
229
+ children: total ? `${formatInt(done)} of ${formatInt(total)} rows` : `${formatInt(done)} rows`
230
+ })]
231
+ })
232
+ });
233
+ };
234
+ //#endregion
235
+ export { ExportDialog, ExportProgressDialog, countedRows };
@@ -0,0 +1,49 @@
1
+ import { columnsFromModel, listColumnNames } from "../../live/columns-from-model.js";
2
+ import { xlsxMaxRows } from "./xlsx.js";
3
+ import { createWhere, printFilter } from "@protobase/schema";
4
+ //#region src/app/export/export-options.ts
5
+ const where = createWhere();
6
+ /** Whether the view's list offers Export: unless the view takes it away. */
7
+ const exportOffered = (view) => view?.export !== false;
8
+ /**
9
+ * The fields a file may hold, in the resource's order: those the user may read, which `/meta` gives, but the sensitive
10
+ * ones, which no list carries and the page shows only when revealed one at a time.
11
+ */
12
+ const exportableFields = (model) => Object.values(model.fields).flatMap((field) => field.sensitive ? [] : [field.name]);
13
+ /** The columns a file starts with: the list's own, in its order. */
14
+ const defaultExportColumns = (model, view) => {
15
+ const exportable = new Set(exportableFields(model));
16
+ return listColumnNames(model, view).filter((name) => exportable.has(name));
17
+ };
18
+ /** Each field's header, as the list's column for it reads. */
19
+ const exportHeaders = (model, view, names) => Object.fromEntries(columnsFromModel(model, view, [], names).map((column) => [column.id, column.header]));
20
+ /** The columns of a file, each with its field, header and the view's hints for showing it. */
21
+ const exportColumns = (model, view, names) => {
22
+ const headers = exportHeaders(model, view, names);
23
+ return names.flatMap((name) => {
24
+ const field = model.fields[name];
25
+ return field ? [{
26
+ name,
27
+ header: headers[name],
28
+ field,
29
+ hints: view?.fields[name] ?? {}
30
+ }] : [];
31
+ });
32
+ };
33
+ /** The most rows a file of `format` holds: the view's limit, and for a workbook what one sheet can hold. */
34
+ const exportLimit = (view, format) => {
35
+ const viewLimit = view?.export ? view.export.maxRows ?? Infinity : Infinity;
36
+ return Math.min(viewLimit, format === "xlsx" ? xlsxMaxRows : Infinity);
37
+ };
38
+ /** Whether selected rows can be exported: they are found again by their key, which must be a single field. */
39
+ const exportsSelection = (model) => model.primaryKey.length === 1;
40
+ /** Filters that find the selected records again by key, a page's worth in each. */
41
+ const selectionFilters = (model, keys) => {
42
+ const [keyField] = model.primaryKey;
43
+ if (!keyField || model.primaryKey.length !== 1) throw new Error("Not implemented: selected rows are exported by a single-field key");
44
+ const filters = [];
45
+ for (let start = 0; start < keys.length; start += 500) filters.push(printFilter(where.in(keyField, keys.slice(start, start + 500))));
46
+ return filters;
47
+ };
48
+ //#endregion
49
+ export { defaultExportColumns, exportColumns, exportHeaders, exportLimit, exportOffered, exportableFields, exportsSelection, selectionFilters };
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,9 @@
1
+ import { createBuilderHandler } from "./builder-port.js";
2
+ //#region src/app/export/export-worker.ts
3
+ const handle = createBuilderHandler();
4
+ const scope = self;
5
+ scope.onmessage = (event) => {
6
+ const bytes = handle(event.data);
7
+ scope.postMessage({ bytes }, [bytes.buffer]);
8
+ };
9
+ //#endregion
@@ -0,0 +1,62 @@
1
+ const aborted = () => new DOMException("The export was cancelled", "AbortError");
2
+ /**
3
+ * Reads the rows through the list API, page by page with its page tokens, and has the port build the file from each;
4
+ * each piece is written to the sink as it is built, so only a page is held at a time. A cancel stops at the next page
5
+ * and discards what was written.
6
+ */
7
+ const runExport = async ({ client, resource, filters, orderBy, plan, labels, limit, port, sink, signal, onProgress }) => {
8
+ const fields = plan.columns.map((column) => column.name);
9
+ let rows = 0;
10
+ try {
11
+ await sink.write(await port.send({
12
+ kind: "start",
13
+ plan
14
+ }));
15
+ for (const [index, filter] of filters.entries()) {
16
+ let pageToken = "";
17
+ do {
18
+ const page = await client.search(resource, {
19
+ filter,
20
+ orderBy,
21
+ pageSize: 500,
22
+ pageToken,
23
+ fields,
24
+ labels
25
+ });
26
+ if (signal.aborted) throw aborted();
27
+ const records = page.items.slice(0, limit - rows);
28
+ await sink.write(await port.send({
29
+ kind: "page",
30
+ records,
31
+ labels: page.labels ?? {}
32
+ }));
33
+ rows += records.length;
34
+ onProgress(rows);
35
+ pageToken = page.nextPageToken;
36
+ if (rows >= limit) {
37
+ await finish(port, sink);
38
+ return {
39
+ rows,
40
+ truncated: records.length < page.items.length || pageToken !== "" || index + 1 < filters.length
41
+ };
42
+ }
43
+ } while (pageToken !== "");
44
+ }
45
+ await finish(port, sink);
46
+ return {
47
+ rows,
48
+ truncated: false
49
+ };
50
+ } catch (error) {
51
+ await sink.abort();
52
+ throw error;
53
+ } finally {
54
+ port.close();
55
+ }
56
+ };
57
+ const finish = async (port, sink) => {
58
+ await sink.write(await port.send({ kind: "finish" }));
59
+ await sink.close();
60
+ };
61
+ //#endregion
62
+ export { runExport };
@@ -0,0 +1,91 @@
1
+ //#region src/app/export/sink.ts
2
+ const types = {
3
+ xlsx: {
4
+ mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
5
+ extension: ".xlsx",
6
+ description: "Excel workbook"
7
+ },
8
+ csv: {
9
+ mime: "text/csv",
10
+ extension: ".csv",
11
+ description: "CSV file"
12
+ }
13
+ };
14
+ const unsafeName = /[\\/:*?"<>|\u0000-\u001f]/g;
15
+ /** The file's name: the resource's label and the day, such as `Orders 2026-10-16.xlsx`. */
16
+ const exportFileName = (label, format, now = /* @__PURE__ */ new Date()) => {
17
+ const day = [
18
+ now.getFullYear(),
19
+ String(now.getMonth() + 1).padStart(2, "0"),
20
+ String(now.getDate()).padStart(2, "0")
21
+ ].join("-");
22
+ return `${label.replace(unsafeName, " ").trim()} ${day}${types[format].extension}`;
23
+ };
24
+ const blobFlushBytes = 8e6;
25
+ /** Collects the file in a Blob and downloads it under `name` once it is complete, for browsers without a save picker. */
26
+ const createDownloadSink = (name, format) => {
27
+ let blob = new Blob([], { type: types[format].mime });
28
+ let pending = [];
29
+ let pendingBytes = 0;
30
+ const flush = () => {
31
+ blob = new Blob([blob, ...pending], { type: types[format].mime });
32
+ pending = [];
33
+ pendingBytes = 0;
34
+ };
35
+ return {
36
+ write: async (bytes) => {
37
+ if (bytes.length === 0) return;
38
+ pending.push(bytes);
39
+ pendingBytes += bytes.length;
40
+ if (pendingBytes >= blobFlushBytes) flush();
41
+ },
42
+ close: async () => {
43
+ flush();
44
+ const url = URL.createObjectURL(blob);
45
+ const link = document.createElement("a");
46
+ link.href = url;
47
+ link.download = name;
48
+ link.click();
49
+ setTimeout(() => URL.revokeObjectURL(url), 6e4);
50
+ },
51
+ abort: async () => {
52
+ pending = [];
53
+ blob = new Blob([]);
54
+ }
55
+ };
56
+ };
57
+ const savePicker = () => globalThis.showSaveFilePicker;
58
+ /** Whether the browser can write the file to disk as it is built (the File System Access API). */
59
+ const canSaveAsBuilt = () => typeof savePicker() === "function";
60
+ /**
61
+ * Asks where to save the file and writes it there as it is built, so the page holds none of it; `undefined` when the
62
+ * user closes the picker. Call it from the click that starts the export, as the browser asks.
63
+ */
64
+ const pickFileSink = async (name, format) => {
65
+ const pick = savePicker();
66
+ if (!pick) throw new Error("This browser cannot save a file as it is built");
67
+ const type = types[format];
68
+ let handle;
69
+ try {
70
+ handle = await pick({
71
+ suggestedName: name,
72
+ types: [{
73
+ description: type.description,
74
+ accept: { [type.mime]: [type.extension] }
75
+ }]
76
+ });
77
+ } catch (error) {
78
+ if (error instanceof DOMException && error.name === "AbortError") return void 0;
79
+ throw error;
80
+ }
81
+ const writable = await handle.createWritable();
82
+ return {
83
+ write: async (bytes) => {
84
+ if (bytes.length > 0) await writable.write(bytes);
85
+ },
86
+ close: () => writable.close(),
87
+ abort: () => writable.abort()
88
+ };
89
+ };
90
+ //#endregion
91
+ export { canSaveAsBuilt, createDownloadSink, exportFileName, pickFileSink };