@happyvertical/smrt-content 0.43.4 → 0.43.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -86
- package/agents/content-list.md +869 -0
- package/dist/content-query.d.ts +310 -0
- package/dist/content-query.d.ts.map +1 -0
- package/dist/contents.d.ts +22 -0
- package/dist/contents.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +672 -4
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +22 -2
- package/dist/smrt-knowledge.json +38 -5
- package/dist/svelte/components/ContentList.svelte +941 -30
- package/dist/svelte/components/ContentList.svelte.d.ts +44 -1
- package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
- package/dist/svelte/content-list-controller.d.ts +98 -1
- package/dist/svelte/content-list-controller.d.ts.map +1 -1
- package/dist/svelte/content-list-controller.js +290 -19
- package/dist/svelte/content-list-query.d.ts +498 -0
- package/dist/svelte/content-list-query.d.ts.map +1 -0
- package/dist/svelte/content-list-query.js +1294 -0
- package/dist/svelte/content-list-saved-views.d.ts +172 -0
- package/dist/svelte/content-list-saved-views.d.ts.map +1 -0
- package/dist/svelte/content-list-saved-views.js +298 -0
- package/dist/svelte/content-list-url-state.d.ts +211 -0
- package/dist/svelte/content-list-url-state.d.ts.map +1 -0
- package/dist/svelte/content-list-url-state.js +856 -0
- package/dist/svelte/i18n.contribution.d.ts +24 -0
- package/dist/svelte/i18n.contribution.d.ts.map +1 -1
- package/dist/svelte/i18n.contribution.js +26 -0
- package/dist/svelte/index.d.ts +5 -1
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +8 -1
- package/package.json +16 -15
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shareable URL state for ContentList (#2452).
|
|
3
|
+
*
|
|
4
|
+
* A content list view is a link: an operator narrows the list, copies the
|
|
5
|
+
* address bar, and a colleague opens the same result. That makes the query
|
|
6
|
+
* string an *untrusted* input — it arrives from whoever sent the link, not from
|
|
7
|
+
* the operator's own session — so every restored value is re-derived here
|
|
8
|
+
* against the adapter's published column and operator vocabulary rather than
|
|
9
|
+
* trusted as written.
|
|
10
|
+
*
|
|
11
|
+
* The module owns two things:
|
|
12
|
+
*
|
|
13
|
+
* 1. A compact, human-legible parameter shape (`q`, `type`, `status`, `sort`,
|
|
14
|
+
* `page`, `size`, and `<column>.<operator>` for the richer operators). A
|
|
15
|
+
* base64 blob would round-trip just as well but would be unreadable, and an
|
|
16
|
+
* unreadable link is one nobody can sanity-check before sending it.
|
|
17
|
+
* 2. `sanitizeContentListViewState`, the single validator shared with the
|
|
18
|
+
* saved-views module (`content-list-saved-views.ts`), so a crafted URL and a
|
|
19
|
+
* tampered stored view are held to exactly the same allowlist.
|
|
20
|
+
*
|
|
21
|
+
* ## What may be restored
|
|
22
|
+
*
|
|
23
|
+
* | Aspect | Allowlist |
|
|
24
|
+
* |--------|-----------|
|
|
25
|
+
* | filters, sorting | `CONTENT_LIST_VISIBLE_COLUMN_IDS` — the columns the surface descriptor publishes with `filter`/`sort` capability |
|
|
26
|
+
* | projection (order, visibility, widths, pinning) | `CONTENT_LIST_TABLE_COLUMN_IDS`, and a hidden column can never be forced visible |
|
|
27
|
+
* | filter operators | `CONTENT_LIST_FILTER_OPERATORS` |
|
|
28
|
+
* | filter values | strings only, normalized by `normalizeContentListFilterValue` |
|
|
29
|
+
*
|
|
30
|
+
* The search-only `description` column, the structural `select`/`actions`
|
|
31
|
+
* columns, and any column id the adapter does not publish are dropped from
|
|
32
|
+
* filters and sorting. Dropping — never throwing — is deliberate: a stale link
|
|
33
|
+
* or an out-of-date saved view must still open the list, minus the parts that
|
|
34
|
+
* are no longer meaningful.
|
|
35
|
+
*
|
|
36
|
+
* ## What is deliberately NOT URL state
|
|
37
|
+
*
|
|
38
|
+
* Selection and expansion are excluded, and `sanitizeContentListViewState`
|
|
39
|
+
* never emits them either. A shared link must not carry another operator's
|
|
40
|
+
* selection: the recipient would inherit a checked set they never chose, and
|
|
41
|
+
* the very next bulk action — delete included — would run against it. Row ids
|
|
42
|
+
* are also the one part of view state that leaks data (which specific contents
|
|
43
|
+
* someone had singled out) into a URL that gets pasted into chat and tickets.
|
|
44
|
+
*/
|
|
45
|
+
import { CONTENT_LIST_ACTIONS_COLUMN_ID, CONTENT_LIST_HIDDEN_COLUMN_IDS, CONTENT_LIST_SELECTION_COLUMN_ID, CONTENT_LIST_TABLE_COLUMN_IDS, CONTENT_LIST_VISIBLE_COLUMN_IDS, normalizeContentListFilterValue, } from './content-list-controller.js';
|
|
46
|
+
/** Query-string parameter carrying the free-text search. */
|
|
47
|
+
export const CONTENT_LIST_SEARCH_PARAM = 'q';
|
|
48
|
+
/** Query-string parameter carrying the ordered sort rules. */
|
|
49
|
+
export const CONTENT_LIST_SORT_PARAM = 'sort';
|
|
50
|
+
/** Query-string parameter carrying the 1-based page. */
|
|
51
|
+
export const CONTENT_LIST_PAGE_PARAM = 'page';
|
|
52
|
+
/** Query-string parameter carrying the page size. */
|
|
53
|
+
export const CONTENT_LIST_PAGE_SIZE_PARAM = 'size';
|
|
54
|
+
/**
|
|
55
|
+
* Parameter names this module owns. A column may not shadow one of them; the
|
|
56
|
+
* assertion below keeps that true if a future column is ever named `q` or
|
|
57
|
+
* `page`.
|
|
58
|
+
*/
|
|
59
|
+
export const CONTENT_LIST_RESERVED_PARAMS = [
|
|
60
|
+
CONTENT_LIST_SEARCH_PARAM,
|
|
61
|
+
CONTENT_LIST_SORT_PARAM,
|
|
62
|
+
CONTENT_LIST_PAGE_PARAM,
|
|
63
|
+
CONTENT_LIST_PAGE_SIZE_PARAM,
|
|
64
|
+
];
|
|
65
|
+
/** Separator between a column id and a non-default operator in a param name. */
|
|
66
|
+
const OPERATOR_SEPARATOR = '.';
|
|
67
|
+
/** Separator between entries of an `in`/`notIn` list value. */
|
|
68
|
+
const LIST_SEPARATOR = ',';
|
|
69
|
+
/**
|
|
70
|
+
* Escape character for a list entry that contains the separator.
|
|
71
|
+
*
|
|
72
|
+
* Without it, `author in ["Smith, John"]` serializes to `Smith, John` and
|
|
73
|
+
* restores as two values — a silently *different* query rather than a failed
|
|
74
|
+
* one. Both the separator and the escape character itself are escaped on write
|
|
75
|
+
* and unescaped on read, so the round trip is exact.
|
|
76
|
+
*/
|
|
77
|
+
const LIST_ESCAPE = '\\';
|
|
78
|
+
/**
|
|
79
|
+
* The list entry that means the VALUE `null` — "and rows with no value at all".
|
|
80
|
+
*
|
|
81
|
+
* A query string carries text, and `null` has no natural text form: writing it
|
|
82
|
+
* as `null` would be indistinguishable from an author actually called "null".
|
|
83
|
+
* This token cannot collide with any real value, BY CONSTRUCTION rather than by
|
|
84
|
+
* being unlikely: {@link escapeListEntry} doubles every backslash a real value
|
|
85
|
+
* contains, so the only two-character sequences a real entry can begin with are
|
|
86
|
+
* `\\` and `\,`. A lone backslash followed by `0` is therefore unreachable
|
|
87
|
+
* from any string — including the literal two characters `\0`, which serialize
|
|
88
|
+
* as `\\0` and read back as themselves.
|
|
89
|
+
*
|
|
90
|
+
* It survives URL encoding: `URLSearchParams` writes the backslash as `%5C` and
|
|
91
|
+
* reads it back unchanged.
|
|
92
|
+
*/
|
|
93
|
+
const LIST_NULL_TOKEN = `${LIST_ESCAPE}0`;
|
|
94
|
+
function escapeListEntry(value) {
|
|
95
|
+
if (value === null)
|
|
96
|
+
return LIST_NULL_TOKEN;
|
|
97
|
+
return value.replace(/[\\,]/g, (character) => `${LIST_ESCAPE}${character}`);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Splits on unescaped separators only, unescaping each entry as it goes.
|
|
101
|
+
*
|
|
102
|
+
* Returns `null` for {@link LIST_NULL_TOKEN}, which is a value rather than an
|
|
103
|
+
* absence — an empty entry (`a,,b`) is the empty STRING and stays one.
|
|
104
|
+
*/
|
|
105
|
+
function splitListValue(raw) {
|
|
106
|
+
const entries = [];
|
|
107
|
+
let current = '';
|
|
108
|
+
let isNull = false;
|
|
109
|
+
let escaped = false;
|
|
110
|
+
for (const character of raw) {
|
|
111
|
+
if (escaped) {
|
|
112
|
+
// A backslash followed by `0` is the null token; every real backslash
|
|
113
|
+
// arrives doubled, so this sequence cannot come from a string.
|
|
114
|
+
if (character === '0' && current === '')
|
|
115
|
+
isNull = true;
|
|
116
|
+
else
|
|
117
|
+
current += character;
|
|
118
|
+
escaped = false;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (character === LIST_ESCAPE) {
|
|
122
|
+
escaped = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (character === LIST_SEPARATOR) {
|
|
126
|
+
entries.push(isNull ? null : current);
|
|
127
|
+
current = '';
|
|
128
|
+
isNull = false;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
current += character;
|
|
132
|
+
}
|
|
133
|
+
// A trailing lone escape is data, not a prefix: keep it rather than losing it.
|
|
134
|
+
if (escaped)
|
|
135
|
+
current += LIST_ESCAPE;
|
|
136
|
+
entries.push(isNull ? null : current);
|
|
137
|
+
return entries;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Columns a restored filter or sort rule may address: exactly the columns the
|
|
141
|
+
* surface descriptor publishes with `filter` and `sort` capability.
|
|
142
|
+
*/
|
|
143
|
+
export const CONTENT_LIST_QUERYABLE_COLUMN_IDS = CONTENT_LIST_VISIBLE_COLUMN_IDS;
|
|
144
|
+
/**
|
|
145
|
+
* Operators a restored filter may use. This is the full DataTable operator
|
|
146
|
+
* vocabulary the adapter's evaluator implements — narrower than "anything that
|
|
147
|
+
* parses", so an unrecognized operator can never reach the query layer.
|
|
148
|
+
*/
|
|
149
|
+
export const CONTENT_LIST_FILTER_OPERATORS = [
|
|
150
|
+
'equals',
|
|
151
|
+
'notEquals',
|
|
152
|
+
'contains',
|
|
153
|
+
'notContains',
|
|
154
|
+
'startsWith',
|
|
155
|
+
'endsWith',
|
|
156
|
+
'in',
|
|
157
|
+
'notIn',
|
|
158
|
+
'gt',
|
|
159
|
+
'gte',
|
|
160
|
+
'lt',
|
|
161
|
+
'lte',
|
|
162
|
+
'isNull',
|
|
163
|
+
'isNotNull',
|
|
164
|
+
];
|
|
165
|
+
/** Operators that carry no value at all. */
|
|
166
|
+
const VALUELESS_OPERATORS = new Set([
|
|
167
|
+
'isNull',
|
|
168
|
+
'isNotNull',
|
|
169
|
+
]);
|
|
170
|
+
/** Operators whose value is a list rather than a scalar. */
|
|
171
|
+
const LIST_OPERATORS = new Set(['in', 'notIn']);
|
|
172
|
+
/**
|
|
173
|
+
* Upper bound for a restored page size, mirroring the surface descriptor's
|
|
174
|
+
* `limits.maxQueryRows`. A link is an untrusted input into a server query
|
|
175
|
+
* (#2452), so `?size=1000000` must clamp rather than become a row budget.
|
|
176
|
+
*/
|
|
177
|
+
export const CONTENT_LIST_MAX_PAGE_SIZE = 200;
|
|
178
|
+
const QUERYABLE_COLUMNS = new Set(CONTENT_LIST_QUERYABLE_COLUMN_IDS);
|
|
179
|
+
const HIDDEN_COLUMNS = new Set(CONTENT_LIST_HIDDEN_COLUMN_IDS);
|
|
180
|
+
const STRUCTURAL_COLUMNS = new Set([
|
|
181
|
+
CONTENT_LIST_SELECTION_COLUMN_ID,
|
|
182
|
+
CONTENT_LIST_ACTIONS_COLUMN_ID,
|
|
183
|
+
]);
|
|
184
|
+
const LAYOUT_COLUMNS = new Set(CONTENT_LIST_TABLE_COLUMN_IDS);
|
|
185
|
+
const FILTER_OPERATORS = new Set(CONTENT_LIST_FILTER_OPERATORS);
|
|
186
|
+
const RESERVED_PARAMS = new Set(CONTENT_LIST_RESERVED_PARAMS);
|
|
187
|
+
for (const columnId of LAYOUT_COLUMNS) {
|
|
188
|
+
if (RESERVED_PARAMS.has(columnId)) {
|
|
189
|
+
throw new Error(`Content list column "${columnId}" collides with a reserved URL parameter`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function isPlainObject(value) {
|
|
193
|
+
return (typeof value === 'object' &&
|
|
194
|
+
value !== null &&
|
|
195
|
+
!Array.isArray(value) &&
|
|
196
|
+
(Object.getPrototypeOf(value) === Object.prototype ||
|
|
197
|
+
Object.getPrototypeOf(value) === null));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Classifies a column id for filtering and sorting. The three rejection
|
|
201
|
+
* reasons are distinguished because they mean different things to an operator:
|
|
202
|
+
* an unknown column is a stale or crafted link, a hidden column is an attempt
|
|
203
|
+
* to reach a field the surface never publishes, and a structural column is a
|
|
204
|
+
* category error.
|
|
205
|
+
*/
|
|
206
|
+
function classifyQueryColumn(columnId) {
|
|
207
|
+
if (QUERYABLE_COLUMNS.has(columnId))
|
|
208
|
+
return 'allowed';
|
|
209
|
+
if (HIDDEN_COLUMNS.has(columnId))
|
|
210
|
+
return 'hidden-column';
|
|
211
|
+
if (STRUCTURAL_COLUMNS.has(columnId))
|
|
212
|
+
return 'structural-column';
|
|
213
|
+
return 'unknown-column';
|
|
214
|
+
}
|
|
215
|
+
/** Text a filter value may be expressed as. Objects and arrays are refused. */
|
|
216
|
+
function filterText(value) {
|
|
217
|
+
if (typeof value === 'string')
|
|
218
|
+
return value;
|
|
219
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
220
|
+
return String(value);
|
|
221
|
+
if (typeof value === 'boolean')
|
|
222
|
+
return String(value);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Normalizes one filter value the way the adapter does, so a filter restored
|
|
227
|
+
* from a link compares equal to the same filter built by the toolbar.
|
|
228
|
+
*
|
|
229
|
+
* THREE outcomes, not two. Conflating the last two is what let a legitimate
|
|
230
|
+
* value be discarded in three separate layers: each test asked whether the
|
|
231
|
+
* value was PRESENT rather than whether it was VALID, and `null` reads as
|
|
232
|
+
* absent to any check written with truthiness.
|
|
233
|
+
*
|
|
234
|
+
* - a string — the normalized value;
|
|
235
|
+
* - `null` — the VALUE null, which names absence ("and rows with no value");
|
|
236
|
+
* - `undefined` — unusable, and the only case a caller may drop.
|
|
237
|
+
*/
|
|
238
|
+
function normalizedFilterValue(columnId, raw) {
|
|
239
|
+
// A literal `null` is a value the caller wrote deliberately, not a missing
|
|
240
|
+
// one. The executor lowers it to `IS NULL` / `IS NOT NULL` and the local
|
|
241
|
+
// evaluator matches it through `isAbsentContentValue`; dropping it here
|
|
242
|
+
// persisted a WIDER filter than the one in memory, so copying the link
|
|
243
|
+
// brought back exactly the rows it excluded.
|
|
244
|
+
if (raw === null)
|
|
245
|
+
return null;
|
|
246
|
+
const text = filterText(raw);
|
|
247
|
+
if (text === null)
|
|
248
|
+
return undefined;
|
|
249
|
+
// A blank SCALAR clears the filter, matching `applyContentListFilter`. A
|
|
250
|
+
// blank LIST entry is the empty string, which is a real value for a column
|
|
251
|
+
// that stores one — see `sanitizeFilter`.
|
|
252
|
+
if (text.trim() === '')
|
|
253
|
+
return undefined;
|
|
254
|
+
return normalizeContentListFilterValue(columnId, text);
|
|
255
|
+
}
|
|
256
|
+
function sanitizeFilter(raw, drops) {
|
|
257
|
+
if (!isPlainObject(raw)) {
|
|
258
|
+
drops.push({ scope: 'filter', reason: 'malformed' });
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const columnId = raw.columnId;
|
|
262
|
+
if (typeof columnId !== 'string' || columnId.length === 0) {
|
|
263
|
+
drops.push({ scope: 'filter', reason: 'malformed' });
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const classification = classifyQueryColumn(columnId);
|
|
267
|
+
if (classification !== 'allowed') {
|
|
268
|
+
drops.push({ scope: 'filter', reason: classification, columnId });
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
const operator = raw.operator;
|
|
272
|
+
if (typeof operator !== 'string' || !FILTER_OPERATORS.has(operator)) {
|
|
273
|
+
drops.push({
|
|
274
|
+
scope: 'filter',
|
|
275
|
+
reason: 'unsupported-operator',
|
|
276
|
+
columnId,
|
|
277
|
+
detail: typeof operator === 'string' ? operator : undefined,
|
|
278
|
+
});
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const typedOperator = operator;
|
|
282
|
+
if (VALUELESS_OPERATORS.has(typedOperator)) {
|
|
283
|
+
return { columnId, operator: typedOperator };
|
|
284
|
+
}
|
|
285
|
+
if (LIST_OPERATORS.has(typedOperator)) {
|
|
286
|
+
if (!Array.isArray(raw.value)) {
|
|
287
|
+
drops.push({
|
|
288
|
+
scope: 'filter',
|
|
289
|
+
reason: 'unsupported-value',
|
|
290
|
+
columnId,
|
|
291
|
+
detail: operator,
|
|
292
|
+
});
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const values = [];
|
|
296
|
+
for (const entry of raw.value) {
|
|
297
|
+
// An empty entry is the empty STRING — a real value for a column that
|
|
298
|
+
// stores one, and one the list encoding round-trips natively (`a,,b`).
|
|
299
|
+
// Only `undefined` means unusable.
|
|
300
|
+
const normalized = typeof entry === 'string' && entry.trim() === ''
|
|
301
|
+
? ''
|
|
302
|
+
: normalizedFilterValue(columnId, entry);
|
|
303
|
+
if (normalized !== undefined && !values.includes(normalized)) {
|
|
304
|
+
values.push(normalized);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (values.length === 0) {
|
|
308
|
+
drops.push({
|
|
309
|
+
scope: 'filter',
|
|
310
|
+
reason: 'unsupported-value',
|
|
311
|
+
columnId,
|
|
312
|
+
detail: operator,
|
|
313
|
+
});
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
return { columnId, operator: typedOperator, value: values };
|
|
317
|
+
}
|
|
318
|
+
const value = normalizedFilterValue(columnId, raw.value);
|
|
319
|
+
if (value === undefined) {
|
|
320
|
+
drops.push({
|
|
321
|
+
scope: 'filter',
|
|
322
|
+
reason: 'unsupported-value',
|
|
323
|
+
columnId,
|
|
324
|
+
detail: operator,
|
|
325
|
+
});
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
return { columnId, operator: typedOperator, value };
|
|
329
|
+
}
|
|
330
|
+
function sanitizeFilters(raw, drops) {
|
|
331
|
+
if (!Array.isArray(raw)) {
|
|
332
|
+
drops.push({ scope: 'filter', reason: 'malformed' });
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
const filters = [];
|
|
336
|
+
for (const entry of raw) {
|
|
337
|
+
const filter = sanitizeFilter(entry, drops);
|
|
338
|
+
if (filter)
|
|
339
|
+
filters.push(filter);
|
|
340
|
+
}
|
|
341
|
+
return filters;
|
|
342
|
+
}
|
|
343
|
+
function sanitizeSorting(raw, drops) {
|
|
344
|
+
if (!Array.isArray(raw)) {
|
|
345
|
+
drops.push({ scope: 'sorting', reason: 'malformed' });
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
const seen = new Set();
|
|
349
|
+
const sorting = [];
|
|
350
|
+
for (const entry of raw) {
|
|
351
|
+
if (!isPlainObject(entry) || typeof entry.columnId !== 'string') {
|
|
352
|
+
drops.push({ scope: 'sorting', reason: 'malformed' });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const columnId = entry.columnId;
|
|
356
|
+
const classification = classifyQueryColumn(columnId);
|
|
357
|
+
if (classification !== 'allowed') {
|
|
358
|
+
drops.push({ scope: 'sorting', reason: classification, columnId });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (entry.direction !== 'asc' && entry.direction !== 'desc') {
|
|
362
|
+
drops.push({ scope: 'sorting', reason: 'malformed', columnId });
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
// First rule wins, matching the controller's own sort normalization.
|
|
366
|
+
if (seen.has(columnId))
|
|
367
|
+
continue;
|
|
368
|
+
seen.add(columnId);
|
|
369
|
+
sorting.push({ columnId, direction: entry.direction });
|
|
370
|
+
}
|
|
371
|
+
return sorting;
|
|
372
|
+
}
|
|
373
|
+
function sanitizeColumnOrder(raw, drops) {
|
|
374
|
+
if (!Array.isArray(raw)) {
|
|
375
|
+
drops.push({ scope: 'columnOrder', reason: 'malformed' });
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
const order = [];
|
|
379
|
+
for (const entry of raw) {
|
|
380
|
+
if (typeof entry !== 'string' || entry.length === 0) {
|
|
381
|
+
drops.push({ scope: 'columnOrder', reason: 'malformed' });
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (!LAYOUT_COLUMNS.has(entry)) {
|
|
385
|
+
drops.push({
|
|
386
|
+
scope: 'columnOrder',
|
|
387
|
+
reason: 'unknown-column',
|
|
388
|
+
columnId: entry,
|
|
389
|
+
});
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (!order.includes(entry))
|
|
393
|
+
order.push(entry);
|
|
394
|
+
}
|
|
395
|
+
return order;
|
|
396
|
+
}
|
|
397
|
+
function sanitizeColumnVisibility(raw, drops) {
|
|
398
|
+
if (!Array.isArray(raw)) {
|
|
399
|
+
drops.push({ scope: 'columnVisibility', reason: 'malformed' });
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
const entries = new Map();
|
|
403
|
+
for (const entry of raw) {
|
|
404
|
+
if (!isPlainObject(entry) ||
|
|
405
|
+
typeof entry.columnId !== 'string' ||
|
|
406
|
+
typeof entry.visible !== 'boolean') {
|
|
407
|
+
drops.push({ scope: 'columnVisibility', reason: 'malformed' });
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const columnId = entry.columnId;
|
|
411
|
+
if (!LAYOUT_COLUMNS.has(columnId)) {
|
|
412
|
+
drops.push({
|
|
413
|
+
scope: 'columnVisibility',
|
|
414
|
+
reason: 'unknown-column',
|
|
415
|
+
columnId,
|
|
416
|
+
});
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
// A search-only column stays hidden no matter what a stored view asks for:
|
|
420
|
+
// making `description` visible would publish a field the surface
|
|
421
|
+
// descriptor deliberately withholds.
|
|
422
|
+
if (HIDDEN_COLUMNS.has(columnId)) {
|
|
423
|
+
if (entry.visible) {
|
|
424
|
+
drops.push({
|
|
425
|
+
scope: 'columnVisibility',
|
|
426
|
+
reason: 'hidden-column',
|
|
427
|
+
columnId,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
entries.set(columnId, false);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
entries.set(columnId, entry.visible);
|
|
434
|
+
}
|
|
435
|
+
return [...entries.entries()].map(([columnId, visible]) => ({
|
|
436
|
+
columnId,
|
|
437
|
+
visible,
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
function sanitizeColumnWidths(raw, drops) {
|
|
441
|
+
if (!Array.isArray(raw)) {
|
|
442
|
+
drops.push({ scope: 'columnWidths', reason: 'malformed' });
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
const entries = new Map();
|
|
446
|
+
for (const entry of raw) {
|
|
447
|
+
if (!isPlainObject(entry) || typeof entry.columnId !== 'string') {
|
|
448
|
+
drops.push({ scope: 'columnWidths', reason: 'malformed' });
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const columnId = entry.columnId;
|
|
452
|
+
if (!LAYOUT_COLUMNS.has(columnId)) {
|
|
453
|
+
drops.push({
|
|
454
|
+
scope: 'columnWidths',
|
|
455
|
+
reason: 'unknown-column',
|
|
456
|
+
columnId,
|
|
457
|
+
});
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const width = entry.width;
|
|
461
|
+
if (typeof width !== 'number' || !Number.isFinite(width) || width <= 0) {
|
|
462
|
+
drops.push({
|
|
463
|
+
scope: 'columnWidths',
|
|
464
|
+
reason: 'unsupported-value',
|
|
465
|
+
columnId,
|
|
466
|
+
});
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
entries.set(columnId, width);
|
|
470
|
+
}
|
|
471
|
+
return [...entries.entries()].map(([columnId, width]) => ({
|
|
472
|
+
columnId,
|
|
473
|
+
width,
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
function sanitizeColumnPinning(raw, drops) {
|
|
477
|
+
if (!Array.isArray(raw)) {
|
|
478
|
+
drops.push({ scope: 'columnPinning', reason: 'malformed' });
|
|
479
|
+
return [];
|
|
480
|
+
}
|
|
481
|
+
const entries = new Map();
|
|
482
|
+
for (const entry of raw) {
|
|
483
|
+
if (!isPlainObject(entry) || typeof entry.columnId !== 'string') {
|
|
484
|
+
drops.push({ scope: 'columnPinning', reason: 'malformed' });
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const columnId = entry.columnId;
|
|
488
|
+
if (!LAYOUT_COLUMNS.has(columnId)) {
|
|
489
|
+
drops.push({
|
|
490
|
+
scope: 'columnPinning',
|
|
491
|
+
reason: 'unknown-column',
|
|
492
|
+
columnId,
|
|
493
|
+
});
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (entry.position !== 'start' && entry.position !== 'end') {
|
|
497
|
+
drops.push({
|
|
498
|
+
scope: 'columnPinning',
|
|
499
|
+
reason: 'unsupported-value',
|
|
500
|
+
columnId,
|
|
501
|
+
});
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
entries.set(columnId, entry.position);
|
|
505
|
+
}
|
|
506
|
+
return [...entries.entries()].map(([columnId, position]) => ({
|
|
507
|
+
columnId,
|
|
508
|
+
position,
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
511
|
+
function sanitizePage(raw, drops) {
|
|
512
|
+
if (typeof raw !== 'number' ||
|
|
513
|
+
!Number.isFinite(raw) ||
|
|
514
|
+
!Number.isInteger(raw) ||
|
|
515
|
+
raw <= 0) {
|
|
516
|
+
drops.push({ scope: 'page', reason: 'malformed' });
|
|
517
|
+
return 1;
|
|
518
|
+
}
|
|
519
|
+
return raw;
|
|
520
|
+
}
|
|
521
|
+
function sanitizePageSize(raw, maxPageSize, drops) {
|
|
522
|
+
if (raw === null || raw === undefined)
|
|
523
|
+
return null;
|
|
524
|
+
if (typeof raw !== 'number' ||
|
|
525
|
+
!Number.isFinite(raw) ||
|
|
526
|
+
!Number.isInteger(raw) ||
|
|
527
|
+
raw <= 0) {
|
|
528
|
+
drops.push({ scope: 'pageSize', reason: 'malformed' });
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
if (raw > maxPageSize) {
|
|
532
|
+
drops.push({
|
|
533
|
+
scope: 'pageSize',
|
|
534
|
+
reason: 'out-of-range',
|
|
535
|
+
detail: String(raw),
|
|
536
|
+
});
|
|
537
|
+
return maxPageSize;
|
|
538
|
+
}
|
|
539
|
+
return raw;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* The single validator behind both restoration paths.
|
|
543
|
+
*
|
|
544
|
+
* Every restored aspect is re-derived from the adapter's published vocabulary,
|
|
545
|
+
* so neither a crafted query string nor a tampered saved view can introduce a
|
|
546
|
+
* filter, a sort rule, or a projection on a column the surface does not
|
|
547
|
+
* publish. Invalid input is dropped and reported, never thrown, and selection
|
|
548
|
+
* and expansion are never emitted.
|
|
549
|
+
*/
|
|
550
|
+
export function sanitizeContentListViewState(input, options = {}) {
|
|
551
|
+
const dropped = [];
|
|
552
|
+
if (!isPlainObject(input)) {
|
|
553
|
+
return { state: {}, dropped: [{ scope: 'state', reason: 'malformed' }] };
|
|
554
|
+
}
|
|
555
|
+
const maxPageSize = options.maxPageSize ?? CONTENT_LIST_MAX_PAGE_SIZE;
|
|
556
|
+
const state = {};
|
|
557
|
+
if (Object.hasOwn(input, 'search')) {
|
|
558
|
+
if (typeof input.search === 'string') {
|
|
559
|
+
state.search = input.search;
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
dropped.push({ scope: 'search', reason: 'malformed' });
|
|
563
|
+
state.search = '';
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (Object.hasOwn(input, 'filters')) {
|
|
567
|
+
state.filters = sanitizeFilters(input.filters, dropped);
|
|
568
|
+
}
|
|
569
|
+
if (Object.hasOwn(input, 'sorting')) {
|
|
570
|
+
state.sorting = sanitizeSorting(input.sorting, dropped);
|
|
571
|
+
}
|
|
572
|
+
if (Object.hasOwn(input, 'page')) {
|
|
573
|
+
state.page = sanitizePage(input.page, dropped);
|
|
574
|
+
}
|
|
575
|
+
if (Object.hasOwn(input, 'pageSize')) {
|
|
576
|
+
state.pageSize = sanitizePageSize(input.pageSize, maxPageSize, dropped);
|
|
577
|
+
}
|
|
578
|
+
if (Object.hasOwn(input, 'columnOrder')) {
|
|
579
|
+
state.columnOrder = sanitizeColumnOrder(input.columnOrder, dropped);
|
|
580
|
+
}
|
|
581
|
+
if (Object.hasOwn(input, 'columnVisibility')) {
|
|
582
|
+
state.columnVisibility = sanitizeColumnVisibility(input.columnVisibility, dropped);
|
|
583
|
+
}
|
|
584
|
+
if (Object.hasOwn(input, 'columnWidths')) {
|
|
585
|
+
state.columnWidths = sanitizeColumnWidths(input.columnWidths, dropped);
|
|
586
|
+
}
|
|
587
|
+
if (Object.hasOwn(input, 'columnPinning')) {
|
|
588
|
+
state.columnPinning = sanitizeColumnPinning(input.columnPinning, dropped);
|
|
589
|
+
}
|
|
590
|
+
return { state, dropped };
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Merges a patch onto a controller's current state, validating it first.
|
|
594
|
+
*
|
|
595
|
+
* INVARIANT: no exported path may apply unvalidated state to a controller.
|
|
596
|
+
* This function is the only application point the package publishes, and it is
|
|
597
|
+
* routinely composed with values that came from somewhere untrusted — a query
|
|
598
|
+
* string, a `localStorage` blob, an agent command. Sanitizing here means the
|
|
599
|
+
* composition `applyContentListViewState(controller, storedView.snapshot.state)`
|
|
600
|
+
* is safe even though the store's read path deliberately keeps the raw payload
|
|
601
|
+
* so a stale view's drops can still be reported (see
|
|
602
|
+
* `restoreContentListSavedView`). Sanitization is idempotent, so calling this
|
|
603
|
+
* with an already-validated patch changes nothing.
|
|
604
|
+
*
|
|
605
|
+
* Only the *patch* is sanitized, never the merged result: the sanitizer never
|
|
606
|
+
* emits selection or expansion, so sanitizing the merge would silently clear
|
|
607
|
+
* the operator's current selection.
|
|
608
|
+
*
|
|
609
|
+
* Restoration is a state replacement rather than a command: it is not a user
|
|
610
|
+
* interaction and must not reset the page the way `setSearch`/`setFilters` do.
|
|
611
|
+
* Aspects the patch omits — selection and expansion above all — are carried
|
|
612
|
+
* over from the controller untouched.
|
|
613
|
+
*
|
|
614
|
+
* Use {@link sanitizeContentListViewState} directly when the caller needs to
|
|
615
|
+
* report what was refused.
|
|
616
|
+
*/
|
|
617
|
+
export function applyContentListViewState(controller, patch, options = {}) {
|
|
618
|
+
const { state: safe } = sanitizeContentListViewState(patch, options);
|
|
619
|
+
return controller.replaceState({ ...controller.getState(), ...safe });
|
|
620
|
+
}
|
|
621
|
+
function parameterName(name, prefix) {
|
|
622
|
+
return `${prefix}${name}`;
|
|
623
|
+
}
|
|
624
|
+
function filterParameterName(filter, prefix) {
|
|
625
|
+
return filter.operator === 'equals'
|
|
626
|
+
? parameterName(filter.columnId, prefix)
|
|
627
|
+
: parameterName(`${filter.columnId}${OPERATOR_SEPARATOR}${filter.operator}`, prefix);
|
|
628
|
+
}
|
|
629
|
+
function serializeSorting(sorting) {
|
|
630
|
+
return sorting
|
|
631
|
+
.map((rule) => rule.direction === 'desc' ? `-${rule.columnId}` : rule.columnId)
|
|
632
|
+
.join(LIST_SEPARATOR);
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Serializes a view state into shareable query parameters.
|
|
636
|
+
*
|
|
637
|
+
* The state is validated on the way out as well as on the way in: a link this
|
|
638
|
+
* module produces can never carry a filter or sort on a column the surface
|
|
639
|
+
* does not publish, even if the caller's controller somehow holds one.
|
|
640
|
+
* Defaults are omitted so an untouched list produces a clean URL.
|
|
641
|
+
*/
|
|
642
|
+
export function contentListViewStateToSearchParams(state, options = {}) {
|
|
643
|
+
const prefix = options.prefix ?? '';
|
|
644
|
+
const defaultPageSize = options.defaultPageSize ?? null;
|
|
645
|
+
const { state: safe } = sanitizeContentListViewState(state, options);
|
|
646
|
+
const params = new URLSearchParams();
|
|
647
|
+
// Written verbatim so the link round-trips exactly; only a search that is
|
|
648
|
+
// blank once trimmed counts as the default and is omitted.
|
|
649
|
+
const search = safe.search ?? '';
|
|
650
|
+
if (search.trim()) {
|
|
651
|
+
params.set(parameterName(CONTENT_LIST_SEARCH_PARAM, prefix), search);
|
|
652
|
+
}
|
|
653
|
+
for (const filter of safe.filters ?? []) {
|
|
654
|
+
if (VALUELESS_OPERATORS.has(filter.operator)) {
|
|
655
|
+
params.append(filterParameterName(filter, prefix), '1');
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (Array.isArray(filter.value)) {
|
|
659
|
+
params.append(filterParameterName(filter, prefix), filter.value
|
|
660
|
+
.map((entry) => escapeListEntry(entry === null ? null : String(entry)))
|
|
661
|
+
.join(LIST_SEPARATOR));
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
if (filter.value === null) {
|
|
665
|
+
// `equals null` and `isNull` are the same predicate — both lower to
|
|
666
|
+
// `eq null`, and the local evaluator answers them identically. The
|
|
667
|
+
// valueless operator already has a query-string form, so the scalar null
|
|
668
|
+
// is written as that rather than inventing a second token for it.
|
|
669
|
+
const nullOperator = filter.operator === 'notEquals' ? 'isNotNull' : 'isNull';
|
|
670
|
+
params.append(filterParameterName({ ...filter, operator: nullOperator }, prefix), '1');
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
params.append(filterParameterName(filter, prefix), String(filter.value ?? ''));
|
|
674
|
+
}
|
|
675
|
+
if (safe.sorting?.length) {
|
|
676
|
+
params.set(parameterName(CONTENT_LIST_SORT_PARAM, prefix), serializeSorting(safe.sorting));
|
|
677
|
+
}
|
|
678
|
+
if (safe.page !== undefined && safe.page > 1) {
|
|
679
|
+
params.set(parameterName(CONTENT_LIST_PAGE_PARAM, prefix), String(safe.page));
|
|
680
|
+
}
|
|
681
|
+
if (safe.pageSize !== undefined && safe.pageSize !== defaultPageSize) {
|
|
682
|
+
params.set(parameterName(CONTENT_LIST_PAGE_SIZE_PARAM, prefix), safe.pageSize === null ? 'all' : String(safe.pageSize));
|
|
683
|
+
}
|
|
684
|
+
return params;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Writes the owned parameters into a copy of an existing query string,
|
|
688
|
+
* preserving every parameter this module does not own (routing keys, campaign
|
|
689
|
+
* tags, a sibling list's prefixed parameters).
|
|
690
|
+
*/
|
|
691
|
+
export function mergeContentListViewStateIntoSearchParams(params, state, options = {}) {
|
|
692
|
+
const prefix = options.prefix ?? '';
|
|
693
|
+
const next = new URLSearchParams();
|
|
694
|
+
for (const [key, value] of params.entries()) {
|
|
695
|
+
if (!ownedParameter(key, prefix))
|
|
696
|
+
next.append(key, value);
|
|
697
|
+
}
|
|
698
|
+
for (const [key, value] of contentListViewStateToSearchParams(state, options).entries()) {
|
|
699
|
+
next.append(key, value);
|
|
700
|
+
}
|
|
701
|
+
return next;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* True when a parameter name belongs to this module's vocabulary, and may
|
|
705
|
+
* therefore be *removed* while rewriting the query string.
|
|
706
|
+
*
|
|
707
|
+
* Ownership is decided by the BASE name only. A key such as `facet.contains`
|
|
708
|
+
* carries a known operator suffix but names no ContentList column, so it is a
|
|
709
|
+
* host's parameter and must survive — this module promises every parameter it
|
|
710
|
+
* does not own is preserved, and deleting one silently breaks the host's own
|
|
711
|
+
* routing on the very first filter change.
|
|
712
|
+
*
|
|
713
|
+
* This is deliberately narrower than the recognizer in
|
|
714
|
+
* {@link readContentListViewStateFromSearchParams}, which reports an
|
|
715
|
+
* operator-suffixed unknown column so a crafted `evil.contains=` stays visible.
|
|
716
|
+
* Reporting a refusal and deleting a parameter are different acts.
|
|
717
|
+
*/
|
|
718
|
+
function ownedParameter(key, prefix) {
|
|
719
|
+
if (prefix && !key.startsWith(prefix))
|
|
720
|
+
return false;
|
|
721
|
+
const name = prefix ? key.slice(prefix.length) : key;
|
|
722
|
+
if (RESERVED_PARAMS.has(name))
|
|
723
|
+
return true;
|
|
724
|
+
const separator = name.indexOf(OPERATOR_SEPARATOR);
|
|
725
|
+
const columnId = separator === -1 ? name : name.slice(0, separator);
|
|
726
|
+
return LAYOUT_COLUMNS.has(columnId);
|
|
727
|
+
}
|
|
728
|
+
function parseInteger(raw) {
|
|
729
|
+
if (!/^\d+$/.test(raw.trim()))
|
|
730
|
+
return null;
|
|
731
|
+
const value = Number.parseInt(raw.trim(), 10);
|
|
732
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
733
|
+
}
|
|
734
|
+
function parseSortParam(raw, drops) {
|
|
735
|
+
const rules = [];
|
|
736
|
+
for (const token of raw.split(LIST_SEPARATOR)) {
|
|
737
|
+
const trimmed = token.trim();
|
|
738
|
+
if (!trimmed)
|
|
739
|
+
continue;
|
|
740
|
+
const direction = trimmed.startsWith('-') ? 'desc' : 'asc';
|
|
741
|
+
const columnId = trimmed.startsWith('-') || trimmed.startsWith('+')
|
|
742
|
+
? trimmed.slice(1)
|
|
743
|
+
: trimmed;
|
|
744
|
+
if (!columnId) {
|
|
745
|
+
drops.push({ scope: 'sorting', reason: 'malformed' });
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
rules.push({ columnId, direction });
|
|
749
|
+
}
|
|
750
|
+
return rules;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Reads a view state from query parameters and reports everything it refused.
|
|
754
|
+
*
|
|
755
|
+
* Foreign parameters are ignored silently: a shared link routinely carries
|
|
756
|
+
* routing and campaign keys this module knows nothing about, and reporting
|
|
757
|
+
* them as drops would bury the reports that matter. A parameter that *looks*
|
|
758
|
+
* like a content-list filter — a known column, or any name carrying a known
|
|
759
|
+
* operator suffix — is reported when it is refused, which is what makes a
|
|
760
|
+
* crafted `description=` or `evil.contains=` visible rather than silent.
|
|
761
|
+
*/
|
|
762
|
+
export function readContentListViewStateFromSearchParams(params, options = {}) {
|
|
763
|
+
const prefix = options.prefix ?? '';
|
|
764
|
+
const drops = [];
|
|
765
|
+
const candidate = {
|
|
766
|
+
search: '',
|
|
767
|
+
filters: [],
|
|
768
|
+
sorting: [],
|
|
769
|
+
page: 1,
|
|
770
|
+
pageSize: options.defaultPageSize ?? null,
|
|
771
|
+
};
|
|
772
|
+
for (const [key, value] of params.entries()) {
|
|
773
|
+
if (prefix && !key.startsWith(prefix))
|
|
774
|
+
continue;
|
|
775
|
+
const name = prefix ? key.slice(prefix.length) : key;
|
|
776
|
+
if (name === CONTENT_LIST_SEARCH_PARAM) {
|
|
777
|
+
candidate.search = value;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (name === CONTENT_LIST_SORT_PARAM) {
|
|
781
|
+
candidate.sorting = [
|
|
782
|
+
...candidate.sorting,
|
|
783
|
+
...parseSortParam(value, drops),
|
|
784
|
+
];
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (name === CONTENT_LIST_PAGE_PARAM) {
|
|
788
|
+
const page = parseInteger(value);
|
|
789
|
+
if (page === null || page < 1) {
|
|
790
|
+
drops.push({ scope: 'page', reason: 'malformed', detail: value });
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
candidate.page = page;
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (name === CONTENT_LIST_PAGE_SIZE_PARAM) {
|
|
797
|
+
// `all` is the only way a link can say "unpaginated" when the host's
|
|
798
|
+
// default page size is a number, since an omitted parameter means default.
|
|
799
|
+
if (value.trim() === 'all') {
|
|
800
|
+
candidate.pageSize = null;
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
const size = parseInteger(value);
|
|
804
|
+
if (size === null || size < 1) {
|
|
805
|
+
drops.push({ scope: 'pageSize', reason: 'malformed', detail: value });
|
|
806
|
+
candidate.pageSize = options.defaultPageSize ?? null;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
candidate.pageSize = size;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
const separator = name.indexOf(OPERATOR_SEPARATOR);
|
|
813
|
+
const columnId = separator === -1 ? name : name.slice(0, separator);
|
|
814
|
+
const operator = separator === -1 ? 'equals' : name.slice(separator + 1);
|
|
815
|
+
const recognizable = LAYOUT_COLUMNS.has(columnId) ||
|
|
816
|
+
(separator !== -1 && FILTER_OPERATORS.has(operator)) ||
|
|
817
|
+
Boolean(prefix);
|
|
818
|
+
if (!recognizable)
|
|
819
|
+
continue;
|
|
820
|
+
if (separator !== -1 && !FILTER_OPERATORS.has(operator)) {
|
|
821
|
+
drops.push({
|
|
822
|
+
scope: 'filter',
|
|
823
|
+
reason: 'unsupported-operator',
|
|
824
|
+
columnId,
|
|
825
|
+
detail: operator,
|
|
826
|
+
});
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
candidate.filters.push({
|
|
830
|
+
columnId,
|
|
831
|
+
operator,
|
|
832
|
+
...(VALUELESS_OPERATORS.has(operator)
|
|
833
|
+
? {}
|
|
834
|
+
: LIST_OPERATORS.has(operator)
|
|
835
|
+
? // An entirely empty parameter (`?status.in=`) is a list with no
|
|
836
|
+
// values, which the sanitizer refuses and reports. An empty entry
|
|
837
|
+
// WITHIN a list (`?status.in=a,`) is the empty string, which is a
|
|
838
|
+
// real value for a column that stores one.
|
|
839
|
+
{ value: value === '' ? [] : splitListValue(value) }
|
|
840
|
+
: { value }),
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
const sanitized = sanitizeContentListViewState(candidate, options);
|
|
844
|
+
return { state: sanitized.state, dropped: [...drops, ...sanitized.dropped] };
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Reads a view state from query parameters.
|
|
848
|
+
*
|
|
849
|
+
* The result always carries `search`, `filters`, `sorting`, `page`, and
|
|
850
|
+
* `pageSize` — an absent parameter restores that aspect's default, so a clean
|
|
851
|
+
* link restores a clean view rather than leaving stale state in place. Use
|
|
852
|
+
* {@link readContentListViewStateFromSearchParams} when the refusals matter.
|
|
853
|
+
*/
|
|
854
|
+
export function contentListViewStateFromSearchParams(params, options = {}) {
|
|
855
|
+
return readContentListViewStateFromSearchParams(params, options).state;
|
|
856
|
+
}
|