@atscript/ui-table 0.1.58
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/LICENSE +21 -0
- package/README.md +24 -0
- package/dist/index.cjs +1689 -0
- package/dist/index.d.cts +1034 -0
- package/dist/index.d.mts +1034 -0
- package/dist/index.mjs +1622 -0
- package/package.json +63 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1689 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _atscript_db_client = require("@atscript/db-client");
|
|
3
|
+
let _atscript_ui = require("@atscript/ui");
|
|
4
|
+
let _uniqu_url_builder = require("@uniqu/url/builder");
|
|
5
|
+
let _uniqu_url = require("@uniqu/url");
|
|
6
|
+
//#region src/filters/filter-conditions.ts
|
|
7
|
+
/** Conditions that operate purely on nullability — value is ignored. */
|
|
8
|
+
const NULL_OPS = new Set(["null", "notNull"]);
|
|
9
|
+
/** Check if a condition has a filled/meaningful value. */
|
|
10
|
+
function isFilled(condition) {
|
|
11
|
+
const { type, value } = condition;
|
|
12
|
+
if (NULL_OPS.has(type)) return true;
|
|
13
|
+
if (type === "bw") return value.length >= 2 && value[0] != null && value[1] != null && value[0] !== "" && value[1] !== "";
|
|
14
|
+
return value.length > 0 && value[0] != null && value[0] !== "";
|
|
15
|
+
}
|
|
16
|
+
/** Check if a condition type requires a second value (between). */
|
|
17
|
+
function hasSecondValue(type) {
|
|
18
|
+
return type === "bw";
|
|
19
|
+
}
|
|
20
|
+
function isSimpleEq(condition) {
|
|
21
|
+
return condition.type === "eq" && isFilled(condition);
|
|
22
|
+
}
|
|
23
|
+
const CONDITION_LABELS = {
|
|
24
|
+
eq: "equals",
|
|
25
|
+
ne: "not equals",
|
|
26
|
+
gt: "greater than",
|
|
27
|
+
gte: "greater or equal",
|
|
28
|
+
lt: "less than",
|
|
29
|
+
lte: "less or equal",
|
|
30
|
+
contains: "contains",
|
|
31
|
+
starts: "starts with",
|
|
32
|
+
ends: "ends with",
|
|
33
|
+
bw: "between",
|
|
34
|
+
null: "is empty",
|
|
35
|
+
notNull: "is not empty",
|
|
36
|
+
regex: "matches pattern"
|
|
37
|
+
};
|
|
38
|
+
/** Human-readable label for a condition type. */
|
|
39
|
+
function conditionLabel(type) {
|
|
40
|
+
return CONDITION_LABELS[type] ?? type;
|
|
41
|
+
}
|
|
42
|
+
/** Count of fields that have at least one filled condition. */
|
|
43
|
+
function filledFilterCount(filters) {
|
|
44
|
+
let count = 0;
|
|
45
|
+
for (const path in filters) if (filters[path].some(isFilled)) count++;
|
|
46
|
+
return count;
|
|
47
|
+
}
|
|
48
|
+
/** Summarize a field's conditions into a human-readable token label. */
|
|
49
|
+
function filterTokenLabel(path, conditions, columnLabel) {
|
|
50
|
+
const filled = conditions.filter(isFilled);
|
|
51
|
+
if (filled.length === 0) return "";
|
|
52
|
+
const label = columnLabel ?? path;
|
|
53
|
+
if (filled.length === 1) {
|
|
54
|
+
const c = filled[0];
|
|
55
|
+
if (c.type === "null") return `${label}: empty`;
|
|
56
|
+
if (c.type === "notNull") return `${label}: not empty`;
|
|
57
|
+
if (c.type === "bw") return `${label}: ${c.value[0]} – ${c.value[1]}`;
|
|
58
|
+
return `${label} ${conditionLabel(c.type)} ${c.value[0]}`;
|
|
59
|
+
}
|
|
60
|
+
return `${label}: ${filled.length} conditions`;
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/filters/filter-conditions-map.ts
|
|
64
|
+
const TEXT_CONDITIONS = [
|
|
65
|
+
"eq",
|
|
66
|
+
"ne",
|
|
67
|
+
"contains",
|
|
68
|
+
"starts",
|
|
69
|
+
"ends",
|
|
70
|
+
"bw",
|
|
71
|
+
"null",
|
|
72
|
+
"notNull",
|
|
73
|
+
"regex"
|
|
74
|
+
];
|
|
75
|
+
const NUMBER_CONDITIONS = [
|
|
76
|
+
"eq",
|
|
77
|
+
"ne",
|
|
78
|
+
"gt",
|
|
79
|
+
"gte",
|
|
80
|
+
"lt",
|
|
81
|
+
"lte",
|
|
82
|
+
"bw",
|
|
83
|
+
"null",
|
|
84
|
+
"notNull"
|
|
85
|
+
];
|
|
86
|
+
const BOOLEAN_CONDITIONS = [
|
|
87
|
+
"eq",
|
|
88
|
+
"ne",
|
|
89
|
+
"null",
|
|
90
|
+
"notNull"
|
|
91
|
+
];
|
|
92
|
+
const DATE_CONDITIONS = [
|
|
93
|
+
"eq",
|
|
94
|
+
"ne",
|
|
95
|
+
"gt",
|
|
96
|
+
"gte",
|
|
97
|
+
"lt",
|
|
98
|
+
"lte",
|
|
99
|
+
"bw",
|
|
100
|
+
"null",
|
|
101
|
+
"notNull"
|
|
102
|
+
];
|
|
103
|
+
const CONDITIONS_MAP = {
|
|
104
|
+
text: TEXT_CONDITIONS,
|
|
105
|
+
number: NUMBER_CONDITIONS,
|
|
106
|
+
boolean: BOOLEAN_CONDITIONS,
|
|
107
|
+
date: DATE_CONDITIONS,
|
|
108
|
+
enum: TEXT_CONDITIONS,
|
|
109
|
+
ref: TEXT_CONDITIONS
|
|
110
|
+
};
|
|
111
|
+
const NON_NULLABLE_CONDITIONS_MAP = {
|
|
112
|
+
text: TEXT_CONDITIONS.filter((c) => !NULL_OPS.has(c)),
|
|
113
|
+
number: NUMBER_CONDITIONS.filter((c) => !NULL_OPS.has(c)),
|
|
114
|
+
boolean: BOOLEAN_CONDITIONS.filter((c) => !NULL_OPS.has(c)),
|
|
115
|
+
date: DATE_CONDITIONS.filter((c) => !NULL_OPS.has(c)),
|
|
116
|
+
enum: TEXT_CONDITIONS.filter((c) => !NULL_OPS.has(c)),
|
|
117
|
+
ref: TEXT_CONDITIONS.filter((c) => !NULL_OPS.has(c))
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Available filter conditions for a given column filter type.
|
|
121
|
+
* Non-nullable columns drop `null` / `notNull` since they can never match.
|
|
122
|
+
*/
|
|
123
|
+
function conditionsForType(type, nullable = true) {
|
|
124
|
+
const map = nullable ? CONDITIONS_MAP : NON_NULLABLE_CONDITIONS_MAP;
|
|
125
|
+
return map[type] ?? map.text;
|
|
126
|
+
}
|
|
127
|
+
/** Map a ColumnDef display type string to a ColumnFilterType. */
|
|
128
|
+
function columnFilterType(columnType) {
|
|
129
|
+
switch (columnType) {
|
|
130
|
+
case "number": return "number";
|
|
131
|
+
case "boolean": return "boolean";
|
|
132
|
+
case "date": return "date";
|
|
133
|
+
case "enum": return "enum";
|
|
134
|
+
case "ref": return "ref";
|
|
135
|
+
default: return "text";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/filters/escape-regex.ts
|
|
140
|
+
/** Escape special regex characters in user input for safe embedding in $regex. */
|
|
141
|
+
function escapeRegex(input) {
|
|
142
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
143
|
+
}
|
|
144
|
+
/** Reverse of {@link escapeRegex}. */
|
|
145
|
+
function unescapeRegex(input) {
|
|
146
|
+
return input.replace(/\\([.*+?^${}()|[\]\\])/g, "$1");
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/filters/filter-input-format.ts
|
|
150
|
+
/**
|
|
151
|
+
* Coerce a raw string value to the appropriate JS type for the column.
|
|
152
|
+
* Number columns get numeric values; everything else stays as string.
|
|
153
|
+
*/
|
|
154
|
+
function coerceValue(raw, columnType) {
|
|
155
|
+
if (columnType === "number") {
|
|
156
|
+
const n = Number(raw);
|
|
157
|
+
return Number.isNaN(n) ? raw : n;
|
|
158
|
+
}
|
|
159
|
+
return raw;
|
|
160
|
+
}
|
|
161
|
+
/** Default condition type when no symbol matches the input. */
|
|
162
|
+
function defaultCondition(columnType) {
|
|
163
|
+
switch (columnType) {
|
|
164
|
+
case "text":
|
|
165
|
+
case "enum":
|
|
166
|
+
case "ref": return "contains";
|
|
167
|
+
default: return "eq";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Prefix operators in match order (longest first). */
|
|
171
|
+
const PREFIX_OPS = [
|
|
172
|
+
["!=", "ne"],
|
|
173
|
+
[">=", "gte"],
|
|
174
|
+
["<=", "lte"],
|
|
175
|
+
[">", "gt"],
|
|
176
|
+
["<", "lt"],
|
|
177
|
+
["=", "eq"]
|
|
178
|
+
];
|
|
179
|
+
/**
|
|
180
|
+
* Parse a user-typed filter input string into a FilterCondition.
|
|
181
|
+
*
|
|
182
|
+
* Supports operator symbols:
|
|
183
|
+
* *text* → contains
|
|
184
|
+
* text* → starts with
|
|
185
|
+
* *text → ends with
|
|
186
|
+
* =value → eq (explicit)
|
|
187
|
+
* !=value → ne
|
|
188
|
+
* >=value → gte
|
|
189
|
+
* >value → gt
|
|
190
|
+
* <=value → lte
|
|
191
|
+
* <value → lt
|
|
192
|
+
* lo...hi → bw (between)
|
|
193
|
+
* <empty> → null
|
|
194
|
+
* !<empty> → notNull
|
|
195
|
+
* /pattern/ → regex
|
|
196
|
+
*
|
|
197
|
+
* When no symbol matches, the default depends on columnType:
|
|
198
|
+
* text/enum/ref → contains
|
|
199
|
+
* number/date/boolean → eq
|
|
200
|
+
*
|
|
201
|
+
* Returns undefined for empty/invalid input or if the parsed operator
|
|
202
|
+
* is not available for the column type.
|
|
203
|
+
*/
|
|
204
|
+
function parseFilterInput(text, columnType, nullable = true) {
|
|
205
|
+
const trimmed = text.trim();
|
|
206
|
+
if (trimmed === "") return void 0;
|
|
207
|
+
const available = conditionsForType(columnType, nullable);
|
|
208
|
+
const isNumber = columnType === "number";
|
|
209
|
+
const build = (type, value) => {
|
|
210
|
+
if (!available.includes(type)) return void 0;
|
|
211
|
+
if (isNumber) for (const v of value) {
|
|
212
|
+
if (typeof v === "string" && v !== "") return void 0;
|
|
213
|
+
if (typeof v === "number" && Number.isNaN(v)) return void 0;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
type,
|
|
217
|
+
value
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
const coerce = (raw) => coerceValue(raw, columnType);
|
|
221
|
+
const lower = trimmed.toLowerCase();
|
|
222
|
+
if (lower === "!<empty>") return build("notNull", []);
|
|
223
|
+
if (lower === "<empty>") return build("null", []);
|
|
224
|
+
if (trimmed.length >= 3 && trimmed[0] === "/" && trimmed[trimmed.length - 1] === "/") {
|
|
225
|
+
const pattern = trimmed.slice(1, -1);
|
|
226
|
+
if (pattern === "") return void 0;
|
|
227
|
+
return build("regex", [pattern]);
|
|
228
|
+
}
|
|
229
|
+
const bwIdx = trimmed.indexOf("...");
|
|
230
|
+
if (bwIdx > 0 && bwIdx + 3 < trimmed.length) {
|
|
231
|
+
const lo = trimmed.slice(0, bwIdx).trim();
|
|
232
|
+
const hi = trimmed.slice(bwIdx + 3).trim();
|
|
233
|
+
if (lo !== "" && hi !== "") return build("bw", [coerce(lo), coerce(hi)]);
|
|
234
|
+
}
|
|
235
|
+
for (const [sym, op] of PREFIX_OPS) if (trimmed.startsWith(sym) && trimmed.length > sym.length) {
|
|
236
|
+
const val = trimmed.slice(sym.length).trim();
|
|
237
|
+
if (val !== "") return build(op, [coerce(val)]);
|
|
238
|
+
}
|
|
239
|
+
if (trimmed.length >= 3 && trimmed[0] === "*" && trimmed[trimmed.length - 1] === "*") {
|
|
240
|
+
const inner = trimmed.slice(1, -1);
|
|
241
|
+
if (inner !== "") return build("contains", [inner]);
|
|
242
|
+
}
|
|
243
|
+
if (trimmed.length >= 2 && trimmed[trimmed.length - 1] === "*" && trimmed[0] !== "*") {
|
|
244
|
+
const inner = trimmed.slice(0, -1);
|
|
245
|
+
if (inner !== "") return build("starts", [inner]);
|
|
246
|
+
}
|
|
247
|
+
if (trimmed.length >= 2 && trimmed[0] === "*" && trimmed[trimmed.length - 1] !== "*") {
|
|
248
|
+
const inner = trimmed.slice(1);
|
|
249
|
+
if (inner !== "") return build("ends", [inner]);
|
|
250
|
+
}
|
|
251
|
+
return build(defaultCondition(columnType), [coerce(trimmed)]);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Format a FilterCondition for chip/token display using operator symbols.
|
|
255
|
+
*
|
|
256
|
+
* Round-trips with parseFilterInput:
|
|
257
|
+
* formatFilterCondition(parseFilterInput(text, type)) ≈ text
|
|
258
|
+
*/
|
|
259
|
+
function formatFilterCondition(condition) {
|
|
260
|
+
const { type, value } = condition;
|
|
261
|
+
const v0 = value[0] != null ? String(value[0]) : "";
|
|
262
|
+
switch (type) {
|
|
263
|
+
case "eq": return v0;
|
|
264
|
+
case "ne": return `!=${v0}`;
|
|
265
|
+
case "gt": return `>${v0}`;
|
|
266
|
+
case "gte": return `>=${v0}`;
|
|
267
|
+
case "lt": return `<${v0}`;
|
|
268
|
+
case "lte": return `<=${v0}`;
|
|
269
|
+
case "contains": return `*${v0}*`;
|
|
270
|
+
case "starts": return `${v0}*`;
|
|
271
|
+
case "ends": return `*${v0}`;
|
|
272
|
+
case "bw": return `${v0}...${value[1] != null ? String(value[1]) : ""}`;
|
|
273
|
+
case "null": return "<empty>";
|
|
274
|
+
case "notNull": return "!<empty>";
|
|
275
|
+
case "regex": return `/${v0}/`;
|
|
276
|
+
default: return v0;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
//#endregion
|
|
280
|
+
//#region src/filters/filters-to-uniquery.ts
|
|
281
|
+
/** Exclusion condition types — AND'd together per field. */
|
|
282
|
+
const EXCLUSION_TYPES = new Set(["ne", "notNull"]);
|
|
283
|
+
/**
|
|
284
|
+
* Convert a single condition to a Uniquery filter expression.
|
|
285
|
+
* Returns a ComparisonNode with the field as key.
|
|
286
|
+
*/
|
|
287
|
+
function conditionToExpr(field, condition) {
|
|
288
|
+
const v = condition.value;
|
|
289
|
+
switch (condition.type) {
|
|
290
|
+
case "eq": return { [field]: v[0] };
|
|
291
|
+
case "ne": return { [field]: { $ne: v[0] } };
|
|
292
|
+
case "gt": return { [field]: { $gt: v[0] } };
|
|
293
|
+
case "gte": return { [field]: { $gte: v[0] } };
|
|
294
|
+
case "lt": return { [field]: { $lt: v[0] } };
|
|
295
|
+
case "lte": return { [field]: { $lte: v[0] } };
|
|
296
|
+
case "contains": return { [field]: { $regex: `/${escapeRegex(String(v[0]))}/i` } };
|
|
297
|
+
case "starts": return { [field]: { $regex: `/^${escapeRegex(String(v[0]))}/i` } };
|
|
298
|
+
case "ends": return { [field]: { $regex: `/${escapeRegex(String(v[0]))}$/i` } };
|
|
299
|
+
case "bw": return { [field]: {
|
|
300
|
+
$gte: v[0],
|
|
301
|
+
$lte: v[1]
|
|
302
|
+
} };
|
|
303
|
+
case "null": return { [field]: { $exists: false } };
|
|
304
|
+
case "notNull": return { [field]: { $exists: true } };
|
|
305
|
+
case "regex": return { [field]: { $regex: String(v[0]) } };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** Push a group of expressions: unwrap single, wrap multiple with the given operator. */
|
|
309
|
+
function pushGroup(target, items, op) {
|
|
310
|
+
if (items.length === 1) target.push(items[0]);
|
|
311
|
+
else if (items.length > 1) target.push({ [op]: items });
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Convert UI filter model to a Uniquery FilterExpr.
|
|
315
|
+
*
|
|
316
|
+
* Combination logic:
|
|
317
|
+
* - Per field: inclusion (positive) conditions are OR'd, exclusion (negative) conditions are AND'd.
|
|
318
|
+
* - Across fields: all groups are AND'd at the top level.
|
|
319
|
+
*
|
|
320
|
+
* Returns `undefined` when no filled conditions exist.
|
|
321
|
+
*/
|
|
322
|
+
function filtersToUniqueryFilter(fieldFilters) {
|
|
323
|
+
const topGroups = [];
|
|
324
|
+
for (const field in fieldFilters) {
|
|
325
|
+
const conditions = fieldFilters[field];
|
|
326
|
+
let inclusions;
|
|
327
|
+
let exclusions;
|
|
328
|
+
for (const condition of conditions) {
|
|
329
|
+
if (!isFilled(condition)) continue;
|
|
330
|
+
const expr = conditionToExpr(field, condition);
|
|
331
|
+
if (EXCLUSION_TYPES.has(condition.type)) (exclusions ??= []).push(expr);
|
|
332
|
+
else (inclusions ??= []).push(expr);
|
|
333
|
+
}
|
|
334
|
+
if (inclusions) pushGroup(topGroups, inclusions, "$or");
|
|
335
|
+
if (exclusions) pushGroup(topGroups, exclusions, "$and");
|
|
336
|
+
}
|
|
337
|
+
if (topGroups.length === 0) return void 0;
|
|
338
|
+
if (topGroups.length === 1) return topGroups[0];
|
|
339
|
+
return { $and: topGroups };
|
|
340
|
+
}
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/filters/uniquery-to-filters.ts
|
|
343
|
+
const SUPPORTED_TYPES = new Set([
|
|
344
|
+
"eq",
|
|
345
|
+
"ne",
|
|
346
|
+
"gt",
|
|
347
|
+
"gte",
|
|
348
|
+
"lt",
|
|
349
|
+
"lte",
|
|
350
|
+
"contains",
|
|
351
|
+
"starts",
|
|
352
|
+
"ends",
|
|
353
|
+
"bw",
|
|
354
|
+
"null",
|
|
355
|
+
"notNull",
|
|
356
|
+
"regex"
|
|
357
|
+
]);
|
|
358
|
+
function isPrimitive(v) {
|
|
359
|
+
return typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
360
|
+
}
|
|
361
|
+
const REGEX_LITERAL = /^\/(.*)\/([a-z]*)$/s;
|
|
362
|
+
const STARTS_ANCHOR = /^\^(.+)$/s;
|
|
363
|
+
const ENDS_ANCHOR = /^(.+)\$$/s;
|
|
364
|
+
/**
|
|
365
|
+
* Decode a `$regex` value into a contains/starts/ends/regex `FilterCondition`.
|
|
366
|
+
* The encoder emits case-insensitive `/…/i` wrappers for contains/starts/ends
|
|
367
|
+
* shortcuts; round-trip those back. Anything else falls through as a literal
|
|
368
|
+
* `regex` condition.
|
|
369
|
+
*/
|
|
370
|
+
function regexToCondition(raw) {
|
|
371
|
+
if (typeof raw !== "string") return null;
|
|
372
|
+
const m = REGEX_LITERAL.exec(raw);
|
|
373
|
+
if (!m) return {
|
|
374
|
+
type: "regex",
|
|
375
|
+
value: [raw]
|
|
376
|
+
};
|
|
377
|
+
const body = m[1];
|
|
378
|
+
if (m[2] === "i") {
|
|
379
|
+
const startsM = STARTS_ANCHOR.exec(body);
|
|
380
|
+
if (startsM) return {
|
|
381
|
+
type: "starts",
|
|
382
|
+
value: [unescapeRegex(startsM[1])]
|
|
383
|
+
};
|
|
384
|
+
const endsM = ENDS_ANCHOR.exec(body);
|
|
385
|
+
if (endsM) return {
|
|
386
|
+
type: "ends",
|
|
387
|
+
value: [unescapeRegex(endsM[1])]
|
|
388
|
+
};
|
|
389
|
+
return {
|
|
390
|
+
type: "contains",
|
|
391
|
+
value: [unescapeRegex(body)]
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
type: "regex",
|
|
396
|
+
value: [raw]
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function pushIfPrimitive(out, type, v) {
|
|
400
|
+
if (!isPrimitive(v)) return;
|
|
401
|
+
out.push({
|
|
402
|
+
type,
|
|
403
|
+
value: [v]
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Decode a per-field operator object (e.g. `{ $gt: 5, $lte: 10 }`) into one or
|
|
408
|
+
* more `FilterCondition`s. Unknown operators are silently dropped.
|
|
409
|
+
*
|
|
410
|
+
* `$gte` + `$lte` on the same field collapse into a single `bw` condition,
|
|
411
|
+
* matching the encoder's behaviour.
|
|
412
|
+
*/
|
|
413
|
+
function decodeFieldOps(ops) {
|
|
414
|
+
const out = [];
|
|
415
|
+
const hasGte = "$gte" in ops && isPrimitive(ops.$gte);
|
|
416
|
+
const hasLte = "$lte" in ops && isPrimitive(ops.$lte);
|
|
417
|
+
if (hasGte && hasLte) out.push({
|
|
418
|
+
type: "bw",
|
|
419
|
+
value: [ops.$gte, ops.$lte]
|
|
420
|
+
});
|
|
421
|
+
else {
|
|
422
|
+
if (hasGte) pushIfPrimitive(out, "gte", ops.$gte);
|
|
423
|
+
if (hasLte) pushIfPrimitive(out, "lte", ops.$lte);
|
|
424
|
+
}
|
|
425
|
+
if ("$eq" in ops) pushIfPrimitive(out, "eq", ops.$eq);
|
|
426
|
+
if ("$ne" in ops) pushIfPrimitive(out, "ne", ops.$ne);
|
|
427
|
+
if ("$gt" in ops) pushIfPrimitive(out, "gt", ops.$gt);
|
|
428
|
+
if ("$lt" in ops) pushIfPrimitive(out, "lt", ops.$lt);
|
|
429
|
+
if ("$exists" in ops) {
|
|
430
|
+
if (ops.$exists === false) out.push({
|
|
431
|
+
type: "null",
|
|
432
|
+
value: []
|
|
433
|
+
});
|
|
434
|
+
else if (ops.$exists === true) out.push({
|
|
435
|
+
type: "notNull",
|
|
436
|
+
value: []
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if ("$regex" in ops) {
|
|
440
|
+
const cond = regexToCondition(ops.$regex);
|
|
441
|
+
if (cond) out.push(cond);
|
|
442
|
+
}
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Walk a leaf `ComparisonNode` (one or more field=value entries) into the
|
|
447
|
+
* shared `FieldFilters` accumulator. Drops unknown fields and operators.
|
|
448
|
+
*/
|
|
449
|
+
function walkLeaf(node, acc, knownFields) {
|
|
450
|
+
for (const field in node) {
|
|
451
|
+
if (field.startsWith("$")) continue;
|
|
452
|
+
if (knownFields && !knownFields.has(field)) continue;
|
|
453
|
+
const value = node[field];
|
|
454
|
+
let conditions;
|
|
455
|
+
if (value === null || value === void 0) conditions = [{
|
|
456
|
+
type: "null",
|
|
457
|
+
value: []
|
|
458
|
+
}];
|
|
459
|
+
else if (isPrimitive(value)) conditions = [{
|
|
460
|
+
type: "eq",
|
|
461
|
+
value: [value]
|
|
462
|
+
}];
|
|
463
|
+
else if (typeof value === "object" && !Array.isArray(value)) conditions = decodeFieldOps(value);
|
|
464
|
+
else continue;
|
|
465
|
+
if (conditions.length === 0) continue;
|
|
466
|
+
const filtered = conditions.filter((c) => SUPPORTED_TYPES.has(c.type));
|
|
467
|
+
if (filtered.length === 0) continue;
|
|
468
|
+
if (acc[field]) acc[field] = [...acc[field], ...filtered];
|
|
469
|
+
else acc[field] = filtered;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function walkExpr(expr, acc, knownFields) {
|
|
473
|
+
if ("$and" in expr && expr.$and) {
|
|
474
|
+
for (const child of expr.$and) walkExpr(child, acc, knownFields);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if ("$or" in expr && expr.$or) {
|
|
478
|
+
for (const child of expr.$or) walkExpr(child, acc, knownFields);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if ("$not" in expr && expr.$not) return;
|
|
482
|
+
walkLeaf(expr, acc, knownFields);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Convert a Uniquery `FilterExpr` back into the UI's `FieldFilters` shape.
|
|
486
|
+
*
|
|
487
|
+
* Inverse of `filtersToUniqueryFilter`. Drops:
|
|
488
|
+
* - conditions on fields not in `knownFields` (when provided)
|
|
489
|
+
* - operators not in the supported `FilterConditionType` union
|
|
490
|
+
* - `$not` branches (no native UI representation; lossy on hand-crafted URLs)
|
|
491
|
+
*
|
|
492
|
+
* Returns `{}` for an empty/missing expression. Never throws.
|
|
493
|
+
*/
|
|
494
|
+
function uniqueryFilterToFieldFilters(expr, knownFields) {
|
|
495
|
+
const acc = {};
|
|
496
|
+
if (!expr) return acc;
|
|
497
|
+
const known = knownFields == null ? null : knownFields instanceof Set ? knownFields : new Set(knownFields);
|
|
498
|
+
try {
|
|
499
|
+
walkExpr(expr, acc, known);
|
|
500
|
+
} catch {}
|
|
501
|
+
return acc;
|
|
502
|
+
}
|
|
503
|
+
//#endregion
|
|
504
|
+
//#region src/filters/date-shortcuts.ts
|
|
505
|
+
/**
|
|
506
|
+
* Generate date filter shortcuts relative to the given date (defaults to now).
|
|
507
|
+
* Each shortcut produces a `bw` (between) condition range using ISO date strings (YYYY-MM-DD).
|
|
508
|
+
*/
|
|
509
|
+
function dateShortcuts(now) {
|
|
510
|
+
const today = now ?? /* @__PURE__ */ new Date();
|
|
511
|
+
const todayISO = toISO(today);
|
|
512
|
+
return [
|
|
513
|
+
{
|
|
514
|
+
label: "Last 7 Days",
|
|
515
|
+
dates: [toISO(daysAgo(today, 7)), todayISO]
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
label: "Last 30 Days",
|
|
519
|
+
dates: [toISO(daysAgo(today, 30)), todayISO]
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
label: "Month to Date",
|
|
523
|
+
dates: [toISO(monthStart(today)), todayISO]
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
label: "Last 90 Days",
|
|
527
|
+
dates: [toISO(daysAgo(today, 90)), todayISO]
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
label: "Last 6 Months",
|
|
531
|
+
dates: [toISO(monthsAgo(today, 6)), todayISO]
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
label: "Last 12 Months",
|
|
535
|
+
dates: [toISO(monthsAgo(today, 12)), todayISO]
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
label: "Year to Date",
|
|
539
|
+
dates: [toISO(yearStart(today)), todayISO]
|
|
540
|
+
}
|
|
541
|
+
];
|
|
542
|
+
}
|
|
543
|
+
function toISO(date) {
|
|
544
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
545
|
+
}
|
|
546
|
+
function daysAgo(from, days) {
|
|
547
|
+
const d = new Date(from);
|
|
548
|
+
d.setDate(d.getDate() - days);
|
|
549
|
+
return d;
|
|
550
|
+
}
|
|
551
|
+
function monthsAgo(from, months) {
|
|
552
|
+
const d = new Date(from);
|
|
553
|
+
d.setMonth(d.getMonth() - months);
|
|
554
|
+
return d;
|
|
555
|
+
}
|
|
556
|
+
function monthStart(date) {
|
|
557
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
558
|
+
}
|
|
559
|
+
function yearStart(date) {
|
|
560
|
+
return new Date(date.getFullYear(), 0, 1);
|
|
561
|
+
}
|
|
562
|
+
//#endregion
|
|
563
|
+
//#region src/presets/preset-aspects.ts
|
|
564
|
+
const PRESET_ASPECTS = [
|
|
565
|
+
"columns",
|
|
566
|
+
"filters",
|
|
567
|
+
"filterOps",
|
|
568
|
+
"sorters",
|
|
569
|
+
"itemsPerPage"
|
|
570
|
+
];
|
|
571
|
+
function derivePresetAspects(content) {
|
|
572
|
+
if (!content || typeof content !== "object") return [];
|
|
573
|
+
const out = [];
|
|
574
|
+
for (const aspect of PRESET_ASPECTS) if (Object.hasOwn(content, aspect)) out.push(aspect);
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
//#endregion
|
|
578
|
+
//#region src/presets/preset-wire.ts
|
|
579
|
+
/**
|
|
580
|
+
* Convert in-memory dict-form snapshot to the wire form persisted on the
|
|
581
|
+
* server. Entries-arrays are sorted by `field` so consumers (server-side
|
|
582
|
+
* aspect derivation, dirty detection, equality checks) see a stable order.
|
|
583
|
+
*/
|
|
584
|
+
function toWireSnapshot(snapshot) {
|
|
585
|
+
const wire = {};
|
|
586
|
+
if (snapshot.columns) {
|
|
587
|
+
const { columnNames, columnWidths } = snapshot.columns;
|
|
588
|
+
const columns = { columnNames };
|
|
589
|
+
if (columnWidths) {
|
|
590
|
+
const entries = [];
|
|
591
|
+
for (const field of Object.keys(columnWidths).toSorted()) entries.push({
|
|
592
|
+
field,
|
|
593
|
+
width: columnWidths[field]
|
|
594
|
+
});
|
|
595
|
+
if (entries.length > 0) columns.columnWidths = entries;
|
|
596
|
+
}
|
|
597
|
+
wire.columns = columns;
|
|
598
|
+
}
|
|
599
|
+
if (snapshot.filters) wire.filters = snapshot.filters;
|
|
600
|
+
if (snapshot.filterOps) {
|
|
601
|
+
const entries = [];
|
|
602
|
+
for (const field of Object.keys(snapshot.filterOps).toSorted()) entries.push({
|
|
603
|
+
field,
|
|
604
|
+
conditions: snapshot.filterOps[field]
|
|
605
|
+
});
|
|
606
|
+
if (entries.length > 0) wire.filterOps = entries;
|
|
607
|
+
}
|
|
608
|
+
if (snapshot.sorters) wire.sorters = snapshot.sorters;
|
|
609
|
+
if (snapshot.itemsPerPage !== void 0) wire.itemsPerPage = snapshot.itemsPerPage;
|
|
610
|
+
return wire;
|
|
611
|
+
}
|
|
612
|
+
function fromWireSnapshot(wire) {
|
|
613
|
+
const snapshot = {};
|
|
614
|
+
if (wire.columns) {
|
|
615
|
+
const { columnNames, columnWidths } = wire.columns;
|
|
616
|
+
const columns = { columnNames };
|
|
617
|
+
if (columnWidths && columnWidths.length > 0) {
|
|
618
|
+
const dict = {};
|
|
619
|
+
for (const entry of columnWidths) dict[entry.field] = entry.width;
|
|
620
|
+
columns.columnWidths = dict;
|
|
621
|
+
}
|
|
622
|
+
snapshot.columns = columns;
|
|
623
|
+
}
|
|
624
|
+
if (wire.filters) snapshot.filters = wire.filters;
|
|
625
|
+
if (wire.filterOps && wire.filterOps.length > 0) {
|
|
626
|
+
const dict = {};
|
|
627
|
+
for (const entry of wire.filterOps) dict[entry.field] = entry.conditions;
|
|
628
|
+
snapshot.filterOps = dict;
|
|
629
|
+
}
|
|
630
|
+
if (wire.sorters) snapshot.sorters = wire.sorters;
|
|
631
|
+
if (wire.itemsPerPage !== void 0) snapshot.itemsPerPage = wire.itemsPerPage;
|
|
632
|
+
return snapshot;
|
|
633
|
+
}
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/presets/preset-id.ts
|
|
636
|
+
/** Reserved id namespace for synthetic system presets; rejected on write. */
|
|
637
|
+
const SYSTEM_PRESET_PREFIX = "sys:";
|
|
638
|
+
/** Deterministic id prefix for `type='userConf'` rows: `uc:${user}:${app}:${tableKey}`. */
|
|
639
|
+
const USER_CONF_PREFIX = "uc:";
|
|
640
|
+
/** Deterministic id prefix for `type='appConf'` rows: `ac:${user}:${app}`. */
|
|
641
|
+
const APP_CONF_PREFIX = "ac:";
|
|
642
|
+
/** All prefixes the server owns; client-supplied ids starting with any of these are rejected. */
|
|
643
|
+
const RESERVED_ID_PREFIXES = [
|
|
644
|
+
SYSTEM_PRESET_PREFIX,
|
|
645
|
+
"uc:",
|
|
646
|
+
"ac:"
|
|
647
|
+
];
|
|
648
|
+
/** Always-materialised system preset id; consumer may override label/content. */
|
|
649
|
+
const STANDARD_PRESET_ID = `${SYSTEM_PRESET_PREFIX}standard`;
|
|
650
|
+
function userConfId(user, app, tableKey) {
|
|
651
|
+
return `uc:${user}:${app}:${tableKey}`;
|
|
652
|
+
}
|
|
653
|
+
function appConfId(user, app) {
|
|
654
|
+
return `ac:${user}:${app}`;
|
|
655
|
+
}
|
|
656
|
+
function isSystemPresetId(id) {
|
|
657
|
+
return typeof id === "string" && id.startsWith("sys:");
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Auto-prefix a bare system-preset id (`'monitoring'` → `'sys:monitoring'`).
|
|
661
|
+
* Returns the input unchanged if it already carries the prefix.
|
|
662
|
+
*/
|
|
663
|
+
function normaliseSystemPresetId(id) {
|
|
664
|
+
if (id.startsWith("sys:")) return id;
|
|
665
|
+
return `${SYSTEM_PRESET_PREFIX}${id}`;
|
|
666
|
+
}
|
|
667
|
+
//#endregion
|
|
668
|
+
//#region src/presets/system-presets.ts
|
|
669
|
+
const DEFAULT_STANDARD_LABEL = "Standard";
|
|
670
|
+
/**
|
|
671
|
+
* Resolve the consumer's `:system-presets` prop into the canonical render
|
|
672
|
+
* order: Standard at index 0 (consumer override or default empty fallback),
|
|
673
|
+
* named system presets in array order. Duplicate ids are dropped with a
|
|
674
|
+
* console.warn (first wins).
|
|
675
|
+
*/
|
|
676
|
+
function resolveSystemPresets(input) {
|
|
677
|
+
const seen = /* @__PURE__ */ new Set();
|
|
678
|
+
let standardOverride = null;
|
|
679
|
+
const named = [];
|
|
680
|
+
if (input) for (const item of input) {
|
|
681
|
+
const id = normaliseSystemPresetId(item.id);
|
|
682
|
+
if (seen.has(id)) {
|
|
683
|
+
console.warn(`[ui-table] Duplicate system-preset id "${id}"; keeping first.`);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
seen.add(id);
|
|
687
|
+
const preset = {
|
|
688
|
+
id,
|
|
689
|
+
label: item.label,
|
|
690
|
+
content: item.content ?? {}
|
|
691
|
+
};
|
|
692
|
+
if (id === STANDARD_PRESET_ID) standardOverride = preset;
|
|
693
|
+
else named.push(preset);
|
|
694
|
+
}
|
|
695
|
+
return [standardOverride ?? {
|
|
696
|
+
id: STANDARD_PRESET_ID,
|
|
697
|
+
label: DEFAULT_STANDARD_LABEL,
|
|
698
|
+
content: {}
|
|
699
|
+
}, ...named];
|
|
700
|
+
}
|
|
701
|
+
//#endregion
|
|
702
|
+
//#region src/presets/preset-dirty.ts
|
|
703
|
+
/**
|
|
704
|
+
* JSON-stringify with object keys sorted alphabetically at every depth so
|
|
705
|
+
* dict-shaped values (`columnWidths`, `filterOps` after deserialisation)
|
|
706
|
+
* compare structurally regardless of insertion order. Arrays preserve order.
|
|
707
|
+
*
|
|
708
|
+
* Used by the localStorage draft serializer (where a stable string is
|
|
709
|
+
* needed for storage); dirty detection prefers `isDirtyAgainst` (no
|
|
710
|
+
* stringify, short-circuits on first mismatch).
|
|
711
|
+
*/
|
|
712
|
+
function stableStringify(value) {
|
|
713
|
+
return JSON.stringify(value, (_key, val) => {
|
|
714
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
715
|
+
const sorted = {};
|
|
716
|
+
for (const k of Object.keys(val).toSorted()) sorted[k] = val[k];
|
|
717
|
+
return sorted;
|
|
718
|
+
}
|
|
719
|
+
return val;
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
function deepEqual(a, b) {
|
|
723
|
+
if (a === b) return true;
|
|
724
|
+
if (a === null || b === null) return false;
|
|
725
|
+
if (typeof a !== "object" || typeof b !== "object") return false;
|
|
726
|
+
const aIsArr = Array.isArray(a);
|
|
727
|
+
if (aIsArr !== Array.isArray(b)) return false;
|
|
728
|
+
if (aIsArr) {
|
|
729
|
+
const aArr = a;
|
|
730
|
+
const bArr = b;
|
|
731
|
+
if (aArr.length !== bArr.length) return false;
|
|
732
|
+
for (let i = 0; i < aArr.length; i++) if (!deepEqual(aArr[i], bArr[i])) return false;
|
|
733
|
+
return true;
|
|
734
|
+
}
|
|
735
|
+
const aObj = a;
|
|
736
|
+
const bObj = b;
|
|
737
|
+
const aKeys = Object.keys(aObj);
|
|
738
|
+
if (aKeys.length !== Object.keys(bObj).length) return false;
|
|
739
|
+
for (const k of aKeys) {
|
|
740
|
+
if (!Object.hasOwn(bObj, k)) return false;
|
|
741
|
+
if (!deepEqual(aObj[k], bObj[k])) return false;
|
|
742
|
+
}
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Per-aspect dirty check: only aspects the active preset claims (key
|
|
747
|
+
* present) are compared. A column-only preset stays clean while filters
|
|
748
|
+
* change; a filter-ops-only preset doesn't dirty when columns reorder.
|
|
749
|
+
*
|
|
750
|
+
* `current` should be the full snapshot of all available aspects (i.e.
|
|
751
|
+
* `captureSnapshot()` output) so each claimed aspect on the active preset
|
|
752
|
+
* has something to compare against.
|
|
753
|
+
*/
|
|
754
|
+
function isDirtyAgainst(active, current) {
|
|
755
|
+
for (const aspect of PRESET_ASPECTS) {
|
|
756
|
+
const a = active[aspect];
|
|
757
|
+
if (a === void 0) continue;
|
|
758
|
+
const c = current[aspect];
|
|
759
|
+
if (!deepEqual(a, c)) return true;
|
|
760
|
+
}
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
//#endregion
|
|
764
|
+
//#region src/presets/preset-draft.ts
|
|
765
|
+
/** Aspects eligible for local-draft persistence. `filterOps` is excluded by design. */
|
|
766
|
+
const DRAFT_PERSISTED_ASPECTS = [
|
|
767
|
+
"columns",
|
|
768
|
+
"filters",
|
|
769
|
+
"sorters",
|
|
770
|
+
"itemsPerPage"
|
|
771
|
+
];
|
|
772
|
+
function aspectAllowed(aspect, availableAspects) {
|
|
773
|
+
return availableAspects.includes(aspect);
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Capture the persisted-aspect subset of a full snapshot. Aspects not in
|
|
777
|
+
* `availableAspects` are skipped (forward-compat: a deploy that toggles an
|
|
778
|
+
* aspect off doesn't error on previously-saved drafts).
|
|
779
|
+
*/
|
|
780
|
+
function serializeDraft(snapshot, availableAspects) {
|
|
781
|
+
return copyPersistedAspects(snapshot, availableAspects);
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Convert a localStorage draft back to a partial snapshot suitable for
|
|
785
|
+
* `applyPreset`. Aspects no longer in `availableAspects` are silently
|
|
786
|
+
* skipped.
|
|
787
|
+
*/
|
|
788
|
+
function deserializeDraft(draft, availableAspects) {
|
|
789
|
+
return copyPersistedAspects(draft, availableAspects);
|
|
790
|
+
}
|
|
791
|
+
/** True when the draft has zero persisted aspects to apply. */
|
|
792
|
+
function isEmptyDraft(draft) {
|
|
793
|
+
for (const aspect of DRAFT_PERSISTED_ASPECTS) if (draft[aspect] !== void 0) return false;
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
function copyPersistedAspects(source, availableAspects) {
|
|
797
|
+
const out = {};
|
|
798
|
+
for (const aspect of DRAFT_PERSISTED_ASPECTS) {
|
|
799
|
+
if (!aspectAllowed(aspect, availableAspects)) continue;
|
|
800
|
+
const value = source[aspect];
|
|
801
|
+
if (value === void 0) continue;
|
|
802
|
+
out[aspect] = value;
|
|
803
|
+
}
|
|
804
|
+
return out;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* True when the draft's persisted aspects exactly match the active
|
|
808
|
+
* preset's persisted aspects. Used by the watcher to decide whether to
|
|
809
|
+
* `localStorage.removeItem` instead of writing — keeps storage tidy and
|
|
810
|
+
* avoids `localStorage.length` noise after the user reverts edits.
|
|
811
|
+
*/
|
|
812
|
+
function draftMatchesPreset(draft, presetSnapshot, availableAspects) {
|
|
813
|
+
const draftSnapshot = deserializeDraft(draft, availableAspects);
|
|
814
|
+
const presetSubset = serializeDraft(presetSnapshot, availableAspects);
|
|
815
|
+
return stableStringify(draftSnapshot) === stableStringify(presetSubset);
|
|
816
|
+
}
|
|
817
|
+
//#endregion
|
|
818
|
+
//#region src/presets/presets-client.ts
|
|
819
|
+
/**
|
|
820
|
+
* Framework-agnostic wrapper over `@atscript/db-client`'s `Client` for the
|
|
821
|
+
* `AsPresetEntry` table. Handles wire serialisation, list-splitting by
|
|
822
|
+
* `type`, capabilities side-channel, and 401/403 → `denied` semantics.
|
|
823
|
+
*
|
|
824
|
+
* Stateless: every method is a fresh request. The Vue composable layer
|
|
825
|
+
* holds reactive state; this class only translates intent → HTTP.
|
|
826
|
+
*/
|
|
827
|
+
var PresetsClient = class {
|
|
828
|
+
url;
|
|
829
|
+
app;
|
|
830
|
+
tableKey;
|
|
831
|
+
client;
|
|
832
|
+
fetchImpl;
|
|
833
|
+
constructor(cfg) {
|
|
834
|
+
if (!cfg.url) throw new Error("PresetsClient: `url` is required");
|
|
835
|
+
if (!cfg.app) throw new Error("PresetsClient: `app` is required");
|
|
836
|
+
if (!cfg.tableKey) throw new Error("PresetsClient: `tableKey` is required");
|
|
837
|
+
this.url = cfg.url;
|
|
838
|
+
this.app = cfg.app;
|
|
839
|
+
this.tableKey = cfg.tableKey;
|
|
840
|
+
this.client = cfg.client ?? (cfg.clientFactory ?? (0, _atscript_ui.getDefaultClientFactory)())(cfg.url);
|
|
841
|
+
this.fetchImpl = cfg.fetch ?? globalThis.fetch.bind(globalThis);
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Lists owned + public preset rows AND the user's userConf row for this
|
|
845
|
+
* `(app, tableKey)`. By default also fetches `capabilities` in parallel —
|
|
846
|
+
* pass `{ capabilities: false }` for refresh-after-mutation calls (fav
|
|
847
|
+
* toggle, default change, save/save-as, public toggle, rename, delete)
|
|
848
|
+
* where role-derived capabilities can't have changed. Auth errors
|
|
849
|
+
* (401/403) collapse to `denied: true` with empty data so the UI hides
|
|
850
|
+
* itself silently.
|
|
851
|
+
*/
|
|
852
|
+
async list(opts = {}) {
|
|
853
|
+
const fetchCapabilities = opts.capabilities !== false;
|
|
854
|
+
const filter = {
|
|
855
|
+
app: this.app,
|
|
856
|
+
tableKey: this.tableKey,
|
|
857
|
+
type: { $in: ["preset", "userConf"] }
|
|
858
|
+
};
|
|
859
|
+
try {
|
|
860
|
+
const [rows, capabilities] = await Promise.all([this.client.query({ filter }), fetchCapabilities ? this.loadCapabilities().catch((err) => {
|
|
861
|
+
if (isAuthError(err)) throw err;
|
|
862
|
+
return null;
|
|
863
|
+
}) : Promise.resolve(void 0)]);
|
|
864
|
+
const presets = [];
|
|
865
|
+
let userConf = null;
|
|
866
|
+
for (const row of rows) if (row.type === "preset") presets.push(row);
|
|
867
|
+
else if (row.type === "userConf") userConf = row;
|
|
868
|
+
return {
|
|
869
|
+
presets,
|
|
870
|
+
userConf,
|
|
871
|
+
capabilities: fetchCapabilities ? capabilities : void 0,
|
|
872
|
+
denied: false
|
|
873
|
+
};
|
|
874
|
+
} catch (err) {
|
|
875
|
+
if (isAuthError(err)) return {
|
|
876
|
+
presets: [],
|
|
877
|
+
userConf: null,
|
|
878
|
+
capabilities: null,
|
|
879
|
+
denied: true
|
|
880
|
+
};
|
|
881
|
+
throw err;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/** GET `${url}/capabilities?app=…&tableKey=…`. Out-of-band of CRUD plumbing. */
|
|
885
|
+
async loadCapabilities() {
|
|
886
|
+
const params = new URLSearchParams({
|
|
887
|
+
app: this.app,
|
|
888
|
+
tableKey: this.tableKey
|
|
889
|
+
});
|
|
890
|
+
const target = `${this.url.replace(/\/+$/, "")}/capabilities?${params.toString()}`;
|
|
891
|
+
const res = await this.fetchImpl(target, {
|
|
892
|
+
method: "GET",
|
|
893
|
+
credentials: "same-origin",
|
|
894
|
+
headers: { Accept: "application/json" }
|
|
895
|
+
});
|
|
896
|
+
if (!res.ok) throw new PresetsHttpError(res.status, `Capabilities fetch failed (${res.status})`);
|
|
897
|
+
return await res.json();
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Overwrite the active preset's content. Server shallow-merges `data` so
|
|
901
|
+
* `label` is preserved on the row regardless — the caller sends it
|
|
902
|
+
* anyway because the client-side validator can't pick the preset
|
|
903
|
+
* variant of the `data` union without `label` present (it's required
|
|
904
|
+
* on that variant).
|
|
905
|
+
*/
|
|
906
|
+
async savePreset(id, label, snapshot) {
|
|
907
|
+
const payload = {
|
|
908
|
+
id,
|
|
909
|
+
data: {
|
|
910
|
+
label,
|
|
911
|
+
content: toWireSnapshot(snapshot)
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
await this.client.update(payload);
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Create a new preset row. Server stamps `user`, generates a UUID `id`,
|
|
918
|
+
* and derives `aspects` from the snapshot keys.
|
|
919
|
+
*/
|
|
920
|
+
async savePresetAs(label, snapshot, opts = {}) {
|
|
921
|
+
const payload = {
|
|
922
|
+
type: "preset",
|
|
923
|
+
app: this.app,
|
|
924
|
+
tableKey: this.tableKey,
|
|
925
|
+
public: opts.public === true,
|
|
926
|
+
data: {
|
|
927
|
+
label,
|
|
928
|
+
content: toWireSnapshot(snapshot)
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
const id = (await this.client.insert(payload))?.insertedId;
|
|
932
|
+
if (typeof id !== "string" || id.length === 0) throw new Error("PresetsClient.savePresetAs: server did not return an id");
|
|
933
|
+
return { id };
|
|
934
|
+
}
|
|
935
|
+
/** Update only the label. Server re-stamps top-level `label` + `publicLabel`. */
|
|
936
|
+
async renamePreset(id, label) {
|
|
937
|
+
const payload = {
|
|
938
|
+
id,
|
|
939
|
+
data: { label }
|
|
940
|
+
};
|
|
941
|
+
await this.client.update(payload);
|
|
942
|
+
}
|
|
943
|
+
/** Toggle `public`. Server re-stamps `publicLabel` accordingly. */
|
|
944
|
+
async setPublic(id, value) {
|
|
945
|
+
const payload = {
|
|
946
|
+
id,
|
|
947
|
+
public: value
|
|
948
|
+
};
|
|
949
|
+
await this.client.update(payload);
|
|
950
|
+
}
|
|
951
|
+
async deletePreset(id) {
|
|
952
|
+
await this.client.remove(id);
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Upsert the userConf row keyed on `${USER_CONF_PREFIX}${user}:${app}:${tableKey}`.
|
|
956
|
+
* Caller must know whether the row exists from a prior `list()` so we
|
|
957
|
+
* pick the right verb. Server forces `user` and `id` on insert.
|
|
958
|
+
*/
|
|
959
|
+
async upsertUserConf(existing, patch, user) {
|
|
960
|
+
if (existing) {
|
|
961
|
+
const payload = {
|
|
962
|
+
id: existing.id,
|
|
963
|
+
data: patch
|
|
964
|
+
};
|
|
965
|
+
await this.client.update(payload);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const id = user ? userConfId(user, this.app, this.tableKey) : void 0;
|
|
969
|
+
const payload = {
|
|
970
|
+
...id ? { id } : {},
|
|
971
|
+
type: "userConf",
|
|
972
|
+
app: this.app,
|
|
973
|
+
tableKey: this.tableKey,
|
|
974
|
+
data: patch
|
|
975
|
+
};
|
|
976
|
+
await this.client.insert(payload);
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
/**
|
|
980
|
+
* Error raised for non-2xx responses on the capabilities side-channel.
|
|
981
|
+
* Standard CRUD errors flow through `Client`'s `ClientError`.
|
|
982
|
+
*/
|
|
983
|
+
var PresetsHttpError = class extends Error {
|
|
984
|
+
status;
|
|
985
|
+
constructor(status, message) {
|
|
986
|
+
super(message);
|
|
987
|
+
this.status = status;
|
|
988
|
+
this.name = "PresetsHttpError";
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
/** True for HTTP 401/403 across both `ClientError` and `PresetsHttpError`. */
|
|
992
|
+
function isAuthError(err) {
|
|
993
|
+
if (err instanceof _atscript_db_client.ClientError && (err.status === 401 || err.status === 403)) return true;
|
|
994
|
+
if (err instanceof PresetsHttpError && (err.status === 401 || err.status === 403)) return true;
|
|
995
|
+
return false;
|
|
996
|
+
}
|
|
997
|
+
//#endregion
|
|
998
|
+
//#region src/presets/app-prefs-client.ts
|
|
999
|
+
/**
|
|
1000
|
+
* Framework-agnostic wrapper for app-wide user preferences (`type='appConf'`).
|
|
1001
|
+
* Independent of the preset/userConf surface — devs use this directly to
|
|
1002
|
+
* read/write `appearance`, `language`, `density`, etc., without involving
|
|
1003
|
+
* any table.
|
|
1004
|
+
*/
|
|
1005
|
+
var AppPrefsClient = class {
|
|
1006
|
+
app;
|
|
1007
|
+
client;
|
|
1008
|
+
constructor(cfg) {
|
|
1009
|
+
if (!cfg.url) throw new Error("AppPrefsClient: `url` is required");
|
|
1010
|
+
if (!cfg.app) throw new Error("AppPrefsClient: `app` is required");
|
|
1011
|
+
this.app = cfg.app;
|
|
1012
|
+
this.client = cfg.client ?? (cfg.clientFactory ?? (0, _atscript_ui.getDefaultClientFactory)())(cfg.url);
|
|
1013
|
+
}
|
|
1014
|
+
/** Fetch the single `appConf` row for `(user, app)`. */
|
|
1015
|
+
async load() {
|
|
1016
|
+
const filter = {
|
|
1017
|
+
app: this.app,
|
|
1018
|
+
type: "appConf"
|
|
1019
|
+
};
|
|
1020
|
+
try {
|
|
1021
|
+
const rows = await this.client.query({ filter });
|
|
1022
|
+
const row = rows.length > 0 ? rows[0] : null;
|
|
1023
|
+
return {
|
|
1024
|
+
row,
|
|
1025
|
+
prefs: row ? row.data ?? null : null,
|
|
1026
|
+
denied: false
|
|
1027
|
+
};
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
if (isAuthError(err)) return {
|
|
1030
|
+
row: null,
|
|
1031
|
+
prefs: null,
|
|
1032
|
+
denied: true
|
|
1033
|
+
};
|
|
1034
|
+
throw err;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Upsert the `appConf` row. Server forces `id` from session and shallow-
|
|
1039
|
+
* merges `data` so partial patches don't wipe unrelated fields. Caller
|
|
1040
|
+
* passes the prior `existing` row from `load()` so we pick the right verb.
|
|
1041
|
+
*
|
|
1042
|
+
* Returns the row id that owns the value after the write — useful for
|
|
1043
|
+
* state tracking after the first insert (subsequent saves take the
|
|
1044
|
+
* update path).
|
|
1045
|
+
*/
|
|
1046
|
+
async save(existing, patch, user) {
|
|
1047
|
+
if (existing) {
|
|
1048
|
+
const payload = {
|
|
1049
|
+
id: existing.id,
|
|
1050
|
+
data: patch
|
|
1051
|
+
};
|
|
1052
|
+
await this.client.update(payload);
|
|
1053
|
+
return existing.id;
|
|
1054
|
+
}
|
|
1055
|
+
const id = user ? appConfId(user, this.app) : void 0;
|
|
1056
|
+
const payload = {
|
|
1057
|
+
...id ? { id } : {},
|
|
1058
|
+
type: "appConf",
|
|
1059
|
+
app: this.app,
|
|
1060
|
+
data: patch
|
|
1061
|
+
};
|
|
1062
|
+
const result = await this.client.insert(payload);
|
|
1063
|
+
return typeof result?.insertedId === "string" ? result.insertedId : null;
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
//#endregion
|
|
1067
|
+
//#region src/query/merge-sorters.ts
|
|
1068
|
+
/**
|
|
1069
|
+
* Merge force sorters with user sorters.
|
|
1070
|
+
*
|
|
1071
|
+
* Force sorters come first and take priority — if a field appears
|
|
1072
|
+
* in both lists, the force entry wins and the user entry is dropped.
|
|
1073
|
+
*/
|
|
1074
|
+
function mergeSorters(forceSorters, userSorters) {
|
|
1075
|
+
if (forceSorters.length === 0) return userSorters;
|
|
1076
|
+
if (userSorters.length === 0) return forceSorters;
|
|
1077
|
+
const forceFields = new Set(forceSorters.map((s) => s.field));
|
|
1078
|
+
const deduped = userSorters.filter((s) => !forceFields.has(s.field));
|
|
1079
|
+
return [...forceSorters, ...deduped];
|
|
1080
|
+
}
|
|
1081
|
+
//#endregion
|
|
1082
|
+
//#region src/query/merge-filters.ts
|
|
1083
|
+
/**
|
|
1084
|
+
* AND-merge two filter expressions, producing a wire shape that survives
|
|
1085
|
+
* `@uniqu/url`'s `mergeConjunction` parser collapse.
|
|
1086
|
+
*
|
|
1087
|
+
* The collapse problem: when two `$and` siblings target the same field
|
|
1088
|
+
* with the same op (e.g. `{status: 'cancelled'}` AND `{status: 'shipped'}`),
|
|
1089
|
+
* the parser silently merges them on the receiving end and one clause is
|
|
1090
|
+
* dropped. This would let a colliding user-side filter erase a
|
|
1091
|
+
* `forceFilters` clause, breaking that contract.
|
|
1092
|
+
*
|
|
1093
|
+
* Fix: detect same-field same-op collisions and wrap each repeat in
|
|
1094
|
+
* `$not({$not: ...})`. `$not` nodes are preserved verbatim by the parser,
|
|
1095
|
+
* and `!!p ≡ p` is a semantic identity, so the server evaluator sees the
|
|
1096
|
+
* same AND. Non-colliding merges produce the canonical `$and` shape.
|
|
1097
|
+
*/
|
|
1098
|
+
function mergeFilters(a, b) {
|
|
1099
|
+
if (!a) return b;
|
|
1100
|
+
if (!b) return a;
|
|
1101
|
+
return makeParserSafeAnd([a, b]);
|
|
1102
|
+
}
|
|
1103
|
+
/** Op-set for a field value: primitives are `$eq`, op-bags expose their keys. */
|
|
1104
|
+
function getOpsForFieldValue(value) {
|
|
1105
|
+
if (value === null || typeof value !== "object") return new Set(["$eq"]);
|
|
1106
|
+
return new Set(Object.keys(value));
|
|
1107
|
+
}
|
|
1108
|
+
/** True if `expr` has only field keys (no `$`-prefixed logical operators). */
|
|
1109
|
+
function isComparisonNode(expr) {
|
|
1110
|
+
for (const k in expr) if (k.startsWith("$")) return false;
|
|
1111
|
+
return true;
|
|
1112
|
+
}
|
|
1113
|
+
/** Flatten nested `$and` so collision detection sees all sibling comparison nodes. */
|
|
1114
|
+
function flattenAnd(children) {
|
|
1115
|
+
const out = [];
|
|
1116
|
+
for (const child of children) if (child && typeof child === "object" && "$and" in child && Array.isArray(child.$and)) out.push(...flattenAnd(child.$and));
|
|
1117
|
+
else out.push(child);
|
|
1118
|
+
return out;
|
|
1119
|
+
}
|
|
1120
|
+
function makeParserSafeAnd(children) {
|
|
1121
|
+
const flat = flattenAnd(children);
|
|
1122
|
+
const fieldOps = /* @__PURE__ */ new Map();
|
|
1123
|
+
const collidingFields = /* @__PURE__ */ new Set();
|
|
1124
|
+
for (const node of flat) {
|
|
1125
|
+
if (!isComparisonNode(node)) continue;
|
|
1126
|
+
for (const f in node) {
|
|
1127
|
+
const ops = getOpsForFieldValue(node[f]);
|
|
1128
|
+
const seen = fieldOps.get(f);
|
|
1129
|
+
if (!seen) {
|
|
1130
|
+
fieldOps.set(f, ops);
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
for (const op of ops) if (seen.has(op)) {
|
|
1134
|
+
collidingFields.add(f);
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1137
|
+
for (const op of ops) seen.add(op);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
if (collidingFields.size === 0) return { $and: flat };
|
|
1141
|
+
const seenColliding = /* @__PURE__ */ new Set();
|
|
1142
|
+
const out = [];
|
|
1143
|
+
for (const node of flat) {
|
|
1144
|
+
if (!isComparisonNode(node)) {
|
|
1145
|
+
out.push(node);
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
const safe = {};
|
|
1149
|
+
let safeHasKeys = false;
|
|
1150
|
+
for (const f in node) {
|
|
1151
|
+
const v = node[f];
|
|
1152
|
+
if (collidingFields.has(f) && seenColliding.has(f)) out.push({ $not: { $not: { [f]: v } } });
|
|
1153
|
+
else {
|
|
1154
|
+
safe[f] = v;
|
|
1155
|
+
safeHasKeys = true;
|
|
1156
|
+
if (collidingFields.has(f)) seenColliding.add(f);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
if (safeHasKeys) out.push(safe);
|
|
1160
|
+
}
|
|
1161
|
+
return { $and: out };
|
|
1162
|
+
}
|
|
1163
|
+
//#endregion
|
|
1164
|
+
//#region src/query/build-table-query.ts
|
|
1165
|
+
/**
|
|
1166
|
+
* Build a Uniquery object from table UI state.
|
|
1167
|
+
*
|
|
1168
|
+
* Pure function — no framework dependencies.
|
|
1169
|
+
* Combines user filters with force filters, merges sorters,
|
|
1170
|
+
* projects visible columns, and applies pagination.
|
|
1171
|
+
*/
|
|
1172
|
+
function buildTableQuery(opts) {
|
|
1173
|
+
const userFilter = filtersToUniqueryFilter(opts.filters);
|
|
1174
|
+
const filter = mergeFilters(opts.forceFilters, userFilter);
|
|
1175
|
+
const sorters = opts.forceSorters?.length ? mergeSorters(opts.forceSorters, opts.sorters) : opts.sorters;
|
|
1176
|
+
const $sort = {};
|
|
1177
|
+
for (const s of sorters) $sort[s.field] = s.direction === "asc" ? 1 : -1;
|
|
1178
|
+
const controls = {};
|
|
1179
|
+
if (opts.visibleColumnPaths.length > 0) controls.$select = opts.visibleColumnPaths;
|
|
1180
|
+
if (sorters.length > 0) controls.$sort = $sort;
|
|
1181
|
+
if (opts.search) {
|
|
1182
|
+
const searchKey = opts.searchIndex ? `$search:${opts.searchIndex}` : "$search";
|
|
1183
|
+
controls[searchKey] = opts.search;
|
|
1184
|
+
}
|
|
1185
|
+
if (opts.includeActions) controls.$actions = true;
|
|
1186
|
+
const query = { controls };
|
|
1187
|
+
if (filter) query.filter = filter;
|
|
1188
|
+
return query;
|
|
1189
|
+
}
|
|
1190
|
+
//#endregion
|
|
1191
|
+
//#region src/query/url-query.ts
|
|
1192
|
+
function resolveAspectGate(value) {
|
|
1193
|
+
if (value === void 0 || value === true) return "all";
|
|
1194
|
+
if (value === false) return "none";
|
|
1195
|
+
if (value.length === 0) return "none";
|
|
1196
|
+
return new Set(value);
|
|
1197
|
+
}
|
|
1198
|
+
function pickFilterPaths(filters, allow) {
|
|
1199
|
+
const out = {};
|
|
1200
|
+
for (const path in filters) if (allow.has(path)) out[path] = filters[path];
|
|
1201
|
+
return out;
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Serialize the table state subset into a URL query string.
|
|
1205
|
+
*
|
|
1206
|
+
* Reuses `buildTableQuery` for the filter/sort/search shape (no `$select`,
|
|
1207
|
+
* `$actions`, `forceFilters`, `forceSorters` — those are not user state) and
|
|
1208
|
+
* appends `$skip` / `$limit` for pagination.
|
|
1209
|
+
*
|
|
1210
|
+
* Returns `""` (no leading `?`) for the default view.
|
|
1211
|
+
*/
|
|
1212
|
+
function stateToUrlQueryString(state, defaults) {
|
|
1213
|
+
const filtersGate = resolveAspectGate(defaults.sync?.filters);
|
|
1214
|
+
const sortersGate = resolveAspectGate(defaults.sync?.sorters);
|
|
1215
|
+
const searchOff = defaults.sync?.search === false;
|
|
1216
|
+
const paginationOff = defaults.sync?.pagination === false;
|
|
1217
|
+
const filters = filtersGate === "none" ? {} : filtersGate === "all" ? state.filters : pickFilterPaths(state.filters, filtersGate);
|
|
1218
|
+
const query = buildTableQuery({
|
|
1219
|
+
visibleColumnPaths: [],
|
|
1220
|
+
sorters: sortersGate === "none" ? [] : sortersGate === "all" ? state.sorters : state.sorters.filter((s) => sortersGate.has(s.field)),
|
|
1221
|
+
filters,
|
|
1222
|
+
search: searchOff ? void 0 : state.searchTerm || void 0
|
|
1223
|
+
});
|
|
1224
|
+
if (!paginationOff) {
|
|
1225
|
+
const itemsPerPage = state.itemsPerPage ?? defaults.defaultItemsPerPage;
|
|
1226
|
+
const page = state.page ?? 1;
|
|
1227
|
+
if (page > 1) query.controls.$skip = (page - 1) * itemsPerPage;
|
|
1228
|
+
}
|
|
1229
|
+
return (0, _uniqu_url_builder.buildUrl)(query);
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Parse a URL query string back into the table state subset.
|
|
1233
|
+
*
|
|
1234
|
+
* Robust by design — schema drift and copy-paste errors must not break the
|
|
1235
|
+
* recipient's view:
|
|
1236
|
+
* - unknown fields (not in `knownFields`) → silently dropped
|
|
1237
|
+
* - unsupported operators → silently dropped
|
|
1238
|
+
* - unknown controls (e.g. `$weird=42`) → silently ignored
|
|
1239
|
+
* - malformed query → `{ filters: {}, sorters: [], searchTerm: "" }`
|
|
1240
|
+
*
|
|
1241
|
+
* `page` and `itemsPerPage` are returned only when the URL specified them
|
|
1242
|
+
* (`$skip` / `$limit`); callers compose them onto state without overwriting
|
|
1243
|
+
* defaults when the URL was silent.
|
|
1244
|
+
*/
|
|
1245
|
+
function urlQueryStringToState(urlString, opts = {}) {
|
|
1246
|
+
if (!urlString) return {
|
|
1247
|
+
filters: {},
|
|
1248
|
+
sorters: [],
|
|
1249
|
+
searchTerm: ""
|
|
1250
|
+
};
|
|
1251
|
+
let parsed;
|
|
1252
|
+
try {
|
|
1253
|
+
parsed = (0, _uniqu_url.parseUrl)(urlString);
|
|
1254
|
+
} catch {
|
|
1255
|
+
return {
|
|
1256
|
+
filters: {},
|
|
1257
|
+
sorters: [],
|
|
1258
|
+
searchTerm: ""
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
const filtersGate = resolveAspectGate(opts.sync?.filters);
|
|
1262
|
+
const sortersGate = resolveAspectGate(opts.sync?.sorters);
|
|
1263
|
+
const searchOff = opts.sync?.search === false;
|
|
1264
|
+
const paginationOff = opts.sync?.pagination === false;
|
|
1265
|
+
const knownSet = opts.knownFields ? new Set(opts.knownFields) : null;
|
|
1266
|
+
let filterKnown;
|
|
1267
|
+
if (filtersGate === "all") filterKnown = knownSet ?? void 0;
|
|
1268
|
+
else if (filtersGate !== "none") if (knownSet) {
|
|
1269
|
+
filterKnown = /* @__PURE__ */ new Set();
|
|
1270
|
+
for (const path of filtersGate) if (knownSet.has(path)) filterKnown.add(path);
|
|
1271
|
+
} else filterKnown = filtersGate;
|
|
1272
|
+
const filters = filtersGate === "none" ? {} : uniqueryFilterToFieldFilters(parsed.filter, filterKnown);
|
|
1273
|
+
const sorters = [];
|
|
1274
|
+
if (sortersGate !== "none") {
|
|
1275
|
+
const $sort = parsed.controls?.$sort;
|
|
1276
|
+
if ($sort && typeof $sort === "object") for (const field in $sort) {
|
|
1277
|
+
if (knownSet && !knownSet.has(field)) continue;
|
|
1278
|
+
if (sortersGate !== "all" && !sortersGate.has(field)) continue;
|
|
1279
|
+
const dir = $sort[field];
|
|
1280
|
+
if (dir === 1) sorters.push({
|
|
1281
|
+
field,
|
|
1282
|
+
direction: "asc"
|
|
1283
|
+
});
|
|
1284
|
+
else if (dir === -1) sorters.push({
|
|
1285
|
+
field,
|
|
1286
|
+
direction: "desc"
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
const $search = parsed.controls?.$search;
|
|
1291
|
+
const out = {
|
|
1292
|
+
filters,
|
|
1293
|
+
sorters,
|
|
1294
|
+
searchTerm: !searchOff && typeof $search === "string" ? $search : ""
|
|
1295
|
+
};
|
|
1296
|
+
if (!paginationOff) {
|
|
1297
|
+
const $skip = parsed.controls?.$skip;
|
|
1298
|
+
if (typeof $skip === "number" && $skip > 0 && Number.isFinite($skip)) out.skip = $skip;
|
|
1299
|
+
}
|
|
1300
|
+
return out;
|
|
1301
|
+
}
|
|
1302
|
+
//#endregion
|
|
1303
|
+
//#region src/selection/selection-fns.ts
|
|
1304
|
+
/**
|
|
1305
|
+
* Toggle a single PK in the selection.
|
|
1306
|
+
*
|
|
1307
|
+
* - `"none"`: no-op (returns the same array reference).
|
|
1308
|
+
* - `"single"`: replaces the selection. Toggling the already-selected PK clears it.
|
|
1309
|
+
* - `"multi"`: adds when absent, removes when present. Order preserved on add (append).
|
|
1310
|
+
*
|
|
1311
|
+
* Always returns a new array on actual mutation; returns the same reference for `"none"`.
|
|
1312
|
+
*/
|
|
1313
|
+
function togglePk(selection, pk, mode) {
|
|
1314
|
+
if (mode === "none") return selection;
|
|
1315
|
+
if (mode === "single") {
|
|
1316
|
+
if (selection.length === 1 && selection[0] === pk) return [];
|
|
1317
|
+
return [pk];
|
|
1318
|
+
}
|
|
1319
|
+
const idx = selection.indexOf(pk);
|
|
1320
|
+
if (idx === -1) return [...selection, pk];
|
|
1321
|
+
const next = selection.slice();
|
|
1322
|
+
next.splice(idx, 1);
|
|
1323
|
+
return next;
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Drop every PK in `selection` that's not in `presentPks`.
|
|
1327
|
+
*
|
|
1328
|
+
* Returns the same `selection` reference when nothing was dropped, so callers
|
|
1329
|
+
* can rely on identity comparison to detect a no-op without an explicit
|
|
1330
|
+
* length check.
|
|
1331
|
+
*/
|
|
1332
|
+
function trimSelection(selection, presentPks) {
|
|
1333
|
+
if (selection.length === 0) return selection;
|
|
1334
|
+
let removedAny = false;
|
|
1335
|
+
const kept = [];
|
|
1336
|
+
for (const v of selection) if (presentPks.has(v)) kept.push(v);
|
|
1337
|
+
else removedAny = true;
|
|
1338
|
+
return removedAny ? kept : selection;
|
|
1339
|
+
}
|
|
1340
|
+
/** Map rows to their PKs via `rowValueFn`. */
|
|
1341
|
+
function rowsToPks(rows, rowValueFn) {
|
|
1342
|
+
const out = [];
|
|
1343
|
+
for (let i = 0; i < rows.length; i++) out.push(rowValueFn(rows[i]));
|
|
1344
|
+
return out;
|
|
1345
|
+
}
|
|
1346
|
+
//#endregion
|
|
1347
|
+
//#region src/state/tokens.ts
|
|
1348
|
+
const DEFAULT_ROW_HEIGHT_PX = 32;
|
|
1349
|
+
//#endregion
|
|
1350
|
+
//#region src/state/window/page-aligned-blocks.ts
|
|
1351
|
+
/**
|
|
1352
|
+
* Clamp a window's `topIndex` to the valid `[0, totalCount - viewport]` range.
|
|
1353
|
+
* `viewport` past `totalCount` (or zero/negative totals) collapses to `0`.
|
|
1354
|
+
*/
|
|
1355
|
+
function clampTopIndex(topIndex, totalCount, viewport) {
|
|
1356
|
+
const max = Math.max(0, totalCount - viewport);
|
|
1357
|
+
return Math.max(0, Math.min(max, topIndex));
|
|
1358
|
+
}
|
|
1359
|
+
/** Index of the first row in the block that contains `absIdx`. */
|
|
1360
|
+
function blockStartFor(absIdx, blockSize) {
|
|
1361
|
+
return Math.floor(absIdx / blockSize) * blockSize;
|
|
1362
|
+
}
|
|
1363
|
+
function pageAlignedBlocksFor(skip, limit, blockSize) {
|
|
1364
|
+
if (limit <= 0 || blockSize <= 0) return [];
|
|
1365
|
+
const start = Math.max(0, skip);
|
|
1366
|
+
const end = start + limit;
|
|
1367
|
+
const firstBlock = Math.floor(start / blockSize);
|
|
1368
|
+
const lastBlock = Math.floor((end - 1) / blockSize);
|
|
1369
|
+
const blocks = [];
|
|
1370
|
+
for (let b = firstBlock; b <= lastBlock; b++) blocks.push({
|
|
1371
|
+
page: b + 1,
|
|
1372
|
+
firstIndex: b * blockSize
|
|
1373
|
+
});
|
|
1374
|
+
return blocks;
|
|
1375
|
+
}
|
|
1376
|
+
//#endregion
|
|
1377
|
+
//#region src/state/window/results-merge.ts
|
|
1378
|
+
function walkForwardAbsorb(results, resultsStart, cache) {
|
|
1379
|
+
let idx = resultsStart + results.length;
|
|
1380
|
+
const tail = [];
|
|
1381
|
+
while (cache.has(idx)) {
|
|
1382
|
+
const row = cache.get(idx);
|
|
1383
|
+
if (row === void 0) break;
|
|
1384
|
+
tail.push(row);
|
|
1385
|
+
idx++;
|
|
1386
|
+
}
|
|
1387
|
+
if (tail.length === 0) return {
|
|
1388
|
+
newResults: results,
|
|
1389
|
+
newResultsStart: resultsStart
|
|
1390
|
+
};
|
|
1391
|
+
return {
|
|
1392
|
+
newResults: [...results, ...tail],
|
|
1393
|
+
newResultsStart: resultsStart
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
function walkBackwardAbsorb(results, resultsStart, cache) {
|
|
1397
|
+
let idx = resultsStart - 1;
|
|
1398
|
+
const headReversed = [];
|
|
1399
|
+
while (cache.has(idx)) {
|
|
1400
|
+
const row = cache.get(idx);
|
|
1401
|
+
if (row === void 0) break;
|
|
1402
|
+
headReversed.push(row);
|
|
1403
|
+
idx--;
|
|
1404
|
+
}
|
|
1405
|
+
if (headReversed.length === 0) return {
|
|
1406
|
+
newResults: results,
|
|
1407
|
+
newResultsStart: resultsStart
|
|
1408
|
+
};
|
|
1409
|
+
headReversed.reverse();
|
|
1410
|
+
return {
|
|
1411
|
+
newResults: [...headReversed, ...results],
|
|
1412
|
+
newResultsStart: resultsStart - headReversed.length
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
//#endregion
|
|
1416
|
+
//#region src/state/window/plan-fetch.ts
|
|
1417
|
+
function planFetch(args) {
|
|
1418
|
+
const { top, viewport, totalCount, cache, blockSize, buffer } = args;
|
|
1419
|
+
if (viewport <= 0) return null;
|
|
1420
|
+
if (top >= totalCount) return null;
|
|
1421
|
+
const start = top;
|
|
1422
|
+
const effectiveViewport = Math.min(viewport, totalCount - start);
|
|
1423
|
+
if (effectiveViewport <= 0) return null;
|
|
1424
|
+
const end = start + effectiveViewport - 1;
|
|
1425
|
+
if (!cache.has(start) && !cache.has(start - 1)) {
|
|
1426
|
+
const half = Math.floor(blockSize / 2);
|
|
1427
|
+
const lookBack = Math.min(half, start);
|
|
1428
|
+
const lookAhead = half;
|
|
1429
|
+
const skip = Math.max(0, start - lookBack);
|
|
1430
|
+
const wantedLimit = effectiveViewport + lookBack + lookAhead;
|
|
1431
|
+
const limit = Math.min(totalCount - skip, wantedLimit);
|
|
1432
|
+
if (limit <= 0) return null;
|
|
1433
|
+
return {
|
|
1434
|
+
skip,
|
|
1435
|
+
limit,
|
|
1436
|
+
mode: "jump"
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
let fwdCachedInView = 0;
|
|
1440
|
+
while (fwdCachedInView < effectiveViewport && cache.has(start + fwdCachedInView)) fwdCachedInView++;
|
|
1441
|
+
if (fwdCachedInView < effectiveViewport) {
|
|
1442
|
+
const skip = start + fwdCachedInView;
|
|
1443
|
+
if (skip >= totalCount) return null;
|
|
1444
|
+
return {
|
|
1445
|
+
skip,
|
|
1446
|
+
limit: blockSize,
|
|
1447
|
+
mode: "steady"
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
let fwdBuffer = 0;
|
|
1451
|
+
while (cache.has(end + 1 + fwdBuffer)) fwdBuffer++;
|
|
1452
|
+
const forwardEdge = end + 1 + fwdBuffer;
|
|
1453
|
+
if (fwdBuffer < buffer && forwardEdge < totalCount) return {
|
|
1454
|
+
skip: forwardEdge,
|
|
1455
|
+
limit: blockSize,
|
|
1456
|
+
mode: "steady"
|
|
1457
|
+
};
|
|
1458
|
+
let bwdBuffer = 0;
|
|
1459
|
+
while (start - 1 - bwdBuffer >= 0 && cache.has(start - 1 - bwdBuffer)) bwdBuffer++;
|
|
1460
|
+
if (bwdBuffer < buffer && start > 0) {
|
|
1461
|
+
const backwardEdge = start - bwdBuffer;
|
|
1462
|
+
const skip = Math.max(0, backwardEdge - blockSize);
|
|
1463
|
+
const limit = backwardEdge - skip;
|
|
1464
|
+
if (limit <= 0) return null;
|
|
1465
|
+
return {
|
|
1466
|
+
skip,
|
|
1467
|
+
limit,
|
|
1468
|
+
mode: "steady"
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
return null;
|
|
1472
|
+
}
|
|
1473
|
+
//#endregion
|
|
1474
|
+
//#region src/columns/column-widths.ts
|
|
1475
|
+
/** Upper bound applied only to default-width computation. Annotation widths and manual resize are not capped. */
|
|
1476
|
+
const MAX_DEFAULT_COLUMN_WIDTH_PX = 320;
|
|
1477
|
+
/**
|
|
1478
|
+
* Computes a sane default width for a column from its type + length annotation.
|
|
1479
|
+
*
|
|
1480
|
+
* Resolution order:
|
|
1481
|
+
* 1. col.width (`@ui.table.width` annotation) wins outright.
|
|
1482
|
+
* 2. Otherwise pick from a type-based table; for `text` columns with `maxLen`,
|
|
1483
|
+
* derive from char-count using an 8px-per-char heuristic.
|
|
1484
|
+
* 3. Cap the result at MAX_DEFAULT_COLUMN_WIDTH_PX (does not apply to step 1).
|
|
1485
|
+
*/
|
|
1486
|
+
function computeDefaultColumnWidth(col) {
|
|
1487
|
+
if (col.width) return col.width;
|
|
1488
|
+
return `${capDefault(rawDefaultPx(col))}px`;
|
|
1489
|
+
}
|
|
1490
|
+
function rawDefaultPx(col) {
|
|
1491
|
+
switch (col.type) {
|
|
1492
|
+
case "boolean": return 64;
|
|
1493
|
+
case "number": return 96;
|
|
1494
|
+
case "ref": return 200;
|
|
1495
|
+
case "array":
|
|
1496
|
+
case "object": return 240;
|
|
1497
|
+
case "enum":
|
|
1498
|
+
if (col.options?.length) {
|
|
1499
|
+
let longest = 0;
|
|
1500
|
+
for (const opt of col.options) if (opt.label.length > longest) longest = opt.label.length;
|
|
1501
|
+
return Math.max(96, longest * 8 + 48);
|
|
1502
|
+
}
|
|
1503
|
+
return 160;
|
|
1504
|
+
case "text":
|
|
1505
|
+
if (col.maxLen && col.maxLen > 0) return Math.max(96, col.maxLen * 8 + 32);
|
|
1506
|
+
return 200;
|
|
1507
|
+
default: return 160;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
function capDefault(px) {
|
|
1511
|
+
if (px < 64) return 64;
|
|
1512
|
+
if (px > 320) return 320;
|
|
1513
|
+
return px;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Reconciles a current ColumnWidthsMap against the latest column list:
|
|
1517
|
+
* - new columns get a fresh `{ w: d, d }` entry where `d = computeDefaultColumnWidth(col)`
|
|
1518
|
+
* - existing columns keep `w` (preserves manual resize) but refresh `d`
|
|
1519
|
+
* - paths no longer present are kept (preserves user widths if columns reappear)
|
|
1520
|
+
*
|
|
1521
|
+
* Returns a NEW map only when something actually changed; otherwise returns `current`
|
|
1522
|
+
* so a shallowRef writer can short-circuit.
|
|
1523
|
+
*/
|
|
1524
|
+
function reconcileColumnWidthDefaults(allColumns, current) {
|
|
1525
|
+
let next = current;
|
|
1526
|
+
for (const col of allColumns) {
|
|
1527
|
+
const d = computeDefaultColumnWidth(col);
|
|
1528
|
+
const existing = current[col.path];
|
|
1529
|
+
if (!existing) {
|
|
1530
|
+
if (next === current) next = { ...current };
|
|
1531
|
+
next[col.path] = {
|
|
1532
|
+
w: d,
|
|
1533
|
+
d
|
|
1534
|
+
};
|
|
1535
|
+
} else if (existing.d !== d) {
|
|
1536
|
+
if (next === current) next = { ...current };
|
|
1537
|
+
next[col.path] = {
|
|
1538
|
+
w: existing.w,
|
|
1539
|
+
d
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
return next;
|
|
1544
|
+
}
|
|
1545
|
+
//#endregion
|
|
1546
|
+
//#region src/utils/debounce.ts
|
|
1547
|
+
/**
|
|
1548
|
+
* Creates a debounced version of a function that delays invocation
|
|
1549
|
+
* until `ms` milliseconds have elapsed since the last call.
|
|
1550
|
+
*/
|
|
1551
|
+
function debounce(fn, ms) {
|
|
1552
|
+
let timer;
|
|
1553
|
+
const debounced = ((...args) => {
|
|
1554
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1555
|
+
timer = setTimeout(() => {
|
|
1556
|
+
timer = void 0;
|
|
1557
|
+
fn(...args);
|
|
1558
|
+
}, ms);
|
|
1559
|
+
});
|
|
1560
|
+
debounced.cancel = () => {
|
|
1561
|
+
if (timer !== void 0) {
|
|
1562
|
+
clearTimeout(timer);
|
|
1563
|
+
timer = void 0;
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
return debounced;
|
|
1567
|
+
}
|
|
1568
|
+
//#endregion
|
|
1569
|
+
//#region src/utils/equality.ts
|
|
1570
|
+
function arraysEqual(a, b) {
|
|
1571
|
+
if (a === b) return true;
|
|
1572
|
+
if (a.length !== b.length) return false;
|
|
1573
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
1574
|
+
return true;
|
|
1575
|
+
}
|
|
1576
|
+
function sameColumnSet(a, b) {
|
|
1577
|
+
if (a === b) return true;
|
|
1578
|
+
if (a.length !== b.length) return false;
|
|
1579
|
+
const set = new Set(a);
|
|
1580
|
+
for (const c of b) if (!set.has(c)) return false;
|
|
1581
|
+
return true;
|
|
1582
|
+
}
|
|
1583
|
+
function setsEqual(a, b) {
|
|
1584
|
+
if (a === b) return true;
|
|
1585
|
+
if (a.size !== b.size) return false;
|
|
1586
|
+
for (const v of a) if (!b.has(v)) return false;
|
|
1587
|
+
return true;
|
|
1588
|
+
}
|
|
1589
|
+
function sortersEqual(a, b) {
|
|
1590
|
+
if (a === b) return true;
|
|
1591
|
+
if (a.length !== b.length) return false;
|
|
1592
|
+
for (let i = 0; i < a.length; i++) if (a[i].field !== b[i].field || a[i].direction !== b[i].direction) return false;
|
|
1593
|
+
return true;
|
|
1594
|
+
}
|
|
1595
|
+
//#endregion
|
|
1596
|
+
//#region src/utils/reorder-column-names.ts
|
|
1597
|
+
/**
|
|
1598
|
+
* Permute a column-names array: move `fromPath` next to `toPath` at the
|
|
1599
|
+
* resolved insertion side. Pure: returns a new array, never mutates input.
|
|
1600
|
+
*
|
|
1601
|
+
* Returns the input array unchanged when:
|
|
1602
|
+
* - `fromPath === toPath`
|
|
1603
|
+
* - either path is missing from `names`
|
|
1604
|
+
* - the resolved insertion index leaves the array identical
|
|
1605
|
+
* (e.g. `[a,b,c]` from=`b` to=`a` "after" — `b` is already after `a`).
|
|
1606
|
+
*
|
|
1607
|
+
* The insertion index is resolved against post-removal positions to keep
|
|
1608
|
+
* the math symmetric across left-to-right and right-to-left moves.
|
|
1609
|
+
*/
|
|
1610
|
+
function reorderColumnNames(names, fromPath, toPath, position) {
|
|
1611
|
+
if (fromPath === toPath) return names;
|
|
1612
|
+
const fromIndex = names.indexOf(fromPath);
|
|
1613
|
+
if (fromIndex === -1) return names;
|
|
1614
|
+
const toIndex = names.indexOf(toPath);
|
|
1615
|
+
if (toIndex === -1) return names;
|
|
1616
|
+
const without = names.slice(0, fromIndex).concat(names.slice(fromIndex + 1));
|
|
1617
|
+
const toIndexAfterRemoval = toIndex > fromIndex ? toIndex - 1 : toIndex;
|
|
1618
|
+
const insertAt = position === "before" ? toIndexAfterRemoval : toIndexAfterRemoval + 1;
|
|
1619
|
+
if (insertAt === fromIndex) return names;
|
|
1620
|
+
return without.slice(0, insertAt).concat(fromPath, without.slice(insertAt));
|
|
1621
|
+
}
|
|
1622
|
+
//#endregion
|
|
1623
|
+
exports.APP_CONF_PREFIX = APP_CONF_PREFIX;
|
|
1624
|
+
exports.AppPrefsClient = AppPrefsClient;
|
|
1625
|
+
exports.DEFAULT_ROW_HEIGHT_PX = DEFAULT_ROW_HEIGHT_PX;
|
|
1626
|
+
exports.DRAFT_PERSISTED_ASPECTS = DRAFT_PERSISTED_ASPECTS;
|
|
1627
|
+
exports.MAX_DEFAULT_COLUMN_WIDTH_PX = MAX_DEFAULT_COLUMN_WIDTH_PX;
|
|
1628
|
+
exports.NULL_OPS = NULL_OPS;
|
|
1629
|
+
exports.PRESET_ASPECTS = PRESET_ASPECTS;
|
|
1630
|
+
exports.PresetsClient = PresetsClient;
|
|
1631
|
+
exports.PresetsHttpError = PresetsHttpError;
|
|
1632
|
+
exports.RESERVED_ID_PREFIXES = RESERVED_ID_PREFIXES;
|
|
1633
|
+
exports.STANDARD_PRESET_ID = STANDARD_PRESET_ID;
|
|
1634
|
+
exports.SYSTEM_PRESET_PREFIX = SYSTEM_PRESET_PREFIX;
|
|
1635
|
+
exports.USER_CONF_PREFIX = USER_CONF_PREFIX;
|
|
1636
|
+
exports.appConfId = appConfId;
|
|
1637
|
+
exports.arraysEqual = arraysEqual;
|
|
1638
|
+
exports.blockStartFor = blockStartFor;
|
|
1639
|
+
exports.buildTableQuery = buildTableQuery;
|
|
1640
|
+
exports.clampTopIndex = clampTopIndex;
|
|
1641
|
+
exports.columnFilterType = columnFilterType;
|
|
1642
|
+
exports.computeDefaultColumnWidth = computeDefaultColumnWidth;
|
|
1643
|
+
exports.conditionLabel = conditionLabel;
|
|
1644
|
+
exports.conditionsForType = conditionsForType;
|
|
1645
|
+
exports.dateShortcuts = dateShortcuts;
|
|
1646
|
+
exports.debounce = debounce;
|
|
1647
|
+
exports.defaultCondition = defaultCondition;
|
|
1648
|
+
exports.derivePresetAspects = derivePresetAspects;
|
|
1649
|
+
exports.deserializeDraft = deserializeDraft;
|
|
1650
|
+
exports.draftMatchesPreset = draftMatchesPreset;
|
|
1651
|
+
exports.escapeRegex = escapeRegex;
|
|
1652
|
+
exports.filledFilterCount = filledFilterCount;
|
|
1653
|
+
exports.filterTokenLabel = filterTokenLabel;
|
|
1654
|
+
exports.filtersToUniqueryFilter = filtersToUniqueryFilter;
|
|
1655
|
+
exports.formatFilterCondition = formatFilterCondition;
|
|
1656
|
+
exports.fromWireSnapshot = fromWireSnapshot;
|
|
1657
|
+
exports.hasSecondValue = hasSecondValue;
|
|
1658
|
+
exports.isAuthError = isAuthError;
|
|
1659
|
+
exports.isDirtyAgainst = isDirtyAgainst;
|
|
1660
|
+
exports.isEmptyDraft = isEmptyDraft;
|
|
1661
|
+
exports.isFilled = isFilled;
|
|
1662
|
+
exports.isSimpleEq = isSimpleEq;
|
|
1663
|
+
exports.isSystemPresetId = isSystemPresetId;
|
|
1664
|
+
exports.mergeFilters = mergeFilters;
|
|
1665
|
+
exports.mergeSorters = mergeSorters;
|
|
1666
|
+
exports.normaliseSystemPresetId = normaliseSystemPresetId;
|
|
1667
|
+
exports.pageAlignedBlocksFor = pageAlignedBlocksFor;
|
|
1668
|
+
exports.parseFilterInput = parseFilterInput;
|
|
1669
|
+
exports.planFetch = planFetch;
|
|
1670
|
+
exports.reconcileColumnWidthDefaults = reconcileColumnWidthDefaults;
|
|
1671
|
+
exports.reorderColumnNames = reorderColumnNames;
|
|
1672
|
+
exports.resolveAspectGate = resolveAspectGate;
|
|
1673
|
+
exports.resolveSystemPresets = resolveSystemPresets;
|
|
1674
|
+
exports.rowsToPks = rowsToPks;
|
|
1675
|
+
exports.sameColumnSet = sameColumnSet;
|
|
1676
|
+
exports.serializeDraft = serializeDraft;
|
|
1677
|
+
exports.setsEqual = setsEqual;
|
|
1678
|
+
exports.sortersEqual = sortersEqual;
|
|
1679
|
+
exports.stableStringify = stableStringify;
|
|
1680
|
+
exports.stateToUrlQueryString = stateToUrlQueryString;
|
|
1681
|
+
exports.toWireSnapshot = toWireSnapshot;
|
|
1682
|
+
exports.togglePk = togglePk;
|
|
1683
|
+
exports.trimSelection = trimSelection;
|
|
1684
|
+
exports.unescapeRegex = unescapeRegex;
|
|
1685
|
+
exports.uniqueryFilterToFieldFilters = uniqueryFilterToFieldFilters;
|
|
1686
|
+
exports.urlQueryStringToState = urlQueryStringToState;
|
|
1687
|
+
exports.userConfId = userConfId;
|
|
1688
|
+
exports.walkBackwardAbsorb = walkBackwardAbsorb;
|
|
1689
|
+
exports.walkForwardAbsorb = walkForwardAbsorb;
|