@svgrid/enterprise 3.1.22 → 3.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SvGridBoard.svelte +39 -0
- package/dist/SvGridGallery.svelte +23 -0
- package/dist/SvGridScheduler.svelte +25 -7
- package/dist/cdn/svgrid-enterprise.svelte-external.js +13739 -13667
- package/dist/designer/assets/index-CHx3F4Ij.css +1 -0
- package/dist/designer/assets/{index-ICCQKTGR.js → index-CIxqRzKJ.js} +318 -318
- package/dist/designer/assets/merges-Bg-KqPEl.js +1 -0
- package/dist/designer/index.html +2 -2
- package/dist/fields/RecordView.svelte +5 -2
- package/dist/fields/activity.svelte.d.ts +5 -2
- package/dist/fields/activity.svelte.js +4 -3
- package/dist/fields/grid-access.d.ts +1 -1
- package/dist/fields/grid-access.js +13 -3
- package/dist/fields/index.d.ts +18 -0
- package/dist/fields/index.js +31 -0
- package/dist/node/studio.js +2 -2
- package/dist/review/GhostCell.svelte +60 -0
- package/dist/review/GhostCell.svelte.d.ts +14 -0
- package/dist/review/RichCellMark.svelte +72 -0
- package/dist/review/RichCellMark.svelte.d.ts +8 -0
- package/dist/review/SvReviewPanel.svelte +759 -0
- package/dist/review/SvReviewPanel.svelte.d.ts +16 -0
- package/dist/review/adapters.d.ts +79 -0
- package/dist/review/adapters.js +119 -0
- package/dist/review/agent.d.ts +52 -0
- package/dist/review/agent.js +232 -0
- package/dist/review/card-marks.svelte.d.ts +42 -0
- package/dist/review/card-marks.svelte.js +51 -0
- package/dist/review/cluster.d.ts +26 -0
- package/dist/review/cluster.js +377 -0
- package/dist/review/equal.d.ts +9 -0
- package/dist/review/equal.js +35 -0
- package/dist/review/formats.d.ts +31 -0
- package/dist/review/formats.js +37 -0
- package/dist/review/ghost.d.ts +17 -0
- package/dist/review/ghost.js +104 -0
- package/dist/review/index.d.ts +42 -0
- package/dist/review/index.js +36 -0
- package/dist/review/messages.d.ts +113 -0
- package/dist/review/messages.js +135 -0
- package/dist/review/model.svelte.d.ts +262 -0
- package/dist/review/model.svelte.js +883 -0
- package/dist/review/pending-filter.d.ts +26 -0
- package/dist/review/pending-filter.js +71 -0
- package/dist/review/rich-cells.d.ts +1 -0
- package/dist/review/rich-cells.js +36 -0
- package/dist/review/types.d.ts +83 -0
- package/dist/review/types.js +7 -0
- package/dist/upgrade-prompt.d.ts +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/wc/merges-DQTOlMWM.js +109 -0
- package/dist/wc/sv-sheet-element.js +5688 -5735
- package/package.json +9 -3
- package/dist/designer/assets/index-9APkRkTg.css +0 -1
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proposal clustering: put every pending proposal in exactly one group, by
|
|
3
|
+
* the transformation it applies, so a reviewer looks at a few groups instead
|
|
4
|
+
* of thousands of cells. Deterministic and pure; no model is asked.
|
|
5
|
+
*
|
|
6
|
+
* Detectors run in a fixed order and the first that matches wins. The rule
|
|
7
|
+
* detectors go first because they prove their claim for every member:
|
|
8
|
+
*
|
|
9
|
+
* 1. whitespace `after` is `before` trimmed / with spaces collapsed
|
|
10
|
+
* 2. case same letters, different case
|
|
11
|
+
* 3. format same characters apart from punctuation, the same
|
|
12
|
+
* unambiguous date, or text that parses to the number
|
|
13
|
+
* 4. scale `after / before` is the same number, 5+ members
|
|
14
|
+
*
|
|
15
|
+
* Then the judgment detectors, where a person has to look:
|
|
16
|
+
*
|
|
17
|
+
* 5. same-mapping non-blank `before` values that each map to the same
|
|
18
|
+
* `after` at least twice: one group per target, read
|
|
19
|
+
* as a mapping table ("usa", "U.S." -> "United States")
|
|
20
|
+
* 6. fill-empty blank `before`, split by `after`; singletons pooled
|
|
21
|
+
* 7. constant-target many different `before`, one `after`, 3+ members
|
|
22
|
+
* 8. unpatterned the rest, per field
|
|
23
|
+
*
|
|
24
|
+
* A group is keyed by source, field, kind and param, so two producers never
|
|
25
|
+
* share a group and neither do two fields.
|
|
26
|
+
*/
|
|
27
|
+
import { isBlank } from './equal.js';
|
|
28
|
+
import { fill, reviewMessages } from './messages.js';
|
|
29
|
+
const RULE_KINDS = new Set(['whitespace', 'case', 'format', 'scale']);
|
|
30
|
+
const SEP = '\u0001';
|
|
31
|
+
export function clusterProposals(proposals, opts = {}) {
|
|
32
|
+
const fieldLabel = opts.fieldLabel ?? ((f) => f);
|
|
33
|
+
const msg = reviewMessages(opts.messages);
|
|
34
|
+
const minScale = opts.minScale ?? 5;
|
|
35
|
+
const low = opts.lowConfidence ?? 0.6;
|
|
36
|
+
const order = new Map();
|
|
37
|
+
proposals.forEach((p, i) => order.set(p.id, i));
|
|
38
|
+
const buckets = new Map();
|
|
39
|
+
const put = (p, kind, param) => {
|
|
40
|
+
const key = [p.source.id, p.field, kind, param].join(SEP);
|
|
41
|
+
let b = buckets.get(key);
|
|
42
|
+
if (!b)
|
|
43
|
+
buckets.set(key, (b = { kind, param, field: p.field, sourceId: p.source.id, members: [] }));
|
|
44
|
+
b.members.push(p);
|
|
45
|
+
};
|
|
46
|
+
const cell = (p) => p.source.id + SEP + p.field;
|
|
47
|
+
// 1-3: provable per proposal.
|
|
48
|
+
let rest = [];
|
|
49
|
+
for (const p of proposals) {
|
|
50
|
+
const r = ruleOf(p.before, p.after);
|
|
51
|
+
if (r)
|
|
52
|
+
put(p, r.kind, r.param);
|
|
53
|
+
else
|
|
54
|
+
rest.push(p);
|
|
55
|
+
}
|
|
56
|
+
// 4: scale, only once enough members share a ratio.
|
|
57
|
+
rest = bucketPass(rest, (p) => {
|
|
58
|
+
const r = ratioOf(p.before, p.after);
|
|
59
|
+
return r == null ? null : cell(p) + SEP + r;
|
|
60
|
+
}, (members) => members.length >= minScale, (p, k) => put(p, 'scale', k.slice(k.lastIndexOf(SEP) + 1)));
|
|
61
|
+
// 5: non-blank pairs seen at least twice, one group per target value.
|
|
62
|
+
rest = bucketPass(rest, (p) => (isBlank(p.before) ? null : cell(p) + SEP + keyOf(p.before) + SEP + keyOf(p.after)), (members) => members.length >= 2, (p) => put(p, 'same-mapping', keyOf(p.after)));
|
|
63
|
+
// 6: blank before, split by the value proposed; singletons go to one pooled group.
|
|
64
|
+
rest = bucketPass(rest, (p) => (isBlank(p.before) ? cell(p) + SEP + keyOf(p.after) : null), () => true, (p, _k, members) => put(p, 'fill-empty', members.length >= 2 ? keyOf(p.after) : '*'));
|
|
65
|
+
// 7: one target from at least three proposals.
|
|
66
|
+
rest = bucketPass(rest, (p) => cell(p) + SEP + keyOf(p.after), (members) => members.length >= 3, (p) => put(p, 'constant-target', keyOf(p.after)));
|
|
67
|
+
// 8: everything else, per field.
|
|
68
|
+
for (const p of rest)
|
|
69
|
+
put(p, 'unpatterned', '');
|
|
70
|
+
const groups = [];
|
|
71
|
+
for (const [key, b] of buckets) {
|
|
72
|
+
const members = b.members.sort((x, y) => order.get(x.id) - order.get(y.id));
|
|
73
|
+
const confs = members.map((m) => m.confidence).filter((c) => typeof c === 'number');
|
|
74
|
+
groups.push({
|
|
75
|
+
key,
|
|
76
|
+
kind: b.kind,
|
|
77
|
+
rule: RULE_KINDS.has(b.kind),
|
|
78
|
+
param: b.param,
|
|
79
|
+
label: labelOf(b.kind, b.param, fieldLabel(b.field), members, msg),
|
|
80
|
+
field: b.field,
|
|
81
|
+
sourceId: b.sourceId,
|
|
82
|
+
ids: members.map((m) => m.id),
|
|
83
|
+
count: members.length,
|
|
84
|
+
minConfidence: confs.length ? Math.min(...confs) : undefined,
|
|
85
|
+
avgConfidence: confs.length ? confs.reduce((s, c) => s + c, 0) / confs.length : undefined,
|
|
86
|
+
sample: sampleOf(members, low),
|
|
87
|
+
beforeDistribution: b.kind === 'constant-target' || b.kind === 'same-mapping' ? distributionOf(members) : undefined,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return groups.sort((a, b) => rankOf(a, low) - rankOf(b, low) || b.count - a.count || (a.key < b.key ? -1 : 1));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Bucket `items` by `keyFn`. Members of buckets that pass `keep` are handed
|
|
94
|
+
* to `take`; everything else (no key, or a bucket that failed) is returned,
|
|
95
|
+
* in the original order.
|
|
96
|
+
*/
|
|
97
|
+
function bucketPass(items, keyFn, keep, take) {
|
|
98
|
+
const byKey = new Map();
|
|
99
|
+
const keys = items.map((p) => {
|
|
100
|
+
const k = keyFn(p);
|
|
101
|
+
if (k != null) {
|
|
102
|
+
const list = byKey.get(k);
|
|
103
|
+
if (list)
|
|
104
|
+
list.push(p);
|
|
105
|
+
else
|
|
106
|
+
byKey.set(k, [p]);
|
|
107
|
+
}
|
|
108
|
+
return k;
|
|
109
|
+
});
|
|
110
|
+
const out = [];
|
|
111
|
+
items.forEach((p, i) => {
|
|
112
|
+
const k = keys[i];
|
|
113
|
+
const members = k == null ? null : byKey.get(k);
|
|
114
|
+
if (members && keep(members))
|
|
115
|
+
take(p, k, members);
|
|
116
|
+
else
|
|
117
|
+
out.push(p);
|
|
118
|
+
});
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
// ---- rule detectors -------------------------------------------------------
|
|
122
|
+
function ruleOf(before, after) {
|
|
123
|
+
if (typeof before === 'string' && typeof after === 'number') {
|
|
124
|
+
const n = parseLooseNumber(before);
|
|
125
|
+
return n !== null && n === after ? { kind: 'format', param: 'to-number' } : null;
|
|
126
|
+
}
|
|
127
|
+
const dayB = dayOf(before);
|
|
128
|
+
const dayA = dayOf(after);
|
|
129
|
+
// The same day at a different time is a different value, not a new format.
|
|
130
|
+
if (dayB && dayA && dayB === dayA && timeOf(before) === timeOf(after) && !(before instanceof Date && after instanceof Date)) {
|
|
131
|
+
return { kind: 'format', param: 'date:' + (after instanceof Date ? 'Date' : shapeOf(String(after))) };
|
|
132
|
+
}
|
|
133
|
+
if (typeof before !== 'string' || typeof after !== 'string' || before === after)
|
|
134
|
+
return null;
|
|
135
|
+
if (before.trim() === after || before.replace(/\s+/g, ' ').trim() === after)
|
|
136
|
+
return { kind: 'whitespace', param: '' };
|
|
137
|
+
if (before.toLowerCase() === after.toLowerCase())
|
|
138
|
+
return { kind: 'case', param: caseOf(after) };
|
|
139
|
+
// Punctuation can carry meaning in a number: "3.5" -> "35" keeps every
|
|
140
|
+
// digit and is still a different value. Text that reads as a number on
|
|
141
|
+
// either side only counts as reformatting when both read as the same one.
|
|
142
|
+
const nB = parseLooseNumber(before);
|
|
143
|
+
const nA = parseLooseNumber(after);
|
|
144
|
+
if ((nB !== null || nA !== null) && nB !== nA)
|
|
145
|
+
return null;
|
|
146
|
+
const coreB = alnum(before);
|
|
147
|
+
// Punctuation between digits can be part of a number ("$1,234.56" ->
|
|
148
|
+
// "$123,456", "12.5%" -> "1.25%"): every run of digits has to stay as it was.
|
|
149
|
+
if (coreB.length > 0 && coreB === alnum(after) && digitRuns(before) === digitRuns(after)) {
|
|
150
|
+
const digits = coreB.replace(/\D/g, '').length;
|
|
151
|
+
return digits * 2 >= coreB.length
|
|
152
|
+
? { kind: 'format', param: 'shape:' + shapeOf(after) }
|
|
153
|
+
: { kind: 'format', param: 'punct:' + JSON.stringify(punctDiff(before, after)) };
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
function ratioOf(before, after) {
|
|
158
|
+
if (typeof before !== 'number' || typeof after !== 'number')
|
|
159
|
+
return null;
|
|
160
|
+
if (!Number.isFinite(before) || !Number.isFinite(after) || before === 0 || before === after)
|
|
161
|
+
return null;
|
|
162
|
+
return String(Number((after / before).toPrecision(10)));
|
|
163
|
+
}
|
|
164
|
+
const alnum = (s) => s.replace(/[^\p{L}\p{N}]/gu, '');
|
|
165
|
+
const digitRuns = (s) => (s.match(/\d+/g) ?? []).join(',');
|
|
166
|
+
/** The time of day a date value carries, as HH:MM:SS; midnight when it carries none. */
|
|
167
|
+
function timeOf(v) {
|
|
168
|
+
if (v instanceof Date)
|
|
169
|
+
return [v.getHours(), v.getMinutes(), v.getSeconds()].map((n) => String(n).padStart(2, '0')).join(':');
|
|
170
|
+
const m = typeof v === 'string' ? /[T ](\d{1,2}):(\d{2})(?::(\d{2}))?/.exec(v) : null;
|
|
171
|
+
return m ? [m[1], m[2], m[3] ?? '00'].map((n) => n.padStart(2, '0')).join(':') : '00:00:00';
|
|
172
|
+
}
|
|
173
|
+
const shapeOf = (s) => s.replace(/\p{N}/gu, '9').replace(/\p{Lu}/gu, 'A').replace(/\p{Ll}/gu, 'a');
|
|
174
|
+
function caseOf(s) {
|
|
175
|
+
if (s === s.toUpperCase())
|
|
176
|
+
return 'upper';
|
|
177
|
+
if (s === s.toLowerCase())
|
|
178
|
+
return 'lower';
|
|
179
|
+
const words = s.split(/\s+/).filter(Boolean);
|
|
180
|
+
const title = words.every((w) => {
|
|
181
|
+
const head = w.charAt(0);
|
|
182
|
+
return head === head.toUpperCase() && w.slice(1) === w.slice(1).toLowerCase();
|
|
183
|
+
});
|
|
184
|
+
return title ? 'title' : 'mixed';
|
|
185
|
+
}
|
|
186
|
+
/** The punctuation removed and added, each sorted and unique. */
|
|
187
|
+
function punctDiff(before, after) {
|
|
188
|
+
const count = (s) => {
|
|
189
|
+
const m = new Map();
|
|
190
|
+
for (const ch of s.replace(/[\p{L}\p{N}]/gu, ''))
|
|
191
|
+
m.set(ch, (m.get(ch) ?? 0) + 1);
|
|
192
|
+
return m;
|
|
193
|
+
};
|
|
194
|
+
const b = count(before);
|
|
195
|
+
const a = count(after);
|
|
196
|
+
const removed = [...b].filter(([ch, n]) => n > (a.get(ch) ?? 0)).map(([ch]) => ch).sort().join('');
|
|
197
|
+
const added = [...a].filter(([ch, n]) => n > (b.get(ch) ?? 0)).map(([ch]) => ch).sort().join('');
|
|
198
|
+
return [removed, added];
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* A number written as text, or null. Commas count only as thousands
|
|
202
|
+
* separators ("1,200"), so "1,5" (a decimal comma) is not read as 15.
|
|
203
|
+
*/
|
|
204
|
+
function parseLooseNumber(s) {
|
|
205
|
+
const t = s.trim();
|
|
206
|
+
if (!/^[-+]?(\d{1,3}(,\d{3})+|\d+)?(\.\d+)?$/.test(t) || !/\d/.test(t))
|
|
207
|
+
return null;
|
|
208
|
+
return Number(t.replace(/,/g, ''));
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* A calendar day as YYYY-MM-DD, or null when the value is not a date or its
|
|
212
|
+
* day and month cannot be told apart (03/05/2024 could be either).
|
|
213
|
+
*/
|
|
214
|
+
function dayOf(v) {
|
|
215
|
+
if (v instanceof Date)
|
|
216
|
+
return Number.isNaN(v.getTime()) ? null : ymd(v.getFullYear(), v.getMonth() + 1, v.getDate());
|
|
217
|
+
if (typeof v !== 'string')
|
|
218
|
+
return null;
|
|
219
|
+
const s = v.trim();
|
|
220
|
+
let m = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ][\d:.]+Z?)?$/.exec(s);
|
|
221
|
+
if (m)
|
|
222
|
+
return valid(+m[1], +m[2], +m[3]);
|
|
223
|
+
m = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})$/.exec(s);
|
|
224
|
+
if (!m)
|
|
225
|
+
return null;
|
|
226
|
+
const a = +m[1];
|
|
227
|
+
const b = +m[2];
|
|
228
|
+
const y = +m[3];
|
|
229
|
+
if (a === b)
|
|
230
|
+
return valid(y, a, b);
|
|
231
|
+
if (a > 12 && b <= 12)
|
|
232
|
+
return valid(y, b, a);
|
|
233
|
+
if (b > 12 && a <= 12)
|
|
234
|
+
return valid(y, a, b);
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
function valid(y, mo, d) {
|
|
238
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31)
|
|
239
|
+
return null;
|
|
240
|
+
return ymd(y, mo, d);
|
|
241
|
+
}
|
|
242
|
+
const ymd = (y, m, d) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
|
243
|
+
// ---- grouping helpers -----------------------------------------------------
|
|
244
|
+
/** A key that tells values apart by type as well as content. */
|
|
245
|
+
export function keyOf(v) {
|
|
246
|
+
if (v instanceof Date)
|
|
247
|
+
return 'd:' + v.getTime();
|
|
248
|
+
if (isBlank(v))
|
|
249
|
+
return 'blank';
|
|
250
|
+
if (typeof v === 'string')
|
|
251
|
+
return 's:' + v;
|
|
252
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
253
|
+
return typeof v.valueOf() + ':' + String(v);
|
|
254
|
+
try {
|
|
255
|
+
return 'j:' + JSON.stringify(v);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return 'o:' + String(v);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* What to look at first: every member below the low-confidence line (up to
|
|
263
|
+
* ten), at least the two least confident, then members spread evenly across
|
|
264
|
+
* the group until there are five.
|
|
265
|
+
*/
|
|
266
|
+
function sampleOf(members, low) {
|
|
267
|
+
if (members.length <= 5)
|
|
268
|
+
return members.map((m) => m.id);
|
|
269
|
+
const byConfidence = members
|
|
270
|
+
.filter((m) => typeof m.confidence === 'number')
|
|
271
|
+
.sort((x, y) => x.confidence - y.confidence);
|
|
272
|
+
const unsure = byConfidence.filter((m) => m.confidence < low).slice(0, 10);
|
|
273
|
+
const picked = unsure.length >= 2 ? unsure : byConfidence.slice(0, 2);
|
|
274
|
+
const rest = members.filter((m) => !picked.includes(m));
|
|
275
|
+
const need = Math.max(0, 5 - picked.length);
|
|
276
|
+
for (let i = 0; i < need; i++)
|
|
277
|
+
picked.push(rest[Math.round((i * (rest.length - 1)) / Math.max(1, need - 1))]);
|
|
278
|
+
return [...new Set(picked.map((m) => m.id))];
|
|
279
|
+
}
|
|
280
|
+
function distributionOf(members) {
|
|
281
|
+
const counts = new Map();
|
|
282
|
+
for (const m of members) {
|
|
283
|
+
const k = keyOf(m.before);
|
|
284
|
+
const e = counts.get(k);
|
|
285
|
+
if (e)
|
|
286
|
+
e.count++;
|
|
287
|
+
else
|
|
288
|
+
counts.set(k, { value: m.before, count: 1 });
|
|
289
|
+
}
|
|
290
|
+
const sorted = [...counts.values()].sort((a, b) => b.count - a.count);
|
|
291
|
+
const top = sorted.slice(0, 5);
|
|
292
|
+
const other = sorted.slice(5).reduce((s, e) => s + e.count, 0);
|
|
293
|
+
if (other > 0)
|
|
294
|
+
top.push({ value: undefined, count: other, other: true });
|
|
295
|
+
return top;
|
|
296
|
+
}
|
|
297
|
+
function rankOf(g, low) {
|
|
298
|
+
if (g.kind === 'unpatterned')
|
|
299
|
+
return 0;
|
|
300
|
+
if (g.kind === 'constant-target')
|
|
301
|
+
return 1;
|
|
302
|
+
if (!g.rule && g.minConfidence !== undefined && g.minConfidence < low)
|
|
303
|
+
return 2;
|
|
304
|
+
return g.rule ? 4 : 3;
|
|
305
|
+
}
|
|
306
|
+
// ---- labels ---------------------------------------------------------------
|
|
307
|
+
/**
|
|
308
|
+
* Spaces a reader cannot see, drawn as `·`: leading and trailing ones and runs
|
|
309
|
+
* of two or more. A trim proposal otherwise reads "Acme -> Acme".
|
|
310
|
+
*/
|
|
311
|
+
export function visibleSpaces(s) {
|
|
312
|
+
return s.replace(/^\s+|\s+$/g, (m) => '·'.repeat(m.length)).replace(/ {2,}/g, (m) => '·'.repeat(m.length));
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* A value as a group label shows it: short, `(empty)` for blank, spaces
|
|
316
|
+
* visible. A string is quoted only where the quotes tell something: around
|
|
317
|
+
* spaces at its ends, or when it reads as a number, a boolean or the blank mark.
|
|
318
|
+
*/
|
|
319
|
+
export function showValue(v, empty = '(empty)') {
|
|
320
|
+
if (isBlank(v))
|
|
321
|
+
return empty;
|
|
322
|
+
if (v instanceof Date)
|
|
323
|
+
return v.toISOString().slice(0, 10);
|
|
324
|
+
const s = typeof v === 'string' ? v : typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
325
|
+
const short = s.length > 40 ? s.slice(0, 39) + '...' : s;
|
|
326
|
+
if (typeof v !== 'string')
|
|
327
|
+
return short;
|
|
328
|
+
const quote = /^\s|\s$/.test(s) || /^-?[\d.,]+$|^(true|false)$/i.test(s) || s === empty;
|
|
329
|
+
return quote ? `"${visibleSpaces(short)}"` : visibleSpaces(short);
|
|
330
|
+
}
|
|
331
|
+
function labelOf(kind, param, field, members, m) {
|
|
332
|
+
const first = members[0];
|
|
333
|
+
const show = (v) => showValue(v, m.empty);
|
|
334
|
+
switch (kind) {
|
|
335
|
+
case 'whitespace':
|
|
336
|
+
return fill(m.labelWhitespace, { field });
|
|
337
|
+
case 'case': {
|
|
338
|
+
const name = { upper: m.caseUpper, lower: m.caseLower, title: m.caseTitle }[param] ?? m.caseMixed;
|
|
339
|
+
return fill(m.labelCase, { field, case: name });
|
|
340
|
+
}
|
|
341
|
+
case 'format': {
|
|
342
|
+
if (param === 'to-number')
|
|
343
|
+
return fill(m.labelToNumber, { field });
|
|
344
|
+
if (param.startsWith('date:'))
|
|
345
|
+
return fill(m.labelDates, { field, shape: param === 'date:Date' ? m.datesAsDates : param.slice(5) });
|
|
346
|
+
if (param.startsWith('shape:'))
|
|
347
|
+
return fill(m.labelShape, { field, shape: param.slice(6) });
|
|
348
|
+
const [removed, added] = JSON.parse(param.slice(6));
|
|
349
|
+
const chars = (cs) => [...cs].map((c) => (c === ' ' ? m.punctSpaces : `"${c}"`)).join(' ');
|
|
350
|
+
const parts = [];
|
|
351
|
+
if (removed)
|
|
352
|
+
parts.push(fill(m.punctRemove, { chars: chars(removed) }));
|
|
353
|
+
if (added)
|
|
354
|
+
parts.push(fill(m.punctAdd, { chars: chars(added) }));
|
|
355
|
+
const change = parts.join(', ');
|
|
356
|
+
return fill(m.labelPunct, { field, change: change.charAt(0).toUpperCase() + change.slice(1) });
|
|
357
|
+
}
|
|
358
|
+
case 'scale': {
|
|
359
|
+
const r = Number(param);
|
|
360
|
+
const inv = 1 / r;
|
|
361
|
+
if (Math.abs(r) < 1 && Math.abs(inv - Math.round(inv)) < 1e-9)
|
|
362
|
+
return fill(m.labelDivide, { field, n: Math.round(inv) });
|
|
363
|
+
return fill(m.labelMultiply, { field, n: param });
|
|
364
|
+
}
|
|
365
|
+
case 'same-mapping': {
|
|
366
|
+
const froms = new Set(members.map((p) => keyOf(p.before))).size;
|
|
367
|
+
const from = froms === 1 ? show(first.before) : fill(m.mappingValues, { count: froms });
|
|
368
|
+
return fill(m.labelMapping, { field, from, to: show(first.after) });
|
|
369
|
+
}
|
|
370
|
+
case 'fill-empty':
|
|
371
|
+
return param === '*' ? fill(m.labelFillVarious, { field }) : fill(m.labelFill, { field, value: show(first.after) });
|
|
372
|
+
case 'constant-target':
|
|
373
|
+
return fill(m.labelConstant, { field, value: show(first.after) });
|
|
374
|
+
case 'unpatterned':
|
|
375
|
+
return fill(m.labelUnpatterned, { field });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value equality for change review: is a cell still what a proposal saw, and
|
|
3
|
+
* is a proposal a no-op. `Object.is` for primitives, Dates by time, arrays
|
|
4
|
+
* and plain objects by structure. Blank (null, undefined, '') equals blank,
|
|
5
|
+
* the same rule Base sync uses (`fields/base-sync.ts`), so a cell cleared to
|
|
6
|
+
* '' does not make a proposal made against `null` look stale.
|
|
7
|
+
*/
|
|
8
|
+
export declare const isBlank: (v: unknown) => boolean;
|
|
9
|
+
export declare function sameValue(a: unknown, b: unknown): boolean;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value equality for change review: is a cell still what a proposal saw, and
|
|
3
|
+
* is a proposal a no-op. `Object.is` for primitives, Dates by time, arrays
|
|
4
|
+
* and plain objects by structure. Blank (null, undefined, '') equals blank,
|
|
5
|
+
* the same rule Base sync uses (`fields/base-sync.ts`), so a cell cleared to
|
|
6
|
+
* '' does not make a proposal made against `null` look stale.
|
|
7
|
+
*/
|
|
8
|
+
export const isBlank = (v) => v == null || v === '';
|
|
9
|
+
export function sameValue(a, b) {
|
|
10
|
+
if (Object.is(a, b))
|
|
11
|
+
return true;
|
|
12
|
+
if (isBlank(a) && isBlank(b))
|
|
13
|
+
return true;
|
|
14
|
+
if (a instanceof Date || b instanceof Date) {
|
|
15
|
+
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
18
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
19
|
+
return false;
|
|
20
|
+
return a.every((v, i) => sameValue(v, b[i]));
|
|
21
|
+
}
|
|
22
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
23
|
+
const ka = Object.keys(a);
|
|
24
|
+
if (ka.length !== Object.keys(b).length)
|
|
25
|
+
return false;
|
|
26
|
+
return ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && sameValue(a[k], b[k]));
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
function isPlainObject(v) {
|
|
31
|
+
if (v === null || typeof v !== 'object')
|
|
32
|
+
return false;
|
|
33
|
+
const proto = Object.getPrototypeOf(v);
|
|
34
|
+
return proto === Object.prototype || proto === null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tint on cells with a pending proposal, as `conditionalFormats` rules:
|
|
3
|
+
* one rule per field, scoped to that field's columns, matching rows by id.
|
|
4
|
+
* Costs the free grid nothing; the app concatenates these with its own
|
|
5
|
+
* formats, as `BaseTableView.svelte` does for remote changes.
|
|
6
|
+
*/
|
|
7
|
+
import type { ConditionalFormat } from '@svgrid/grid';
|
|
8
|
+
import type { Proposal } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Theme it with `--sg-review-pending-bg`. The default is a light amber wash
|
|
11
|
+
* from `--sg-warning`, kept apart from the accent, which themes use for
|
|
12
|
+
* selection and focus.
|
|
13
|
+
*/
|
|
14
|
+
export declare const PENDING_BG = "var(--sg-review-pending-bg, color-mix(in srgb, var(--sg-warning, #d97706) 13%, transparent))";
|
|
15
|
+
/** The row "Show in grid" lands on, for a moment: a stronger wash of the same amber. */
|
|
16
|
+
export declare const FLASH_BG = "var(--sg-review-flash-bg, color-mix(in srgb, var(--sg-warning, #d97706) 32%, transparent))";
|
|
17
|
+
/** Drawn before the value, so a pending cell is never marked by colour alone. */
|
|
18
|
+
export declare const PENDING_ICON = "\u2022";
|
|
19
|
+
/**
|
|
20
|
+
* A cell two sources disagree on: a red wash (`--sg-review-conflict-bg`) and
|
|
21
|
+
* a "!" before the value, so the grid shows where the panel's conflicts are.
|
|
22
|
+
*/
|
|
23
|
+
export declare const CONFLICT_BG = "var(--sg-review-conflict-bg, color-mix(in srgb, var(--sg-danger, #dc2626) 12%, transparent))";
|
|
24
|
+
export declare const CONFLICT_ICON = "!";
|
|
25
|
+
export declare function pendingFormats<TData>(pending: ReadonlyArray<Proposal>, rowId: (row: TData, index: number) => string, columns: ReadonlyArray<{
|
|
26
|
+
id: string;
|
|
27
|
+
field?: string;
|
|
28
|
+
}>, look?: {
|
|
29
|
+
background: string;
|
|
30
|
+
icon: string;
|
|
31
|
+
}): ConditionalFormat<any>[];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme it with `--sg-review-pending-bg`. The default is a light amber wash
|
|
3
|
+
* from `--sg-warning`, kept apart from the accent, which themes use for
|
|
4
|
+
* selection and focus.
|
|
5
|
+
*/
|
|
6
|
+
export const PENDING_BG = 'var(--sg-review-pending-bg, color-mix(in srgb, var(--sg-warning, #d97706) 13%, transparent))';
|
|
7
|
+
/** The row "Show in grid" lands on, for a moment: a stronger wash of the same amber. */
|
|
8
|
+
export const FLASH_BG = 'var(--sg-review-flash-bg, color-mix(in srgb, var(--sg-warning, #d97706) 32%, transparent))';
|
|
9
|
+
/** Drawn before the value, so a pending cell is never marked by colour alone. */
|
|
10
|
+
export const PENDING_ICON = '•';
|
|
11
|
+
/**
|
|
12
|
+
* A cell two sources disagree on: a red wash (`--sg-review-conflict-bg`) and
|
|
13
|
+
* a "!" before the value, so the grid shows where the panel's conflicts are.
|
|
14
|
+
*/
|
|
15
|
+
export const CONFLICT_BG = 'var(--sg-review-conflict-bg, color-mix(in srgb, var(--sg-danger, #dc2626) 12%, transparent))';
|
|
16
|
+
export const CONFLICT_ICON = '!';
|
|
17
|
+
export function pendingFormats(pending, rowId, columns, look = { background: PENDING_BG, icon: PENDING_ICON }) {
|
|
18
|
+
const rowsByField = new Map();
|
|
19
|
+
for (const p of pending) {
|
|
20
|
+
let ids = rowsByField.get(p.field);
|
|
21
|
+
if (!ids)
|
|
22
|
+
rowsByField.set(p.field, (ids = new Set()));
|
|
23
|
+
ids.add(p.rowId);
|
|
24
|
+
}
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const [field, ids] of rowsByField) {
|
|
27
|
+
const cols = columns.filter((c) => (c.field ?? c.id) === field).map((c) => c.id);
|
|
28
|
+
out.push({
|
|
29
|
+
type: 'rule',
|
|
30
|
+
columns: cols.length ? cols : [field],
|
|
31
|
+
when: ({ row }) => ids.has(String(rowId(row, 0))),
|
|
32
|
+
background: look.background,
|
|
33
|
+
icon: look.icon,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ReviewMessages } from './messages.js';
|
|
2
|
+
import type { Proposal } from './types.js';
|
|
3
|
+
type Ghostable = {
|
|
4
|
+
pendingFor(rowId: string, field: string): Proposal | undefined;
|
|
5
|
+
messages?: ReviewMessages;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Whether wrapping this column's `cell` keeps the rest of its cells as they
|
|
9
|
+
* were. `columns` is every leaf column, which a field type's fill may read.
|
|
10
|
+
*/
|
|
11
|
+
export declare function canGhost(raw: any, sample?: unknown, columns?: ReadonlyArray<any>): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Wrap the `cell` of every ghostable column whose field has a pending
|
|
14
|
+
* proposal. Returns the same array when nothing needs wrapping.
|
|
15
|
+
*/
|
|
16
|
+
export declare function wrapColumns<C extends ReadonlyArray<any>>(cols: C, fields: ReadonlySet<string>, review: Ghostable, sample?: (field: string) => unknown): C;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proposed value drawn in its cell, then the current value struck
|
|
3
|
+
* through.
|
|
4
|
+
*
|
|
5
|
+
* Done by wrapping a column's `cell` template, which needs no free-grid seam.
|
|
6
|
+
* But setting `cell` turns off the grid's own drawing for the whole column:
|
|
7
|
+
* field types, checkboxes, list/chips, sparklines and booleans
|
|
8
|
+
* (`SvGrid.svelte`, every branch gated on `cell == null`). So only columns
|
|
9
|
+
* that already have a `cell`, or draw plain text, are wrapped. The rest keep
|
|
10
|
+
* the tint and the glyph from `formats.ts`.
|
|
11
|
+
*
|
|
12
|
+
* A field type (`@svgrid/enterprise/fields`, SvGrid Base) counts as plain
|
|
13
|
+
* text when its type draws no cell of its own (single line text, email,
|
|
14
|
+
* number...): the check runs on the column as the fields module fills it in,
|
|
15
|
+
* the way the grid will see it.
|
|
16
|
+
*/
|
|
17
|
+
import { formatValueForExport, getGridExtension, renderComponent } from '@svgrid/grid';
|
|
18
|
+
import { visibleSpaces } from './cluster.js';
|
|
19
|
+
import { isBlank } from './equal.js';
|
|
20
|
+
import { fill } from './messages.js';
|
|
21
|
+
import GhostCell from './GhostCell.svelte';
|
|
22
|
+
/**
|
|
23
|
+
* Whether wrapping this column's `cell` keeps the rest of its cells as they
|
|
24
|
+
* were. `columns` is every leaf column, which a field type's fill may read.
|
|
25
|
+
*/
|
|
26
|
+
export function canGhost(raw, sample, columns = []) {
|
|
27
|
+
if (!raw || raw.columns?.length)
|
|
28
|
+
return false;
|
|
29
|
+
const ext = getGridExtension();
|
|
30
|
+
// The grid draws a field type's own cell when the module has one for it;
|
|
31
|
+
// otherwise the column draws as an ordinary one.
|
|
32
|
+
if (raw.fieldType && raw.cell == null && ext?.cell && ext.hasCell?.(raw) !== false)
|
|
33
|
+
return false;
|
|
34
|
+
const def = raw.fieldType && ext?.column ? ext.column(raw, columns) : raw;
|
|
35
|
+
const field = def.field ?? def.id;
|
|
36
|
+
if (!field || def.sparkline)
|
|
37
|
+
return false;
|
|
38
|
+
if (raw.cell != null)
|
|
39
|
+
return true;
|
|
40
|
+
if (def.editorType === 'checkbox' || def.editorType === 'list' || def.editorType === 'chips')
|
|
41
|
+
return false;
|
|
42
|
+
if (def.cellDataType === 'boolean' || typeof sample === 'boolean')
|
|
43
|
+
return false;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Wrap the `cell` of every ghostable column whose field has a pending
|
|
48
|
+
* proposal. Returns the same array when nothing needs wrapping.
|
|
49
|
+
*/
|
|
50
|
+
export function wrapColumns(cols, fields, review, sample = () => undefined) {
|
|
51
|
+
if (!fields.size)
|
|
52
|
+
return cols;
|
|
53
|
+
const leaves = leafColumns(cols);
|
|
54
|
+
let changed = false;
|
|
55
|
+
const out = cols.map((def) => {
|
|
56
|
+
if (def?.columns?.length) {
|
|
57
|
+
const inner = wrapColumns(def.columns, fields, review, sample);
|
|
58
|
+
if (inner === def.columns)
|
|
59
|
+
return def;
|
|
60
|
+
changed = true;
|
|
61
|
+
return { ...def, columns: inner };
|
|
62
|
+
}
|
|
63
|
+
const field = def?.field ?? def?.id;
|
|
64
|
+
if (!fields.has(field) || !canGhost(def, sample(field), leaves))
|
|
65
|
+
return def;
|
|
66
|
+
changed = true;
|
|
67
|
+
const original = def.cell;
|
|
68
|
+
return {
|
|
69
|
+
...def,
|
|
70
|
+
// Grids published before the core refreshed `columnDef` on a soft
|
|
71
|
+
// cache hit (`getAllColumns` in core.ts) kept a reused column's old
|
|
72
|
+
// `cell`, and the peer range admits them. A fresh `fieldProps` object
|
|
73
|
+
// makes those rebuild the column (same id, so no state resets). A field
|
|
74
|
+
// type reads its settings from it; the copy holds the same ones.
|
|
75
|
+
fieldProps: { ...def.fieldProps },
|
|
76
|
+
cell: (ctx) => {
|
|
77
|
+
const own = typeof original === 'function' ? original(ctx) : original;
|
|
78
|
+
const p = review.pendingFor(String(ctx.row.id), field);
|
|
79
|
+
if (!p)
|
|
80
|
+
return own;
|
|
81
|
+
// The column as the grid holds it: a field type's format is filled in after this wrap.
|
|
82
|
+
const format = ctx.column?.columnDef?.format ?? def.format;
|
|
83
|
+
const after = formatValueForExport(p.after, format);
|
|
84
|
+
const m = review.messages;
|
|
85
|
+
return renderComponent(GhostCell, {
|
|
86
|
+
own,
|
|
87
|
+
before: ctx.getValue(),
|
|
88
|
+
after,
|
|
89
|
+
empty: m?.empty ?? '(empty)',
|
|
90
|
+
// The current value too: a narrow cell may have no room to show it struck.
|
|
91
|
+
title: fill(m?.proposes ?? '{source} proposes {value}', { source: p.source.label, value: after || (m?.empty ?? '(empty)') }) + (p.reason ? ` - ${p.reason}` : '')
|
|
92
|
+
+ (isBlank(ctx.getValue()) ? '' : '\n' + fill(m?.nowValue ?? 'Now: {value}', { value: formatValueForExport(ctx.getValue(), format) })),
|
|
93
|
+
beforeText: typeof ctx.getValue() === 'string' && !format
|
|
94
|
+
? visibleSpaces(ctx.getValue())
|
|
95
|
+
: formatValueForExport(ctx.getValue(), format),
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
return changed ? out : cols;
|
|
101
|
+
}
|
|
102
|
+
function leafColumns(cols) {
|
|
103
|
+
return cols.flatMap((c) => (c?.columns?.length ? leafColumns(c.columns) : [c]));
|
|
104
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @svgrid/enterprise/review - change review. Writes nobody typed (AI helper
|
|
3
|
+
* results, agent edits, imports, syncs) arrive as proposals held apart from
|
|
4
|
+
* the data, grouped by the transformation they apply, and are accepted or
|
|
5
|
+
* rejected a group at a time. Accepted values are written to the app's data,
|
|
6
|
+
* a cell that changed since the proposal was made is never overwritten, and
|
|
7
|
+
* every decision is one Ctrl+Z.
|
|
8
|
+
*
|
|
9
|
+
* ```svelte
|
|
10
|
+
* <script lang="ts">
|
|
11
|
+
* import { SvGrid } from '@svgrid/grid'
|
|
12
|
+
* import { createReview, reviewSmartFill, SvReviewPanel } from '@svgrid/enterprise/review'
|
|
13
|
+
*
|
|
14
|
+
* let rows = $state(initialRows)
|
|
15
|
+
* let api = $state()
|
|
16
|
+
* const review = createReview({ api: () => api, setRows: (next) => (rows = next) })
|
|
17
|
+
* </script>
|
|
18
|
+
*
|
|
19
|
+
* <SvGrid data={rows} columns={review.columns(columns)} getRowId={(r) => r.id}
|
|
20
|
+
* conditionalFormats={review.formats} onApiReady={(a) => (api = a)} />
|
|
21
|
+
* <SvReviewPanel {review} />
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Adds nothing to `@svgrid/grid`: the tint is `conditionalFormats`, the
|
|
25
|
+
* proposed value is a wrapped `cell`, the undo step is the public command
|
|
26
|
+
* context.
|
|
27
|
+
*/
|
|
28
|
+
export { createReview } from './model.svelte.js';
|
|
29
|
+
export type { Review, ReviewOptions, ReviewOutcome, ReviewGridApi, ReviewJSON, ReviewDecision, AddInput } from './model.svelte.js';
|
|
30
|
+
export { createGridTools, registerWebMcpTools } from './agent.js';
|
|
31
|
+
export type { GridTool, GridToolsOptions } from './agent.js';
|
|
32
|
+
export { clusterProposals, showValue } from './cluster.js';
|
|
33
|
+
export type { ClusterOptions } from './cluster.js';
|
|
34
|
+
export { sameValue, isBlank } from './equal.js';
|
|
35
|
+
export { REVIEW_MESSAGES } from './messages.js';
|
|
36
|
+
export type { ReviewMessages } from './messages.js';
|
|
37
|
+
export { pendingFormats, PENDING_BG, PENDING_ICON, CONFLICT_BG, CONFLICT_ICON } from './formats.js';
|
|
38
|
+
export { canGhost } from './ghost.js';
|
|
39
|
+
export { fromSmartFill, fromClassify, fromRows, fromStaged, reviewSmartFill, reviewClassify, blankTargets } from './adapters.js';
|
|
40
|
+
export type { AdapterResult } from './adapters.js';
|
|
41
|
+
export type { Proposal, ProposalInput, ProposalSource, ProposalStatus, ChangeSet, GroupKind, ProposalGroup } from './types.js';
|
|
42
|
+
export { default as SvReviewPanel } from './SvReviewPanel.svelte';
|