@atscript/ui-table 0.1.133 → 0.1.135

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/index.cjs CHANGED
@@ -1681,10 +1681,230 @@ function reorderColumnNames(names, fromPath, toPath, position) {
1681
1681
  return without.slice(0, insertAt).concat(fromPath, without.slice(insertAt));
1682
1682
  }
1683
1683
  //#endregion
1684
+ //#region src/export/csv.ts
1685
+ const FORMULA_LEADERS = new Set([
1686
+ "=",
1687
+ "+",
1688
+ "-",
1689
+ "@",
1690
+ " ",
1691
+ "\r"
1692
+ ]);
1693
+ /** RFC 4180 row separator. */
1694
+ const CRLF = "\r\n";
1695
+ /**
1696
+ * Render one value as an RFC 4180 field: formula-escaped (unless opted out),
1697
+ * then quoted when it contains the delimiter, a quote, CR or LF — with inner
1698
+ * quotes doubled.
1699
+ */
1700
+ function csvCell(value, opts = {}) {
1701
+ const delimiter = opts.delimiter ?? ",";
1702
+ let text = value === null || value === void 0 ? "" : String(value);
1703
+ if (typeof value === "string" && opts.escapeFormulas !== false && text.length > 0 && FORMULA_LEADERS.has(text[0])) text = `'${text}`;
1704
+ return text.includes(delimiter) || text.includes("\"") || text.includes("\n") || text.includes("\r") ? `"${text.replaceAll("\"", "\"\"")}"` : text;
1705
+ }
1706
+ /**
1707
+ * Serialise a header row + data rows to an RFC 4180 CSV string (`\r\n` row
1708
+ * separators, quoted fields where required, optional UTF-8 BOM).
1709
+ *
1710
+ * Pure — no DOM, no framework. See {@link csvCell} for the per-field rules.
1711
+ */
1712
+ function toCsv(header, rows, opts = {}) {
1713
+ const delimiter = opts.delimiter ?? ",";
1714
+ const lines = [header.map((h) => csvCell(h, opts)).join(delimiter)];
1715
+ for (const row of rows) lines.push(row.map((v) => csvCell(v, opts)).join(delimiter));
1716
+ const body = lines.join(CRLF) + CRLF;
1717
+ return opts.bom ? `${body}` : body;
1718
+ }
1719
+ //#endregion
1720
+ //#region src/export/export-paging.ts
1721
+ /** Default page size used by the export pager when the caller gives none. */
1722
+ const DEFAULT_EXPORT_PAGE_SIZE = 500;
1723
+ /** Rejection raised when an export is cancelled through its `AbortSignal`. */
1724
+ var ExportAbortError = class extends Error {
1725
+ /** Matches the DOM convention so `err.name === "AbortError"` checks work. */
1726
+ name = "AbortError";
1727
+ constructor(message = "Export aborted") {
1728
+ super(message);
1729
+ }
1730
+ };
1731
+ /**
1732
+ * Append the primary key(s) to a query's `$sort` as a tiebreaker so paging is
1733
+ * stable: two rows equal on every user sorter keep a deterministic relative
1734
+ * order, and no row is skipped or duplicated across page boundaries.
1735
+ *
1736
+ * Existing sort fields are preserved in order and never replaced — a pk the
1737
+ * user already sorts by is left where it is. Returns the input query when
1738
+ * there is nothing to add.
1739
+ */
1740
+ function withStableOrder(query, primaryKeys) {
1741
+ if (primaryKeys.length === 0) return query;
1742
+ const current = query.controls?.$sort ?? {};
1743
+ const missing = primaryKeys.filter((pk) => !(pk in current));
1744
+ if (missing.length === 0) return query;
1745
+ const $sort = { ...current };
1746
+ for (const pk of missing) $sort[pk] = 1;
1747
+ return {
1748
+ ...query,
1749
+ controls: {
1750
+ ...query.controls,
1751
+ $sort
1752
+ }
1753
+ };
1754
+ }
1755
+ /**
1756
+ * Page through `query` until the endpoint runs out of rows, collecting every
1757
+ * row into one array.
1758
+ *
1759
+ * Stops when a page comes back short (fewer rows than `pageSize`), when the
1760
+ * reported `count` is reached, or when `maxRows` is hit. Checks `signal`
1761
+ * before and after every request so a cancel lands between pages rather than
1762
+ * mid-flight.
1763
+ */
1764
+ async function collectExportRows(opts) {
1765
+ const pageSize = opts.pageSize && opts.pageSize > 0 ? opts.pageSize : 500;
1766
+ const mapRow = opts.mapRow ?? ((row) => row);
1767
+ const rows = [];
1768
+ let total;
1769
+ let page = 1;
1770
+ for (;;) {
1771
+ throwIfAborted(opts.signal);
1772
+ const result = await opts.fetchPage(opts.query, page, pageSize);
1773
+ throwIfAborted(opts.signal);
1774
+ if (page === 1 && typeof result.count === "number") total = result.count;
1775
+ const data = result.data ?? [];
1776
+ const take = opts.maxRows === void 0 ? data.length : Math.min(data.length, opts.maxRows - rows.length);
1777
+ for (let i = 0; i < take; i++) rows.push(mapRow(data[i]));
1778
+ opts.onProgress?.(rows.length, total);
1779
+ if (data.length < pageSize || opts.maxRows !== void 0 && rows.length >= opts.maxRows || total !== void 0 && rows.length >= total) break;
1780
+ page++;
1781
+ }
1782
+ return {
1783
+ rows,
1784
+ total
1785
+ };
1786
+ }
1787
+ function throwIfAborted(signal) {
1788
+ if (signal?.aborted) throw new ExportAbortError();
1789
+ }
1790
+ //#endregion
1791
+ //#region src/export/export-value.ts
1792
+ /**
1793
+ * Turn a raw cell value into the scalar an export carries, using the same
1794
+ * column metadata the cell renderers read:
1795
+ *
1796
+ * - `null` / `undefined` → `null` (an empty CSV field).
1797
+ * - union / enum columns (`column.options`) → the option's **label**, so the
1798
+ * file reads like the screen; an unknown key falls back to the raw value.
1799
+ * - `Date` → ISO 8601 (spreadsheet- and re-import-friendly, unlike a
1800
+ * locale-formatted date).
1801
+ * - arrays → comma-joined scalars; other objects → JSON.
1802
+ * - numbers / booleans pass through untouched so a spreadsheet keeps them
1803
+ * numeric.
1804
+ *
1805
+ * Callers override per column (`formatters`) or globally (`formatCell`) when
1806
+ * they want different text.
1807
+ */
1808
+ function resolveExportValue(value, column) {
1809
+ if (value === null || value === void 0) return null;
1810
+ const options = column?.options;
1811
+ if (options?.length) {
1812
+ const key = typeof value === "string" || typeof value === "number" ? String(value) : void 0;
1813
+ if (key !== void 0) {
1814
+ const hit = options.find((o) => o.key === key);
1815
+ if (hit) return hit.label;
1816
+ }
1817
+ }
1818
+ if (typeof value === "number" || typeof value === "boolean") return value;
1819
+ if (typeof value === "string") return value;
1820
+ if (value instanceof Date) return value.toISOString();
1821
+ if (Array.isArray(value)) return value.map((v) => scalarText(v)).join(", ");
1822
+ return scalarText(value);
1823
+ }
1824
+ function scalarText(value) {
1825
+ if (value === null || value === void 0 || typeof value === "function") return "";
1826
+ try {
1827
+ return (0, _atscript_ui.str)(value);
1828
+ } catch {
1829
+ return "";
1830
+ }
1831
+ }
1832
+ //#endregion
1833
+ //#region src/columns/display-columns.ts
1834
+ /** Columns with no explicit `order` land after every server column. */
1835
+ const TRAILING_ORDER = Number.MAX_SAFE_INTEGER;
1836
+ /** Turn a {@link DisplayColumnDef} into the `ColumnDef` the table renders. */
1837
+ function displayColumnToDef(def) {
1838
+ return {
1839
+ path: def.key,
1840
+ label: def.label,
1841
+ type: def.type ?? "text",
1842
+ component: def.component,
1843
+ sortable: def.sortable === "local",
1844
+ filterable: false,
1845
+ nullable: true,
1846
+ order: def.order ?? TRAILING_ORDER,
1847
+ width: def.width,
1848
+ local: true
1849
+ };
1850
+ }
1851
+ /**
1852
+ * Merge client-owned columns into the server's column list, ordered by each
1853
+ * column's `order`. `Array#sort` is stable, so server columns that share an
1854
+ * `order` keep their `/meta` sequence. Returns `base` untouched when there is
1855
+ * nothing to merge.
1856
+ */
1857
+ function mergeDisplayColumns(base, display) {
1858
+ if (display.length === 0) return base;
1859
+ return [...base, ...display.map(displayColumnToDef)].toSorted((a, b) => a.order - b.order);
1860
+ }
1861
+ //#endregion
1862
+ //#region src/utils/sort-rows.ts
1863
+ /**
1864
+ * Coerce a primitive cell value to the text used for comparing and matching
1865
+ * cells — the string compare in {@link sortRowsLocally} and the substring
1866
+ * search of an in-memory table. Objects and functions collapse to `""` so
1867
+ * `'[object Object]'` never drives an ordering or matches a search.
1868
+ */
1869
+ function cellAsString(v) {
1870
+ if (v == null) return "";
1871
+ if (typeof v === "string") return v;
1872
+ if (typeof v === "number" || typeof v === "boolean") return v.toString();
1873
+ return "";
1874
+ }
1875
+ /**
1876
+ * Sort rows in memory by `sorters`, numerically when both sides are numbers
1877
+ * and locale-aware otherwise. Returns a NEW array; the input is untouched. A
1878
+ * no-op (returns the input reference) when there is nothing to sort.
1879
+ *
1880
+ * `getValue` reads a sorter's field off a row — pass a dot-path reader for
1881
+ * nested fields; the default is a flat property read.
1882
+ */
1883
+ function sortRowsLocally(rows, sorters, getValue = (row, field) => row[field]) {
1884
+ if (sorters.length === 0 || rows.length < 2) return rows;
1885
+ return rows.toSorted((a, b) => {
1886
+ for (const s of sorters) {
1887
+ const dir = s.direction === "desc" ? -1 : 1;
1888
+ const av = getValue(a, s.field);
1889
+ const bv = getValue(b, s.field);
1890
+ if (typeof av === "number" && typeof bv === "number") {
1891
+ if (av < bv) return -dir;
1892
+ if (av > bv) return dir;
1893
+ } else {
1894
+ const cmp = cellAsString(av).localeCompare(cellAsString(bv));
1895
+ if (cmp !== 0) return cmp * dir;
1896
+ }
1897
+ }
1898
+ return 0;
1899
+ });
1900
+ }
1901
+ //#endregion
1684
1902
  exports.APP_CONF_PREFIX = APP_CONF_PREFIX;
1685
1903
  exports.AppPrefsClient = AppPrefsClient;
1904
+ exports.DEFAULT_EXPORT_PAGE_SIZE = DEFAULT_EXPORT_PAGE_SIZE;
1686
1905
  exports.DEFAULT_ROW_HEIGHT_PX = DEFAULT_ROW_HEIGHT_PX;
1687
1906
  exports.DRAFT_PERSISTED_ASPECTS = DRAFT_PERSISTED_ASPECTS;
1907
+ exports.ExportAbortError = ExportAbortError;
1688
1908
  exports.MAX_DEFAULT_COLUMN_WIDTH_PX = MAX_DEFAULT_COLUMN_WIDTH_PX;
1689
1909
  exports.NULL_OPS = NULL_OPS;
1690
1910
  exports.PRESET_ASPECTS = PRESET_ASPECTS;
@@ -1698,11 +1918,14 @@ exports.appConfId = appConfId;
1698
1918
  exports.arraysEqual = arraysEqual;
1699
1919
  exports.blockStartFor = blockStartFor;
1700
1920
  exports.buildTableQuery = buildTableQuery;
1921
+ exports.cellAsString = cellAsString;
1701
1922
  exports.clampTopIndex = clampTopIndex;
1923
+ exports.collectExportRows = collectExportRows;
1702
1924
  exports.columnFilterType = columnFilterType;
1703
1925
  exports.computeDefaultColumnWidth = computeDefaultColumnWidth;
1704
1926
  exports.conditionLabel = conditionLabel;
1705
1927
  exports.conditionsForType = conditionsForType;
1928
+ exports.csvCell = csvCell;
1706
1929
  exports.dateShortcuts = dateShortcuts;
1707
1930
  exports.debounce = debounce;
1708
1931
  exports.defaultCondition = defaultCondition;
@@ -1723,6 +1946,7 @@ exports.isFilled = isFilled;
1723
1946
  exports.isSimpleEq = isSimpleEq;
1724
1947
  exports.isSystemPresetId = isSystemPresetId;
1725
1948
  exports.isUnavailableError = isUnavailableError;
1949
+ exports.mergeDisplayColumns = mergeDisplayColumns;
1726
1950
  exports.mergeFilters = mergeFilters;
1727
1951
  exports.mergeSorters = mergeSorters;
1728
1952
  exports.normaliseSystemPresetId = normaliseSystemPresetId;
@@ -1732,14 +1956,17 @@ exports.planFetch = planFetch;
1732
1956
  exports.reconcileColumnWidthDefaults = reconcileColumnWidthDefaults;
1733
1957
  exports.reorderColumnNames = reorderColumnNames;
1734
1958
  exports.resolveAspectGate = resolveAspectGate;
1959
+ exports.resolveExportValue = resolveExportValue;
1735
1960
  exports.resolveSystemPresets = resolveSystemPresets;
1736
1961
  exports.rowsToPks = rowsToPks;
1737
1962
  exports.sameColumnSet = sameColumnSet;
1738
1963
  exports.serializeDraft = serializeDraft;
1739
1964
  exports.setsEqual = setsEqual;
1965
+ exports.sortRowsLocally = sortRowsLocally;
1740
1966
  exports.sortersEqual = sortersEqual;
1741
1967
  exports.stableStringify = stableStringify;
1742
1968
  exports.stateToUrlQueryString = stateToUrlQueryString;
1969
+ exports.toCsv = toCsv;
1743
1970
  exports.toWireSnapshot = toWireSnapshot;
1744
1971
  exports.togglePk = togglePk;
1745
1972
  exports.trimSelection = trimSelection;
@@ -1750,3 +1977,4 @@ exports.urlQueryStringToState = urlQueryStringToState;
1750
1977
  exports.userConfId = userConfId;
1751
1978
  exports.walkBackwardAbsorb = walkBackwardAbsorb;
1752
1979
  exports.walkForwardAbsorb = walkForwardAbsorb;
1980
+ exports.withStableOrder = withStableOrder;
package/dist/index.d.cts CHANGED
@@ -1106,4 +1106,188 @@ type ColumnReorderPosition = "before" | "after";
1106
1106
  */
1107
1107
  declare function reorderColumnNames(names: string[], fromPath: string, toPath: string, position: ColumnReorderPosition): string[];
1108
1108
  //#endregion
1109
- export { APP_CONF_PREFIX, type AppConfData, AppPrefsClient, type AppPrefsClientConfig, type AppPrefsLoadResult, type AsPresetEntryData, type AsPresetEntryRow, type AsPresetsErrorCode, type AspectGate, type AspectMask, type BuildTableQueryOptions, type ColumnFilterType, type ColumnReorderPosition, type ColumnWidthEntry, type ColumnWidthsMap, type ConfigTab, DEFAULT_ROW_HEIGHT_PX, DRAFT_PERSISTED_ASPECTS, type DateShortcut, type DraftPersistedAspect, type FetchPlan, type FetchPlanMode, type FieldFilters, type FilterCondition, type FilterConditionType, MAX_DEFAULT_COLUMN_WIDTH_PX, type MergeResult, NULL_OPS, PRESET_ASPECTS, type PageAlignedBlock, type PlanFetchArgs, type PresetAspect, type PresetCapabilities, type PresetColumnWidthEntry, type PresetData, type PresetDraft, type PresetFilterOpEntry, type PresetLimitReachedBody, type PresetSnapshot, type PresetSnapshotWire, type PresetSorterEntry, PresetsClient, type PresetsClientConfig, PresetsHttpError, type PresetsListResult, type PresetsSaveAsOptions, type PresetsSaveResult, type QueryOptions, RESERVED_ID_PREFIXES, STANDARD_PRESET_ID, SYSTEM_PRESET_PREFIX, type SelectionMode, type SystemPreset, type SystemPresetInput, type TableStateData, type TableStateMethods, USER_CONF_PREFIX, type UrlQueryDefaults, type UrlQueryParseOptions, type UrlQueryStateLike, type UrlQueryStateSnapshot, type UrlQuerySync, type UserConfData, appConfId, arraysEqual, blockStartFor, buildTableQuery, clampTopIndex, columnFilterType, computeDefaultColumnWidth, conditionLabel, conditionsForType, dateShortcuts, debounce, defaultCondition, derivePresetAspects, deserializeDraft, draftMatchesPreset, escapeRegex, filledFilterCount, filterTokenLabel, filtersToUniqueryFilter, formatFilterCondition, fromWireSnapshot, hasSecondValue, isAuthError, isDirtyAgainst, isEmptyDraft, isFilled, isSimpleEq, isSystemPresetId, isUnavailableError, mergeFilters, mergeSorters, normaliseSystemPresetId, pageAlignedBlocksFor, parseFilterInput, planFetch, reconcileColumnWidthDefaults, reorderColumnNames, resolveAspectGate, resolveSystemPresets, rowsToPks, sameColumnSet, serializeDraft, setsEqual, sortersEqual, stableStringify, stateToUrlQueryString, toWireSnapshot, togglePk, trimSelection, unescapeRegex, uniqueryFilterToFieldFilters, urlQueryConsumesKey, urlQueryStringToState, userConfId, walkBackwardAbsorb, walkForwardAbsorb };
1109
+ //#region src/export/csv.d.ts
1110
+ /** A cell value after export-time resolution — always a spreadsheet scalar. */
1111
+ type ExportScalar = string | number | boolean | null;
1112
+ /** Options shared by {@link csvCell} and {@link toCsv}. */
1113
+ interface CsvOptions {
1114
+ /**
1115
+ * Prepend a UTF-8 byte-order mark. Excel needs it to detect UTF-8 in a
1116
+ * `.csv` opened by double-click; most other tools don't care. Default `false`.
1117
+ */
1118
+ bom?: boolean;
1119
+ /**
1120
+ * Prefix a STRING cell starting with `=`, `+`, `-`, `@`, TAB or CR with a
1121
+ * single quote so a spreadsheet treats it as text instead of a formula
1122
+ * (CSV-injection defence). Numbers and booleans are never touched — a
1123
+ * negative number stays numeric. Default `true`; set `false` when the file
1124
+ * is consumed by a parser rather than a spreadsheet.
1125
+ */
1126
+ escapeFormulas?: boolean;
1127
+ /** Field delimiter. Default `","`. */
1128
+ delimiter?: string;
1129
+ }
1130
+ /**
1131
+ * Render one value as an RFC 4180 field: formula-escaped (unless opted out),
1132
+ * then quoted when it contains the delimiter, a quote, CR or LF — with inner
1133
+ * quotes doubled.
1134
+ */
1135
+ declare function csvCell(value: ExportScalar | undefined, opts?: CsvOptions): string;
1136
+ /**
1137
+ * Serialise a header row + data rows to an RFC 4180 CSV string (`\r\n` row
1138
+ * separators, quoted fields where required, optional UTF-8 BOM).
1139
+ *
1140
+ * Pure — no DOM, no framework. See {@link csvCell} for the per-field rules.
1141
+ */
1142
+ declare function toCsv(header: readonly string[], rows: readonly (readonly ExportScalar[])[], opts?: CsvOptions): string;
1143
+ //#endregion
1144
+ //#region src/export/export-paging.d.ts
1145
+ /** Default page size used by the export pager when the caller gives none. */
1146
+ declare const DEFAULT_EXPORT_PAGE_SIZE = 500;
1147
+ /** One page of an export run — the subset of `PageResult` the pager needs. */
1148
+ interface ExportPage {
1149
+ data: Record<string, unknown>[];
1150
+ /** Total row count, when the endpoint reports one. */
1151
+ count?: number;
1152
+ }
1153
+ /** Fetches one page of the export query. Mirrors `client.pages(query, page, size)`. */
1154
+ type ExportPageFetcher = (query: Uniquery, page: number, size: number) => Promise<ExportPage>;
1155
+ /** Rejection raised when an export is cancelled through its `AbortSignal`. */
1156
+ declare class ExportAbortError extends Error {
1157
+ /** Matches the DOM convention so `err.name === "AbortError"` checks work. */
1158
+ readonly name = "AbortError";
1159
+ constructor(message?: string);
1160
+ }
1161
+ interface CollectExportRowsOptions<T = Record<string, unknown>> {
1162
+ /** The query to page through. Pass it through {@link withStableOrder} first. */
1163
+ query: Uniquery;
1164
+ /** Rows per request. Default {@link DEFAULT_EXPORT_PAGE_SIZE}. */
1165
+ pageSize?: number;
1166
+ fetchPage: ExportPageFetcher;
1167
+ /** Aborts between pages; the returned promise rejects with {@link ExportAbortError}. */
1168
+ signal?: AbortSignal;
1169
+ /** Called after every settled page. `total` comes from the first page's `count`. */
1170
+ onProgress?: (done: number, total?: number) => void;
1171
+ /** Hard ceiling on collected rows — the pager stops once it is reached. */
1172
+ maxRows?: number;
1173
+ /**
1174
+ * Project each row as it arrives — the collected array holds the projection,
1175
+ * not the raw row, so a formatted export never keeps both in memory.
1176
+ * Identity by default.
1177
+ */
1178
+ mapRow?: (row: Record<string, unknown>) => T;
1179
+ }
1180
+ /**
1181
+ * Append the primary key(s) to a query's `$sort` as a tiebreaker so paging is
1182
+ * stable: two rows equal on every user sorter keep a deterministic relative
1183
+ * order, and no row is skipped or duplicated across page boundaries.
1184
+ *
1185
+ * Existing sort fields are preserved in order and never replaced — a pk the
1186
+ * user already sorts by is left where it is. Returns the input query when
1187
+ * there is nothing to add.
1188
+ */
1189
+ declare function withStableOrder(query: Uniquery, primaryKeys: readonly string[]): Uniquery;
1190
+ /**
1191
+ * Page through `query` until the endpoint runs out of rows, collecting every
1192
+ * row into one array.
1193
+ *
1194
+ * Stops when a page comes back short (fewer rows than `pageSize`), when the
1195
+ * reported `count` is reached, or when `maxRows` is hit. Checks `signal`
1196
+ * before and after every request so a cancel lands between pages rather than
1197
+ * mid-flight.
1198
+ */
1199
+ declare function collectExportRows<T = Record<string, unknown>>(opts: CollectExportRowsOptions<T>): Promise<{
1200
+ rows: T[];
1201
+ total?: number;
1202
+ }>;
1203
+ //#endregion
1204
+ //#region src/export/export-value.d.ts
1205
+ /**
1206
+ * Turn a raw cell value into the scalar an export carries, using the same
1207
+ * column metadata the cell renderers read:
1208
+ *
1209
+ * - `null` / `undefined` → `null` (an empty CSV field).
1210
+ * - union / enum columns (`column.options`) → the option's **label**, so the
1211
+ * file reads like the screen; an unknown key falls back to the raw value.
1212
+ * - `Date` → ISO 8601 (spreadsheet- and re-import-friendly, unlike a
1213
+ * locale-formatted date).
1214
+ * - arrays → comma-joined scalars; other objects → JSON.
1215
+ * - numbers / booleans pass through untouched so a spreadsheet keeps them
1216
+ * numeric.
1217
+ *
1218
+ * Callers override per column (`formatters`) or globally (`formatCell`) when
1219
+ * they want different text.
1220
+ */
1221
+ declare function resolveExportValue(value: unknown, column?: ColumnDef): ExportScalar;
1222
+ //#endregion
1223
+ //#region src/columns/display-columns.d.ts
1224
+ /**
1225
+ * A client-owned column the app adds on top of the server's `/meta` columns —
1226
+ * a derived value, a count, a link, a widget. It has a label, a width and a
1227
+ * renderer, takes part in column visibility / reordering / presets like any
1228
+ * other column, but never reaches the backend: no `$select`, no server sort,
1229
+ * no filters.
1230
+ *
1231
+ * Since 0.1.134.
1232
+ */
1233
+ interface DisplayColumnDef {
1234
+ /**
1235
+ * Stable key. Becomes the column's `path`, so it is what `cell-<key>` /
1236
+ * `header-<key>` slots, the `columnNames` model and presets refer to. Must
1237
+ * not collide with a real field path.
1238
+ */
1239
+ key: string;
1240
+ /** Header label. Unlike `fixed` chrome columns, a display column is labelled. */
1241
+ label: string;
1242
+ /** Default width (any CSS length), e.g. `"8em"`. */
1243
+ width?: string;
1244
+ /** Initial position among the server columns (lower = earlier). Appended when omitted. */
1245
+ order?: number;
1246
+ /**
1247
+ * `'local'` makes the header offer sorting, applied client-side over the
1248
+ * rows currently loaded (page-local). Anything else — including the default
1249
+ * — leaves the column unsortable, since the server cannot sort it.
1250
+ *
1251
+ * Paged tables only: `<AsWindowTable>` caches rows by absolute index, so
1252
+ * there is no page to re-order and the affordance is not offered there.
1253
+ */
1254
+ sortable?: false | "local";
1255
+ /**
1256
+ * The value `sortable: 'local'` orders by. A display column has no server
1257
+ * field behind it, so without this the sorter reads `row[key]` — which is
1258
+ * `undefined` unless the app happens to carry that key on the row. Ignored
1259
+ * when the column is not sorted locally.
1260
+ */
1261
+ sortValue?: (row: Record<string, unknown>) => unknown;
1262
+ /** Named cell component (`components[name]` on the table context). */
1263
+ component?: string;
1264
+ /** Cell type for the `types[type]` dispatch. Default `"text"`. */
1265
+ type?: string;
1266
+ }
1267
+ /**
1268
+ * Merge client-owned columns into the server's column list, ordered by each
1269
+ * column's `order`. `Array#sort` is stable, so server columns that share an
1270
+ * `order` keep their `/meta` sequence. Returns `base` untouched when there is
1271
+ * nothing to merge.
1272
+ */
1273
+ declare function mergeDisplayColumns(base: ColumnDef[], display: readonly DisplayColumnDef[]): ColumnDef[];
1274
+ //#endregion
1275
+ //#region src/utils/sort-rows.d.ts
1276
+ /**
1277
+ * Coerce a primitive cell value to the text used for comparing and matching
1278
+ * cells — the string compare in {@link sortRowsLocally} and the substring
1279
+ * search of an in-memory table. Objects and functions collapse to `""` so
1280
+ * `'[object Object]'` never drives an ordering or matches a search.
1281
+ */
1282
+ declare function cellAsString(v: unknown): string;
1283
+ /**
1284
+ * Sort rows in memory by `sorters`, numerically when both sides are numbers
1285
+ * and locale-aware otherwise. Returns a NEW array; the input is untouched. A
1286
+ * no-op (returns the input reference) when there is nothing to sort.
1287
+ *
1288
+ * `getValue` reads a sorter's field off a row — pass a dot-path reader for
1289
+ * nested fields; the default is a flat property read.
1290
+ */
1291
+ declare function sortRowsLocally<T extends Record<string, unknown>>(rows: T[], sorters: readonly SortControl[], getValue?: (row: T, field: string) => unknown): T[];
1292
+ //#endregion
1293
+ export { APP_CONF_PREFIX, type AppConfData, AppPrefsClient, type AppPrefsClientConfig, type AppPrefsLoadResult, type AsPresetEntryData, type AsPresetEntryRow, type AsPresetsErrorCode, type AspectGate, type AspectMask, type BuildTableQueryOptions, type ColumnFilterType, type ColumnReorderPosition, type ColumnWidthEntry, type ColumnWidthsMap, type ConfigTab, type CsvOptions, DEFAULT_EXPORT_PAGE_SIZE, DEFAULT_ROW_HEIGHT_PX, DRAFT_PERSISTED_ASPECTS, type DateShortcut, type DisplayColumnDef, type DraftPersistedAspect, ExportAbortError, type ExportPage, type ExportPageFetcher, type ExportScalar, type FetchPlan, type FetchPlanMode, type FieldFilters, type FilterCondition, type FilterConditionType, MAX_DEFAULT_COLUMN_WIDTH_PX, type MergeResult, NULL_OPS, PRESET_ASPECTS, type PageAlignedBlock, type PlanFetchArgs, type PresetAspect, type PresetCapabilities, type PresetColumnWidthEntry, type PresetData, type PresetDraft, type PresetFilterOpEntry, type PresetLimitReachedBody, type PresetSnapshot, type PresetSnapshotWire, type PresetSorterEntry, PresetsClient, type PresetsClientConfig, PresetsHttpError, type PresetsListResult, type PresetsSaveAsOptions, type PresetsSaveResult, type QueryOptions, RESERVED_ID_PREFIXES, STANDARD_PRESET_ID, SYSTEM_PRESET_PREFIX, type SelectionMode, type SystemPreset, type SystemPresetInput, type TableStateData, type TableStateMethods, USER_CONF_PREFIX, type UrlQueryDefaults, type UrlQueryParseOptions, type UrlQueryStateLike, type UrlQueryStateSnapshot, type UrlQuerySync, type UserConfData, appConfId, arraysEqual, blockStartFor, buildTableQuery, cellAsString, clampTopIndex, collectExportRows, columnFilterType, computeDefaultColumnWidth, conditionLabel, conditionsForType, csvCell, dateShortcuts, debounce, defaultCondition, derivePresetAspects, deserializeDraft, draftMatchesPreset, escapeRegex, filledFilterCount, filterTokenLabel, filtersToUniqueryFilter, formatFilterCondition, fromWireSnapshot, hasSecondValue, isAuthError, isDirtyAgainst, isEmptyDraft, isFilled, isSimpleEq, isSystemPresetId, isUnavailableError, mergeDisplayColumns, mergeFilters, mergeSorters, normaliseSystemPresetId, pageAlignedBlocksFor, parseFilterInput, planFetch, reconcileColumnWidthDefaults, reorderColumnNames, resolveAspectGate, resolveExportValue, resolveSystemPresets, rowsToPks, sameColumnSet, serializeDraft, setsEqual, sortRowsLocally, sortersEqual, stableStringify, stateToUrlQueryString, toCsv, toWireSnapshot, togglePk, trimSelection, unescapeRegex, uniqueryFilterToFieldFilters, urlQueryConsumesKey, urlQueryStringToState, userConfId, walkBackwardAbsorb, walkForwardAbsorb, withStableOrder };
package/dist/index.d.mts CHANGED
@@ -1106,4 +1106,188 @@ type ColumnReorderPosition = "before" | "after";
1106
1106
  */
1107
1107
  declare function reorderColumnNames(names: string[], fromPath: string, toPath: string, position: ColumnReorderPosition): string[];
1108
1108
  //#endregion
1109
- export { APP_CONF_PREFIX, type AppConfData, AppPrefsClient, type AppPrefsClientConfig, type AppPrefsLoadResult, type AsPresetEntryData, type AsPresetEntryRow, type AsPresetsErrorCode, type AspectGate, type AspectMask, type BuildTableQueryOptions, type ColumnFilterType, type ColumnReorderPosition, type ColumnWidthEntry, type ColumnWidthsMap, type ConfigTab, DEFAULT_ROW_HEIGHT_PX, DRAFT_PERSISTED_ASPECTS, type DateShortcut, type DraftPersistedAspect, type FetchPlan, type FetchPlanMode, type FieldFilters, type FilterCondition, type FilterConditionType, MAX_DEFAULT_COLUMN_WIDTH_PX, type MergeResult, NULL_OPS, PRESET_ASPECTS, type PageAlignedBlock, type PlanFetchArgs, type PresetAspect, type PresetCapabilities, type PresetColumnWidthEntry, type PresetData, type PresetDraft, type PresetFilterOpEntry, type PresetLimitReachedBody, type PresetSnapshot, type PresetSnapshotWire, type PresetSorterEntry, PresetsClient, type PresetsClientConfig, PresetsHttpError, type PresetsListResult, type PresetsSaveAsOptions, type PresetsSaveResult, type QueryOptions, RESERVED_ID_PREFIXES, STANDARD_PRESET_ID, SYSTEM_PRESET_PREFIX, type SelectionMode, type SystemPreset, type SystemPresetInput, type TableStateData, type TableStateMethods, USER_CONF_PREFIX, type UrlQueryDefaults, type UrlQueryParseOptions, type UrlQueryStateLike, type UrlQueryStateSnapshot, type UrlQuerySync, type UserConfData, appConfId, arraysEqual, blockStartFor, buildTableQuery, clampTopIndex, columnFilterType, computeDefaultColumnWidth, conditionLabel, conditionsForType, dateShortcuts, debounce, defaultCondition, derivePresetAspects, deserializeDraft, draftMatchesPreset, escapeRegex, filledFilterCount, filterTokenLabel, filtersToUniqueryFilter, formatFilterCondition, fromWireSnapshot, hasSecondValue, isAuthError, isDirtyAgainst, isEmptyDraft, isFilled, isSimpleEq, isSystemPresetId, isUnavailableError, mergeFilters, mergeSorters, normaliseSystemPresetId, pageAlignedBlocksFor, parseFilterInput, planFetch, reconcileColumnWidthDefaults, reorderColumnNames, resolveAspectGate, resolveSystemPresets, rowsToPks, sameColumnSet, serializeDraft, setsEqual, sortersEqual, stableStringify, stateToUrlQueryString, toWireSnapshot, togglePk, trimSelection, unescapeRegex, uniqueryFilterToFieldFilters, urlQueryConsumesKey, urlQueryStringToState, userConfId, walkBackwardAbsorb, walkForwardAbsorb };
1109
+ //#region src/export/csv.d.ts
1110
+ /** A cell value after export-time resolution — always a spreadsheet scalar. */
1111
+ type ExportScalar = string | number | boolean | null;
1112
+ /** Options shared by {@link csvCell} and {@link toCsv}. */
1113
+ interface CsvOptions {
1114
+ /**
1115
+ * Prepend a UTF-8 byte-order mark. Excel needs it to detect UTF-8 in a
1116
+ * `.csv` opened by double-click; most other tools don't care. Default `false`.
1117
+ */
1118
+ bom?: boolean;
1119
+ /**
1120
+ * Prefix a STRING cell starting with `=`, `+`, `-`, `@`, TAB or CR with a
1121
+ * single quote so a spreadsheet treats it as text instead of a formula
1122
+ * (CSV-injection defence). Numbers and booleans are never touched — a
1123
+ * negative number stays numeric. Default `true`; set `false` when the file
1124
+ * is consumed by a parser rather than a spreadsheet.
1125
+ */
1126
+ escapeFormulas?: boolean;
1127
+ /** Field delimiter. Default `","`. */
1128
+ delimiter?: string;
1129
+ }
1130
+ /**
1131
+ * Render one value as an RFC 4180 field: formula-escaped (unless opted out),
1132
+ * then quoted when it contains the delimiter, a quote, CR or LF — with inner
1133
+ * quotes doubled.
1134
+ */
1135
+ declare function csvCell(value: ExportScalar | undefined, opts?: CsvOptions): string;
1136
+ /**
1137
+ * Serialise a header row + data rows to an RFC 4180 CSV string (`\r\n` row
1138
+ * separators, quoted fields where required, optional UTF-8 BOM).
1139
+ *
1140
+ * Pure — no DOM, no framework. See {@link csvCell} for the per-field rules.
1141
+ */
1142
+ declare function toCsv(header: readonly string[], rows: readonly (readonly ExportScalar[])[], opts?: CsvOptions): string;
1143
+ //#endregion
1144
+ //#region src/export/export-paging.d.ts
1145
+ /** Default page size used by the export pager when the caller gives none. */
1146
+ declare const DEFAULT_EXPORT_PAGE_SIZE = 500;
1147
+ /** One page of an export run — the subset of `PageResult` the pager needs. */
1148
+ interface ExportPage {
1149
+ data: Record<string, unknown>[];
1150
+ /** Total row count, when the endpoint reports one. */
1151
+ count?: number;
1152
+ }
1153
+ /** Fetches one page of the export query. Mirrors `client.pages(query, page, size)`. */
1154
+ type ExportPageFetcher = (query: Uniquery, page: number, size: number) => Promise<ExportPage>;
1155
+ /** Rejection raised when an export is cancelled through its `AbortSignal`. */
1156
+ declare class ExportAbortError extends Error {
1157
+ /** Matches the DOM convention so `err.name === "AbortError"` checks work. */
1158
+ readonly name = "AbortError";
1159
+ constructor(message?: string);
1160
+ }
1161
+ interface CollectExportRowsOptions<T = Record<string, unknown>> {
1162
+ /** The query to page through. Pass it through {@link withStableOrder} first. */
1163
+ query: Uniquery;
1164
+ /** Rows per request. Default {@link DEFAULT_EXPORT_PAGE_SIZE}. */
1165
+ pageSize?: number;
1166
+ fetchPage: ExportPageFetcher;
1167
+ /** Aborts between pages; the returned promise rejects with {@link ExportAbortError}. */
1168
+ signal?: AbortSignal;
1169
+ /** Called after every settled page. `total` comes from the first page's `count`. */
1170
+ onProgress?: (done: number, total?: number) => void;
1171
+ /** Hard ceiling on collected rows — the pager stops once it is reached. */
1172
+ maxRows?: number;
1173
+ /**
1174
+ * Project each row as it arrives — the collected array holds the projection,
1175
+ * not the raw row, so a formatted export never keeps both in memory.
1176
+ * Identity by default.
1177
+ */
1178
+ mapRow?: (row: Record<string, unknown>) => T;
1179
+ }
1180
+ /**
1181
+ * Append the primary key(s) to a query's `$sort` as a tiebreaker so paging is
1182
+ * stable: two rows equal on every user sorter keep a deterministic relative
1183
+ * order, and no row is skipped or duplicated across page boundaries.
1184
+ *
1185
+ * Existing sort fields are preserved in order and never replaced — a pk the
1186
+ * user already sorts by is left where it is. Returns the input query when
1187
+ * there is nothing to add.
1188
+ */
1189
+ declare function withStableOrder(query: Uniquery, primaryKeys: readonly string[]): Uniquery;
1190
+ /**
1191
+ * Page through `query` until the endpoint runs out of rows, collecting every
1192
+ * row into one array.
1193
+ *
1194
+ * Stops when a page comes back short (fewer rows than `pageSize`), when the
1195
+ * reported `count` is reached, or when `maxRows` is hit. Checks `signal`
1196
+ * before and after every request so a cancel lands between pages rather than
1197
+ * mid-flight.
1198
+ */
1199
+ declare function collectExportRows<T = Record<string, unknown>>(opts: CollectExportRowsOptions<T>): Promise<{
1200
+ rows: T[];
1201
+ total?: number;
1202
+ }>;
1203
+ //#endregion
1204
+ //#region src/export/export-value.d.ts
1205
+ /**
1206
+ * Turn a raw cell value into the scalar an export carries, using the same
1207
+ * column metadata the cell renderers read:
1208
+ *
1209
+ * - `null` / `undefined` → `null` (an empty CSV field).
1210
+ * - union / enum columns (`column.options`) → the option's **label**, so the
1211
+ * file reads like the screen; an unknown key falls back to the raw value.
1212
+ * - `Date` → ISO 8601 (spreadsheet- and re-import-friendly, unlike a
1213
+ * locale-formatted date).
1214
+ * - arrays → comma-joined scalars; other objects → JSON.
1215
+ * - numbers / booleans pass through untouched so a spreadsheet keeps them
1216
+ * numeric.
1217
+ *
1218
+ * Callers override per column (`formatters`) or globally (`formatCell`) when
1219
+ * they want different text.
1220
+ */
1221
+ declare function resolveExportValue(value: unknown, column?: ColumnDef): ExportScalar;
1222
+ //#endregion
1223
+ //#region src/columns/display-columns.d.ts
1224
+ /**
1225
+ * A client-owned column the app adds on top of the server's `/meta` columns —
1226
+ * a derived value, a count, a link, a widget. It has a label, a width and a
1227
+ * renderer, takes part in column visibility / reordering / presets like any
1228
+ * other column, but never reaches the backend: no `$select`, no server sort,
1229
+ * no filters.
1230
+ *
1231
+ * Since 0.1.134.
1232
+ */
1233
+ interface DisplayColumnDef {
1234
+ /**
1235
+ * Stable key. Becomes the column's `path`, so it is what `cell-<key>` /
1236
+ * `header-<key>` slots, the `columnNames` model and presets refer to. Must
1237
+ * not collide with a real field path.
1238
+ */
1239
+ key: string;
1240
+ /** Header label. Unlike `fixed` chrome columns, a display column is labelled. */
1241
+ label: string;
1242
+ /** Default width (any CSS length), e.g. `"8em"`. */
1243
+ width?: string;
1244
+ /** Initial position among the server columns (lower = earlier). Appended when omitted. */
1245
+ order?: number;
1246
+ /**
1247
+ * `'local'` makes the header offer sorting, applied client-side over the
1248
+ * rows currently loaded (page-local). Anything else — including the default
1249
+ * — leaves the column unsortable, since the server cannot sort it.
1250
+ *
1251
+ * Paged tables only: `<AsWindowTable>` caches rows by absolute index, so
1252
+ * there is no page to re-order and the affordance is not offered there.
1253
+ */
1254
+ sortable?: false | "local";
1255
+ /**
1256
+ * The value `sortable: 'local'` orders by. A display column has no server
1257
+ * field behind it, so without this the sorter reads `row[key]` — which is
1258
+ * `undefined` unless the app happens to carry that key on the row. Ignored
1259
+ * when the column is not sorted locally.
1260
+ */
1261
+ sortValue?: (row: Record<string, unknown>) => unknown;
1262
+ /** Named cell component (`components[name]` on the table context). */
1263
+ component?: string;
1264
+ /** Cell type for the `types[type]` dispatch. Default `"text"`. */
1265
+ type?: string;
1266
+ }
1267
+ /**
1268
+ * Merge client-owned columns into the server's column list, ordered by each
1269
+ * column's `order`. `Array#sort` is stable, so server columns that share an
1270
+ * `order` keep their `/meta` sequence. Returns `base` untouched when there is
1271
+ * nothing to merge.
1272
+ */
1273
+ declare function mergeDisplayColumns(base: ColumnDef[], display: readonly DisplayColumnDef[]): ColumnDef[];
1274
+ //#endregion
1275
+ //#region src/utils/sort-rows.d.ts
1276
+ /**
1277
+ * Coerce a primitive cell value to the text used for comparing and matching
1278
+ * cells — the string compare in {@link sortRowsLocally} and the substring
1279
+ * search of an in-memory table. Objects and functions collapse to `""` so
1280
+ * `'[object Object]'` never drives an ordering or matches a search.
1281
+ */
1282
+ declare function cellAsString(v: unknown): string;
1283
+ /**
1284
+ * Sort rows in memory by `sorters`, numerically when both sides are numbers
1285
+ * and locale-aware otherwise. Returns a NEW array; the input is untouched. A
1286
+ * no-op (returns the input reference) when there is nothing to sort.
1287
+ *
1288
+ * `getValue` reads a sorter's field off a row — pass a dot-path reader for
1289
+ * nested fields; the default is a flat property read.
1290
+ */
1291
+ declare function sortRowsLocally<T extends Record<string, unknown>>(rows: T[], sorters: readonly SortControl[], getValue?: (row: T, field: string) => unknown): T[];
1292
+ //#endregion
1293
+ export { APP_CONF_PREFIX, type AppConfData, AppPrefsClient, type AppPrefsClientConfig, type AppPrefsLoadResult, type AsPresetEntryData, type AsPresetEntryRow, type AsPresetsErrorCode, type AspectGate, type AspectMask, type BuildTableQueryOptions, type ColumnFilterType, type ColumnReorderPosition, type ColumnWidthEntry, type ColumnWidthsMap, type ConfigTab, type CsvOptions, DEFAULT_EXPORT_PAGE_SIZE, DEFAULT_ROW_HEIGHT_PX, DRAFT_PERSISTED_ASPECTS, type DateShortcut, type DisplayColumnDef, type DraftPersistedAspect, ExportAbortError, type ExportPage, type ExportPageFetcher, type ExportScalar, type FetchPlan, type FetchPlanMode, type FieldFilters, type FilterCondition, type FilterConditionType, MAX_DEFAULT_COLUMN_WIDTH_PX, type MergeResult, NULL_OPS, PRESET_ASPECTS, type PageAlignedBlock, type PlanFetchArgs, type PresetAspect, type PresetCapabilities, type PresetColumnWidthEntry, type PresetData, type PresetDraft, type PresetFilterOpEntry, type PresetLimitReachedBody, type PresetSnapshot, type PresetSnapshotWire, type PresetSorterEntry, PresetsClient, type PresetsClientConfig, PresetsHttpError, type PresetsListResult, type PresetsSaveAsOptions, type PresetsSaveResult, type QueryOptions, RESERVED_ID_PREFIXES, STANDARD_PRESET_ID, SYSTEM_PRESET_PREFIX, type SelectionMode, type SystemPreset, type SystemPresetInput, type TableStateData, type TableStateMethods, USER_CONF_PREFIX, type UrlQueryDefaults, type UrlQueryParseOptions, type UrlQueryStateLike, type UrlQueryStateSnapshot, type UrlQuerySync, type UserConfData, appConfId, arraysEqual, blockStartFor, buildTableQuery, cellAsString, clampTopIndex, collectExportRows, columnFilterType, computeDefaultColumnWidth, conditionLabel, conditionsForType, csvCell, dateShortcuts, debounce, defaultCondition, derivePresetAspects, deserializeDraft, draftMatchesPreset, escapeRegex, filledFilterCount, filterTokenLabel, filtersToUniqueryFilter, formatFilterCondition, fromWireSnapshot, hasSecondValue, isAuthError, isDirtyAgainst, isEmptyDraft, isFilled, isSimpleEq, isSystemPresetId, isUnavailableError, mergeDisplayColumns, mergeFilters, mergeSorters, normaliseSystemPresetId, pageAlignedBlocksFor, parseFilterInput, planFetch, reconcileColumnWidthDefaults, reorderColumnNames, resolveAspectGate, resolveExportValue, resolveSystemPresets, rowsToPks, sameColumnSet, serializeDraft, setsEqual, sortRowsLocally, sortersEqual, stableStringify, stateToUrlQueryString, toCsv, toWireSnapshot, togglePk, trimSelection, unescapeRegex, uniqueryFilterToFieldFilters, urlQueryConsumesKey, urlQueryStringToState, userConfId, walkBackwardAbsorb, walkForwardAbsorb, withStableOrder };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ClientError } from "@atscript/db-client";
2
- import { getDefaultClientFactory } from "@atscript/ui";
2
+ import { getDefaultClientFactory, str } from "@atscript/ui";
3
3
  import { buildUrl } from "@uniqu/url/builder";
4
4
  import { parseUrl } from "@uniqu/url";
5
5
  //#region src/filters/filter-conditions.ts
@@ -1680,4 +1680,222 @@ function reorderColumnNames(names, fromPath, toPath, position) {
1680
1680
  return without.slice(0, insertAt).concat(fromPath, without.slice(insertAt));
1681
1681
  }
1682
1682
  //#endregion
1683
- export { APP_CONF_PREFIX, AppPrefsClient, DEFAULT_ROW_HEIGHT_PX, DRAFT_PERSISTED_ASPECTS, MAX_DEFAULT_COLUMN_WIDTH_PX, NULL_OPS, PRESET_ASPECTS, PresetsClient, PresetsHttpError, RESERVED_ID_PREFIXES, STANDARD_PRESET_ID, SYSTEM_PRESET_PREFIX, USER_CONF_PREFIX, appConfId, arraysEqual, blockStartFor, buildTableQuery, clampTopIndex, columnFilterType, computeDefaultColumnWidth, conditionLabel, conditionsForType, dateShortcuts, debounce, defaultCondition, derivePresetAspects, deserializeDraft, draftMatchesPreset, escapeRegex, filledFilterCount, filterTokenLabel, filtersToUniqueryFilter, formatFilterCondition, fromWireSnapshot, hasSecondValue, isAuthError, isDirtyAgainst, isEmptyDraft, isFilled, isSimpleEq, isSystemPresetId, isUnavailableError, mergeFilters, mergeSorters, normaliseSystemPresetId, pageAlignedBlocksFor, parseFilterInput, planFetch, reconcileColumnWidthDefaults, reorderColumnNames, resolveAspectGate, resolveSystemPresets, rowsToPks, sameColumnSet, serializeDraft, setsEqual, sortersEqual, stableStringify, stateToUrlQueryString, toWireSnapshot, togglePk, trimSelection, unescapeRegex, uniqueryFilterToFieldFilters, urlQueryConsumesKey, urlQueryStringToState, userConfId, walkBackwardAbsorb, walkForwardAbsorb };
1683
+ //#region src/export/csv.ts
1684
+ const FORMULA_LEADERS = new Set([
1685
+ "=",
1686
+ "+",
1687
+ "-",
1688
+ "@",
1689
+ " ",
1690
+ "\r"
1691
+ ]);
1692
+ /** RFC 4180 row separator. */
1693
+ const CRLF = "\r\n";
1694
+ /**
1695
+ * Render one value as an RFC 4180 field: formula-escaped (unless opted out),
1696
+ * then quoted when it contains the delimiter, a quote, CR or LF — with inner
1697
+ * quotes doubled.
1698
+ */
1699
+ function csvCell(value, opts = {}) {
1700
+ const delimiter = opts.delimiter ?? ",";
1701
+ let text = value === null || value === void 0 ? "" : String(value);
1702
+ if (typeof value === "string" && opts.escapeFormulas !== false && text.length > 0 && FORMULA_LEADERS.has(text[0])) text = `'${text}`;
1703
+ return text.includes(delimiter) || text.includes("\"") || text.includes("\n") || text.includes("\r") ? `"${text.replaceAll("\"", "\"\"")}"` : text;
1704
+ }
1705
+ /**
1706
+ * Serialise a header row + data rows to an RFC 4180 CSV string (`\r\n` row
1707
+ * separators, quoted fields where required, optional UTF-8 BOM).
1708
+ *
1709
+ * Pure — no DOM, no framework. See {@link csvCell} for the per-field rules.
1710
+ */
1711
+ function toCsv(header, rows, opts = {}) {
1712
+ const delimiter = opts.delimiter ?? ",";
1713
+ const lines = [header.map((h) => csvCell(h, opts)).join(delimiter)];
1714
+ for (const row of rows) lines.push(row.map((v) => csvCell(v, opts)).join(delimiter));
1715
+ const body = lines.join(CRLF) + CRLF;
1716
+ return opts.bom ? `${body}` : body;
1717
+ }
1718
+ //#endregion
1719
+ //#region src/export/export-paging.ts
1720
+ /** Default page size used by the export pager when the caller gives none. */
1721
+ const DEFAULT_EXPORT_PAGE_SIZE = 500;
1722
+ /** Rejection raised when an export is cancelled through its `AbortSignal`. */
1723
+ var ExportAbortError = class extends Error {
1724
+ /** Matches the DOM convention so `err.name === "AbortError"` checks work. */
1725
+ name = "AbortError";
1726
+ constructor(message = "Export aborted") {
1727
+ super(message);
1728
+ }
1729
+ };
1730
+ /**
1731
+ * Append the primary key(s) to a query's `$sort` as a tiebreaker so paging is
1732
+ * stable: two rows equal on every user sorter keep a deterministic relative
1733
+ * order, and no row is skipped or duplicated across page boundaries.
1734
+ *
1735
+ * Existing sort fields are preserved in order and never replaced — a pk the
1736
+ * user already sorts by is left where it is. Returns the input query when
1737
+ * there is nothing to add.
1738
+ */
1739
+ function withStableOrder(query, primaryKeys) {
1740
+ if (primaryKeys.length === 0) return query;
1741
+ const current = query.controls?.$sort ?? {};
1742
+ const missing = primaryKeys.filter((pk) => !(pk in current));
1743
+ if (missing.length === 0) return query;
1744
+ const $sort = { ...current };
1745
+ for (const pk of missing) $sort[pk] = 1;
1746
+ return {
1747
+ ...query,
1748
+ controls: {
1749
+ ...query.controls,
1750
+ $sort
1751
+ }
1752
+ };
1753
+ }
1754
+ /**
1755
+ * Page through `query` until the endpoint runs out of rows, collecting every
1756
+ * row into one array.
1757
+ *
1758
+ * Stops when a page comes back short (fewer rows than `pageSize`), when the
1759
+ * reported `count` is reached, or when `maxRows` is hit. Checks `signal`
1760
+ * before and after every request so a cancel lands between pages rather than
1761
+ * mid-flight.
1762
+ */
1763
+ async function collectExportRows(opts) {
1764
+ const pageSize = opts.pageSize && opts.pageSize > 0 ? opts.pageSize : 500;
1765
+ const mapRow = opts.mapRow ?? ((row) => row);
1766
+ const rows = [];
1767
+ let total;
1768
+ let page = 1;
1769
+ for (;;) {
1770
+ throwIfAborted(opts.signal);
1771
+ const result = await opts.fetchPage(opts.query, page, pageSize);
1772
+ throwIfAborted(opts.signal);
1773
+ if (page === 1 && typeof result.count === "number") total = result.count;
1774
+ const data = result.data ?? [];
1775
+ const take = opts.maxRows === void 0 ? data.length : Math.min(data.length, opts.maxRows - rows.length);
1776
+ for (let i = 0; i < take; i++) rows.push(mapRow(data[i]));
1777
+ opts.onProgress?.(rows.length, total);
1778
+ if (data.length < pageSize || opts.maxRows !== void 0 && rows.length >= opts.maxRows || total !== void 0 && rows.length >= total) break;
1779
+ page++;
1780
+ }
1781
+ return {
1782
+ rows,
1783
+ total
1784
+ };
1785
+ }
1786
+ function throwIfAborted(signal) {
1787
+ if (signal?.aborted) throw new ExportAbortError();
1788
+ }
1789
+ //#endregion
1790
+ //#region src/export/export-value.ts
1791
+ /**
1792
+ * Turn a raw cell value into the scalar an export carries, using the same
1793
+ * column metadata the cell renderers read:
1794
+ *
1795
+ * - `null` / `undefined` → `null` (an empty CSV field).
1796
+ * - union / enum columns (`column.options`) → the option's **label**, so the
1797
+ * file reads like the screen; an unknown key falls back to the raw value.
1798
+ * - `Date` → ISO 8601 (spreadsheet- and re-import-friendly, unlike a
1799
+ * locale-formatted date).
1800
+ * - arrays → comma-joined scalars; other objects → JSON.
1801
+ * - numbers / booleans pass through untouched so a spreadsheet keeps them
1802
+ * numeric.
1803
+ *
1804
+ * Callers override per column (`formatters`) or globally (`formatCell`) when
1805
+ * they want different text.
1806
+ */
1807
+ function resolveExportValue(value, column) {
1808
+ if (value === null || value === void 0) return null;
1809
+ const options = column?.options;
1810
+ if (options?.length) {
1811
+ const key = typeof value === "string" || typeof value === "number" ? String(value) : void 0;
1812
+ if (key !== void 0) {
1813
+ const hit = options.find((o) => o.key === key);
1814
+ if (hit) return hit.label;
1815
+ }
1816
+ }
1817
+ if (typeof value === "number" || typeof value === "boolean") return value;
1818
+ if (typeof value === "string") return value;
1819
+ if (value instanceof Date) return value.toISOString();
1820
+ if (Array.isArray(value)) return value.map((v) => scalarText(v)).join(", ");
1821
+ return scalarText(value);
1822
+ }
1823
+ function scalarText(value) {
1824
+ if (value === null || value === void 0 || typeof value === "function") return "";
1825
+ try {
1826
+ return str(value);
1827
+ } catch {
1828
+ return "";
1829
+ }
1830
+ }
1831
+ //#endregion
1832
+ //#region src/columns/display-columns.ts
1833
+ /** Columns with no explicit `order` land after every server column. */
1834
+ const TRAILING_ORDER = Number.MAX_SAFE_INTEGER;
1835
+ /** Turn a {@link DisplayColumnDef} into the `ColumnDef` the table renders. */
1836
+ function displayColumnToDef(def) {
1837
+ return {
1838
+ path: def.key,
1839
+ label: def.label,
1840
+ type: def.type ?? "text",
1841
+ component: def.component,
1842
+ sortable: def.sortable === "local",
1843
+ filterable: false,
1844
+ nullable: true,
1845
+ order: def.order ?? TRAILING_ORDER,
1846
+ width: def.width,
1847
+ local: true
1848
+ };
1849
+ }
1850
+ /**
1851
+ * Merge client-owned columns into the server's column list, ordered by each
1852
+ * column's `order`. `Array#sort` is stable, so server columns that share an
1853
+ * `order` keep their `/meta` sequence. Returns `base` untouched when there is
1854
+ * nothing to merge.
1855
+ */
1856
+ function mergeDisplayColumns(base, display) {
1857
+ if (display.length === 0) return base;
1858
+ return [...base, ...display.map(displayColumnToDef)].toSorted((a, b) => a.order - b.order);
1859
+ }
1860
+ //#endregion
1861
+ //#region src/utils/sort-rows.ts
1862
+ /**
1863
+ * Coerce a primitive cell value to the text used for comparing and matching
1864
+ * cells — the string compare in {@link sortRowsLocally} and the substring
1865
+ * search of an in-memory table. Objects and functions collapse to `""` so
1866
+ * `'[object Object]'` never drives an ordering or matches a search.
1867
+ */
1868
+ function cellAsString(v) {
1869
+ if (v == null) return "";
1870
+ if (typeof v === "string") return v;
1871
+ if (typeof v === "number" || typeof v === "boolean") return v.toString();
1872
+ return "";
1873
+ }
1874
+ /**
1875
+ * Sort rows in memory by `sorters`, numerically when both sides are numbers
1876
+ * and locale-aware otherwise. Returns a NEW array; the input is untouched. A
1877
+ * no-op (returns the input reference) when there is nothing to sort.
1878
+ *
1879
+ * `getValue` reads a sorter's field off a row — pass a dot-path reader for
1880
+ * nested fields; the default is a flat property read.
1881
+ */
1882
+ function sortRowsLocally(rows, sorters, getValue = (row, field) => row[field]) {
1883
+ if (sorters.length === 0 || rows.length < 2) return rows;
1884
+ return rows.toSorted((a, b) => {
1885
+ for (const s of sorters) {
1886
+ const dir = s.direction === "desc" ? -1 : 1;
1887
+ const av = getValue(a, s.field);
1888
+ const bv = getValue(b, s.field);
1889
+ if (typeof av === "number" && typeof bv === "number") {
1890
+ if (av < bv) return -dir;
1891
+ if (av > bv) return dir;
1892
+ } else {
1893
+ const cmp = cellAsString(av).localeCompare(cellAsString(bv));
1894
+ if (cmp !== 0) return cmp * dir;
1895
+ }
1896
+ }
1897
+ return 0;
1898
+ });
1899
+ }
1900
+ //#endregion
1901
+ export { APP_CONF_PREFIX, AppPrefsClient, DEFAULT_EXPORT_PAGE_SIZE, DEFAULT_ROW_HEIGHT_PX, DRAFT_PERSISTED_ASPECTS, ExportAbortError, MAX_DEFAULT_COLUMN_WIDTH_PX, NULL_OPS, PRESET_ASPECTS, PresetsClient, PresetsHttpError, RESERVED_ID_PREFIXES, STANDARD_PRESET_ID, SYSTEM_PRESET_PREFIX, USER_CONF_PREFIX, appConfId, arraysEqual, blockStartFor, buildTableQuery, cellAsString, clampTopIndex, collectExportRows, columnFilterType, computeDefaultColumnWidth, conditionLabel, conditionsForType, csvCell, dateShortcuts, debounce, defaultCondition, derivePresetAspects, deserializeDraft, draftMatchesPreset, escapeRegex, filledFilterCount, filterTokenLabel, filtersToUniqueryFilter, formatFilterCondition, fromWireSnapshot, hasSecondValue, isAuthError, isDirtyAgainst, isEmptyDraft, isFilled, isSimpleEq, isSystemPresetId, isUnavailableError, mergeDisplayColumns, mergeFilters, mergeSorters, normaliseSystemPresetId, pageAlignedBlocksFor, parseFilterInput, planFetch, reconcileColumnWidthDefaults, reorderColumnNames, resolveAspectGate, resolveExportValue, resolveSystemPresets, rowsToPks, sameColumnSet, serializeDraft, setsEqual, sortRowsLocally, sortersEqual, stableStringify, stateToUrlQueryString, toCsv, toWireSnapshot, togglePk, trimSelection, unescapeRegex, uniqueryFilterToFieldFilters, urlQueryConsumesKey, urlQueryStringToState, userConfId, walkBackwardAbsorb, walkForwardAbsorb, withStableOrder };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/ui-table",
3
- "version": "0.1.133",
3
+ "version": "0.1.135",
4
4
  "description": "Framework-agnostic filter model, filter-to-Uniquery conversion, and preset serialization for atscript tables",
5
5
  "keywords": [
6
6
  "atscript",
@@ -42,7 +42,7 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@atscript/ui": "0.1.133"
45
+ "@atscript/ui": "0.1.135"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@atscript/db-client": "^0.1.129",