@sheetpane/grid 0.0.0-stage → 0.1.0
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 +429 -2
- package/custom-elements.json +1246 -0
- package/dist/errors/element.d.ts +108 -0
- package/dist/errors/element.d.ts.map +1 -0
- package/dist/errors/element.js +456 -0
- package/dist/errors/element.js.map +1 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/excel/element.d.ts +434 -0
- package/dist/excel/element.d.ts.map +1 -0
- package/dist/excel/element.js +3382 -0
- package/dist/excel/element.js.map +1 -0
- package/dist/excel/geometry.d.ts +81 -0
- package/dist/excel/geometry.d.ts.map +1 -0
- package/dist/excel/geometry.js +137 -0
- package/dist/excel/geometry.js.map +1 -0
- package/dist/excel/index.d.ts +2 -0
- package/dist/excel/index.d.ts.map +1 -0
- package/dist/excel/index.js +3 -0
- package/dist/excel/index.js.map +1 -0
- package/dist/excel/selection.d.ts +103 -0
- package/dist/excel/selection.d.ts.map +1 -0
- package/dist/excel/selection.js +189 -0
- package/dist/excel/selection.js.map +1 -0
- package/dist/excel/styles.d.ts +3 -0
- package/dist/excel/styles.d.ts.map +1 -0
- package/dist/excel/styles.js +206 -0
- package/dist/excel/styles.js.map +1 -0
- package/dist/frameworks/elements.d.ts +136 -0
- package/dist/frameworks/jsx.d.ts +64 -0
- package/dist/frameworks/jsx.js +2 -0
- package/dist/frameworks/solid.d.ts +133 -0
- package/dist/frameworks/solid.js +2 -0
- package/dist/frameworks/svelte.d.ts +102 -0
- package/dist/frameworks/svelte.js +2 -0
- package/dist/frameworks/vue.d.ts +69 -0
- package/dist/frameworks/vue.js +2 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/llms-full.txt +8028 -0
- package/dist/llms.txt +111 -0
- package/dist/pane/index.d.ts +2 -0
- package/dist/pane/index.d.ts.map +1 -0
- package/dist/pane/index.js +3 -0
- package/dist/pane/index.js.map +1 -0
- package/dist/pane/registry.d.ts +33 -0
- package/dist/pane/registry.d.ts.map +1 -0
- package/dist/pane/registry.js +73 -0
- package/dist/pane/registry.js.map +1 -0
- package/dist/pane/rules.d.ts +231 -0
- package/dist/pane/rules.d.ts.map +1 -0
- package/dist/pane/rules.js +792 -0
- package/dist/pane/rules.js.map +1 -0
- package/dist/pane/store.d.ts +385 -0
- package/dist/pane/store.d.ts.map +1 -0
- package/dist/pane/store.js +1028 -0
- package/dist/pane/store.js.map +1 -0
- package/dist/pane/types.d.ts +130 -0
- package/dist/pane/types.d.ts.map +1 -0
- package/dist/pane/types.js +22 -0
- package/dist/pane/types.js.map +1 -0
- package/dist/plugins/host.d.ts +56 -0
- package/dist/plugins/host.d.ts.map +1 -0
- package/dist/plugins/host.js +124 -0
- package/dist/plugins/host.js.map +1 -0
- package/dist/plugins/types.d.ts +159 -0
- package/dist/plugins/types.d.ts.map +1 -0
- package/dist/plugins/types.js +12 -0
- package/dist/plugins/types.js.map +1 -0
- package/dist/rules.schema.json +52 -0
- package/dist/shared/dom.d.ts +23 -0
- package/dist/shared/dom.d.ts.map +1 -0
- package/dist/shared/dom.js +32 -0
- package/dist/shared/dom.js.map +1 -0
- package/dist/skills-bin.d.ts +3 -0
- package/dist/skills-bin.d.ts.map +1 -0
- package/dist/skills-bin.js +9 -0
- package/dist/skills-bin.js.map +1 -0
- package/dist/style.css +3 -0
- package/dist/tokens.d.ts +49 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +43 -0
- package/dist/tokens.js.map +1 -0
- package/dist/vscode.css-custom-data.json +365 -0
- package/dist/vscode.html-custom-data.json +218 -0
- package/dist/web-types.json +914 -0
- package/package.json +72 -3
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
// Column rules (W-27) and the rules file (W-33): the host's checks on what a
|
|
2
|
+
// user types, by column title and by cell. A core provider on the `validate`
|
|
3
|
+
// hook (events verdict decision 8): its errors are `author: 'host'`, they
|
|
4
|
+
// resolve themselves when the cell passes, and a rule never edits a cell — it
|
|
5
|
+
// only reports (decision 13). Findings are cached per cell and updated on commit
|
|
6
|
+
// and undo, so `validate` stays cheap on a 250,000-row sheet; the `load` phase
|
|
7
|
+
// checks every row in time-sliced chunks with its progress shown.
|
|
8
|
+
//
|
|
9
|
+
// Where a cell's rule comes from, weakest first: the element's `columns`, the
|
|
10
|
+
// rules file's `"*"` sheet, the rules file's named sheet (SR-GRID 22, 22d).
|
|
11
|
+
import { cellRef, colName, parseRange, parseRef, spError, spMessage } from '@sheetpane/export';
|
|
12
|
+
import { serialToParts } from '@sheetpane/import';
|
|
13
|
+
export const RULES_PROVIDER_ID = 'host-rules';
|
|
14
|
+
/** The error `type` of a column rule's finding. */
|
|
15
|
+
export const RULE_TYPE = 'column-rule';
|
|
16
|
+
/** The error `type` of a rules file's cell rule finding. */
|
|
17
|
+
export const CELL_RULE_TYPE = 'cell-rule';
|
|
18
|
+
const PATTERN_LIMIT = 10_000;
|
|
19
|
+
const SLICE_MS = 8;
|
|
20
|
+
/** A cell rule over more cells than this is checked only where the sheet has data. */
|
|
21
|
+
const SMALL_RANGE = 10_000;
|
|
22
|
+
const RULE_WORDS = new Set(['required', 'type', 'min', 'max', 'minLength', 'maxLength', 'pattern', 'oneOf', 'noneOf', 'equals', 'unique', 'message', 'note', 'severity', 'onInvalid']);
|
|
23
|
+
const TYPES = new Set(['text', 'number', 'integer', 'date', 'boolean']);
|
|
24
|
+
const blank = (v) => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
|
|
25
|
+
const NUMERIC = /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?$|^[-+]?\d{1,3}(?:,\d{3})+(?:\.\d+)?$/i;
|
|
26
|
+
const asNumber = (v) => {
|
|
27
|
+
if (typeof v === 'number')
|
|
28
|
+
return Number.isFinite(v) ? v : undefined;
|
|
29
|
+
if (typeof v === 'string' && NUMERIC.test(v.trim()))
|
|
30
|
+
return Number(v.trim().replace(/,/g, ''));
|
|
31
|
+
return undefined;
|
|
32
|
+
};
|
|
33
|
+
const norm = (v) => String(v ?? '').trim().toLowerCase();
|
|
34
|
+
const show = (x) => (typeof x === 'string' ? x : String(x));
|
|
35
|
+
/** Does a listed value (`oneOf`, `noneOf`, `equals`) match the cell? Text trimmed and without case; numbers by value. */
|
|
36
|
+
const matches = (o, value, t, n) => typeof o === 'number' ? n === o : typeof o === 'boolean' ? (typeof value === 'boolean' ? value === o : t.toLowerCase() === String(o)) : o.trim().toLowerCase() === t.toLowerCase();
|
|
37
|
+
export class HostRules {
|
|
38
|
+
host;
|
|
39
|
+
cfg = { columns: {}, validate: undefined, onInvalid: 'flag', headerRow: 1, document: undefined };
|
|
40
|
+
/** The element's patterns, compiled (an invalid one throws at `configure`). */
|
|
41
|
+
patterns = new Map();
|
|
42
|
+
/** The rules file, checked: per sheet (`*` and named), and the problems found in it. */
|
|
43
|
+
doc;
|
|
44
|
+
sheetRules = new Map();
|
|
45
|
+
/** Per sheet, per column: the rule its title resolves to (resolved once per load, not per cell). */
|
|
46
|
+
colRules = new Map();
|
|
47
|
+
/** Per sheet: title (normalised) → column; the titles by column. */
|
|
48
|
+
headers = new Map();
|
|
49
|
+
notes = [];
|
|
50
|
+
/** Per sheet, per cell: the column rule's failure. */
|
|
51
|
+
json = new Map();
|
|
52
|
+
/** Per sheet, per cell: a cell rule's failure (rules file). */
|
|
53
|
+
cellJson = new Map();
|
|
54
|
+
/** Per sheet, per cell: the function's latest result. */
|
|
55
|
+
fn = new Map();
|
|
56
|
+
/** Per sheet and column: normalised value → its row, or its rows once it repeats (for `unique`; a Set per distinct value would be 250,000 Sets). */
|
|
57
|
+
uniq = new Map();
|
|
58
|
+
inflight = new Map();
|
|
59
|
+
gen = 0;
|
|
60
|
+
/** The load phase: rows checked of rows to check; undefined when idle. */
|
|
61
|
+
progress;
|
|
62
|
+
provider = { id: RULES_PROVIDER_ID, provides: ['validate'], validate: (sheet) => this.drafts(sheet) };
|
|
63
|
+
constructor(host) {
|
|
64
|
+
this.host = host;
|
|
65
|
+
}
|
|
66
|
+
get active() { return Object.keys(this.cfg.columns).length > 0 || !!this.cfg.validate || this.cfg.document !== undefined; }
|
|
67
|
+
get config() { return this.cfg; }
|
|
68
|
+
/** Whether a rules file is in force (its errors are labelled "Rules file"). */
|
|
69
|
+
get hasDocument() { return this.cfg.document !== undefined; }
|
|
70
|
+
/** Rules may change after a workbook is loaded (decision 12): every row is checked again and stale host errors go. */
|
|
71
|
+
configure(c) {
|
|
72
|
+
const patterns = new Map();
|
|
73
|
+
for (const [title, r] of Object.entries(c.columns ?? {})) {
|
|
74
|
+
if (r.pattern === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
try {
|
|
77
|
+
patterns.set(r, new RegExp(r.pattern));
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
throw spError('SP-6001', `sheetpane: the "${title}" column rule's pattern is not a regular expression: ${e.message}`, TypeError);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.patterns = patterns;
|
|
84
|
+
this.cfg = { columns: { ...(c.columns ?? {}) }, validate: c.validate, onInvalid: c.onInvalid ?? 'flag', headerRow: Math.max(1, Math.floor(c.headerRow ?? 1)), document: c.document };
|
|
85
|
+
this.doc = c.document === undefined ? undefined : checkDocument(c.document);
|
|
86
|
+
void this.loadPhase(true);
|
|
87
|
+
}
|
|
88
|
+
/** A new workbook: forget everything and check it (`load`). */
|
|
89
|
+
reset() { void this.loadPhase(false); }
|
|
90
|
+
// ── the validate provider ───────────────────────────────────────────────
|
|
91
|
+
drafts(sheet) {
|
|
92
|
+
if (!this.active)
|
|
93
|
+
return [];
|
|
94
|
+
const out = this.notes.filter((n) => n.sheet === sheet);
|
|
95
|
+
const j = this.json.get(sheet), f = this.fn.get(sheet), k = this.cellJson.get(sheet);
|
|
96
|
+
if (j)
|
|
97
|
+
out.push(...j.values());
|
|
98
|
+
if (k)
|
|
99
|
+
out.push(...k.values());
|
|
100
|
+
if (f)
|
|
101
|
+
for (const [ref, d] of f)
|
|
102
|
+
if (!j?.has(ref))
|
|
103
|
+
out.push(d); // one column error per cell: the JSON rule's first
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
// ── merging: element columns < file "*" < file named sheet ───────────────
|
|
107
|
+
compileSheet(name) {
|
|
108
|
+
const star = this.doc?.sheets.get('*'), own = this.doc?.sheets.get(name);
|
|
109
|
+
const byTitle = new Map();
|
|
110
|
+
const put = (title, rule, re, from) => {
|
|
111
|
+
const k = title.trim().toLowerCase(), had = byTitle.get(k);
|
|
112
|
+
if (had && from !== 'element' && this.cfg.columns[had[0]] === had[1])
|
|
113
|
+
this.notes.push(this.note('SP-6025', name, `A${this.cfg.headerRow}`, title, `The rules file's rule for “${title}” replaces the one set on the element`, `override:${k}`));
|
|
114
|
+
byTitle.set(k, [title, rule, re]);
|
|
115
|
+
};
|
|
116
|
+
for (const [t, r] of Object.entries(this.cfg.columns))
|
|
117
|
+
put(t, r, this.patterns.get(r), 'element');
|
|
118
|
+
for (const src of [star, own])
|
|
119
|
+
if (src)
|
|
120
|
+
for (const [t, r] of Object.entries(src.columns))
|
|
121
|
+
put(t, r, filePattern(r), 'file');
|
|
122
|
+
const cells = [];
|
|
123
|
+
for (const src of [star, own])
|
|
124
|
+
if (src)
|
|
125
|
+
for (const [ref, r] of Object.entries(src.cells)) {
|
|
126
|
+
const box = parseRange(ref);
|
|
127
|
+
if (box)
|
|
128
|
+
cells.push({ ref, box, rule: r, re: filePattern(r) });
|
|
129
|
+
}
|
|
130
|
+
return { headerRow: Math.max(1, Math.floor(own?.headerRow ?? star?.headerRow ?? this.cfg.headerRow)), columns: [...byTitle.values()], cells };
|
|
131
|
+
}
|
|
132
|
+
// ── headers ─────────────────────────────────────────────────────────────
|
|
133
|
+
readHeaders() {
|
|
134
|
+
this.headers.clear();
|
|
135
|
+
this.sheetRules.clear();
|
|
136
|
+
this.colRules.clear();
|
|
137
|
+
this.notes = [];
|
|
138
|
+
const sheets = this.host.bookSheets() ?? [];
|
|
139
|
+
const first = sheets[0];
|
|
140
|
+
// the rules file's own problems, and the sheets it names that the workbook lacks
|
|
141
|
+
for (const p of this.doc?.problems ?? [])
|
|
142
|
+
this.notes.push(this.note(p.code, p.sheet && sheets.some((s) => s.name === p.sheet) ? p.sheet : first?.name ?? '', 'A1', p.found, p.message, `doc:${p.message}`));
|
|
143
|
+
for (const name of this.doc?.sheets.keys() ?? [])
|
|
144
|
+
if (name !== '*' && first && !sheets.some((s) => s.name === name))
|
|
145
|
+
this.notes.push(this.note('SP-6022', first.name, 'A1', name, `No sheet is named “${name}” — its rules are not checked`, `sheet:${name}`));
|
|
146
|
+
const found = new Map(); // title → sheets that have it
|
|
147
|
+
for (const s of sheets) {
|
|
148
|
+
const sr = this.compileSheet(s.name);
|
|
149
|
+
this.sheetRules.set(s.name, sr);
|
|
150
|
+
const hr = sr.headerRow - 1;
|
|
151
|
+
const titles = [];
|
|
152
|
+
const byTitle = new Map();
|
|
153
|
+
const width = s.rows[hr]?.length ?? 0;
|
|
154
|
+
for (let c = 0; c < width; c++) {
|
|
155
|
+
const t = this.host.text(s.name, cellRef(hr, c)).trim();
|
|
156
|
+
titles[c] = t;
|
|
157
|
+
if (!t)
|
|
158
|
+
continue;
|
|
159
|
+
const k = t.toLowerCase();
|
|
160
|
+
if (byTitle.has(k)) {
|
|
161
|
+
if (this.ruleFor(s.name, t))
|
|
162
|
+
this.notes.push(this.note('SP-6024', s.name, cellRef(hr, c), t, `Two columns are titled “${t}” — the rules check column ${colName(byTitle.get(k))}, the first`));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
byTitle.set(k, c);
|
|
166
|
+
if (!found.has(k))
|
|
167
|
+
found.set(k, new Set());
|
|
168
|
+
found.get(k).add(s.name);
|
|
169
|
+
}
|
|
170
|
+
this.headers.set(s.name, { byTitle, titles });
|
|
171
|
+
const cr = new Map();
|
|
172
|
+
for (const [, c] of byTitle) {
|
|
173
|
+
const hit = this.ruleFor(s.name, titles[c]);
|
|
174
|
+
if (hit)
|
|
175
|
+
cr.set(c, { header: titles[c], rule: hit.rule, re: hit.re });
|
|
176
|
+
}
|
|
177
|
+
this.colRules.set(s.name, cr);
|
|
178
|
+
}
|
|
179
|
+
if (!first)
|
|
180
|
+
return;
|
|
181
|
+
// a title nobody has: the element's and "*"'s across the workbook; a named sheet's on that sheet
|
|
182
|
+
const general = new Set([...Object.keys(this.cfg.columns), ...Object.keys(this.doc?.sheets.get('*')?.columns ?? {})]);
|
|
183
|
+
for (const title of general)
|
|
184
|
+
if (!found.has(title.trim().toLowerCase()))
|
|
185
|
+
this.notes.push(this.note('SP-6023', first.name, cellRef(this.cfg.headerRow - 1, 0), title, `No column is titled “${title}” — its rule is not checked`, `title:${title}`));
|
|
186
|
+
for (const [name, d] of this.doc?.sheets ?? []) {
|
|
187
|
+
if (name === '*' || !this.headers.has(name))
|
|
188
|
+
continue;
|
|
189
|
+
const hr = this.sheetRules.get(name).headerRow;
|
|
190
|
+
for (const title of Object.keys(d.columns))
|
|
191
|
+
if (!found.get(title.trim().toLowerCase())?.has(name))
|
|
192
|
+
this.notes.push(this.note('SP-6023', name, cellRef(hr - 1, 0), title, `No column is titled “${title}” in row ${hr} of “${name}” — its rule is not checked`, `title:${title}`));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** A `note` error about the rules themselves; it carries its SP code (W-40). */
|
|
196
|
+
note(code, sheet, range, found, message, key) {
|
|
197
|
+
const d = { sheet, range, type: RULE_TYPE, severity: 'note', found, expected: '', message, author: 'host', code };
|
|
198
|
+
if (key)
|
|
199
|
+
d.key = key; // several notes may share a cell
|
|
200
|
+
return d;
|
|
201
|
+
}
|
|
202
|
+
/** The rule for a title on a sheet (matched trimmed and case-insensitive; an exact title wins). */
|
|
203
|
+
ruleFor(sheet, header) {
|
|
204
|
+
const h = header.trim(), k = h.toLowerCase();
|
|
205
|
+
const all = this.sheetRules.get(sheet)?.columns;
|
|
206
|
+
if (!k || !all)
|
|
207
|
+
return undefined;
|
|
208
|
+
const hit = all.find(([t]) => t.trim() === h) ?? all.find(([t]) => t.trim().toLowerCase() === k);
|
|
209
|
+
return hit && { title: hit[0], rule: hit[1], re: hit[2] };
|
|
210
|
+
}
|
|
211
|
+
headerRow(sheet) { return this.sheetRules.get(sheet)?.headerRow ?? this.cfg.headerRow; }
|
|
212
|
+
/** The column's title on a sheet, when the column is the first of its title. */
|
|
213
|
+
headerAt(sheet, col) {
|
|
214
|
+
const h = this.headers.get(sheet);
|
|
215
|
+
const t = h?.titles[col];
|
|
216
|
+
return t && h.byTitle.get(t.toLowerCase()) === col ? t : undefined;
|
|
217
|
+
}
|
|
218
|
+
context(phase, sheet, row, col, value) {
|
|
219
|
+
const ref = cellRef(row, col);
|
|
220
|
+
const h = this.headers.get(sheet);
|
|
221
|
+
const rowValues = {};
|
|
222
|
+
if (h)
|
|
223
|
+
for (const [, c] of h.byTitle)
|
|
224
|
+
rowValues[h.titles[c]] = c === col ? value : this.host.value(sheet, cellRef(row, c));
|
|
225
|
+
return { phase, sheet, ref, row, column: { index: col, letter: colName(col), header: h?.titles[col] ?? '' }, value, was: this.host.value(sheet, ref), original: this.host.original(sheet, ref).value, rowValues };
|
|
226
|
+
}
|
|
227
|
+
// ── checking ────────────────────────────────────────────────────────────
|
|
228
|
+
/** A rule's first failing constraint for a value, or null. `label` names what is checked (a column's title or a cell). */
|
|
229
|
+
/** A rule's first failing constraint for a value, or null. `label` names what is checked (a column's title or a cell).
|
|
230
|
+
* `at` is where the value sits (its shown text is read only when a constraint needs it — reading fills the store's
|
|
231
|
+
* text cache) or, for a draft, the text itself. No closure is made per call: the load phase checks a million cells. */
|
|
232
|
+
checkRule(rule, re, label, value, at, unique) {
|
|
233
|
+
if (blank(value))
|
|
234
|
+
return rule.required ? this.fail(rule, label, 'a value is required', 'required') : rule.equals !== undefined ? this.fail(rule, label, `must be ${show(rule.equals)}`, 'equals') : null;
|
|
235
|
+
const n = asNumber(value);
|
|
236
|
+
if (rule.equals !== undefined && !matches(rule.equals, value, this.trimmed(value, at), n))
|
|
237
|
+
return this.fail(rule, label, `must be ${show(rule.equals)}`, 'equals');
|
|
238
|
+
switch (rule.type) {
|
|
239
|
+
case 'number':
|
|
240
|
+
if (n === undefined)
|
|
241
|
+
return this.fail(rule, label, 'must be a number', 'type: number');
|
|
242
|
+
break;
|
|
243
|
+
case 'integer':
|
|
244
|
+
if (n === undefined || !Number.isInteger(n))
|
|
245
|
+
return this.fail(rule, label, 'must be a whole number', 'type: integer');
|
|
246
|
+
break;
|
|
247
|
+
case 'date':
|
|
248
|
+
if (this.dateMs(value, this.shownText(at)) === undefined)
|
|
249
|
+
return this.fail(rule, label, 'must be a date', 'type: date');
|
|
250
|
+
break;
|
|
251
|
+
case 'boolean':
|
|
252
|
+
if (typeof value !== 'boolean' && !/^(true|false)$/i.test(this.trimmed(value, at)))
|
|
253
|
+
return this.fail(rule, label, 'must be TRUE or FALSE', 'type: boolean');
|
|
254
|
+
break;
|
|
255
|
+
default: break;
|
|
256
|
+
}
|
|
257
|
+
if (rule.min !== undefined || rule.max !== undefined) {
|
|
258
|
+
const asDate = rule.type === 'date';
|
|
259
|
+
const v = asDate ? this.dateMs(value, this.shownText(at)) : n;
|
|
260
|
+
const lo = rule.min === undefined ? undefined : asDate ? this.boundMs(rule.min) : Number(rule.min);
|
|
261
|
+
const hi = rule.max === undefined ? undefined : asDate ? this.boundMs(rule.max) : Number(rule.max);
|
|
262
|
+
if (v === undefined) {
|
|
263
|
+
if (rule.type === undefined)
|
|
264
|
+
return this.fail(rule, label, `must be a ${asDate ? 'date' : 'number'}`, rule.min !== undefined ? 'min' : 'max');
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
if (lo !== undefined && v < lo)
|
|
268
|
+
return this.fail(rule, label, `must be at least ${show(rule.min)}`, 'min');
|
|
269
|
+
if (hi !== undefined && v > hi)
|
|
270
|
+
return this.fail(rule, label, `must be at most ${show(rule.max)}`, 'max');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (rule.minLength !== undefined || rule.maxLength !== undefined || re) {
|
|
274
|
+
const raw = typeof value === 'string' ? value : this.shownText(at);
|
|
275
|
+
if (rule.minLength !== undefined && raw.length < rule.minLength)
|
|
276
|
+
return this.fail(rule, label, `must be at least ${rule.minLength} characters`, 'minLength');
|
|
277
|
+
if (rule.maxLength !== undefined && raw.length > rule.maxLength)
|
|
278
|
+
return this.fail(rule, label, `must be at most ${rule.maxLength} characters`, 'maxLength');
|
|
279
|
+
if (re && raw.length <= PATTERN_LIMIT) {
|
|
280
|
+
re.lastIndex = 0;
|
|
281
|
+
if (!re.test(raw))
|
|
282
|
+
return this.fail(rule, label, 'does not match the expected format', 'pattern');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (rule.oneOf || rule.noneOf || (rule.unique && unique)) {
|
|
286
|
+
const t = this.trimmed(value, at);
|
|
287
|
+
if (rule.oneOf && !rule.oneOf.some((o) => matches(o, value, t, n)))
|
|
288
|
+
return this.fail(rule, label, `must be one of ${rule.oneOf.map(show).join(', ')}`, 'oneOf');
|
|
289
|
+
if (rule.noneOf?.some((o) => matches(o, value, t, n)))
|
|
290
|
+
return this.fail(rule, label, `“${t}” is not allowed`, 'noneOf');
|
|
291
|
+
if (rule.unique && unique) {
|
|
292
|
+
const rows = this.uniq.get(`${at.sheet}|${at.col}`)?.get(norm(t));
|
|
293
|
+
if (rows !== undefined && typeof rows !== 'number' && [...rows].some((r) => r !== at.row))
|
|
294
|
+
return this.fail(rule, label, `“${t}” appears more than once`, 'unique');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
fail(rule, label, what, name) {
|
|
300
|
+
return { severity: rule.severity ?? 'error', message: rule.message ?? `${label}: ${what} (${name})`, expected: describeRule(rule), onInvalid: rule.onInvalid ?? this.cfg.onInvalid };
|
|
301
|
+
}
|
|
302
|
+
shownText(at) { return at.text ?? this.host.text(at.sheet, cellRef(at.row, at.col)); }
|
|
303
|
+
trimmed(value, at) { return (typeof value === 'string' ? value : this.shownText(at)).trim(); }
|
|
304
|
+
/** The column rule's verdict for a cell, or null. `at` is the cell's place, or a draft's text. */
|
|
305
|
+
checkJson(sheet, row, col, value, at) {
|
|
306
|
+
if (row <= this.headerRow(sheet) - 1)
|
|
307
|
+
return null;
|
|
308
|
+
const hit = this.colRules.get(sheet)?.get(col);
|
|
309
|
+
return hit ? this.checkRule(hit.rule, hit.re, hit.header, value, { sheet, row, col, text: at }, true) : null;
|
|
310
|
+
}
|
|
311
|
+
/** The first failing cell rule (rules file) for a cell, or null. */
|
|
312
|
+
checkCells(sheet, row, col, value, at) {
|
|
313
|
+
const rules = this.sheetRules.get(sheet)?.cells;
|
|
314
|
+
if (!rules?.length)
|
|
315
|
+
return null;
|
|
316
|
+
for (const c of rules) {
|
|
317
|
+
if (row < c.box.top || row > c.box.bottom || col < c.box.left || col > c.box.right)
|
|
318
|
+
continue;
|
|
319
|
+
const o = this.checkRule(c.rule, c.re, cellRef(row, col), value, { sheet, row, col, text: at }, false);
|
|
320
|
+
if (o)
|
|
321
|
+
return o;
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
dateMs(value, text) {
|
|
326
|
+
if (typeof value === 'number') {
|
|
327
|
+
// a date cell is a serial shown as a date: its text must read as one
|
|
328
|
+
if (!/[a-z]{3}|\d[\/.-]\d/i.test(text))
|
|
329
|
+
return undefined;
|
|
330
|
+
const p = serialToParts(value, this.host.date1904());
|
|
331
|
+
return Date.UTC(p.y, p.mo, p.d) + Math.round((value % 1) * 86_400_000);
|
|
332
|
+
}
|
|
333
|
+
if (typeof value === 'string' && value.trim()) {
|
|
334
|
+
const ms = Date.parse(value.trim());
|
|
335
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
336
|
+
}
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
boundMs(b) {
|
|
340
|
+
if (typeof b === 'number') {
|
|
341
|
+
const p = serialToParts(b, this.host.date1904());
|
|
342
|
+
return Date.UTC(p.y, p.mo, p.d);
|
|
343
|
+
}
|
|
344
|
+
const ms = Date.parse(b);
|
|
345
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
346
|
+
}
|
|
347
|
+
toDraft(sheet, ref, text, o, type = RULE_TYPE) {
|
|
348
|
+
return { sheet, range: ref, type, severity: o.severity ?? 'error', found: text === '' ? '(blank)' : text, expected: o.expected ?? '', message: o.message, author: 'host' };
|
|
349
|
+
}
|
|
350
|
+
/** Is this cell a data cell in a titled column of a sheet with rules? */
|
|
351
|
+
inScope(sheet, row, col) {
|
|
352
|
+
return row > this.headerRow(sheet) - 1 && !!this.headers.get(sheet)?.titles[col];
|
|
353
|
+
}
|
|
354
|
+
inCellRule(sheet, row, col) {
|
|
355
|
+
return (this.sheetRules.get(sheet)?.cells ?? []).some((c) => row >= c.box.top && row <= c.box.bottom && col >= c.box.left && col <= c.box.right);
|
|
356
|
+
}
|
|
357
|
+
setJson(sheet, row, col) {
|
|
358
|
+
const m = this.json.get(sheet);
|
|
359
|
+
const o = this.checkJson(sheet, row, col, this.host.valueAt(sheet, row, col), undefined);
|
|
360
|
+
if (o) {
|
|
361
|
+
const ref = cellRef(row, col), d = this.toDraft(sheet, ref, this.host.text(sheet, ref), o);
|
|
362
|
+
if (m)
|
|
363
|
+
m.set(ref, d);
|
|
364
|
+
else
|
|
365
|
+
this.json.set(sheet, new Map([[ref, d]]));
|
|
366
|
+
}
|
|
367
|
+
else if (m?.size)
|
|
368
|
+
m.delete(cellRef(row, col));
|
|
369
|
+
}
|
|
370
|
+
setCell(sheet, row, col) {
|
|
371
|
+
const m = this.cellJson.get(sheet);
|
|
372
|
+
const o = this.checkCells(sheet, row, col, this.host.valueAt(sheet, row, col), undefined);
|
|
373
|
+
if (o) {
|
|
374
|
+
const ref = cellRef(row, col), d = this.toDraft(sheet, ref, this.host.text(sheet, ref), o, CELL_RULE_TYPE);
|
|
375
|
+
if (m)
|
|
376
|
+
m.set(ref, d);
|
|
377
|
+
else
|
|
378
|
+
this.cellJson.set(sheet, new Map([[ref, d]]));
|
|
379
|
+
}
|
|
380
|
+
else if (m?.size)
|
|
381
|
+
m.delete(cellRef(row, col));
|
|
382
|
+
}
|
|
383
|
+
/** Calls the host's function for one cell; a sync result lands now, an async one later (unless superseded). */
|
|
384
|
+
callFn(phase, sheet, row, col, gen) {
|
|
385
|
+
const f = this.cfg.validate;
|
|
386
|
+
if (!f)
|
|
387
|
+
return undefined;
|
|
388
|
+
const ref = cellRef(row, col), k = `${sheet}!${ref}`;
|
|
389
|
+
this.inflight.get(k)?.abort();
|
|
390
|
+
const ac = new AbortController();
|
|
391
|
+
this.inflight.set(k, ac);
|
|
392
|
+
const value = this.host.value(sheet, ref);
|
|
393
|
+
const land = (r) => {
|
|
394
|
+
if (ac.signal.aborted || gen !== this.gen)
|
|
395
|
+
return false; // a newer edit or a new workbook: drop it
|
|
396
|
+
this.inflight.delete(k);
|
|
397
|
+
let m = this.fn.get(sheet);
|
|
398
|
+
if (r) {
|
|
399
|
+
if (!m)
|
|
400
|
+
this.fn.set(sheet, (m = new Map()));
|
|
401
|
+
m.set(ref, this.toDraft(sheet, ref, this.host.text(sheet, ref), r));
|
|
402
|
+
}
|
|
403
|
+
else
|
|
404
|
+
m?.delete(ref);
|
|
405
|
+
return true;
|
|
406
|
+
};
|
|
407
|
+
let r;
|
|
408
|
+
try {
|
|
409
|
+
r = f(this.context(phase, sheet, row, col, value), ac.signal);
|
|
410
|
+
}
|
|
411
|
+
catch (e) {
|
|
412
|
+
console.error(spMessage('SP-6002', 'sheetpane: the column-rules validate function threw'), e);
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
if (r && typeof r.then === 'function') {
|
|
416
|
+
return r.then((x) => { if (land(x) && phase !== 'load')
|
|
417
|
+
this.host.revalidate([sheet]); }, (e) => { if (!ac.signal.aborted)
|
|
418
|
+
console.error(spMessage('SP-6002', 'sheetpane: the column-rules validate function failed'), e); });
|
|
419
|
+
}
|
|
420
|
+
land(r);
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
// ── phases ──────────────────────────────────────────────────────────────
|
|
424
|
+
/** `load` (decision 7): every row of every sheet with titles, then every cell a cell rule names, in time slices; progress is shown. */
|
|
425
|
+
async loadPhase(rulesChanged) {
|
|
426
|
+
const gen = ++this.gen;
|
|
427
|
+
for (const ac of this.inflight.values())
|
|
428
|
+
ac.abort();
|
|
429
|
+
this.inflight.clear();
|
|
430
|
+
this.json.clear();
|
|
431
|
+
this.cellJson.clear();
|
|
432
|
+
this.fn.clear();
|
|
433
|
+
this.uniq.clear();
|
|
434
|
+
const sheets = this.host.bookSheets();
|
|
435
|
+
if (!sheets || !this.active) {
|
|
436
|
+
this.notes = [];
|
|
437
|
+
this.headers.clear();
|
|
438
|
+
this.sheetRules.clear();
|
|
439
|
+
this.progress = undefined;
|
|
440
|
+
if (rulesChanged) {
|
|
441
|
+
this.host.prune(RULES_PROVIDER_ID, new Set());
|
|
442
|
+
this.host.revalidate((sheets ?? []).map((s) => s.name));
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
this.readHeaders();
|
|
447
|
+
const cellBoxes = (s) => (this.sheetRules.get(s.name)?.cells ?? []).map((c) => {
|
|
448
|
+
const cells = (c.box.bottom - c.box.top + 1) * (c.box.right - c.box.left + 1);
|
|
449
|
+
const width = s.rows.reduce((m, r) => Math.max(m, r.length), 0);
|
|
450
|
+
return cells <= SMALL_RANGE ? c.box : { top: c.box.top, left: c.box.left, bottom: Math.min(c.box.bottom, s.rows.length - 1), right: Math.min(c.box.right, Math.max(width - 1, c.box.left)) };
|
|
451
|
+
});
|
|
452
|
+
const total = sheets.reduce((n, s) => n + Math.max(0, s.rows.length - this.headerRow(s.name)) + cellBoxes(s).reduce((m, b) => m + Math.max(0, b.bottom - b.top + 1), 0), 0);
|
|
453
|
+
this.progress = { done: 0, total };
|
|
454
|
+
this.host.progress();
|
|
455
|
+
let t0 = now();
|
|
456
|
+
let lastShown = t0;
|
|
457
|
+
// unique: index every titled column with a unique rule before checking (sliced too: 250,000 rows is a long index)
|
|
458
|
+
for (const s of sheets) {
|
|
459
|
+
const h = this.headers.get(s.name), first = this.headerRow(s.name);
|
|
460
|
+
for (const [, c] of h.byTitle) {
|
|
461
|
+
if (!this.ruleFor(s.name, h.titles[c])?.rule.unique)
|
|
462
|
+
continue;
|
|
463
|
+
const idx = new Map();
|
|
464
|
+
for (let r = first; r < s.rows.length; r++) {
|
|
465
|
+
const v = norm(this.host.valueAt(s.name, r, c));
|
|
466
|
+
if (v)
|
|
467
|
+
addRow(idx, v, r);
|
|
468
|
+
if ((r & 1023) === 0 && now() - t0 > SLICE_MS) {
|
|
469
|
+
await yieldToPage();
|
|
470
|
+
if (gen !== this.gen)
|
|
471
|
+
return;
|
|
472
|
+
t0 = now();
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
this.uniq.set(`${s.name}|${c}`, idx);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const tick = async () => {
|
|
479
|
+
this.progress.done++;
|
|
480
|
+
if (now() - t0 <= SLICE_MS)
|
|
481
|
+
return true;
|
|
482
|
+
if (now() - lastShown > 100) {
|
|
483
|
+
this.host.progress();
|
|
484
|
+
lastShown = now();
|
|
485
|
+
}
|
|
486
|
+
await yieldToPage();
|
|
487
|
+
t0 = now();
|
|
488
|
+
return gen === this.gen; // false: a newer workbook or newer rules
|
|
489
|
+
};
|
|
490
|
+
for (const s of sheets) {
|
|
491
|
+
const h = this.headers.get(s.name), first = this.headerRow(s.name);
|
|
492
|
+
const ruled = [...h.byTitle.values()].filter((c) => this.ruleFor(s.name, h.titles[c]));
|
|
493
|
+
const titled = this.cfg.validate ? [...h.byTitle.values()] : [];
|
|
494
|
+
if (!ruled.length && !titled.length)
|
|
495
|
+
this.progress.done += Math.max(0, s.rows.length - first);
|
|
496
|
+
else
|
|
497
|
+
for (let r = first; r < s.rows.length; r++) {
|
|
498
|
+
for (const c of ruled)
|
|
499
|
+
this.setJson(s.name, r, c);
|
|
500
|
+
const pending = [];
|
|
501
|
+
for (const c of titled) {
|
|
502
|
+
const p = this.callFn('load', s.name, r, c, gen);
|
|
503
|
+
if (p)
|
|
504
|
+
pending.push(p);
|
|
505
|
+
}
|
|
506
|
+
if (pending.length)
|
|
507
|
+
await Promise.all(pending);
|
|
508
|
+
if (gen !== this.gen || !(await tick()))
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
for (const b of cellBoxes(s))
|
|
512
|
+
for (let r = b.top; r <= b.bottom; r++) {
|
|
513
|
+
for (let c = b.left; c <= b.right; c++)
|
|
514
|
+
this.setCell(s.name, r, c);
|
|
515
|
+
if (!(await tick()))
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
this.progress = undefined;
|
|
520
|
+
this.host.progress();
|
|
521
|
+
if (rulesChanged) {
|
|
522
|
+
const produced = new Set();
|
|
523
|
+
for (const s of sheets)
|
|
524
|
+
for (const d of this.drafts(s.name))
|
|
525
|
+
produced.add(`${RULES_PROVIDER_ID}|${d.sheet}|${d.range}|${d.type}${d.key ? `|${d.key}` : ''}`);
|
|
526
|
+
this.host.prune(RULES_PROVIDER_ID, produced);
|
|
527
|
+
}
|
|
528
|
+
this.host.revalidate(sheets.map((s) => s.name));
|
|
529
|
+
}
|
|
530
|
+
/** `commit` (decision 7): cells changed by an edit, a paste or undo. */
|
|
531
|
+
touch(cells) {
|
|
532
|
+
if (!this.active || !this.host.bookSheets())
|
|
533
|
+
return;
|
|
534
|
+
if (cells.some((c) => parseRef(c.ref)?.row === this.headerRow(c.sheet) - 1)) {
|
|
535
|
+
void this.loadPhase(true);
|
|
536
|
+
return;
|
|
537
|
+
} // a title changed
|
|
538
|
+
const rows = new Set();
|
|
539
|
+
for (const c of cells) {
|
|
540
|
+
const p = parseRef(c.ref);
|
|
541
|
+
if (!p)
|
|
542
|
+
continue;
|
|
543
|
+
if (this.inCellRule(c.sheet, p.row, p.col))
|
|
544
|
+
this.setCell(c.sheet, p.row, p.col);
|
|
545
|
+
if (!this.inScope(c.sheet, p.row, p.col))
|
|
546
|
+
continue;
|
|
547
|
+
const header = this.headerAt(c.sheet, p.col);
|
|
548
|
+
if (header && this.ruleFor(c.sheet, header)?.rule.unique)
|
|
549
|
+
this.reindexUnique(c.sheet, p.row, p.col);
|
|
550
|
+
else
|
|
551
|
+
this.setJson(c.sheet, p.row, p.col);
|
|
552
|
+
rows.add(`${c.sheet}\u0000${p.row}`);
|
|
553
|
+
}
|
|
554
|
+
// the function sees the whole row (cross-column checks): every titled cell of a touched row is asked again
|
|
555
|
+
if (this.cfg.validate)
|
|
556
|
+
for (const key of rows) {
|
|
557
|
+
const [sheet, r] = key.split('\u0000');
|
|
558
|
+
const h = this.headers.get(sheet);
|
|
559
|
+
if (h)
|
|
560
|
+
for (const c of h.byTitle.values())
|
|
561
|
+
void this.callFn('commit', sheet, Number(r), c, this.gen);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
reindexUnique(sheet, row, col) {
|
|
565
|
+
const idx = this.uniq.get(`${sheet}|${col}`);
|
|
566
|
+
if (!idx) {
|
|
567
|
+
this.setJson(sheet, row, col);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const affected = new Set([row]);
|
|
571
|
+
for (const [v, rows] of idx) {
|
|
572
|
+
if (rows === row) {
|
|
573
|
+
idx.delete(v);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
if (typeof rows !== 'number' && rows.delete(row)) {
|
|
577
|
+
for (const r of rows)
|
|
578
|
+
affected.add(r);
|
|
579
|
+
if (rows.size === 1)
|
|
580
|
+
idx.set(v, [...rows][0]);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const v = norm(this.host.valueAt(sheet, row, col));
|
|
584
|
+
if (v) {
|
|
585
|
+
addRow(idx, v, row);
|
|
586
|
+
const rows = idx.get(v);
|
|
587
|
+
if (typeof rows !== 'number')
|
|
588
|
+
for (const r of rows)
|
|
589
|
+
affected.add(r);
|
|
590
|
+
}
|
|
591
|
+
for (const r of affected)
|
|
592
|
+
this.setJson(sheet, r, col);
|
|
593
|
+
}
|
|
594
|
+
/** `input` (decision 7): the draft checked as typed — nothing is stored. */
|
|
595
|
+
async checkInput(sheet, ref, value, text, signal) {
|
|
596
|
+
if (!this.active)
|
|
597
|
+
return null;
|
|
598
|
+
const p = parseRef(ref);
|
|
599
|
+
if (!p)
|
|
600
|
+
return null;
|
|
601
|
+
const j = (this.inScope(sheet, p.row, p.col) ? this.checkJson(sheet, p.row, p.col, value, text) : null) ?? this.checkCells(sheet, p.row, p.col, value, text);
|
|
602
|
+
if (j)
|
|
603
|
+
return j;
|
|
604
|
+
const f = this.cfg.validate;
|
|
605
|
+
if (!f || !this.inScope(sheet, p.row, p.col))
|
|
606
|
+
return null;
|
|
607
|
+
try {
|
|
608
|
+
const r = await f(this.context('input', sheet, p.row, p.col, value), signal);
|
|
609
|
+
return r && !signal.aborted ? { severity: r.severity ?? 'error', message: r.message, expected: r.expected ?? '', onInvalid: this.cfg.onInvalid } : null;
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/** The `block` check at commit (decision 9): a failing rule set to block refuses the value. Async results can only flag. */
|
|
616
|
+
blocked(sheet, ref, value, text) {
|
|
617
|
+
if (!this.active)
|
|
618
|
+
return null;
|
|
619
|
+
const p = parseRef(ref);
|
|
620
|
+
if (!p)
|
|
621
|
+
return null;
|
|
622
|
+
for (const j of [this.inScope(sheet, p.row, p.col) ? this.checkJson(sheet, p.row, p.col, value, text) : null, this.checkCells(sheet, p.row, p.col, value, text)])
|
|
623
|
+
if (j?.onInvalid === 'block')
|
|
624
|
+
return j;
|
|
625
|
+
const f = this.cfg.validate;
|
|
626
|
+
if (!f || this.cfg.onInvalid !== 'block' || !this.inScope(sheet, p.row, p.col))
|
|
627
|
+
return null;
|
|
628
|
+
try {
|
|
629
|
+
const r = f(this.context('commit', sheet, p.row, p.col, value), new AbortController().signal);
|
|
630
|
+
if (r && typeof r.then !== 'function') {
|
|
631
|
+
const x = r;
|
|
632
|
+
return { severity: x.severity ?? 'error', message: x.message, expected: x.expected ?? '', onInvalid: 'block' };
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
catch { /* a throwing function never blocks */ }
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/** Adds a row to a `unique` index entry: a bare number until the value repeats. */
|
|
640
|
+
function addRow(idx, v, r) {
|
|
641
|
+
const had = idx.get(v);
|
|
642
|
+
if (had === undefined)
|
|
643
|
+
idx.set(v, r);
|
|
644
|
+
else if (typeof had === 'number') {
|
|
645
|
+
if (had !== r)
|
|
646
|
+
idx.set(v, new Set([had, r]));
|
|
647
|
+
}
|
|
648
|
+
else
|
|
649
|
+
had.add(r);
|
|
650
|
+
}
|
|
651
|
+
/** A file pattern: compiled, or undefined when it is not a regular expression (the document check named it already). */
|
|
652
|
+
const patternCache = new WeakMap();
|
|
653
|
+
function filePattern(r) {
|
|
654
|
+
if (r.pattern === undefined)
|
|
655
|
+
return undefined;
|
|
656
|
+
let re = patternCache.get(r);
|
|
657
|
+
if (re === undefined) {
|
|
658
|
+
try {
|
|
659
|
+
re = new RegExp(r.pattern);
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
re = null;
|
|
663
|
+
}
|
|
664
|
+
patternCache.set(r, re);
|
|
665
|
+
}
|
|
666
|
+
return re ?? undefined;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Checks a rules file (W-33) by hand — no schema library at run time. Every problem
|
|
670
|
+
* is named (an unknown key, version or word, a bad ref or pattern) and the rest
|
|
671
|
+
* still applies; nothing throws (SR-GRID decision 22d).
|
|
672
|
+
* @param doc - The parsed JSON, as given.
|
|
673
|
+
* @returns The usable rules per sheet, and one problem per thing wrong.
|
|
674
|
+
*/
|
|
675
|
+
export function checkDocument(doc) {
|
|
676
|
+
const problems = [];
|
|
677
|
+
const sheets = new Map();
|
|
678
|
+
const obj = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
|
|
679
|
+
if (!obj(doc))
|
|
680
|
+
return { sheets, problems: [{ code: 'SP-6010', found: typeof doc, message: 'The rules file is not a JSON object — no rule from it is checked' }] };
|
|
681
|
+
if ('__error' in doc)
|
|
682
|
+
return { sheets, problems: [{ code: 'SP-6011', found: String(doc.__error), message: `The rules file could not be read — ${String(doc.__error)}` }] };
|
|
683
|
+
if (doc.version !== 1)
|
|
684
|
+
return { sheets, problems: [{ code: 'SP-6012', found: String(doc.version), message: `Rules file version ${JSON.stringify(doc.version)} is not one this grid reads (1) — no rule from it is checked` }] };
|
|
685
|
+
for (const k of Object.keys(doc))
|
|
686
|
+
if (!['$schema', 'version', 'sheets'].includes(k))
|
|
687
|
+
problems.push({ code: 'SP-6013', found: k, message: `Unknown key “${k}” in the rules file` });
|
|
688
|
+
if (!obj(doc.sheets)) {
|
|
689
|
+
problems.push({ code: 'SP-6014', found: 'sheets', message: 'The rules file has no “sheets” object' });
|
|
690
|
+
return { sheets, problems };
|
|
691
|
+
}
|
|
692
|
+
const rule = (r, where, sheet) => {
|
|
693
|
+
if (!obj(r)) {
|
|
694
|
+
problems.push({ code: 'SP-6015', sheet, found: where, message: `The rule for ${where} is not an object` });
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
for (const k of Object.keys(r))
|
|
698
|
+
if (!RULE_WORDS.has(k))
|
|
699
|
+
problems.push({ code: 'SP-6016', sheet, found: k, message: `Unknown rule word “${k}” for ${where}` });
|
|
700
|
+
if (r.type !== undefined && !TYPES.has(String(r.type)))
|
|
701
|
+
problems.push({ code: 'SP-6017', sheet, found: String(r.type), message: `Unknown type “${String(r.type)}” for ${where} (text, number, integer, date, boolean)` });
|
|
702
|
+
if (r.pattern !== undefined) {
|
|
703
|
+
try {
|
|
704
|
+
new RegExp(String(r.pattern));
|
|
705
|
+
}
|
|
706
|
+
catch (e) {
|
|
707
|
+
problems.push({ code: 'SP-6018', sheet, found: String(r.pattern), message: `The pattern for ${where} is not a regular expression (${e.message}) — it is not tested` });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
for (const k of ['oneOf', 'noneOf'])
|
|
711
|
+
if (r[k] !== undefined && !Array.isArray(r[k]))
|
|
712
|
+
problems.push({ code: 'SP-6019', sheet, found: k, message: `“${k}” for ${where} is not a list` });
|
|
713
|
+
return r;
|
|
714
|
+
};
|
|
715
|
+
for (const [name, s] of Object.entries(doc.sheets)) {
|
|
716
|
+
if (!obj(s)) {
|
|
717
|
+
problems.push({ code: 'SP-6015', sheet: name, found: name, message: `The rules for sheet “${name}” are not an object` });
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
for (const k of Object.keys(s))
|
|
721
|
+
if (!['headerRow', 'columns', 'cells'].includes(k))
|
|
722
|
+
problems.push({ code: 'SP-6013', sheet: name, found: k, message: `Unknown key “${k}” in sheet “${name}”` });
|
|
723
|
+
const d = { columns: {}, cells: {} };
|
|
724
|
+
if (s.headerRow !== undefined) {
|
|
725
|
+
if (typeof s.headerRow === 'number' && s.headerRow >= 1)
|
|
726
|
+
d.headerRow = s.headerRow;
|
|
727
|
+
else
|
|
728
|
+
problems.push({ code: 'SP-6020', sheet: name, found: String(s.headerRow), message: `“headerRow” in sheet “${name}” is not a row number` });
|
|
729
|
+
}
|
|
730
|
+
if (obj(s.columns))
|
|
731
|
+
for (const [t, r] of Object.entries(s.columns)) {
|
|
732
|
+
const x = rule(r, `column “${t}”`, name);
|
|
733
|
+
if (x)
|
|
734
|
+
d.columns[t] = x;
|
|
735
|
+
}
|
|
736
|
+
if (obj(s.cells))
|
|
737
|
+
for (const [ref, r] of Object.entries(s.cells)) {
|
|
738
|
+
if (!parseRange(ref)) {
|
|
739
|
+
problems.push({ code: 'SP-6021', sheet: name, found: ref, message: `“${ref}” in sheet “${name}” is not a cell or a range` });
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const x = rule(r, `cell ${ref}`, name);
|
|
743
|
+
if (x)
|
|
744
|
+
d.cells[ref] = x;
|
|
745
|
+
}
|
|
746
|
+
sheets.set(name, d);
|
|
747
|
+
}
|
|
748
|
+
return { sheets, problems };
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* A rule in words, for an error's `expected`: "a whole number from 18 to 99", "one of Active, Inactive".
|
|
752
|
+
* @param r - The rule.
|
|
753
|
+
* @returns The rule as a phrase.
|
|
754
|
+
*/
|
|
755
|
+
export function describeRule(r) {
|
|
756
|
+
const parts = [];
|
|
757
|
+
const kind = r.type === 'integer' ? 'a whole number' : r.type === 'number' ? 'a number' : r.type === 'date' ? 'a date' : r.type === 'boolean' ? 'TRUE or FALSE' : r.type === 'text' ? 'text' : '';
|
|
758
|
+
if (r.equals !== undefined)
|
|
759
|
+
parts.push(`exactly ${show(r.equals)}`);
|
|
760
|
+
if (r.min !== undefined && r.max !== undefined)
|
|
761
|
+
parts.push(`${kind || 'a value'} from ${show(r.min)} to ${show(r.max)}`);
|
|
762
|
+
else if (r.min !== undefined)
|
|
763
|
+
parts.push(`${kind || 'a value'} of at least ${show(r.min)}`);
|
|
764
|
+
else if (r.max !== undefined)
|
|
765
|
+
parts.push(`${kind || 'a value'} of at most ${show(r.max)}`);
|
|
766
|
+
else if (kind)
|
|
767
|
+
parts.push(kind);
|
|
768
|
+
if (r.minLength !== undefined && r.maxLength !== undefined)
|
|
769
|
+
parts.push(`${r.minLength}–${r.maxLength} characters`);
|
|
770
|
+
else if (r.minLength !== undefined)
|
|
771
|
+
parts.push(`at least ${r.minLength} characters`);
|
|
772
|
+
else if (r.maxLength !== undefined)
|
|
773
|
+
parts.push(`at most ${r.maxLength} characters`);
|
|
774
|
+
if (r.pattern !== undefined)
|
|
775
|
+
parts.push(`matching /${r.pattern}/`);
|
|
776
|
+
if (r.oneOf)
|
|
777
|
+
parts.push(`one of ${r.oneOf.map(show).join(', ')}`);
|
|
778
|
+
if (r.noneOf)
|
|
779
|
+
parts.push(`not ${r.noneOf.map(show).join(', ')}`);
|
|
780
|
+
if (r.unique)
|
|
781
|
+
parts.push('unique in the column');
|
|
782
|
+
if (r.required)
|
|
783
|
+
parts.push('required');
|
|
784
|
+
return parts.join('; ');
|
|
785
|
+
}
|
|
786
|
+
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
787
|
+
/**
|
|
788
|
+
* Yields until the page is idle (verdict decision 7: idle-time chunks): frames paint, and the browser's
|
|
789
|
+
* own idle work — garbage collection after a large read — runs between slices instead of as one long pause.
|
|
790
|
+
*/
|
|
791
|
+
const yieldToPage = () => new Promise((r) => (typeof requestIdleCallback === 'function' ? requestIdleCallback(() => r(), { timeout: 100 }) : setTimeout(r, 0)));
|
|
792
|
+
//# sourceMappingURL=rules.js.map
|