@sheetpane/grid 0.3.0 → 0.4.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/README.md +294 -13
- package/custom-elements.json +292 -27
- package/dist/check.d.ts +75 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +151 -0
- package/dist/check.js.map +1 -0
- package/dist/errors/element.d.ts +90 -12
- package/dist/errors/element.d.ts.map +1 -1
- package/dist/errors/element.js +572 -50
- package/dist/errors/element.js.map +1 -1
- package/dist/excel/element.d.ts +168 -15
- package/dist/excel/element.d.ts.map +1 -1
- package/dist/excel/element.js +752 -90
- package/dist/excel/element.js.map +1 -1
- package/dist/excel/link.d.ts +40 -0
- package/dist/excel/link.d.ts.map +1 -0
- package/dist/excel/link.js +90 -0
- package/dist/excel/link.js.map +1 -0
- package/dist/excel/styles.d.ts.map +1 -1
- package/dist/excel/styles.js +37 -5
- package/dist/excel/styles.js.map +1 -1
- package/dist/frameworks/elements.d.ts +57 -17
- package/dist/frameworks/jsx.d.ts +15 -9
- package/dist/frameworks/solid.d.ts +51 -11
- package/dist/frameworks/svelte.d.ts +28 -16
- package/dist/frameworks/vue.d.ts +15 -9
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/llms-full.txt +3027 -239
- package/dist/llms.txt +92 -86
- package/dist/pane/protection.d.ts +61 -0
- package/dist/pane/protection.d.ts.map +1 -0
- package/dist/pane/protection.js +89 -0
- package/dist/pane/protection.js.map +1 -0
- package/dist/pane/rules.d.ts +1 -1
- package/dist/pane/rules.d.ts.map +1 -1
- package/dist/pane/store.d.ts +336 -15
- package/dist/pane/store.d.ts.map +1 -1
- package/dist/pane/store.js +891 -44
- package/dist/pane/store.js.map +1 -1
- package/dist/pane/structure.d.ts +58 -0
- package/dist/pane/structure.d.ts.map +1 -0
- package/dist/pane/structure.js +238 -0
- package/dist/pane/structure.js.map +1 -0
- package/dist/pane/types.d.ts +195 -6
- package/dist/pane/types.d.ts.map +1 -1
- package/dist/pane/types.js +57 -0
- package/dist/pane/types.js.map +1 -1
- package/dist/plugins/types.d.ts +34 -6
- package/dist/plugins/types.d.ts.map +1 -1
- package/dist/plugins/types.js.map +1 -1
- package/dist/shared/detail.d.ts +24 -0
- package/dist/shared/detail.d.ts.map +1 -0
- package/dist/shared/detail.js +75 -0
- package/dist/shared/detail.js.map +1 -0
- package/dist/shared/dom.d.ts +3 -0
- package/dist/shared/dom.d.ts.map +1 -1
- package/dist/shared/dom.js +3 -0
- package/dist/shared/dom.js.map +1 -1
- package/dist/static.d.ts +81 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js +352 -0
- package/dist/static.js.map +1 -0
- package/dist/style.css +3 -3
- package/dist/tokens.d.ts +1 -0
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +2 -2
- package/dist/tokens.js.map +1 -1
- package/dist/vscode.css-custom-data.json +11 -1
- package/dist/vscode.html-custom-data.json +89 -2
- package/dist/web-types.json +204 -21
- package/package.json +12 -4
package/dist/pane/store.js
CHANGED
|
@@ -2,14 +2,22 @@
|
|
|
2
2
|
// SR-LAW-7, SR-LAW-9). Framework-free: subscribe / getSnapshot, and mutators.
|
|
3
3
|
// Every mutation goes through commit(): undo (W-23), derive and validate
|
|
4
4
|
// (W-14), "matches suggestion" (B-022) and one notification.
|
|
5
|
-
import { cellRef, detailOf, parseRange, parseRef, patchXlsx, rangesIntersect, spError, spMessage, toCsv, toXlsx } from '@sheetpane/export';
|
|
6
|
-
import { formatCode, general, ImportError, read, subsetCode } from '@sheetpane/import';
|
|
5
|
+
import { cellRef, detailOf, inverseOp, isRowOp, parseRange, parseRef, patchXlsx, rangesIntersect, spError, spMessage, toCsv, toXlsx } from '@sheetpane/export';
|
|
6
|
+
import { formatCode, general, ImportError, read, restructureWorkbook, sha256, subsetCode } from '@sheetpane/import';
|
|
7
7
|
import { checkPlugins, mergeDecorations } from '../plugins/host.js';
|
|
8
|
+
import { STYLE_PROPS } from '../plugins/types.js';
|
|
9
|
+
import { formulaHiddenIn, lockedIn, protectionModeOf, sheetProtectionIn, structureLockedIn, structureProtectionIn } from './protection.js';
|
|
8
10
|
import { HostRules } from './rules.js';
|
|
9
|
-
import { machineAuthored } from './types.js';
|
|
11
|
+
import { DEFAULT_LAYER, ERROR_META_MAX, RESULTS_LEVELS, SEVERITY_ORDER, copyError, isLayerName, isResult, layerOf, machineAuthored } from './types.js';
|
|
12
|
+
import { STRUCTURE_REF, shiftChanges, shiftComposing, shiftDerived, shiftErrors, shiftPluginErrors, shiftSelection, structureRefusal } from './structure.js';
|
|
10
13
|
/** The undo stack's depth per pane; the oldest entry goes first (W-23, SR-GRID decision 7). */
|
|
11
14
|
export const HISTORY_CAP = 500;
|
|
12
15
|
const BLANK = '(blank)';
|
|
16
|
+
/** The CSS a run's span may carry (W-53): the `decorate` allow-list. */
|
|
17
|
+
const ALLOWED_PROPS = new Set(STYLE_PROPS);
|
|
18
|
+
/** The cells a range covers. */
|
|
19
|
+
const areaOf = (r) => (r.bottom - r.top + 1) * (r.right - r.left + 1);
|
|
20
|
+
const inBox = (r, row, col) => row >= r.top && row <= r.bottom && col >= r.left && col <= r.right;
|
|
13
21
|
/**
|
|
14
22
|
* The change-map key of a cell: `Sheet1!B3` (exported as `cellKey`).
|
|
15
23
|
* @param sheet - The sheet name.
|
|
@@ -55,8 +63,103 @@ export function parseInput(text, original, source) {
|
|
|
55
63
|
}
|
|
56
64
|
return text;
|
|
57
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* The cell or range a change-map ref is about (W-64): `B3` → `B3`, `#style:B3` → `B3`, `#merge:A1:C1` → `A1:C1`,
|
|
68
|
+
* and `''` for a ref about the whole sheet (`#freeze`, `#hiddenRows`, `#sheetState`).
|
|
69
|
+
* @param ref - A change-map ref (the part of the key after `sheet!`).
|
|
70
|
+
* @returns The A1 cell or range, or `''`.
|
|
71
|
+
*/
|
|
72
|
+
export function editRef(ref) {
|
|
73
|
+
if (!ref.startsWith('#'))
|
|
74
|
+
return ref;
|
|
75
|
+
const m = /^#[A-Za-z]+:(.+)$/.exec(ref);
|
|
76
|
+
return m ? m[1] : '';
|
|
77
|
+
}
|
|
78
|
+
/** An edit's range at most this many cells is looked up cell by cell; a larger one scans the sheet's problems. */
|
|
79
|
+
const EDIT_LOOKUP_CELLS = 4096;
|
|
58
80
|
/** A sheet's state as read: `state` (W-49), else the older `hidden` flag. */
|
|
59
81
|
export const sheetStateOf = (s) => (s.state ?? (s.hidden ? 'hidden' : 'visible'));
|
|
82
|
+
/** An error's `meta` as kept (W-68): its JSON round trip, or `undefined` — logged as SP-3016 — when it is not JSON or weighs over ERROR_META_MAX. */
|
|
83
|
+
function cleanMeta(id, meta) {
|
|
84
|
+
let json;
|
|
85
|
+
try {
|
|
86
|
+
json = JSON.stringify(meta);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
json = undefined;
|
|
90
|
+
} // a cycle or a BigInt
|
|
91
|
+
if (json === undefined) {
|
|
92
|
+
console.warn(spMessage('SP-3016', `${id || 'a new error'}: its meta is not JSON, so it was not kept`));
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
// UTF-8 weighs one to three bytes per UTF-16 unit: encode only when the length cannot tell
|
|
96
|
+
const bytes = json.length * 3 <= ERROR_META_MAX ? json.length : new TextEncoder().encode(json).length;
|
|
97
|
+
if (bytes > ERROR_META_MAX) {
|
|
98
|
+
console.warn(spMessage('SP-3016', `${id || 'a new error'}: its meta is ${bytes} bytes of JSON, over the ${ERROR_META_MAX}-byte cap, so it was not kept`));
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
return JSON.parse(json);
|
|
102
|
+
}
|
|
103
|
+
/** The error with `meta` set to what may be kept, or without it. */
|
|
104
|
+
function withMeta(e, meta) {
|
|
105
|
+
const { meta: _was, ...rest } = e;
|
|
106
|
+
const m = meta === undefined ? undefined : cleanMeta(e.id, meta);
|
|
107
|
+
return m === undefined ? rest : { ...rest, meta: m };
|
|
108
|
+
}
|
|
109
|
+
/** The error without who, when and why (W-69): only an `accepted` error carries them. */
|
|
110
|
+
function withoutAcceptance(e) {
|
|
111
|
+
if (!('acceptedBy' in e || 'acceptedAt' in e || 'reason' in e))
|
|
112
|
+
return e;
|
|
113
|
+
const { acceptedBy: _by, acceptedAt: _at, reason: _why, ...rest } = e;
|
|
114
|
+
return rest;
|
|
115
|
+
}
|
|
116
|
+
/** Why an error may not stand as `accepted` (W-69), or `undefined` when it may: it needs a reason, and the workbook's own errors are B-029's. */
|
|
117
|
+
function acceptRefusal(e) {
|
|
118
|
+
if (e.author === 'workbook' || e.author === 'file')
|
|
119
|
+
return `${e.id}: it is the workbook's own (author "${e.author}"), which is not accepted here`;
|
|
120
|
+
if (typeof e.reason !== 'string' || !e.reason.trim())
|
|
121
|
+
return `${e.id || 'an error'}: no reason was given`;
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
/** An error as a restore keeps it (W-69): `accepted` with who, when and why — without a reason it comes back `open`, logged as SP-3018; any other status, without them. */
|
|
125
|
+
function restoredStatus(e) {
|
|
126
|
+
if (e.status !== 'accepted')
|
|
127
|
+
return withoutAcceptance(e);
|
|
128
|
+
const why = acceptRefusal(e);
|
|
129
|
+
if (why) {
|
|
130
|
+
console.warn(spMessage('SP-3018', `${why}, so it was restored as open`));
|
|
131
|
+
return { ...withoutAcceptance(e), status: 'open' };
|
|
132
|
+
}
|
|
133
|
+
return { ...e, acceptedBy: typeof e.acceptedBy === 'string' ? e.acceptedBy : '', acceptedAt: typeof e.acceptedAt === 'string' ? e.acceptedAt : '' };
|
|
134
|
+
}
|
|
135
|
+
/** An error's layer as a restore keeps it (W-51): its own name, or `review` — an unusable one logged as SP-3021. */
|
|
136
|
+
function keptLayer(e) {
|
|
137
|
+
if (e.layer === undefined)
|
|
138
|
+
return DEFAULT_LAYER;
|
|
139
|
+
if (isLayerName(e.layer))
|
|
140
|
+
return e.layer;
|
|
141
|
+
console.warn(spMessage('SP-3021', `${e.id || 'an error'}: its layer ${JSON.stringify(e.layer)} is not a layer name, so it went into "${DEFAULT_LAYER}"`));
|
|
142
|
+
return DEFAULT_LAYER;
|
|
143
|
+
}
|
|
144
|
+
/** The layers whose errors differ between two lists of the pane's errors (W-51): an error added, removed or changed. */
|
|
145
|
+
function changedLayers(a, b) {
|
|
146
|
+
const out = new Set();
|
|
147
|
+
if (a === b)
|
|
148
|
+
return out;
|
|
149
|
+
const was = new Map(a.map((e) => [e.id, e]));
|
|
150
|
+
for (const e of b) {
|
|
151
|
+
const w = was.get(e.id);
|
|
152
|
+
if (w !== e) {
|
|
153
|
+
out.add(layerOf(e));
|
|
154
|
+
if (w)
|
|
155
|
+
out.add(layerOf(w));
|
|
156
|
+
}
|
|
157
|
+
was.delete(e.id);
|
|
158
|
+
}
|
|
159
|
+
for (const e of was.values())
|
|
160
|
+
out.add(layerOf(e));
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
60
163
|
const same = (a, b) => a === b || (typeof a === 'number' && typeof b === 'number' && Object.is(a, b)) || JSON.stringify(a) === JSON.stringify(b);
|
|
61
164
|
/**
|
|
62
165
|
* The in-memory workbook one pane shares (SR-GRID decisions 1–8): the uploaded file, every
|
|
@@ -95,6 +198,27 @@ export class WorkbookStore {
|
|
|
95
198
|
/** Plugin errors by `plugin|sheet|range|type` → error id. */
|
|
96
199
|
pluginErrors = new Map();
|
|
97
200
|
errorsViews = 0;
|
|
201
|
+
// results (W-50): the pane's level, the range Show ▸ marks, and the index the grid reads per cell
|
|
202
|
+
resultsLevel = 'summary';
|
|
203
|
+
focus;
|
|
204
|
+
resultIdx;
|
|
205
|
+
/** The pane's layers in the order first named (W-51); `shown` the visible ones, `null` for every layer. */
|
|
206
|
+
known = [];
|
|
207
|
+
shown = null;
|
|
208
|
+
layersDirty = false;
|
|
209
|
+
/** Plugins already told their draft's layer is no name (SP-3021, once each). */
|
|
210
|
+
badLayerWarned = new Set();
|
|
211
|
+
// the edits list (W-64): when each change-map entry was written (a commit's sequence and time) and the problems on its
|
|
212
|
+
// cells before the cell first changed — keyed by the entry object, so undo and redo bring both back with the entry
|
|
213
|
+
editMeta = new WeakMap();
|
|
214
|
+
effects = new WeakMap();
|
|
215
|
+
editSeq = 0;
|
|
216
|
+
/** The workbook as read, before any insert or delete (W-52); undefined before a load. */
|
|
217
|
+
baseBook;
|
|
218
|
+
/** A moved or rewritten change-map entry keeps its edit's time and baseline (W-52 × W-64). */
|
|
219
|
+
carryEdit = (from, to) => { const m = this.editMeta.get(from); if (m && !this.editMeta.has(to))
|
|
220
|
+
this.editMeta.set(to, m); };
|
|
221
|
+
problemIdx;
|
|
98
222
|
/** The host's column rules (W-27): a built-in `validate` provider, never part of the frozen plugin set. */
|
|
99
223
|
rules = new HostRules({
|
|
100
224
|
bookSheets: () => this.snap.book?.sheets,
|
|
@@ -112,9 +236,9 @@ export class WorkbookStore {
|
|
|
112
236
|
this.plugins = checkPlugins(o.plugins ?? []);
|
|
113
237
|
this.snap = {
|
|
114
238
|
load: { status: 'empty' }, source: undefined, book: undefined,
|
|
115
|
-
changes: new Map(), derived: new Map(), errors: [],
|
|
239
|
+
changes: new Map(), derived: new Map(), errors: [], layers: [], visibleLayers: [], visibleErrors: [],
|
|
116
240
|
selection: { sheet: '', ref: 'A1' }, mode: 'view', composing: null,
|
|
117
|
-
canUndo: false, canRedo: false, version: 0,
|
|
241
|
+
canUndo: false, canRedo: false, protection: 'honour', version: 0,
|
|
118
242
|
};
|
|
119
243
|
}
|
|
120
244
|
// ── subscription (useSyncExternalStore-shaped) ───────────────────────────
|
|
@@ -125,7 +249,29 @@ export class WorkbookStore {
|
|
|
125
249
|
/** Store events, for the elements to turn into DOM events. */
|
|
126
250
|
on(fn) { this.eventListeners.add(fn); return () => this.eventListeners.delete(fn); }
|
|
127
251
|
set(patch, events = []) {
|
|
128
|
-
|
|
252
|
+
const prev = this.snap;
|
|
253
|
+
const next = { ...prev, ...patch, canUndo: this.undoStack.length > 0, canRedo: this.redoStack.length > 0, version: prev.version + 1 };
|
|
254
|
+
if (next.errors !== prev.errors || this.layersDirty) { // W-51: the layers named so far, the visible ones and their errors
|
|
255
|
+
const seen = new Set(this.known);
|
|
256
|
+
for (const l of [...next.errors.map(layerOf), ...(this.shown ?? [])])
|
|
257
|
+
if (!seen.has(l)) {
|
|
258
|
+
seen.add(l);
|
|
259
|
+
this.known.push(l);
|
|
260
|
+
}
|
|
261
|
+
if (this.layersDirty || this.known.length !== prev.layers.length) {
|
|
262
|
+
next.layers = [...this.known];
|
|
263
|
+
next.visibleLayers = this.shown ? [...this.shown] : next.layers;
|
|
264
|
+
}
|
|
265
|
+
const vis = this.shown && new Set(this.shown);
|
|
266
|
+
next.visibleErrors = vis ? next.errors.filter((e) => vis.has(layerOf(e))) : next.errors;
|
|
267
|
+
this.layersDirty = false;
|
|
268
|
+
}
|
|
269
|
+
for (const e of events)
|
|
270
|
+
if (e.type === 'errors' && !e.layers) {
|
|
271
|
+
const c = changedLayers(prev.errors, next.errors);
|
|
272
|
+
e.layers = next.layers.filter((l) => c.has(l));
|
|
273
|
+
}
|
|
274
|
+
this.snap = next;
|
|
129
275
|
for (const fn of [...this.listeners])
|
|
130
276
|
fn();
|
|
131
277
|
for (const e of events)
|
|
@@ -161,12 +307,16 @@ export class WorkbookStore {
|
|
|
161
307
|
}
|
|
162
308
|
if (seq !== this.loadSeq)
|
|
163
309
|
return;
|
|
164
|
-
const source = { name, kind: book.source, size: bytes.length, bytes, readMs: Math.round(performance.now() - t0) };
|
|
310
|
+
const source = { name, kind: book.source, size: bytes.length, bytes, readMs: Math.round(performance.now() - t0), ...(book.sha256 ? { sha256: book.sha256 } : {}) };
|
|
165
311
|
this.undoStack = [];
|
|
166
312
|
this.redoStack = [];
|
|
167
313
|
this.textCache.clear();
|
|
168
314
|
this.pluginErrors.clear();
|
|
315
|
+
this.focus = undefined;
|
|
316
|
+
this.known = [];
|
|
317
|
+
this.layersDirty = true; // W-51: a new workbook starts with the visible layers named, and no others
|
|
169
318
|
const first = book.sheets[book.activeSheet] ?? book.sheets[0];
|
|
319
|
+
this.baseBook = book; // the workbook as read: a reverted `structure` edit replays what still stands on it (W-52 × W-64)
|
|
170
320
|
this.set({ load: { status: 'ready' }, source, book, changes: new Map(), derived: new Map(), errors: [], composing: null, selection: { sheet: first.name, ref: 'A1' } }, [{ type: 'load', origin }]);
|
|
171
321
|
this.rules.reset(); // column rules: the `load` phase (time-sliced; it re-runs validate when done)
|
|
172
322
|
this.runValidate(book.sheets.map((s) => s.name), origin);
|
|
@@ -211,7 +361,8 @@ export class WorkbookStore {
|
|
|
211
361
|
}
|
|
212
362
|
/** What the cell bar edits: the formula when a `derive` provider (a formula engine) is installed, else the raw value. */
|
|
213
363
|
input(sheet, ref) {
|
|
214
|
-
|
|
364
|
+
// a hidden formula on a protected sheet shows its value only (W-62)
|
|
365
|
+
const f = this.plugins.some((x) => x.provides.includes('derive')) && !this.isFormulaHidden(sheet, ref) ? this.formula(sheet, ref) : undefined;
|
|
215
366
|
if (f !== undefined)
|
|
216
367
|
return f;
|
|
217
368
|
const v = this.value(sheet, ref);
|
|
@@ -280,7 +431,49 @@ export class WorkbookStore {
|
|
|
280
431
|
const book = this.snap.book, s = this.sheet(sheet), p = parseRef(ref);
|
|
281
432
|
if (!book || !s || !p)
|
|
282
433
|
return undefined;
|
|
283
|
-
|
|
434
|
+
const runs = this.runs(sheet, ref);
|
|
435
|
+
return { store: this, book, sheet: s, ref, row: p.row, col: p.col, value: value === undefined ? this.value(sheet, ref) : value, styleIndex: s.styleIndex[ref], ...(runs ? { runs } : {}) };
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* A rich-text cell's runs from the file (W-53, SR-IMPORT decision 12) while it still holds the file's value — no
|
|
439
|
+
* value or formula change, no derived value; `undefined` otherwise. An edit writes plain text, so its runs go.
|
|
440
|
+
*/
|
|
441
|
+
runs(sheet, ref) {
|
|
442
|
+
const r = this.sheet(sheet)?.runs?.[ref];
|
|
443
|
+
if (!r)
|
|
444
|
+
return undefined;
|
|
445
|
+
const k = key(sheet, ref), c = this.snap.changes.get(k);
|
|
446
|
+
if ((c && (c.kind === 'value' || c.kind === 'formula')) || this.snap.derived.has(k))
|
|
447
|
+
return undefined;
|
|
448
|
+
return r;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* A rich-text cell as styled pieces (W-53): the last `decorate` provider's `decorateRuns` that answers, its CSS
|
|
452
|
+
* allow-listed to `STYLE_PROPS`; a `font-family` throws (SP-3010). `undefined` — draw the text plain — when the
|
|
453
|
+
* cell has no runs, no provider answers, or the pieces' text is not the display text.
|
|
454
|
+
*/
|
|
455
|
+
decorateRuns(sheet, ref) {
|
|
456
|
+
if (!this.runs(sheet, ref))
|
|
457
|
+
return undefined;
|
|
458
|
+
const ctx = this.cellContext(sheet, ref);
|
|
459
|
+
if (!ctx)
|
|
460
|
+
return undefined;
|
|
461
|
+
let out;
|
|
462
|
+
for (const p of this.plugins) {
|
|
463
|
+
if (!p.provides.includes('decorate') || !p.decorateRuns)
|
|
464
|
+
continue;
|
|
465
|
+
const pieces = p.decorateRuns(ctx);
|
|
466
|
+
if (!pieces)
|
|
467
|
+
continue;
|
|
468
|
+
out = pieces.map((x) => {
|
|
469
|
+
const style = mergeDecorations([{ id: p.id, style: x.style }]);
|
|
470
|
+
for (const k of Object.keys(style))
|
|
471
|
+
if (!ALLOWED_PROPS.has(k))
|
|
472
|
+
delete style[k];
|
|
473
|
+
return Object.keys(style).length ? { text: x.text, style } : { text: x.text };
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return out && out.map((x) => x.text).join('') === this.text(sheet, ref) ? out : undefined;
|
|
284
477
|
}
|
|
285
478
|
/** The merged `decorate` result for a cell; `{}` with no decorate plugin. */
|
|
286
479
|
decorate(sheet, ref) {
|
|
@@ -307,6 +500,7 @@ export class WorkbookStore {
|
|
|
307
500
|
this.textCache.delete(key(sheet, baseRef(ref)));
|
|
308
501
|
}
|
|
309
502
|
const errors = this.withMatches(next.errors, next.changes);
|
|
503
|
+
this.stampEdits(before, next.changes, keys);
|
|
310
504
|
if (o.record !== false) {
|
|
311
505
|
const last = this.undoStack[this.undoStack.length - 1];
|
|
312
506
|
if (o.coalesce && last && last.coalesce === o.coalesce) {
|
|
@@ -373,6 +567,8 @@ export class WorkbookStore {
|
|
|
373
567
|
if (!book || !p || !this.sheet(sheet))
|
|
374
568
|
return undefined;
|
|
375
569
|
const a1 = cellRef(p.row, p.col);
|
|
570
|
+
if (this.isLocked(sheet, a1))
|
|
571
|
+
return undefined; // W-62: a locked cell of a protected sheet refuses every write
|
|
376
572
|
const orig = this.original(sheet, a1);
|
|
377
573
|
const now = typeof input === 'string' && o.parse !== false ? parseInput(input, orig.value, book.source) : input;
|
|
378
574
|
const k = key(sheet, a1);
|
|
@@ -443,6 +639,168 @@ export class WorkbookStore {
|
|
|
443
639
|
if (keys.length)
|
|
444
640
|
this.commit({ changes, errors: this.snap.errors }, keys, origin, events);
|
|
445
641
|
}
|
|
642
|
+
// ── the edits list (W-64): the change map read as a list, newest first ──
|
|
643
|
+
/** Stamps the entries a commit wrote with its sequence and time, and the problems on their cells before they first changed. */
|
|
644
|
+
stampEdits(before, changes, keys) {
|
|
645
|
+
const seq = ++this.editSeq, at = Date.now();
|
|
646
|
+
for (const k of keys) {
|
|
647
|
+
const entry = changes.get(k), prev = before.changes.get(k);
|
|
648
|
+
if (!entry || entry === prev || this.editMeta.has(entry))
|
|
649
|
+
continue;
|
|
650
|
+
this.editMeta.set(entry, { seq, at, base: prev ? this.editMeta.get(prev)?.base : this.baselineOf(before.errors, k) });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Every edit in the pane — the change map as a list, newest first (W-64): one entry per change-map key, whatever its
|
|
655
|
+
* kind, so a paste lists each cell and a change a plugin keeps as one entry is one. Derived cells are not edits.
|
|
656
|
+
* @returns The edits, newest first; the edits one step made keep the change map's order.
|
|
657
|
+
* @example
|
|
658
|
+
* ```ts
|
|
659
|
+
* import { createWorkbook } from '@sheetpane/grid';
|
|
660
|
+
* const wb = createWorkbook();
|
|
661
|
+
* await wb.load(bytes, 'q3.xlsx');
|
|
662
|
+
* wb.setCell('Holdings', 'G7', '6.5');
|
|
663
|
+
* wb.edits(); // [{ key: 'Holdings!G7', sheet: 'Holdings', ref: 'G7', kind: 'value', was: 6.63, now: 6.5, at: '2026-10-19T…' }]
|
|
664
|
+
* ```
|
|
665
|
+
*/
|
|
666
|
+
edits() {
|
|
667
|
+
const out = [];
|
|
668
|
+
for (const [k, c] of this.snap.changes) {
|
|
669
|
+
const { sheet, ref } = splitKey(k), m = this.editMeta.get(c);
|
|
670
|
+
out.push({ seq: m?.seq ?? 0, e: { key: k, sheet, ref: editRef(ref), kind: c.kind, was: c.was, now: c.now, at: new Date(m?.at ?? 0).toISOString() } });
|
|
671
|
+
}
|
|
672
|
+
return out.sort((a, b) => b.seq - a.seq).map((x) => x.e); // stable: one step's edits stay in map order
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* What one edit did to the problems on its cells (W-64), compared with before its cell first changed: workbook, file
|
|
676
|
+
* and rule errors `cleared` (now resolved or gone) and `raised` (open now, not before), and a person's errors whose
|
|
677
|
+
* suggestion the cell now `matched`. An edit about a whole sheet has none.
|
|
678
|
+
* @param k - The edit's change-map key (`Edit.key`).
|
|
679
|
+
* @returns Error ids, each list in the errors' order.
|
|
680
|
+
*/
|
|
681
|
+
editEffects(k) {
|
|
682
|
+
const c = this.snap.changes.get(k);
|
|
683
|
+
const none = { cleared: [], raised: [], matched: [] };
|
|
684
|
+
const base = c && this.editMeta.get(c)?.base;
|
|
685
|
+
if (!c || !base)
|
|
686
|
+
return none;
|
|
687
|
+
const errors = this.snap.errors, hit = this.effects.get(c);
|
|
688
|
+
if (hit?.errors === errors)
|
|
689
|
+
return hit.fx;
|
|
690
|
+
const now = this.problemsOn(errors, k) ?? new Map();
|
|
691
|
+
const byId = this.indexProblems(errors).byId;
|
|
692
|
+
const fx = { cleared: [], raised: [], matched: [] };
|
|
693
|
+
for (const [id, b] of base) {
|
|
694
|
+
if (!b.open || !b.machine)
|
|
695
|
+
continue;
|
|
696
|
+
const e = byId.get(id);
|
|
697
|
+
if (!e || e.status === 'resolved')
|
|
698
|
+
fx.cleared.push(id);
|
|
699
|
+
}
|
|
700
|
+
for (const e of now.values()) {
|
|
701
|
+
const b = base.get(e.id);
|
|
702
|
+
if (machineAuthored(e.author)) {
|
|
703
|
+
if (e.status === 'open' && !b?.open)
|
|
704
|
+
fx.raised.push(e.id);
|
|
705
|
+
}
|
|
706
|
+
else if (e.matchesExpected && !b?.matches)
|
|
707
|
+
fx.matched.push(e.id);
|
|
708
|
+
}
|
|
709
|
+
this.effects.set(c, { errors, fx });
|
|
710
|
+
return fx;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Reverts edits to the file as one undo step (W-64): each key's change-map entry goes, whatever its kind. A sheet's
|
|
714
|
+
* `structure` entry (W-52) takes every insert and delete on that sheet back with it — the workbook as read, the other
|
|
715
|
+
* edits moved back. Undo brings them back, each with its time.
|
|
716
|
+
* @param keys - Change-map keys (`Edit.key`); omitted, every edit in the pane (*Revert all*).
|
|
717
|
+
* @param origin - The element that asked.
|
|
718
|
+
* @returns How many edits were reverted.
|
|
719
|
+
*/
|
|
720
|
+
revertEdits(keys, origin) {
|
|
721
|
+
const all = keys ?? [...this.snap.changes.keys()];
|
|
722
|
+
if (all.some((k) => this.snap.changes.get(k)?.kind === 'structure'))
|
|
723
|
+
return this.revertStructure(all, origin);
|
|
724
|
+
const changes = new Map(this.snap.changes);
|
|
725
|
+
const done = [], events = [];
|
|
726
|
+
for (const k of all) {
|
|
727
|
+
const prev = changes.get(k);
|
|
728
|
+
if (!prev)
|
|
729
|
+
continue;
|
|
730
|
+
changes.delete(k);
|
|
731
|
+
done.push(k);
|
|
732
|
+
const { sheet, ref } = splitKey(k);
|
|
733
|
+
events.push({ type: 'change', origin, sheet, ref: baseRef(ref), kind: prev.kind, was: prev.now, now: prev.was });
|
|
734
|
+
}
|
|
735
|
+
if (done.length)
|
|
736
|
+
this.commit({ changes, errors: this.snap.errors }, done, origin, events);
|
|
737
|
+
return done.length;
|
|
738
|
+
}
|
|
739
|
+
/** The problems an edit's key covers before its cell first changed, or undefined for an edit about a whole sheet. */
|
|
740
|
+
baselineOf(errors, k) {
|
|
741
|
+
const on = this.problemsOn(errors, k);
|
|
742
|
+
if (!on)
|
|
743
|
+
return undefined;
|
|
744
|
+
const out = new Map();
|
|
745
|
+
for (const e of on.values())
|
|
746
|
+
out.set(e.id, { open: e.status === 'open', matches: e.matchesExpected, machine: machineAuthored(e.author) });
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
/** The problems (not results) whose range meets an edit key's cells, by id; undefined when the key is about no cell. */
|
|
750
|
+
problemsOn(errors, k) {
|
|
751
|
+
const { sheet, ref } = splitKey(k), r = parseRange(editRef(ref));
|
|
752
|
+
if (!r)
|
|
753
|
+
return undefined;
|
|
754
|
+
const out = new Map();
|
|
755
|
+
const x = this.indexProblems(errors).sheets.get(sheet);
|
|
756
|
+
if (!x)
|
|
757
|
+
return out;
|
|
758
|
+
if (areaOf(r) <= EDIT_LOOKUP_CELLS) {
|
|
759
|
+
for (let row = r.top; row <= r.bottom; row++)
|
|
760
|
+
for (let col = r.left; col <= r.right; col++)
|
|
761
|
+
for (const e of x.cells.get(`${row},${col}`) ?? [])
|
|
762
|
+
out.set(e.id, e);
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
for (const [c, list] of x.cells) {
|
|
766
|
+
const [row, col] = c.split(',').map(Number);
|
|
767
|
+
if (inBox(r, row, col))
|
|
768
|
+
for (const e of list)
|
|
769
|
+
out.set(e.id, e);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
for (const y of x.ranges)
|
|
773
|
+
if (rangesIntersect(y.r, r))
|
|
774
|
+
out.set(y.e.id, y.e);
|
|
775
|
+
return out;
|
|
776
|
+
}
|
|
777
|
+
indexProblems(errors) {
|
|
778
|
+
if (this.problemIdx?.errors === errors)
|
|
779
|
+
return this.problemIdx;
|
|
780
|
+
const sheets = new Map(), byId = new Map();
|
|
781
|
+
for (const e of errors) {
|
|
782
|
+
byId.set(e.id, e);
|
|
783
|
+
if (isResult(e))
|
|
784
|
+
continue;
|
|
785
|
+
const r = parseRange(e.range);
|
|
786
|
+
if (!r)
|
|
787
|
+
continue;
|
|
788
|
+
let x = sheets.get(e.sheet);
|
|
789
|
+
if (!x)
|
|
790
|
+
sheets.set(e.sheet, (x = { cells: new Map(), ranges: [] }));
|
|
791
|
+
if (r.top === r.bottom && r.left === r.right) {
|
|
792
|
+
const c = `${r.top},${r.left}`, l = x.cells.get(c);
|
|
793
|
+
if (l)
|
|
794
|
+
l.push(e);
|
|
795
|
+
else
|
|
796
|
+
x.cells.set(c, [e]);
|
|
797
|
+
}
|
|
798
|
+
else
|
|
799
|
+
x.ranges.push({ r, e });
|
|
800
|
+
}
|
|
801
|
+
this.problemIdx = { errors, sheets, byId };
|
|
802
|
+
return this.problemIdx;
|
|
803
|
+
}
|
|
446
804
|
// ── merged ranges: core, changed by plugins as kind `merge` (key `sheet!#merge:A1:C1`, now true | false) ──
|
|
447
805
|
/** The sheet's merged ranges: the file's with `merge` changes applied. */
|
|
448
806
|
merges(sheet) {
|
|
@@ -510,8 +868,8 @@ export class WorkbookStore {
|
|
|
510
868
|
*/
|
|
511
869
|
setSheetState(sheet, state, origin) {
|
|
512
870
|
const s = this.sheet(sheet), book = this.snap.book;
|
|
513
|
-
if (!s || !book)
|
|
514
|
-
return false;
|
|
871
|
+
if (!s || !book || this.structureLocked())
|
|
872
|
+
return false; // W-62: a protected structure keeps its sheets as they are
|
|
515
873
|
if (state !== 'visible' && !book.sheets.some((x) => x.name !== sheet && this.hidden(x.name).state === 'visible'))
|
|
516
874
|
return false;
|
|
517
875
|
this.setChange(sheet, '#sheetState', 'sheetState', state, sheetStateOf(s), origin);
|
|
@@ -610,8 +968,10 @@ export class WorkbookStore {
|
|
|
610
968
|
return m === e.matchesExpected ? e : { ...e, matchesExpected: m };
|
|
611
969
|
});
|
|
612
970
|
}
|
|
613
|
-
/** The uploaded display text of a range's first cell, as `found` freezes it. */
|
|
971
|
+
/** The uploaded display text of a range's first cell, as `found` freezes it; `""` for a sheet- or workbook-level error (no cell, W-55). */
|
|
614
972
|
foundFor(sheet, range) {
|
|
973
|
+
if (range === '')
|
|
974
|
+
return '';
|
|
615
975
|
const r = parseRange(range);
|
|
616
976
|
if (!r)
|
|
617
977
|
return BLANK;
|
|
@@ -628,18 +988,27 @@ export class WorkbookStore {
|
|
|
628
988
|
return false;
|
|
629
989
|
return (this.original(e.sheet, cellRef(r.top, r.left)).text || BLANK) !== e.found;
|
|
630
990
|
}
|
|
631
|
-
/** Adds a person's error with a new id; `cellChanged` also writes `expected` into the cell in the same undo step. */
|
|
991
|
+
/** Adds a person's error with a new id; `cellChanged` also writes `expected` into the cell in the same undo step. `range: ''` is the whole sheet, and with `sheet: ''` the workbook (W-55): no cell, so `found` is `''` and nothing is written. `meta` is the host's data (W-68): kept as JSON, or dropped with SP-3016. */
|
|
632
992
|
addError(d, origin) {
|
|
633
|
-
|
|
993
|
+
let layer = this.defaultLayer;
|
|
994
|
+
if (d.layer !== undefined) {
|
|
995
|
+
if (isLayerName(d.layer))
|
|
996
|
+
layer = d.layer;
|
|
997
|
+
else
|
|
998
|
+
console.warn(spMessage('SP-3021', `addError: ${JSON.stringify(d.layer)} is not a layer name, so the error went into "${layer}"`));
|
|
999
|
+
}
|
|
1000
|
+
const e = withMeta({
|
|
634
1001
|
v: 1, id: this.newId(), sheet: d.sheet, range: d.range, type: d.type, severity: d.severity,
|
|
635
1002
|
found: this.foundFor(d.sheet, d.range), expected: d.expected ?? '', message: d.message ?? '',
|
|
636
|
-
cellChanged: !!d.cellChanged, status: 'open', matchesExpected: false, author: d.author ?? '', createdAt: new Date().toISOString(),
|
|
637
|
-
};
|
|
1003
|
+
cellChanged: !!d.cellChanged, status: 'open', matchesExpected: false, author: d.author ?? '', createdAt: new Date().toISOString(), layer,
|
|
1004
|
+
}, d.meta);
|
|
1005
|
+
if (this.resultsLevel === 'off' && isResult(e))
|
|
1006
|
+
return e; // `results="off"`: a result is dropped on arrival (W-50)
|
|
638
1007
|
let changes = this.snap.changes;
|
|
639
1008
|
const keys = [];
|
|
640
1009
|
const events = [{ type: 'errors', origin }];
|
|
641
1010
|
const r = parseRange(d.range);
|
|
642
|
-
if (d.cellChanged && r && e.expected !== '') {
|
|
1011
|
+
if (d.cellChanged && r && e.expected !== '' && !this.isLocked(d.sheet, cellRef(r.top, r.left))) { // W-62: a locked cell is not written
|
|
643
1012
|
const ref = cellRef(r.top, r.left), k = key(d.sheet, ref), orig = this.original(d.sheet, ref);
|
|
644
1013
|
const now = parseInput(e.expected, orig.value, this.snap.book?.source ?? 'xlsx');
|
|
645
1014
|
const m = new Map(changes);
|
|
@@ -654,11 +1023,64 @@ export class WorkbookStore {
|
|
|
654
1023
|
this.commit({ changes, errors: [...this.snap.errors, e] }, keys, origin, events, { extra: { composing: null } });
|
|
655
1024
|
return this.snap.errors.find((x) => x.id === e.id);
|
|
656
1025
|
}
|
|
657
|
-
/**
|
|
1026
|
+
/**
|
|
1027
|
+
* Changes an error's status, severity, expected value, message, type or `meta` (W-68: `meta: undefined` removes it; a
|
|
1028
|
+
* `meta` that may not be kept is dropped with SP-3016 and the error keeps its own); undoable. `status: 'accepted'`
|
|
1029
|
+
* (W-69) needs a `reason` and stamps `acceptedAt`; any other status drops `acceptedBy`, `acceptedAt` and `reason`.
|
|
1030
|
+
* @returns `false` when nothing changed: no such error, or an acceptance refused (SP-3018).
|
|
1031
|
+
*/
|
|
658
1032
|
updateError(id, patch, origin) {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
1033
|
+
const cur = this.snap.errors.find((e) => e.id === id);
|
|
1034
|
+
if (!cur)
|
|
1035
|
+
return false;
|
|
1036
|
+
const { meta, reason, acceptedBy, layer: _layer, ...rest } = patch; // an edit never moves an error to another layer (W-51)
|
|
1037
|
+
let out = { ...cur, ...rest };
|
|
1038
|
+
if (out.status === 'accepted') {
|
|
1039
|
+
if (reason !== undefined)
|
|
1040
|
+
out.reason = typeof reason === 'string' ? reason.trim() : reason;
|
|
1041
|
+
if (acceptedBy !== undefined || cur.status !== 'accepted')
|
|
1042
|
+
out.acceptedBy = acceptedBy ?? '';
|
|
1043
|
+
if (patch.status === 'accepted' || cur.status !== 'accepted')
|
|
1044
|
+
out.acceptedAt = new Date().toISOString();
|
|
1045
|
+
const why = acceptRefusal(out);
|
|
1046
|
+
if (why) {
|
|
1047
|
+
console.warn(spMessage('SP-3018', why));
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
out = { ...withoutAcceptance(out), acceptedBy: out.acceptedBy, acceptedAt: out.acceptedAt, reason: out.reason }; // keep the key order stable
|
|
1051
|
+
}
|
|
1052
|
+
else
|
|
1053
|
+
out = withoutAcceptance(out);
|
|
1054
|
+
if ('meta' in patch) {
|
|
1055
|
+
if (meta === undefined)
|
|
1056
|
+
out = withMeta(out, undefined);
|
|
1057
|
+
else {
|
|
1058
|
+
const m = cleanMeta(id, meta);
|
|
1059
|
+
if (m !== undefined)
|
|
1060
|
+
out = { ...out, meta: m };
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
this.commit({ changes: this.snap.changes, errors: this.snap.errors.map((e) => (e.id === id ? out : e)) }, [], origin, [{ type: 'errors', origin }]);
|
|
1064
|
+
return true;
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Accepts an error as a known deviation (W-69): it stays in the review and the download, recorded with who, when and
|
|
1068
|
+
* why, and is no longer counted as open. One undo step; Reopen (`updateError(id, { status: 'open' })`) takes it back.
|
|
1069
|
+
* The workbook's own errors (author `workbook` or `file`) are not accepted here.
|
|
1070
|
+
* @param id - The error's id.
|
|
1071
|
+
* @param reason - Why the deviation is accepted — required; blank is refused with SP-3018.
|
|
1072
|
+
* @param o - `by`: who accepted it (the errors element passes its `author`).
|
|
1073
|
+
* @returns `false` when nothing changed.
|
|
1074
|
+
* @example
|
|
1075
|
+
* ```ts
|
|
1076
|
+
* import { createWorkbook } from '@sheetpane/grid';
|
|
1077
|
+
* const wb = createWorkbook();
|
|
1078
|
+
* const e = wb.addError({ sheet: '', range: '', type: 'other', severity: 'warning', message: 'Page 4 is 2 mm wider than the spec' });
|
|
1079
|
+
* wb.acceptError(e.id, 'Approved by the customer on 18 Oct', { by: 'Ravi' }); // true
|
|
1080
|
+
* ```
|
|
1081
|
+
*/
|
|
1082
|
+
acceptError(id, reason, o = {}, origin) {
|
|
1083
|
+
return this.updateError(id, { status: 'accepted', reason, acceptedBy: o.by ?? '' }, origin);
|
|
662
1084
|
}
|
|
663
1085
|
/** Deletes an error; a cell it changed keeps the change (contract decision 6). */
|
|
664
1086
|
deleteError(id, origin) {
|
|
@@ -666,22 +1088,128 @@ export class WorkbookStore {
|
|
|
666
1088
|
return;
|
|
667
1089
|
this.commit({ changes: this.snap.changes, errors: this.snap.errors.filter((e) => e.id !== id) }, [], origin, [{ type: 'errors', origin }]);
|
|
668
1090
|
}
|
|
669
|
-
/**
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1091
|
+
/**
|
|
1092
|
+
* A copy of the pane's errors, `meta` included (W-68): every layer's, or with `layer` that layer's only (W-51).
|
|
1093
|
+
* @param o - `layer`: one layer's name; an unusable one is logged as SP-3021 and gives `[]`.
|
|
1094
|
+
* @returns The errors, each with its `layer`.
|
|
1095
|
+
*/
|
|
1096
|
+
getErrors(o = {}) {
|
|
1097
|
+
if (o.layer === undefined)
|
|
1098
|
+
return this.snap.errors.map(copyError);
|
|
1099
|
+
if (!isLayerName(o.layer)) {
|
|
1100
|
+
console.warn(spMessage('SP-3021', `getErrors: ${JSON.stringify(o.layer)} is not a layer name`));
|
|
1101
|
+
return [];
|
|
1102
|
+
}
|
|
1103
|
+
return this.snap.errors.filter((e) => layerOf(e) === o.layer).map(copyError);
|
|
1104
|
+
}
|
|
1105
|
+
/** Bumps the id counter past an `ERR-n` id, so a new error never reuses it. */
|
|
1106
|
+
reserve(id) { const n = /^ERR-(\d+)$/.exec(id); if (n)
|
|
1107
|
+
this.nextError = Math.max(this.nextError, Number(n[1]) + 1); }
|
|
1108
|
+
/**
|
|
1109
|
+
* The host restores a saved review, or replaces one layer (W-51). Each `meta` is kept as JSON, or dropped with SP-3016 (W-68).
|
|
1110
|
+
*
|
|
1111
|
+
* - **The whole review** (no `layer`): a list, or the `sheetpane-errors` envelope — each error in its own `layer`, one
|
|
1112
|
+
* without it in `review`; the envelope's `layers` come back too, an empty one included. Not a user action: the undo
|
|
1113
|
+
* stack is cleared (W-23).
|
|
1114
|
+
* - **One layer** (`layer`): the list becomes that layer's errors, each stamped with it; every other layer is left as it
|
|
1115
|
+
* is. An id another layer holds (or a blank one) gets a new one. The undo stack is kept, and every step in it sees the
|
|
1116
|
+
* new layer, so undo neither brings back the replaced errors nor takes away the new ones.
|
|
1117
|
+
*
|
|
1118
|
+
* @param review - The errors, or a saved envelope (`{ v?, errors, plugins?, layers? }`; one saved before W-51 has no `v`).
|
|
1119
|
+
* @param o - `layer`: the one layer to replace; an unusable name is logged as SP-3021 and nothing changes.
|
|
1120
|
+
* @param origin - The element that asked.
|
|
1121
|
+
* @example
|
|
1122
|
+
* ```ts
|
|
1123
|
+
* import { createWorkbook } from '@sheetpane/grid';
|
|
1124
|
+
* const wb = createWorkbook();
|
|
1125
|
+
* wb.setErrors(validationResults, { layer: 'validation' });
|
|
1126
|
+
* wb.setErrors(pdfResults, { layer: 'pdf' }); // the validation layer is untouched
|
|
1127
|
+
* wb.setVisibleLayers(['pdf']);
|
|
1128
|
+
* ```
|
|
1129
|
+
*/
|
|
1130
|
+
setErrors(review, o = {}, origin) {
|
|
1131
|
+
const env = Array.isArray(review) ? undefined : review;
|
|
1132
|
+
const list = env ? (Array.isArray(env.errors) ? env.errors : []) : review;
|
|
1133
|
+
if (o.layer !== undefined) {
|
|
1134
|
+
if (!isLayerName(o.layer)) {
|
|
1135
|
+
console.warn(spMessage('SP-3021', `setErrors: ${JSON.stringify(o.layer)} is not a layer name, so nothing changed`));
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
this.replaceLayer(list, o.layer, origin);
|
|
1139
|
+
return;
|
|
678
1140
|
}
|
|
1141
|
+
const errors = this.arriving(list).map((e) => restoredStatus(withMeta({ ...e, v: 1, matchesExpected: false, layer: keptLayer(e) }, e.meta)));
|
|
1142
|
+
for (const e of errors)
|
|
1143
|
+
this.reserve(e.id);
|
|
679
1144
|
this.undoStack = [];
|
|
680
1145
|
this.redoStack = [];
|
|
681
1146
|
this.pluginErrors.clear(); // ids now name the restored list's errors; validate re-adopts its own by what they say, never by id
|
|
1147
|
+
this.known = (env && Array.isArray(env.layers) ? env.layers : []).filter((l, i, a) => keptLayer({ layer: l }) === l && a.indexOf(l) === i);
|
|
1148
|
+
this.layersDirty = true;
|
|
682
1149
|
this.set({ errors: this.withMatches(errors, this.snap.changes) }, [{ type: 'errors', origin }, { type: 'history', origin, reason: 'cleared' }]);
|
|
683
1150
|
this.runValidate(this.allSheets(), origin);
|
|
684
1151
|
}
|
|
1152
|
+
/** `setErrors(list, { layer })` (W-51): one layer replaced; the other layers, and the undo stack rebased onto the new layer, kept. */
|
|
1153
|
+
replaceLayer(list, layer, origin) {
|
|
1154
|
+
const mine = (e) => layerOf(e) === layer;
|
|
1155
|
+
const history = [...this.undoStack, ...this.redoStack];
|
|
1156
|
+
const taken = new Set(this.snap.errors.filter((e) => !mine(e)).map((e) => e.id));
|
|
1157
|
+
for (const h of history)
|
|
1158
|
+
for (const e of [...h.before.errors, ...h.after.errors])
|
|
1159
|
+
if (!mine(e))
|
|
1160
|
+
taken.add(e.id);
|
|
1161
|
+
const incoming = this.arriving(list).map((e) => restoredStatus(withMeta({ ...e, v: 1, matchesExpected: false, layer }, e.meta)));
|
|
1162
|
+
for (const e of incoming)
|
|
1163
|
+
this.reserve(e.id);
|
|
1164
|
+
const fresh = incoming.map((e) => { const ok = typeof e.id === 'string' && e.id !== '' && !taken.has(e.id); const x = ok ? e : { ...e, id: this.newId() }; taken.add(x.id); return x; });
|
|
1165
|
+
const swap = (errors) => [...errors.filter((e) => !mine(e)), ...fresh];
|
|
1166
|
+
for (const h of history) {
|
|
1167
|
+
h.before = { changes: h.before.changes, errors: swap(h.before.errors) };
|
|
1168
|
+
h.after = { changes: h.after.changes, errors: swap(h.after.errors) };
|
|
1169
|
+
}
|
|
1170
|
+
const gone = new Set(this.snap.errors.filter(mine).map((e) => e.id));
|
|
1171
|
+
for (const [k, id] of this.pluginErrors)
|
|
1172
|
+
if (gone.has(id))
|
|
1173
|
+
this.pluginErrors.delete(k); // a provider re-adopts its own by what they say
|
|
1174
|
+
if (!this.known.includes(layer))
|
|
1175
|
+
this.known.push(layer); // a check that found nothing is still a layer
|
|
1176
|
+
this.layersDirty = true;
|
|
1177
|
+
this.set({ errors: this.withMatches(swap(this.snap.errors), this.snap.changes) }, [{ type: 'errors', origin }]);
|
|
1178
|
+
this.runValidate(this.allSheets(), origin);
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Shows the errors of some layers only (W-51): the sheet's marks, pins and counts, the tab counts, the error list and
|
|
1182
|
+
* the comments an export writes all follow. `null` or `[]` shows every layer. Naming a layer that has no errors yet
|
|
1183
|
+
* adds it to the pane's layers. Not undone; it is view state, like the mode.
|
|
1184
|
+
* @param names - The layers to show, or `null` for every layer.
|
|
1185
|
+
* @param origin - The element that asked.
|
|
1186
|
+
* @returns `false` when a name is unusable (logged as SP-3021) — nothing changes.
|
|
1187
|
+
*/
|
|
1188
|
+
setVisibleLayers(names, origin) {
|
|
1189
|
+
const list = names && names.length ? [...new Set(names)] : null;
|
|
1190
|
+
const bad = list?.find((l) => !isLayerName(l));
|
|
1191
|
+
if (list && bad !== undefined) {
|
|
1192
|
+
console.warn(spMessage('SP-3021', `layers: ${JSON.stringify(bad)} is not a layer name, so the visible layers did not change`));
|
|
1193
|
+
return false;
|
|
1194
|
+
}
|
|
1195
|
+
if ((list ?? []).join(' ') === (this.shown ?? []).join(' '))
|
|
1196
|
+
return true;
|
|
1197
|
+
this.shown = list;
|
|
1198
|
+
this.layersDirty = true;
|
|
1199
|
+
this.set({}, [{ type: 'layers', origin }]);
|
|
1200
|
+
return true;
|
|
1201
|
+
}
|
|
1202
|
+
/** The layer a new error goes into when none is named (W-51): `review` while it is visible, else the first visible layer — so what a person adds stays in sight. */
|
|
1203
|
+
get defaultLayer() { const v = this.shown; return !v || v.includes(DEFAULT_LAYER) ? DEFAULT_LAYER : v[0]; }
|
|
1204
|
+
/**
|
|
1205
|
+
* What `sheetpane-errors` carries (W-51, `v: 2`): every error, the plugin ids and the pane's layers — what a host saves
|
|
1206
|
+
* and gives back to `setErrors()`.
|
|
1207
|
+
* @param changed - The layers whose errors changed in this event.
|
|
1208
|
+
* @returns A new envelope; its errors are copies.
|
|
1209
|
+
*/
|
|
1210
|
+
envelope(changed = []) {
|
|
1211
|
+
return { v: 2, errors: this.getErrors(), plugins: this.pluginIds, layers: [...this.snap.layers], changed: [...changed] };
|
|
1212
|
+
}
|
|
685
1213
|
// ── undo / redo (W-23): one stack per pane ──────────────────────────────
|
|
686
1214
|
/** Undoes the pane's last step (changes and errors; never selection or mode). */
|
|
687
1215
|
undo(origin) { this.travel(this.undoStack, this.redoStack, 'before', origin, 'undo'); }
|
|
@@ -694,6 +1222,10 @@ export class WorkbookStore {
|
|
|
694
1222
|
h.coalesce = undefined;
|
|
695
1223
|
to.push(h);
|
|
696
1224
|
const state = h[side];
|
|
1225
|
+
if (h.structure) {
|
|
1226
|
+
this.travelStructure(h, side, origin, reason);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
697
1229
|
const events = [{ type: 'history', origin, reason }];
|
|
698
1230
|
const cur = this.snap.changes;
|
|
699
1231
|
for (const k of new Set([...h.keys, ...[...cur.keys()].filter((x) => cur.get(x) !== state.changes.get(x))])) {
|
|
@@ -706,7 +1238,7 @@ export class WorkbookStore {
|
|
|
706
1238
|
}
|
|
707
1239
|
if (this.snap.errors !== state.errors)
|
|
708
1240
|
events.push({ type: 'errors', origin });
|
|
709
|
-
this.set({ changes: state.changes, errors: this.withMatches(state.errors, state.changes) }, events);
|
|
1241
|
+
this.set({ changes: state.changes, errors: this.withMatches(this.arriving(state.errors), state.changes) }, events);
|
|
710
1242
|
const sheets = [...new Set(h.keys.map((k) => splitKey(k).sheet))];
|
|
711
1243
|
if (sheets.length) {
|
|
712
1244
|
this.rules.touch(h.keys.map(splitKey).filter((c) => !c.ref.startsWith('#')));
|
|
@@ -716,6 +1248,162 @@ export class WorkbookStore {
|
|
|
716
1248
|
void this.runDerive({ kind: 'change', cells });
|
|
717
1249
|
}
|
|
718
1250
|
}
|
|
1251
|
+
// ── rows and columns (W-52): one change of kind `structure` per operation ──
|
|
1252
|
+
/**
|
|
1253
|
+
* Inserts rows above row `at` (0-based): everything at and below it moves down — cells, changes,
|
|
1254
|
+
* errors, merges, rules, notes, drawings, sizes, hidden and outline state, and every formula that
|
|
1255
|
+
* points there. One undo step; written into the download.
|
|
1256
|
+
* @param sheet - The sheet's name.
|
|
1257
|
+
* @param at - The first new row, 0-based.
|
|
1258
|
+
* @param count - How many rows (default 1).
|
|
1259
|
+
* @param origin - The element that asked.
|
|
1260
|
+
* @returns False when refused (see {@link WorkbookStore.structureRefusal}) or nothing is loaded.
|
|
1261
|
+
*/
|
|
1262
|
+
insertRows(sheet, at, count = 1, origin) { return this.restructure({ sheet, op: 'insertRows', at, count }, origin); }
|
|
1263
|
+
/**
|
|
1264
|
+
* Deletes rows `at` … `at + count − 1` (0-based) and what sits on them; references to them become `#REF!`.
|
|
1265
|
+
* @param sheet - The sheet's name.
|
|
1266
|
+
* @param at - The first row to delete, 0-based.
|
|
1267
|
+
* @param count - How many rows (default 1).
|
|
1268
|
+
* @param origin - The element that asked.
|
|
1269
|
+
* @returns False when refused or nothing is loaded.
|
|
1270
|
+
*/
|
|
1271
|
+
deleteRows(sheet, at, count = 1, origin) { return this.restructure({ sheet, op: 'deleteRows', at, count }, origin); }
|
|
1272
|
+
/**
|
|
1273
|
+
* Inserts columns left of column `at` (0-based: A is 0); everything at and right of it moves right.
|
|
1274
|
+
* @param sheet - The sheet's name.
|
|
1275
|
+
* @param at - The first new column, 0-based.
|
|
1276
|
+
* @param count - How many columns (default 1).
|
|
1277
|
+
* @param origin - The element that asked.
|
|
1278
|
+
* @returns False when refused or nothing is loaded.
|
|
1279
|
+
*/
|
|
1280
|
+
insertCols(sheet, at, count = 1, origin) { return this.restructure({ sheet, op: 'insertCols', at, count }, origin); }
|
|
1281
|
+
/**
|
|
1282
|
+
* Deletes columns `at` … `at + count − 1` (0-based) and what sits on them.
|
|
1283
|
+
* @param sheet - The sheet's name.
|
|
1284
|
+
* @param at - The first column to delete, 0-based.
|
|
1285
|
+
* @param count - How many columns (default 1).
|
|
1286
|
+
* @param origin - The element that asked.
|
|
1287
|
+
* @returns False when refused or nothing is loaded.
|
|
1288
|
+
*/
|
|
1289
|
+
deleteCols(sheet, at, count = 1, origin) { return this.restructure({ sheet, op: 'deleteCols', at, count }, origin); }
|
|
1290
|
+
/**
|
|
1291
|
+
* Why an insert or delete would be refused — the file's protection first (SP-3026, W-62: the workbook's structure
|
|
1292
|
+
* is protected, or the sheet is protected without the matching permission), then a merge cut (SP-3022), a pivot
|
|
1293
|
+
* moved (SP-3023), cells pushed off the sheet (SP-3024), a table cut (SP-3025) — or `undefined` when it may go ahead.
|
|
1294
|
+
* @param o - The operation.
|
|
1295
|
+
* @returns The SP code and a sentence for the person, or `undefined`.
|
|
1296
|
+
*/
|
|
1297
|
+
structureRefusal(o) {
|
|
1298
|
+
const book = this.snap.book;
|
|
1299
|
+
return book ? structureProtectionIn(this.snap, o) ?? structureRefusal(book, this.merges(o.sheet), o) : undefined;
|
|
1300
|
+
}
|
|
1301
|
+
/** Every insert and delete made since the file was read, in order per sheet — what the download replays first. */
|
|
1302
|
+
structureOps() {
|
|
1303
|
+
return [...this.snap.changes].filter(([, c]) => c.kind === 'structure').flatMap(([, c]) => c.now);
|
|
1304
|
+
}
|
|
1305
|
+
/** The one structural path: check, move every table, one undo step, one `structure` change event. */
|
|
1306
|
+
restructure(o, origin) {
|
|
1307
|
+
const book = this.snap.book;
|
|
1308
|
+
if (!book || !this.sheet(o.sheet) || !Number.isInteger(o.at) || o.at < 0 || !Number.isInteger(o.count) || o.count < 1)
|
|
1309
|
+
return false;
|
|
1310
|
+
const no = this.structureRefusal(o);
|
|
1311
|
+
if (no) {
|
|
1312
|
+
console.warn(spMessage(no.code, `sheetpane: ${no.message}`));
|
|
1313
|
+
return false;
|
|
1314
|
+
}
|
|
1315
|
+
const before = { book, derived: this.snap.derived, pluginErrors: new Map(this.pluginErrors) };
|
|
1316
|
+
const moved = shiftErrors(this.snap.errors, o);
|
|
1317
|
+
const after = { book: restructureWorkbook(book, o), derived: shiftDerived(this.snap.derived, o), pluginErrors: shiftPluginErrors(this.pluginErrors, this.snap.errors, moved.errors, moved.gone) };
|
|
1318
|
+
const changes = shiftChanges(this.snap.changes, o, this.carryEdit);
|
|
1319
|
+
const k = key(o.sheet, STRUCTURE_REF);
|
|
1320
|
+
this.editMeta.set(changes.get(k), { seq: ++this.editSeq, at: Date.now(), base: undefined }); // the sheet's one Edits row, newest (W-64)
|
|
1321
|
+
this.undoStack.push({ before: { changes: this.snap.changes, errors: this.snap.errors }, after: { changes, errors: moved.errors }, keys: [k], coalesce: undefined, structure: { ops: [o], before, after } });
|
|
1322
|
+
if (this.undoStack.length > HISTORY_CAP)
|
|
1323
|
+
this.undoStack.shift();
|
|
1324
|
+
this.redoStack = [];
|
|
1325
|
+
this.applyStructure([o], after, changes, moved.errors, origin, [{ type: 'errors', origin }]);
|
|
1326
|
+
return true;
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Reverts edits that include a sheet's `structure` entry (W-52 × W-64) as one undo step: the other edits named go,
|
|
1330
|
+
* then each named sheet's inserts and deletes are undone, newest first — the changes, errors, derived values and
|
|
1331
|
+
* selection moved back — and the workbook is the one as read with every operation still standing replayed on it, so
|
|
1332
|
+
* deleted rows come back as the file had them. An edit made inside a deleted row was dropped with it and stays gone.
|
|
1333
|
+
*/
|
|
1334
|
+
revertStructure(all, origin) {
|
|
1335
|
+
const book0 = this.snap.book;
|
|
1336
|
+
if (!book0 || !this.baseBook)
|
|
1337
|
+
return 0;
|
|
1338
|
+
let changes = new Map(this.snap.changes);
|
|
1339
|
+
const struct = [], done = [], events = [];
|
|
1340
|
+
for (const k of all) {
|
|
1341
|
+
const prev = changes.get(k);
|
|
1342
|
+
if (!prev)
|
|
1343
|
+
continue;
|
|
1344
|
+
if (prev.kind === 'structure') {
|
|
1345
|
+
struct.push(k);
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
changes.delete(k);
|
|
1349
|
+
done.push(k);
|
|
1350
|
+
const { sheet, ref } = splitKey(k);
|
|
1351
|
+
events.push({ type: 'change', origin, sheet, ref: baseRef(ref), kind: prev.kind, was: prev.now, now: prev.was });
|
|
1352
|
+
}
|
|
1353
|
+
const opsOf = (c) => (Array.isArray(c?.now) ? c.now : []);
|
|
1354
|
+
const ops = struct.flatMap((k) => [...opsOf(this.snap.changes.get(k))].reverse().map(inverseOp));
|
|
1355
|
+
let errors = this.snap.errors, derived = this.snap.derived, plugin = new Map(this.pluginErrors);
|
|
1356
|
+
for (const o of ops) {
|
|
1357
|
+
const moved = shiftErrors(errors, o);
|
|
1358
|
+
plugin = shiftPluginErrors(plugin, errors, moved.errors, moved.gone);
|
|
1359
|
+
errors = moved.errors;
|
|
1360
|
+
changes = shiftChanges(changes, o, this.carryEdit);
|
|
1361
|
+
derived = shiftDerived(derived, o);
|
|
1362
|
+
}
|
|
1363
|
+
for (const k of struct)
|
|
1364
|
+
changes.delete(k); // the undoing operations were appended to the entry: it goes whole
|
|
1365
|
+
let book = this.baseBook;
|
|
1366
|
+
for (const [, c] of changes)
|
|
1367
|
+
if (c.kind === 'structure')
|
|
1368
|
+
for (const o of opsOf(c))
|
|
1369
|
+
book = restructureWorkbook(book, o);
|
|
1370
|
+
const before = { book: book0, derived: this.snap.derived, pluginErrors: new Map(this.pluginErrors) };
|
|
1371
|
+
const after = { book, derived, pluginErrors: plugin };
|
|
1372
|
+
this.undoStack.push({ before: { changes: this.snap.changes, errors: this.snap.errors }, after: { changes, errors }, keys: [...done, ...struct], coalesce: undefined, structure: { ops, before, after } });
|
|
1373
|
+
if (this.undoStack.length > HISTORY_CAP)
|
|
1374
|
+
this.undoStack.shift();
|
|
1375
|
+
this.redoStack = [];
|
|
1376
|
+
this.applyStructure(ops, after, changes, errors, origin, [{ type: 'errors', origin }], events);
|
|
1377
|
+
return done.length + struct.length;
|
|
1378
|
+
}
|
|
1379
|
+
travelStructure(h, side, origin, reason) {
|
|
1380
|
+
const s = h.structure, ops = side === 'before' ? [...s.ops].reverse().map(inverseOp) : s.ops;
|
|
1381
|
+
this.applyStructure(ops, s[side], h[side].changes, h[side].errors, origin, [{ type: 'history', origin, reason }, { type: 'errors', origin }]);
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Puts a structural state in place: the moved book first (formatting reads it), then one notification — a `structure`
|
|
1385
|
+
* change event per operation, after `events[0]`, then `extra` — then validate and derive.
|
|
1386
|
+
*/
|
|
1387
|
+
applyStructure(ops, st, changes, errors, origin, events, extra = []) {
|
|
1388
|
+
this.textCache.clear();
|
|
1389
|
+
this.pluginErrors.clear();
|
|
1390
|
+
this.focus = undefined; // W-50: Show ▸'s range named cells that have moved
|
|
1391
|
+
for (const [k, v] of st.pluginErrors)
|
|
1392
|
+
this.pluginErrors.set(k, v);
|
|
1393
|
+
this.snap = { ...this.snap, book: st.book, changes, derived: st.derived };
|
|
1394
|
+
const moves = ops.map((o) => {
|
|
1395
|
+
const last = o.at + o.count - 1;
|
|
1396
|
+
const ref = isRowOp(o) ? `${o.at + 1}:${last + 1}` : `${cellRef(0, o.at).replace(/\d+$/, '')}:${cellRef(0, last).replace(/\d+$/, '')}`;
|
|
1397
|
+
return { type: 'change', origin, sheet: o.sheet, ref, kind: 'structure', was: null, now: null, op: o.op, at: o.at, count: o.count };
|
|
1398
|
+
});
|
|
1399
|
+
const selection = ops.reduce(shiftSelection, this.snap.selection), composing = ops.reduce(shiftComposing, this.snap.composing);
|
|
1400
|
+
this.set({ book: st.book, changes, derived: st.derived, errors: this.withMatches(errors, changes), selection, composing }, [events[0], ...moves, ...extra, ...events.slice(1)]);
|
|
1401
|
+
this.rules.reset();
|
|
1402
|
+
this.runValidate(this.allSheets(), origin);
|
|
1403
|
+
const o = ops[ops.length - 1];
|
|
1404
|
+
if (o)
|
|
1405
|
+
void this.runDerive({ kind: 'restructure', sheet: o.sheet, op: o.op, at: o.at, count: o.count }); // the whole derived set comes back
|
|
1406
|
+
}
|
|
719
1407
|
// ── plugin hooks: derive, validate ───────────────────────────────────────
|
|
720
1408
|
async runDerive(input) {
|
|
721
1409
|
const p = this.plugins.find((x) => x.provides.includes('derive'));
|
|
@@ -732,7 +1420,7 @@ export class WorkbookStore {
|
|
|
732
1420
|
}
|
|
733
1421
|
if (load !== this.loadSeq)
|
|
734
1422
|
return; // a newer workbook replaced this one
|
|
735
|
-
const derived = new Map(input.kind === 'load' ? [] : this.snap.derived);
|
|
1423
|
+
const derived = new Map(input.kind === 'load' || input.kind === 'restructure' ? [] : this.snap.derived); // a load or a restructure returns the whole set
|
|
736
1424
|
for (const d of out) {
|
|
737
1425
|
const k = key(d.sheet, d.ref);
|
|
738
1426
|
if (d.clear)
|
|
@@ -754,10 +1442,11 @@ export class WorkbookStore {
|
|
|
754
1442
|
return;
|
|
755
1443
|
let errors = [...this.snap.errors];
|
|
756
1444
|
let changed = false;
|
|
1445
|
+
const gone = new Set();
|
|
757
1446
|
const at = new Map(errors.map((e, i) => [e.id, i]));
|
|
758
1447
|
// restored errors a provider may adopt (setErrors): by what they say, built once
|
|
759
1448
|
let adoptable;
|
|
760
|
-
const contentKey = (e) => `${e.sheet}|${e.range}|${e.type}|${e.author}`;
|
|
1449
|
+
const contentKey = (e) => `${e.sheet}|${e.range}|${e.type}|${e.author}|${layerOf(e)}`;
|
|
761
1450
|
const adopt = (d) => {
|
|
762
1451
|
if (!adoptable) {
|
|
763
1452
|
adoptable = new Map();
|
|
@@ -784,11 +1473,22 @@ export class WorkbookStore {
|
|
|
784
1473
|
continue;
|
|
785
1474
|
}
|
|
786
1475
|
const seen = new Set();
|
|
787
|
-
for (const
|
|
788
|
-
|
|
1476
|
+
for (const raw of drafts) {
|
|
1477
|
+
if (this.resultsLevel === 'off' && isResult(raw))
|
|
1478
|
+
continue; // `results="off"` (W-50)
|
|
1479
|
+
// W-51: a draft lands in `review` unless its plugin names a layer
|
|
1480
|
+
let layer = DEFAULT_LAYER;
|
|
1481
|
+
if (raw.layer !== undefined && isLayerName(raw.layer))
|
|
1482
|
+
layer = raw.layer;
|
|
1483
|
+
else if (raw.layer !== undefined && !this.badLayerWarned.has(p.id)) {
|
|
1484
|
+
this.badLayerWarned.add(p.id);
|
|
1485
|
+
console.warn(spMessage('SP-3021', `plugin "${p.id}": ${JSON.stringify(raw.layer)} is not a layer name, so its errors went into "${DEFAULT_LAYER}"`));
|
|
1486
|
+
}
|
|
1487
|
+
const d = { ...raw, layer };
|
|
1488
|
+
const k = `${p.id}|${d.sheet}|${d.range}|${d.type}${d.key ? `|${d.key}` : ''}${layer === DEFAULT_LAYER ? '' : `|@${layer}`}`;
|
|
789
1489
|
seen.add(k);
|
|
790
1490
|
const id = this.pluginErrors.get(k);
|
|
791
|
-
const mine = (e) => !!e && e.sheet === d.sheet && e.range === d.range && e.type === d.type && e.author === d.author;
|
|
1491
|
+
const mine = (e) => !!e && e.sheet === d.sheet && e.range === d.range && e.type === d.type && e.author === d.author && layerOf(e) === layer;
|
|
792
1492
|
let i = id !== undefined ? at.get(id) ?? -1 : -1;
|
|
793
1493
|
if (i >= 0 && !mine(errors[i]))
|
|
794
1494
|
i = -1;
|
|
@@ -798,7 +1498,7 @@ export class WorkbookStore {
|
|
|
798
1498
|
this.pluginErrors.set(k, errors[i].id);
|
|
799
1499
|
}
|
|
800
1500
|
if (i < 0) {
|
|
801
|
-
const e = { v: 1, id: this.newId(), sheet: d.sheet, range: d.range, type: d.type, severity: d.severity, found: d.found, expected: d.expected, message: d.message, cellChanged: !!d.cellChanged, status: d.status ?? 'open', matchesExpected: false, author: d.author, createdAt: new Date().toISOString() }; // `key` stays out of the error
|
|
1501
|
+
const e = { v: 1, id: this.newId(), sheet: d.sheet, range: d.range, type: d.type, severity: d.severity, found: d.found, expected: d.expected, message: d.message, cellChanged: !!d.cellChanged, status: d.status ?? 'open', matchesExpected: false, author: d.author, createdAt: new Date().toISOString(), layer }; // `key` stays out of the error
|
|
802
1502
|
if (d.code)
|
|
803
1503
|
e.code = d.code;
|
|
804
1504
|
this.pluginErrors.set(k, e.id);
|
|
@@ -806,9 +1506,9 @@ export class WorkbookStore {
|
|
|
806
1506
|
errors.push(e);
|
|
807
1507
|
changed = true;
|
|
808
1508
|
}
|
|
809
|
-
else if (errors[i].author !== 'file' && (errors[i].status === 'resolved' || errors[i].found !== d.found || errors[i].message !== d.message || errors[i].severity !== d.severity || errors[i].code !== d.code)) {
|
|
810
|
-
// a machine-authored error follows the cell: reopened, and its found / message refreshed
|
|
811
|
-
const { code: _was, ...rest } = errors[i];
|
|
1509
|
+
else if (errors[i].author !== 'file' && (errors[i].status === 'resolved' || errors[i].found !== d.found || errors[i].expected !== d.expected || errors[i].message !== d.message || errors[i].severity !== d.severity || errors[i].code !== d.code)) {
|
|
1510
|
+
// a machine-authored error follows the cell: reopened, and its found / expected / message refreshed (W-60: a dependent list's choices change with its parent)
|
|
1511
|
+
const { code: _was, ...rest } = withoutAcceptance(errors[i]); // an acceptance was for what it found then (W-69)
|
|
812
1512
|
errors[i] = { ...rest, status: d.status ?? 'open', message: d.message, expected: d.expected, found: d.found, severity: d.severity, ...(d.code ? { code: d.code } : {}) };
|
|
813
1513
|
changed = true;
|
|
814
1514
|
}
|
|
@@ -818,16 +1518,117 @@ export class WorkbookStore {
|
|
|
818
1518
|
if (!k.startsWith(`${p.id}|${sheet}|`) || seen.has(k))
|
|
819
1519
|
continue;
|
|
820
1520
|
const i = at.get(id) ?? -1;
|
|
821
|
-
|
|
822
|
-
|
|
1521
|
+
// a result the provider no longer reports is no longer true: it goes, it never resolves (W-50)
|
|
1522
|
+
if (i >= 0 && isResult(errors[i])) {
|
|
1523
|
+
gone.add(id);
|
|
1524
|
+
this.pluginErrors.delete(k);
|
|
1525
|
+
changed = true;
|
|
1526
|
+
}
|
|
1527
|
+
else if (i >= 0 && (errors[i].author === 'workbook' || errors[i].author === 'host') && (errors[i].status === 'open' || errors[i].status === 'accepted')) {
|
|
1528
|
+
errors[i] = { ...withoutAcceptance(errors[i]), status: 'resolved' };
|
|
823
1529
|
changed = true;
|
|
824
1530
|
}
|
|
825
1531
|
}
|
|
826
1532
|
}
|
|
827
1533
|
}
|
|
1534
|
+
if (gone.size)
|
|
1535
|
+
errors = errors.filter((e) => !gone.has(e.id));
|
|
828
1536
|
if (changed)
|
|
829
1537
|
this.set({ errors: this.withMatches(errors, this.snap.changes) }, [{ type: 'errors', origin }]);
|
|
830
1538
|
}
|
|
1539
|
+
// ── results (W-50): `pass` and `skipped` entries — counted apart, marked only when asked ──
|
|
1540
|
+
/** How much of the results the pane shows: `off`, `summary` (the default) or `marks`. */
|
|
1541
|
+
get results() { return this.resultsLevel; }
|
|
1542
|
+
/**
|
|
1543
|
+
* Sets the pane's results level — a view setting, never a change or an undo step. `off` drops
|
|
1544
|
+
* the results the pane holds and every one that arrives after.
|
|
1545
|
+
* @param level - `off`, `summary` or `marks`; anything else is ignored.
|
|
1546
|
+
*/
|
|
1547
|
+
setResults(level) {
|
|
1548
|
+
if (!RESULTS_LEVELS.includes(level) || level === this.resultsLevel)
|
|
1549
|
+
return;
|
|
1550
|
+
this.resultsLevel = level;
|
|
1551
|
+
this.focus = undefined;
|
|
1552
|
+
if (level === 'off' && this.snap.errors.some(isResult))
|
|
1553
|
+
this.set({ errors: this.snap.errors.filter((e) => !isResult(e)) }, [{ type: 'errors', origin: undefined }]);
|
|
1554
|
+
else
|
|
1555
|
+
this.set({});
|
|
1556
|
+
}
|
|
1557
|
+
/** The range whose results are marked on the sheet below `marks` (Show ▸ in the errors panel), or undefined. */
|
|
1558
|
+
get resultFocus() { return this.focus; }
|
|
1559
|
+
/**
|
|
1560
|
+
* Marks one range's results on the sheet even at `summary` (Show ▸); `undefined` clears it. A view setting.
|
|
1561
|
+
* @param sheet - The sheet's name.
|
|
1562
|
+
* @param range - An A1 range, or undefined to clear.
|
|
1563
|
+
*/
|
|
1564
|
+
focusResults(sheet, range) {
|
|
1565
|
+
const next = range && parseRange(range) ? { sheet, range } : undefined;
|
|
1566
|
+
if (next?.sheet === this.focus?.sheet && next?.range === this.focus?.range)
|
|
1567
|
+
return;
|
|
1568
|
+
this.focus = next;
|
|
1569
|
+
this.set({});
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* The result a cell shows — `pass` or `skipped` — or undefined. A problem always wins: a cell any
|
|
1573
|
+
* problem covers has no result. Indexed once per errors list, so the grid asks per cell.
|
|
1574
|
+
* @param sheet - The sheet's name.
|
|
1575
|
+
* @param row - The 0-based row.
|
|
1576
|
+
* @param col - The 0-based column.
|
|
1577
|
+
* @returns The result entry, or undefined.
|
|
1578
|
+
*/
|
|
1579
|
+
resultAt(sheet, row, col) {
|
|
1580
|
+
const x = this.indexResults().sheets.get(sheet);
|
|
1581
|
+
if (!x)
|
|
1582
|
+
return undefined;
|
|
1583
|
+
const k = `${row},${col}`;
|
|
1584
|
+
if (x.problemCells.has(k) || x.problemRanges.some((r) => inBox(r, row, col)))
|
|
1585
|
+
return undefined;
|
|
1586
|
+
return x.cells.get(k) ?? x.ranges.find((y) => inBox(y.r, row, col))?.e;
|
|
1587
|
+
}
|
|
1588
|
+
/** Problems (entries) and results (cells) in the pane — what the summary line and the status line say. */
|
|
1589
|
+
resultCounts() { return this.indexResults().counts; }
|
|
1590
|
+
indexResults() {
|
|
1591
|
+
const errors = this.snap.visibleErrors; // W-51: what the visible layers hold
|
|
1592
|
+
if (this.resultIdx?.errors === errors)
|
|
1593
|
+
return this.resultIdx;
|
|
1594
|
+
const sheets = new Map(), counts = { problems: 0, open: 0, pass: 0, skipped: 0 };
|
|
1595
|
+
for (const e of errors) {
|
|
1596
|
+
const r = parseRange(e.range);
|
|
1597
|
+
const result = isResult(e);
|
|
1598
|
+
if (!result) {
|
|
1599
|
+
counts.problems++;
|
|
1600
|
+
if (e.status === 'open')
|
|
1601
|
+
counts.open++;
|
|
1602
|
+
}
|
|
1603
|
+
if (!r)
|
|
1604
|
+
continue;
|
|
1605
|
+
if (result)
|
|
1606
|
+
counts[e.severity] += areaOf(r);
|
|
1607
|
+
let x = sheets.get(e.sheet);
|
|
1608
|
+
if (!x)
|
|
1609
|
+
sheets.set(e.sheet, (x = { cells: new Map(), ranges: [], problemCells: new Set(), problemRanges: [] }));
|
|
1610
|
+
const one = r.top === r.bottom && r.left === r.right, k = `${r.top},${r.left}`;
|
|
1611
|
+
if (!result) {
|
|
1612
|
+
if (one)
|
|
1613
|
+
x.problemCells.add(k);
|
|
1614
|
+
else
|
|
1615
|
+
x.problemRanges.push(r);
|
|
1616
|
+
}
|
|
1617
|
+
else if (!one)
|
|
1618
|
+
x.ranges.push({ r, e });
|
|
1619
|
+
else {
|
|
1620
|
+
const was = x.cells.get(k);
|
|
1621
|
+
if (!was || SEVERITY_ORDER[e.severity] > SEVERITY_ORDER[was.severity])
|
|
1622
|
+
x.cells.set(k, e);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
this.resultIdx = { errors, sheets, counts };
|
|
1626
|
+
return this.resultIdx;
|
|
1627
|
+
}
|
|
1628
|
+
/** A list as it arrives in the pane: at `off`, without its results. */
|
|
1629
|
+
arriving(list) {
|
|
1630
|
+
return this.resultsLevel === 'off' ? list.filter((e) => !isResult(e)) : list;
|
|
1631
|
+
}
|
|
831
1632
|
// ── column rules (W-27) ──────────────────────────────────────────────────
|
|
832
1633
|
/** The host's column rules for this pane. Setting them checks every row again; host errors no longer produced go. */
|
|
833
1634
|
setColumnRules(c) { this.rules.configure(c); }
|
|
@@ -837,6 +1638,44 @@ export class WorkbookStore {
|
|
|
837
1638
|
get ruleProgress() { return this.rules.progress; }
|
|
838
1639
|
/** Whether a rules file (W-33) is in force for this pane. */
|
|
839
1640
|
get hasRulesDocument() { return this.rules.hasDocument; }
|
|
1641
|
+
// ── protection (W-62, SR-GRID decision 21e): what the file locks, kept unless the host rules otherwise ──
|
|
1642
|
+
/** `honour` (the default) keeps the file's sheet and workbook protection; `ignore` is the host's decision. */
|
|
1643
|
+
get protection() { return this.snap.protection; }
|
|
1644
|
+
/**
|
|
1645
|
+
* Keeps (`honour`) or sets aside (`ignore`) the file's protection for this pane — a view setting, never a change or
|
|
1646
|
+
* an undo step. The `protection` attribute of `<sheetpane-excel>` sets it.
|
|
1647
|
+
* @param mode - `honour` or `ignore`; anything else is `honour`.
|
|
1648
|
+
*/
|
|
1649
|
+
setProtection(mode) { const m = protectionModeOf(mode); if (m !== this.snap.protection)
|
|
1650
|
+
this.set({ protection: m }); }
|
|
1651
|
+
/**
|
|
1652
|
+
* A sheet's protection while it is honoured: the file's switches (`true` = refused while protected) and whether a
|
|
1653
|
+
* password is stored — never the password.
|
|
1654
|
+
* @param sheet - The sheet's name.
|
|
1655
|
+
* @returns The switches, or undefined when the sheet is not protected or protection is ignored.
|
|
1656
|
+
*/
|
|
1657
|
+
sheetProtection(sheet) { return sheetProtectionIn(this.snap, sheet); }
|
|
1658
|
+
/**
|
|
1659
|
+
* Whether a cell refuses edits: a protected sheet, and the cell's format locked (Excel's default). `setCell`,
|
|
1660
|
+
* `setCells` and an error's "Also change the cell" leave it as it is.
|
|
1661
|
+
* @param sheet - The sheet's name.
|
|
1662
|
+
* @param ref - The cell, `A1`.
|
|
1663
|
+
* @returns True when the cell is locked.
|
|
1664
|
+
*/
|
|
1665
|
+
isLocked(sheet, ref) { return lockedIn(this.snap, sheet, ref); }
|
|
1666
|
+
/**
|
|
1667
|
+
* Whether a cell's formula is hidden: a protected sheet, and the cell's format `hidden`. It shows its value only.
|
|
1668
|
+
* @param sheet - The sheet's name.
|
|
1669
|
+
* @param ref - The cell, `A1`.
|
|
1670
|
+
* @returns True when the formula is not shown.
|
|
1671
|
+
*/
|
|
1672
|
+
isFormulaHidden(sheet, ref) { return formulaHiddenIn(this.snap, sheet, ref); }
|
|
1673
|
+
/**
|
|
1674
|
+
* Whether the workbook's structure is protected (`lockStructure`, honoured): hiding or unhiding a sheet is
|
|
1675
|
+
* refused, and inserting or deleting rows and columns. The one predicate a structural control asks.
|
|
1676
|
+
* @returns True while structural edits are refused.
|
|
1677
|
+
*/
|
|
1678
|
+
structureLocked() { return structureLockedIn(this.snap); }
|
|
840
1679
|
// ── flagging (W-33): `off` leaves the errors panel as the one place errors are read ──
|
|
841
1680
|
flaggingOn = true;
|
|
842
1681
|
/** Whether Flag issue is on for this pane (SR-GRID decision 22e). */
|
|
@@ -912,8 +1751,15 @@ export class WorkbookStore {
|
|
|
912
1751
|
}
|
|
913
1752
|
return Array.from(rows, (r) => Array.from(r ?? [], (v) => v ?? null));
|
|
914
1753
|
}
|
|
915
|
-
/**
|
|
1754
|
+
/**
|
|
1755
|
+
* Every change applied; the upload never modified (SR-LAW-6). `sha256` is the hash of the
|
|
1756
|
+
* blob's bytes, taken once per export (W-65) — `undefined` where Web Crypto is missing (SP-1009).
|
|
1757
|
+
*/
|
|
916
1758
|
async exportFile(o = {}) {
|
|
1759
|
+
const r = await this.buildExport(o);
|
|
1760
|
+
return { ...r, sha256: await sha256(r.blob) };
|
|
1761
|
+
}
|
|
1762
|
+
async buildExport(o) {
|
|
917
1763
|
const src = this.snap.source, book = this.snap.book;
|
|
918
1764
|
if (!src || !book)
|
|
919
1765
|
throw spError('SP-3001', 'sheetpane: nothing is loaded to export');
|
|
@@ -948,7 +1794,8 @@ export class WorkbookStore {
|
|
|
948
1794
|
const outline = has('#outlineRows') || has('#outlineCols') ? { outline: { ...(has('#outlineRows') ? { rows: o.rows } : {}), ...(has('#outlineCols') ? { cols: o.cols } : {}) } } : {};
|
|
949
1795
|
return { sheet, ...(has('#hiddenRows') ? { rows: h.rows } : {}), ...(has('#hiddenCols') ? { cols: h.cols } : {}), ...(has('#sheetState') ? { state: h.state } : {}), ...outline };
|
|
950
1796
|
});
|
|
951
|
-
const
|
|
1797
|
+
const structure = this.structureOps(); // replayed on the upload first; everything below is in the sheet as it stands now
|
|
1798
|
+
const bytes = await patchXlsx(src.bytes, this.exportChanges(), { exportPatch: hooks, ...(structure.length ? { structure } : {}), ...(freeze.length ? { freeze } : {}), ...(merges.length ? { merges } : {}), ...(hidden.length ? { hidden } : {}) });
|
|
952
1799
|
return { blob: new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), kind: 'xlsx', name: src.name, guarded: [], converted: false };
|
|
953
1800
|
}
|
|
954
1801
|
// ── stale formula marks (W-01 DoD 15) ───────────────────────────────────
|