@oml/markdown 0.7.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 +39 -0
- package/out/index.d.ts +2 -0
- package/out/index.js +4 -0
- package/out/index.js.map +1 -0
- package/out/md/index.d.ts +6 -0
- package/out/md/index.js +8 -0
- package/out/md/index.js.map +1 -0
- package/out/md/md-execution.d.ts +33 -0
- package/out/md/md-execution.js +3 -0
- package/out/md/md-execution.js.map +1 -0
- package/out/md/md-executor.d.ts +21 -0
- package/out/md/md-executor.js +498 -0
- package/out/md/md-executor.js.map +1 -0
- package/out/md/md-frontmatter.d.ts +4 -0
- package/out/md/md-frontmatter.js +48 -0
- package/out/md/md-frontmatter.js.map +1 -0
- package/out/md/md-registry.d.ts +7 -0
- package/out/md/md-registry.js +19 -0
- package/out/md/md-registry.js.map +1 -0
- package/out/md/md-runtime.d.ts +10 -0
- package/out/md/md-runtime.js +166 -0
- package/out/md/md-runtime.js.map +1 -0
- package/out/md/md-types.d.ts +40 -0
- package/out/md/md-types.js +3 -0
- package/out/md/md-types.js.map +1 -0
- package/out/md/md-yaml.d.ts +1 -0
- package/out/md/md-yaml.js +15 -0
- package/out/md/md-yaml.js.map +1 -0
- package/out/renderers/chart-renderer.d.ts +6 -0
- package/out/renderers/chart-renderer.js +392 -0
- package/out/renderers/chart-renderer.js.map +1 -0
- package/out/renderers/diagram-renderer.d.ts +7 -0
- package/out/renderers/diagram-renderer.js +2354 -0
- package/out/renderers/diagram-renderer.js.map +1 -0
- package/out/renderers/graph-renderer.d.ts +6 -0
- package/out/renderers/graph-renderer.js +1384 -0
- package/out/renderers/graph-renderer.js.map +1 -0
- package/out/renderers/index.d.ts +14 -0
- package/out/renderers/index.js +16 -0
- package/out/renderers/index.js.map +1 -0
- package/out/renderers/list-renderer.d.ts +6 -0
- package/out/renderers/list-renderer.js +252 -0
- package/out/renderers/list-renderer.js.map +1 -0
- package/out/renderers/matrix-renderer.d.ts +14 -0
- package/out/renderers/matrix-renderer.js +498 -0
- package/out/renderers/matrix-renderer.js.map +1 -0
- package/out/renderers/message-renderer.d.ts +6 -0
- package/out/renderers/message-renderer.js +14 -0
- package/out/renderers/message-renderer.js.map +1 -0
- package/out/renderers/registry.d.ts +9 -0
- package/out/renderers/registry.js +41 -0
- package/out/renderers/registry.js.map +1 -0
- package/out/renderers/renderer.d.ts +28 -0
- package/out/renderers/renderer.js +61 -0
- package/out/renderers/renderer.js.map +1 -0
- package/out/renderers/table-editor-renderer.d.ts +4 -0
- package/out/renderers/table-editor-renderer.js +9 -0
- package/out/renderers/table-editor-renderer.js.map +1 -0
- package/out/renderers/table-renderer.d.ts +95 -0
- package/out/renderers/table-renderer.js +1571 -0
- package/out/renderers/table-renderer.js.map +1 -0
- package/out/renderers/text-renderer.d.ts +7 -0
- package/out/renderers/text-renderer.js +219 -0
- package/out/renderers/text-renderer.js.map +1 -0
- package/out/renderers/tree-renderer.d.ts +4 -0
- package/out/renderers/tree-renderer.js +9 -0
- package/out/renderers/tree-renderer.js.map +1 -0
- package/out/renderers/types.d.ts +18 -0
- package/out/renderers/types.js +3 -0
- package/out/renderers/types.js.map +1 -0
- package/out/renderers/wikilink-utils.d.ts +6 -0
- package/out/renderers/wikilink-utils.js +100 -0
- package/out/renderers/wikilink-utils.js.map +1 -0
- package/out/static/browser-runtime.bundle.js +74155 -0
- package/out/static/browser-runtime.bundle.js.map +7 -0
- package/out/static/browser-runtime.d.ts +1 -0
- package/out/static/browser-runtime.js +218 -0
- package/out/static/browser-runtime.js.map +1 -0
- package/out/static/index.d.ts +1 -0
- package/out/static/index.js +3 -0
- package/out/static/index.js.map +1 -0
- package/out/static/runtime-assets.d.ts +2 -0
- package/out/static/runtime-assets.js +174 -0
- package/out/static/runtime-assets.js.map +1 -0
- package/package.json +74 -0
- package/src/index.ts +4 -0
- package/src/md/index.ts +8 -0
- package/src/md/md-execution.ts +51 -0
- package/src/md/md-executor.ts +598 -0
- package/src/md/md-frontmatter.ts +53 -0
- package/src/md/md-registry.ts +22 -0
- package/src/md/md-runtime.ts +191 -0
- package/src/md/md-types.ts +48 -0
- package/src/md/md-yaml.ts +17 -0
- package/src/renderers/chart-renderer.ts +473 -0
- package/src/renderers/diagram-renderer.ts +2520 -0
- package/src/renderers/graph-renderer.ts +1653 -0
- package/src/renderers/index.ts +16 -0
- package/src/renderers/list-renderer.ts +289 -0
- package/src/renderers/matrix-renderer.ts +616 -0
- package/src/renderers/message-renderer.ts +18 -0
- package/src/renderers/registry.ts +45 -0
- package/src/renderers/renderer.ts +84 -0
- package/src/renderers/table-editor-renderer.ts +8 -0
- package/src/renderers/table-renderer.ts +1868 -0
- package/src/renderers/text-renderer.ts +252 -0
- package/src/renderers/tree-renderer.ts +7 -0
- package/src/renderers/types.ts +22 -0
- package/src/renderers/wikilink-utils.ts +108 -0
- package/src/static/browser-runtime.ts +249 -0
- package/src/static/index.ts +3 -0
- package/src/static/runtime-assets.ts +175 -0
|
@@ -0,0 +1,1868 @@
|
|
|
1
|
+
// Copyright (c) 2026 Modelware. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import { QueryMarkdownBlockRenderer, type TableBlockKind } from './renderer.js';
|
|
4
|
+
import type { MdBlockExecutionResult } from './types.js';
|
|
5
|
+
import { appendTokenizedValueParts, isIriValue, shortLabelFromIri } from './wikilink-utils.js';
|
|
6
|
+
|
|
7
|
+
type TableEditorActionKind = 'add' | 'remove' | 'validate' | 'link' | 'undo' | 'redo' | 'ai' | 'properties';
|
|
8
|
+
type TableEditorActionContext = {
|
|
9
|
+
action: TableEditorActionKind;
|
|
10
|
+
root: HTMLElement;
|
|
11
|
+
columns: string[];
|
|
12
|
+
rows: Array<{ index: number; cells: string[] }>;
|
|
13
|
+
row?: { index: number; cells: string[] };
|
|
14
|
+
propertyDefinitions?: Array<{
|
|
15
|
+
name: string;
|
|
16
|
+
predicate: string;
|
|
17
|
+
multiple: boolean;
|
|
18
|
+
datatypeIri?: string;
|
|
19
|
+
classIri?: string;
|
|
20
|
+
nodeKind?: string;
|
|
21
|
+
enumValues?: string[];
|
|
22
|
+
editor?: 'multiline';
|
|
23
|
+
}>;
|
|
24
|
+
blockSource?: string;
|
|
25
|
+
typeOptions?: string[];
|
|
26
|
+
instanceNamePrefix?: string;
|
|
27
|
+
pointerX?: number;
|
|
28
|
+
pointerY?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class TableMarkdownBlockRenderer extends QueryMarkdownBlockRenderer {
|
|
32
|
+
protected readonly tableKinds: ReadonlyArray<TableBlockKind> = ['table'];
|
|
33
|
+
|
|
34
|
+
canRender(result: MdBlockExecutionResult): boolean {
|
|
35
|
+
return result.status === 'ok'
|
|
36
|
+
&& result.format === 'table'
|
|
37
|
+
&& !!result.payload
|
|
38
|
+
&& this.tableKinds.includes(result.kind as TableBlockKind);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
render(result: MdBlockExecutionResult): HTMLElement {
|
|
42
|
+
const container = this.createResultContainer(result.status);
|
|
43
|
+
container.classList.add('oml-md-result-table');
|
|
44
|
+
const stylesheet = compileTableStylesheet(result.options);
|
|
45
|
+
const tableRoot = this.renderInteractiveTable(
|
|
46
|
+
result.payload?.columns ?? [],
|
|
47
|
+
result.payload?.rows ?? [],
|
|
48
|
+
stylesheet,
|
|
49
|
+
result.options,
|
|
50
|
+
result.blockSource
|
|
51
|
+
);
|
|
52
|
+
if (typeof result.blockId === 'string' && result.blockId.length > 0) {
|
|
53
|
+
tableRoot.dataset.tableBlockId = result.blockId;
|
|
54
|
+
}
|
|
55
|
+
container.appendChild(tableRoot);
|
|
56
|
+
return container;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
protected appendCustomLeftControls(
|
|
60
|
+
_leftControls: HTMLDivElement,
|
|
61
|
+
_context: Omit<TableEditorActionContext, 'action'> & { options?: Record<string, unknown> }
|
|
62
|
+
): void {
|
|
63
|
+
// Default tables do not add extra controls.
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
protected decorateCellValueElement(params: {
|
|
67
|
+
row: { index: number; cells: string[] };
|
|
68
|
+
columnIndex: number;
|
|
69
|
+
columns: string[];
|
|
70
|
+
value: string;
|
|
71
|
+
valueElement: HTMLElement;
|
|
72
|
+
}): HTMLElement {
|
|
73
|
+
return params.valueElement;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
protected shouldIgnoreRowDoubleClick(_event: MouseEvent): boolean {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
protected onRowDoubleClick(_context: Omit<TableEditorActionContext, 'action'>): void {
|
|
81
|
+
// Default tables do not define row double-click behavior.
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
protected dispatchTableEditorAction(context: TableEditorActionContext): void {
|
|
85
|
+
const event = new CustomEvent('oml-table-editor-action', { detail: context });
|
|
86
|
+
window.dispatchEvent(event);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
protected renderInteractiveTable(
|
|
90
|
+
columns: string[],
|
|
91
|
+
rows: string[][],
|
|
92
|
+
stylesheet: CompiledTableStyleRule[],
|
|
93
|
+
options: Record<string, unknown> | undefined,
|
|
94
|
+
blockSource?: string
|
|
95
|
+
): HTMLElement {
|
|
96
|
+
const root = document.createElement('div');
|
|
97
|
+
root.className = 'table-root graph-root';
|
|
98
|
+
const isTree = this.tableKinds.includes('tree');
|
|
99
|
+
const allRowsWithIndex = rows.map((cells, index) => ({ index, cells }));
|
|
100
|
+
const containmentColumns = isTree ? resolveContainmentColumns(columns, options) : [];
|
|
101
|
+
const baseVisibleColumnIndexes = isTree
|
|
102
|
+
? columns.map((_, index) => index).filter((index) => !containmentColumns.includes(index))
|
|
103
|
+
: columns.map((_, index) => index);
|
|
104
|
+
const baseVisibleColumns = baseVisibleColumnIndexes.map((index) => columns[index]);
|
|
105
|
+
const baseRowsWithIndex = allRowsWithIndex.map((row) => ({
|
|
106
|
+
...row,
|
|
107
|
+
cells: baseVisibleColumnIndexes.map((columnIndex) => row.cells[columnIndex] ?? ''),
|
|
108
|
+
}));
|
|
109
|
+
const selectorRowsByIndex = new Map(baseRowsWithIndex.map((row) => [row.index, row] as const));
|
|
110
|
+
const baseColumnContexts = createColumnContexts(baseVisibleColumns, baseRowsWithIndex);
|
|
111
|
+
const hiddenColumns = resolveHiddenColumns(baseVisibleColumns, baseColumnContexts, stylesheet);
|
|
112
|
+
const visibleColumnIndexes = baseVisibleColumnIndexes.filter((_, index) => !hiddenColumns.has(baseVisibleColumns[index]));
|
|
113
|
+
const visibleColumns = visibleColumnIndexes.map((index) => columns[index]);
|
|
114
|
+
const rowsWithIndex = allRowsWithIndex.map((row) => ({
|
|
115
|
+
...row,
|
|
116
|
+
cells: visibleColumnIndexes.map((columnIndex) => row.cells[columnIndex] ?? ''),
|
|
117
|
+
}));
|
|
118
|
+
const columnContexts = createColumnContexts(visibleColumns, rowsWithIndex);
|
|
119
|
+
const treeModel = isTree
|
|
120
|
+
? this.createTreeModel(columns, allRowsWithIndex, visibleColumnIndexes, options)
|
|
121
|
+
: undefined;
|
|
122
|
+
const fullyExpandedTreeRows = isTree && treeModel
|
|
123
|
+
? this.flattenAllTreeRows(treeModel)
|
|
124
|
+
: undefined;
|
|
125
|
+
|
|
126
|
+
const state = {
|
|
127
|
+
search: '',
|
|
128
|
+
pageSize: 50,
|
|
129
|
+
page: 0,
|
|
130
|
+
sortKey: '' as string,
|
|
131
|
+
sortDir: 'asc' as 'asc' | 'desc',
|
|
132
|
+
hasUserSort: false,
|
|
133
|
+
columnWidths: new Array<number | undefined>(columns.length).fill(undefined),
|
|
134
|
+
initialAutosizeApplied: false,
|
|
135
|
+
expanded: new Set<string>(treeModel?.expandableNodes ?? []),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const controls = document.createElement('div');
|
|
139
|
+
controls.className = 'table-controls';
|
|
140
|
+
|
|
141
|
+
const controlsLeft = document.createElement('div');
|
|
142
|
+
controlsLeft.className = 'table-controls-left';
|
|
143
|
+
const pageSize = document.createElement('select');
|
|
144
|
+
pageSize.className = 'table-pagesize';
|
|
145
|
+
for (const size of [10, 25, 50, 100]) {
|
|
146
|
+
const option = document.createElement('option');
|
|
147
|
+
option.value = String(size);
|
|
148
|
+
option.textContent = `Show ${size}`;
|
|
149
|
+
if (size === state.pageSize) {
|
|
150
|
+
option.selected = true;
|
|
151
|
+
}
|
|
152
|
+
pageSize.appendChild(option);
|
|
153
|
+
}
|
|
154
|
+
pageSize.addEventListener('change', () => {
|
|
155
|
+
state.pageSize = Number.parseInt(pageSize.value, 10);
|
|
156
|
+
state.page = 0;
|
|
157
|
+
renderPage();
|
|
158
|
+
});
|
|
159
|
+
if (!isTree) {
|
|
160
|
+
controlsLeft.appendChild(pageSize);
|
|
161
|
+
} else if (treeModel) {
|
|
162
|
+
const expandAllButton = this.createTreeActionButton(
|
|
163
|
+
'Expand all',
|
|
164
|
+
'Expand all',
|
|
165
|
+
'M7 7h10v2H7zM7 11h10v2H7zM7 15h10v2H7zM3 11h2V9h2V7H5V5H3v2H1v2h2z'
|
|
166
|
+
);
|
|
167
|
+
expandAllButton.addEventListener('click', () => {
|
|
168
|
+
state.expanded = new Set<string>(treeModel.expandableNodes);
|
|
169
|
+
renderPage();
|
|
170
|
+
});
|
|
171
|
+
controlsLeft.appendChild(expandAllButton);
|
|
172
|
+
|
|
173
|
+
const collapseAllButton = this.createTreeActionButton(
|
|
174
|
+
'Collapse all',
|
|
175
|
+
'Collapse all',
|
|
176
|
+
'M7 7h10v2H7zM7 11h10v2H7zM7 15h10v2H7zM1 7h6v2H1z'
|
|
177
|
+
);
|
|
178
|
+
collapseAllButton.addEventListener('click', () => {
|
|
179
|
+
state.expanded = new Set<string>();
|
|
180
|
+
renderPage();
|
|
181
|
+
});
|
|
182
|
+
controlsLeft.appendChild(collapseAllButton);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const controlsRight = document.createElement('div');
|
|
186
|
+
controlsRight.className = 'table-controls-right';
|
|
187
|
+
const searchInput = document.createElement('input');
|
|
188
|
+
searchInput.type = 'search';
|
|
189
|
+
searchInput.className = 'tree-filter';
|
|
190
|
+
searchInput.placeholder = 'Filter...';
|
|
191
|
+
searchInput.addEventListener('input', () => {
|
|
192
|
+
state.search = searchInput.value.trim().toLowerCase();
|
|
193
|
+
state.page = 0;
|
|
194
|
+
renderPage();
|
|
195
|
+
});
|
|
196
|
+
controlsRight.appendChild(searchInput);
|
|
197
|
+
|
|
198
|
+
const downloadButton = document.createElement('button');
|
|
199
|
+
downloadButton.className = 'tree-download-btn';
|
|
200
|
+
downloadButton.title = 'Download CSV';
|
|
201
|
+
downloadButton.setAttribute('aria-label', 'Download CSV');
|
|
202
|
+
const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
203
|
+
iconSvg.setAttribute('viewBox', '0 0 24 24');
|
|
204
|
+
iconSvg.setAttribute('width', '20');
|
|
205
|
+
iconSvg.setAttribute('height', '20');
|
|
206
|
+
iconSvg.setAttribute('aria-hidden', 'true');
|
|
207
|
+
iconSvg.setAttribute('focusable', 'false');
|
|
208
|
+
iconSvg.style.fill = 'currentColor';
|
|
209
|
+
const iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
210
|
+
iconPath.setAttribute('d', 'M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z');
|
|
211
|
+
iconSvg.appendChild(iconPath);
|
|
212
|
+
downloadButton.appendChild(iconSvg);
|
|
213
|
+
downloadButton.addEventListener('click', () => {
|
|
214
|
+
const sourceRows = isTree && treeModel
|
|
215
|
+
? this.resolveTreeSourceRows(treeModel, visibleColumns, state.expanded, state.search)
|
|
216
|
+
: rowsWithIndex;
|
|
217
|
+
if (isTree && treeModel) {
|
|
218
|
+
this.requestTreeJsonDownload(visibleColumns, treeModel, sourceRows);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const filtered = this.applyFiltersAndSorting(columns, sourceRows, state);
|
|
222
|
+
this.requestCsvDownload(visibleColumns, filtered.map((entry) => entry.cells));
|
|
223
|
+
});
|
|
224
|
+
controlsRight.appendChild(downloadButton);
|
|
225
|
+
|
|
226
|
+
controls.appendChild(controlsLeft);
|
|
227
|
+
controls.appendChild(controlsRight);
|
|
228
|
+
root.appendChild(controls);
|
|
229
|
+
|
|
230
|
+
const wrapper = document.createElement('div');
|
|
231
|
+
wrapper.className = 'table-results-table-container hover-highlight';
|
|
232
|
+
const table = document.createElement('div');
|
|
233
|
+
table.className = 'table-results-table';
|
|
234
|
+
const headerRow = document.createElement('div');
|
|
235
|
+
headerRow.className = 'table-header-row';
|
|
236
|
+
const body = document.createElement('div');
|
|
237
|
+
body.className = 'table-body';
|
|
238
|
+
wrapper.appendChild(table);
|
|
239
|
+
root.appendChild(wrapper);
|
|
240
|
+
|
|
241
|
+
const footer = document.createElement('div');
|
|
242
|
+
footer.className = 'table-footer';
|
|
243
|
+
const info = document.createElement('div');
|
|
244
|
+
info.className = 'table-info-bar';
|
|
245
|
+
const footerPagination = document.createElement('div');
|
|
246
|
+
footerPagination.className = 'table-footer-pagination';
|
|
247
|
+
const pager = document.createElement('div');
|
|
248
|
+
pager.className = 'table-pagination';
|
|
249
|
+
const prev = document.createElement('button');
|
|
250
|
+
prev.className = 'table-page-btn';
|
|
251
|
+
prev.textContent = '<';
|
|
252
|
+
const next = document.createElement('button');
|
|
253
|
+
next.className = 'table-page-btn';
|
|
254
|
+
next.textContent = '>';
|
|
255
|
+
const pageLabel = document.createElement('span');
|
|
256
|
+
pageLabel.className = 'table-page-label';
|
|
257
|
+
prev.addEventListener('click', () => {
|
|
258
|
+
if (isTree) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (state.page > 0) {
|
|
262
|
+
state.page -= 1;
|
|
263
|
+
renderPage();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
next.addEventListener('click', () => {
|
|
267
|
+
if (isTree) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const filtered = this.applyFiltersAndSorting(columns, rowsWithIndex, state);
|
|
271
|
+
const totalPages = Math.max(1, Math.ceil(filtered.length / state.pageSize));
|
|
272
|
+
if (state.page < totalPages - 1) {
|
|
273
|
+
state.page += 1;
|
|
274
|
+
renderPage();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
pager.appendChild(prev);
|
|
278
|
+
pager.appendChild(pageLabel);
|
|
279
|
+
pager.appendChild(next);
|
|
280
|
+
if (!isTree) {
|
|
281
|
+
footerPagination.appendChild(pager);
|
|
282
|
+
}
|
|
283
|
+
footer.appendChild(info);
|
|
284
|
+
if (!isTree) {
|
|
285
|
+
footer.appendChild(footerPagination);
|
|
286
|
+
}
|
|
287
|
+
root.appendChild(footer);
|
|
288
|
+
|
|
289
|
+
const renderPage = () => {
|
|
290
|
+
const sourceRows = isTree && treeModel
|
|
291
|
+
? this.resolveTreeSourceRows(treeModel, visibleColumns, state.expanded, state.search)
|
|
292
|
+
: rowsWithIndex;
|
|
293
|
+
const filtered = isTree ? sourceRows.slice() : this.applyFiltersAndSorting(columns, sourceRows, state);
|
|
294
|
+
const total = filtered.length;
|
|
295
|
+
const totalTreeRows = isTree
|
|
296
|
+
? (fullyExpandedTreeRows?.length ?? total)
|
|
297
|
+
: total;
|
|
298
|
+
const totalPages = isTree ? 1 : Math.max(1, Math.ceil(total / state.pageSize));
|
|
299
|
+
if (!isTree && state.page >= totalPages) {
|
|
300
|
+
state.page = totalPages - 1;
|
|
301
|
+
}
|
|
302
|
+
const gridTemplateColumns = this.buildGridTemplate(visibleColumns.length, state.columnWidths);
|
|
303
|
+
|
|
304
|
+
headerRow.innerHTML = '';
|
|
305
|
+
headerRow.style.gridTemplateColumns = gridTemplateColumns;
|
|
306
|
+
for (let columnIndex = 0; columnIndex < visibleColumns.length; columnIndex += 1) {
|
|
307
|
+
const column = visibleColumns[columnIndex];
|
|
308
|
+
const th = document.createElement('div');
|
|
309
|
+
th.className = 'table-header-cell';
|
|
310
|
+
const sorted = !isTree && state.hasUserSort && state.sortKey === column;
|
|
311
|
+
const indicator = sorted ? (state.sortDir === 'asc' ? ' ▲' : ' ▼') : '';
|
|
312
|
+
const label = document.createElement('span');
|
|
313
|
+
label.className = 'table-header-label';
|
|
314
|
+
label.textContent = `${column}${indicator}`;
|
|
315
|
+
th.appendChild(label);
|
|
316
|
+
const headerContext = createHeaderContext(column);
|
|
317
|
+
this.applyStylesheet(th, undefined, stylesheet, [{ kind: 'header', context: headerContext }]);
|
|
318
|
+
if (!isTree) {
|
|
319
|
+
th.addEventListener('click', () => {
|
|
320
|
+
if (state.sortKey === column) {
|
|
321
|
+
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
|
|
322
|
+
} else {
|
|
323
|
+
state.sortKey = column;
|
|
324
|
+
state.sortDir = 'asc';
|
|
325
|
+
}
|
|
326
|
+
state.hasUserSort = true;
|
|
327
|
+
state.page = 0;
|
|
328
|
+
renderPage();
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
this.installColumnResizeHandle({
|
|
332
|
+
handleHost: th,
|
|
333
|
+
columnIndex,
|
|
334
|
+
state,
|
|
335
|
+
root,
|
|
336
|
+
columns: visibleColumns,
|
|
337
|
+
rows: fullyExpandedTreeRows ?? rowsWithIndex,
|
|
338
|
+
referenceCell: label,
|
|
339
|
+
rerender: renderPage,
|
|
340
|
+
});
|
|
341
|
+
headerRow.appendChild(th);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
body.innerHTML = '';
|
|
345
|
+
const start = isTree ? 0 : state.page * state.pageSize;
|
|
346
|
+
const pageRows = isTree ? filtered : filtered.slice(start, start + state.pageSize);
|
|
347
|
+
for (const rowEntry of pageRows) {
|
|
348
|
+
const tr = document.createElement('div');
|
|
349
|
+
tr.className = 'table-row';
|
|
350
|
+
tr.style.gridTemplateColumns = gridTemplateColumns;
|
|
351
|
+
const selectorRow = selectorRowsByIndex.get(rowEntry.index) ?? rowEntry;
|
|
352
|
+
const rowContext = createRowContext(baseVisibleColumns, selectorRow);
|
|
353
|
+
for (let columnIndex = 0; columnIndex < visibleColumns.length; columnIndex += 1) {
|
|
354
|
+
const value = rowEntry.cells[columnIndex] ?? '';
|
|
355
|
+
const td = document.createElement('div');
|
|
356
|
+
td.className = 'table-cell';
|
|
357
|
+
const valueElement = this.renderValue(this.formatCellDisplayValue(value, {
|
|
358
|
+
columnIndex,
|
|
359
|
+
columns: visibleColumns,
|
|
360
|
+
blockSource,
|
|
361
|
+
options,
|
|
362
|
+
}));
|
|
363
|
+
if (isTree && columnIndex === 0) {
|
|
364
|
+
const treeCell = this.renderTreeCell(valueElement, rowEntry, treeModel, state.expanded, () => renderPage());
|
|
365
|
+
td.appendChild(treeCell);
|
|
366
|
+
} else {
|
|
367
|
+
td.appendChild(this.decorateCellValueElement({
|
|
368
|
+
row: rowEntry,
|
|
369
|
+
columnIndex,
|
|
370
|
+
columns: visibleColumns,
|
|
371
|
+
value,
|
|
372
|
+
valueElement,
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
const cellContext = createCellContext(rowContext, visibleColumns[columnIndex], value);
|
|
376
|
+
const columnContext = columnContexts[columnIndex];
|
|
377
|
+
this.applyStylesheet(td, valueElement, stylesheet, [
|
|
378
|
+
{ kind: 'row', context: rowContext },
|
|
379
|
+
{ kind: 'column', context: columnContext },
|
|
380
|
+
{ kind: 'cell', context: cellContext },
|
|
381
|
+
]);
|
|
382
|
+
tr.appendChild(td);
|
|
383
|
+
}
|
|
384
|
+
tr.addEventListener('dblclick', (event) => {
|
|
385
|
+
if (this.shouldIgnoreRowDoubleClick(event)) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.onRowDoubleClick({
|
|
389
|
+
root,
|
|
390
|
+
columns: visibleColumns,
|
|
391
|
+
rows: rowsWithIndex.map((row) => ({ index: row.index, cells: row.cells.slice() })),
|
|
392
|
+
row: { index: rowEntry.index, cells: rowEntry.cells.slice() },
|
|
393
|
+
blockSource,
|
|
394
|
+
typeOptions: this.extractTypeOptions(blockSource),
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
body.appendChild(tr);
|
|
398
|
+
}
|
|
399
|
+
table.replaceChildren(headerRow, body);
|
|
400
|
+
|
|
401
|
+
info.textContent = `Showing ${pageRows.length} of ${isTree ? totalTreeRows : total} entries`;
|
|
402
|
+
if (!isTree) {
|
|
403
|
+
pageLabel.textContent = `${state.page + 1}/${totalPages}`;
|
|
404
|
+
prev.disabled = state.page === 0;
|
|
405
|
+
next.disabled = state.page >= totalPages - 1;
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
this.appendCustomLeftControls(controlsLeft, {
|
|
410
|
+
root,
|
|
411
|
+
columns: visibleColumns,
|
|
412
|
+
rows: rowsWithIndex.map((row) => ({ index: row.index, cells: row.cells.slice() })),
|
|
413
|
+
blockSource,
|
|
414
|
+
options,
|
|
415
|
+
typeOptions: this.extractTypeOptions(blockSource),
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
renderPage();
|
|
419
|
+
this.scheduleInitialAutosize({
|
|
420
|
+
root,
|
|
421
|
+
columns: visibleColumns,
|
|
422
|
+
rows: fullyExpandedTreeRows ?? rowsWithIndex,
|
|
423
|
+
state,
|
|
424
|
+
rerender: renderPage,
|
|
425
|
+
});
|
|
426
|
+
return root;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
protected extractTypeOptions(_blockSource: string | undefined): string[] {
|
|
430
|
+
return [];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private resolveTreeSourceRows(
|
|
434
|
+
model: TreeModel,
|
|
435
|
+
columns: string[],
|
|
436
|
+
expanded: ReadonlySet<string>,
|
|
437
|
+
search: string
|
|
438
|
+
): TableRowData[] {
|
|
439
|
+
if (search) {
|
|
440
|
+
return this.filterTreeRowsBySearch(model, columns, search);
|
|
441
|
+
}
|
|
442
|
+
return this.flattenTreeRows(model, expanded);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private filterTreeRowsBySearch(model: TreeModel, columns: string[], search: string): TableRowData[] {
|
|
446
|
+
const fullyExpanded = this.flattenTreeRows(model, new Set<string>(model.expandableNodes));
|
|
447
|
+
const matchedIds = new Set<string>();
|
|
448
|
+
for (const row of fullyExpanded) {
|
|
449
|
+
const nodeId = row.treeNodeId ?? '';
|
|
450
|
+
if (!nodeId) {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (matchesFilterQuery(columns, row.cells, search)) {
|
|
454
|
+
matchedIds.add(nodeId);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (matchedIds.size === 0) {
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const includeIds = new Set<string>(matchedIds);
|
|
462
|
+
const stack = Array.from(matchedIds);
|
|
463
|
+
while (stack.length > 0) {
|
|
464
|
+
const childId = stack.pop();
|
|
465
|
+
if (!childId) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const parents = model.parentsByChild.get(childId) ?? [];
|
|
469
|
+
for (const parentId of parents) {
|
|
470
|
+
if (includeIds.has(parentId)) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
includeIds.add(parentId);
|
|
474
|
+
stack.push(parentId);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return fullyExpanded.filter((row) => {
|
|
479
|
+
const nodeId = row.treeNodeId ?? '';
|
|
480
|
+
return !!nodeId && includeIds.has(nodeId);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private createTreeModel(
|
|
485
|
+
allColumns: string[],
|
|
486
|
+
allRows: TableRowData[],
|
|
487
|
+
visibleColumnIndexes: number[],
|
|
488
|
+
options: Record<string, unknown> | undefined
|
|
489
|
+
): TreeModel {
|
|
490
|
+
const idColumnIndex = 0;
|
|
491
|
+
const containmentColumns = resolveContainmentColumns(allColumns, options);
|
|
492
|
+
const rowsById = new Map<string, TableRowData>();
|
|
493
|
+
const order: string[] = [];
|
|
494
|
+
const projectRow = (row: TableRowData): TableRowData => ({
|
|
495
|
+
...row,
|
|
496
|
+
cells: visibleColumnIndexes.map((columnIndex) => row.cells[columnIndex] ?? ''),
|
|
497
|
+
});
|
|
498
|
+
for (const row of allRows) {
|
|
499
|
+
const id = row.cells[idColumnIndex] ?? '';
|
|
500
|
+
if (!id) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (!rowsById.has(id)) {
|
|
504
|
+
rowsById.set(id, projectRow(row));
|
|
505
|
+
order.push(id);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const childrenByParent = new Map<string, string[]>();
|
|
510
|
+
const parentsByChild = new Map<string, string[]>();
|
|
511
|
+
const parentCountByChild = new Map<string, number>();
|
|
512
|
+
const pushChild = (parent: string, child: string) => {
|
|
513
|
+
if (!parent || !child) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const existing = childrenByParent.get(parent) ?? [];
|
|
517
|
+
if (!existing.includes(child)) {
|
|
518
|
+
existing.push(child);
|
|
519
|
+
childrenByParent.set(parent, existing);
|
|
520
|
+
const parents = parentsByChild.get(child) ?? [];
|
|
521
|
+
if (!parents.includes(parent)) {
|
|
522
|
+
parents.push(parent);
|
|
523
|
+
parentsByChild.set(child, parents);
|
|
524
|
+
}
|
|
525
|
+
parentCountByChild.set(child, (parentCountByChild.get(child) ?? 0) + 1);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
for (const row of allRows) {
|
|
530
|
+
const parent = row.cells[idColumnIndex] ?? '';
|
|
531
|
+
if (!parent) {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
for (const columnIndex of containmentColumns) {
|
|
535
|
+
const raw = row.cells[columnIndex] ?? '';
|
|
536
|
+
const children = raw.split(/[\s,]+/).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
537
|
+
for (const child of children) {
|
|
538
|
+
pushChild(parent, child);
|
|
539
|
+
if (!rowsById.has(child)) {
|
|
540
|
+
const syntheticAll = new Array<string>(allColumns.length).fill('');
|
|
541
|
+
syntheticAll[idColumnIndex] = child;
|
|
542
|
+
const synthetic = visibleColumnIndexes.map((columnIndex) => syntheticAll[columnIndex] ?? '');
|
|
543
|
+
const syntheticRow: TableRowData = { index: allRows.length + rowsById.size, cells: synthetic };
|
|
544
|
+
rowsById.set(child, syntheticRow);
|
|
545
|
+
order.push(child);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const roots = order.filter((id) => (parentCountByChild.get(id) ?? 0) === 0);
|
|
552
|
+
return {
|
|
553
|
+
rowsById,
|
|
554
|
+
childrenByParent,
|
|
555
|
+
parentsByChild,
|
|
556
|
+
roots: roots.length > 0 ? roots : order.slice(),
|
|
557
|
+
expandableNodes: Array.from(childrenByParent.keys()),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private flattenTreeRows(model: TreeModel, expanded: ReadonlySet<string>): TableRowData[] {
|
|
562
|
+
const flattened: TableRowData[] = [];
|
|
563
|
+
const visit = (id: string, depth: number, path: Set<string>) => {
|
|
564
|
+
const row = model.rowsById.get(id);
|
|
565
|
+
if (!row) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const children = model.childrenByParent.get(id) ?? [];
|
|
569
|
+
flattened.push({
|
|
570
|
+
...row,
|
|
571
|
+
treeNodeId: id,
|
|
572
|
+
treeDepth: depth,
|
|
573
|
+
treeHasChildren: children.length > 0,
|
|
574
|
+
});
|
|
575
|
+
if (!expanded.has(id)) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const nextPath = new Set(path);
|
|
579
|
+
nextPath.add(id);
|
|
580
|
+
for (const child of children) {
|
|
581
|
+
if (nextPath.has(child)) {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
visit(child, depth + 1, nextPath);
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
for (const rootId of model.roots) {
|
|
588
|
+
visit(rootId, 0, new Set<string>());
|
|
589
|
+
}
|
|
590
|
+
return flattened;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private flattenAllTreeRows(model: TreeModel): TableRowData[] {
|
|
594
|
+
const flattened: TableRowData[] = [];
|
|
595
|
+
const visit = (id: string, depth: number, path: Set<string>) => {
|
|
596
|
+
const row = model.rowsById.get(id);
|
|
597
|
+
if (!row) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const children = model.childrenByParent.get(id) ?? [];
|
|
601
|
+
flattened.push({
|
|
602
|
+
...row,
|
|
603
|
+
treeNodeId: id,
|
|
604
|
+
treeDepth: depth,
|
|
605
|
+
treeHasChildren: children.length > 0,
|
|
606
|
+
});
|
|
607
|
+
const nextPath = new Set(path);
|
|
608
|
+
nextPath.add(id);
|
|
609
|
+
for (const child of children) {
|
|
610
|
+
if (nextPath.has(child)) {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
visit(child, depth + 1, nextPath);
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
for (const rootId of model.roots) {
|
|
617
|
+
visit(rootId, 0, new Set<string>());
|
|
618
|
+
}
|
|
619
|
+
return flattened;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private renderTreeCell(
|
|
623
|
+
valueElement: HTMLElement,
|
|
624
|
+
row: TableRowData,
|
|
625
|
+
model: TreeModel | undefined,
|
|
626
|
+
expanded: Set<string>,
|
|
627
|
+
rerender: () => void
|
|
628
|
+
): HTMLElement {
|
|
629
|
+
const wrapper = document.createElement('div');
|
|
630
|
+
wrapper.style.display = 'flex';
|
|
631
|
+
wrapper.style.alignItems = 'center';
|
|
632
|
+
wrapper.style.gap = '0.35rem';
|
|
633
|
+
const depth = row.treeDepth ?? 0;
|
|
634
|
+
wrapper.style.paddingLeft = `${Math.max(0, depth) * 16}px`;
|
|
635
|
+
const nodeId = row.treeNodeId ?? '';
|
|
636
|
+
const hasChildren = !!row.treeHasChildren && !!nodeId && !!model;
|
|
637
|
+
if (hasChildren) {
|
|
638
|
+
const toggle = document.createElement('button');
|
|
639
|
+
toggle.type = 'button';
|
|
640
|
+
toggle.className = 'tree-toggle-button';
|
|
641
|
+
toggle.textContent = expanded.has(nodeId) ? '-' : '+';
|
|
642
|
+
toggle.style.width = '1rem';
|
|
643
|
+
toggle.style.height = '1rem';
|
|
644
|
+
toggle.style.padding = '0';
|
|
645
|
+
toggle.style.lineHeight = '1';
|
|
646
|
+
toggle.style.fontSize = '0.85rem';
|
|
647
|
+
toggle.style.border = '1px solid var(--vscode-editorWidget-border)';
|
|
648
|
+
toggle.style.background = 'var(--vscode-editor-background)';
|
|
649
|
+
toggle.style.color = 'var(--vscode-foreground)';
|
|
650
|
+
toggle.style.cursor = 'pointer';
|
|
651
|
+
toggle.addEventListener('click', (event) => {
|
|
652
|
+
event.preventDefault();
|
|
653
|
+
event.stopPropagation();
|
|
654
|
+
if (expanded.has(nodeId)) {
|
|
655
|
+
expanded.delete(nodeId);
|
|
656
|
+
} else {
|
|
657
|
+
expanded.add(nodeId);
|
|
658
|
+
}
|
|
659
|
+
rerender();
|
|
660
|
+
});
|
|
661
|
+
wrapper.appendChild(toggle);
|
|
662
|
+
} else {
|
|
663
|
+
const spacer = document.createElement('span');
|
|
664
|
+
spacer.style.display = 'inline-block';
|
|
665
|
+
spacer.style.width = '1rem';
|
|
666
|
+
wrapper.appendChild(spacer);
|
|
667
|
+
}
|
|
668
|
+
wrapper.appendChild(valueElement);
|
|
669
|
+
return wrapper;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private createTreeActionButton(title: string, ariaLabel: string, iconPath: string): HTMLButtonElement {
|
|
673
|
+
const button = document.createElement('button');
|
|
674
|
+
button.type = 'button';
|
|
675
|
+
button.className = 'tree-download-btn';
|
|
676
|
+
button.title = title;
|
|
677
|
+
button.setAttribute('aria-label', ariaLabel);
|
|
678
|
+
const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
679
|
+
iconSvg.setAttribute('viewBox', '0 0 24 24');
|
|
680
|
+
iconSvg.setAttribute('width', '20');
|
|
681
|
+
iconSvg.setAttribute('height', '20');
|
|
682
|
+
iconSvg.setAttribute('aria-hidden', 'true');
|
|
683
|
+
iconSvg.setAttribute('focusable', 'false');
|
|
684
|
+
iconSvg.style.fill = 'currentColor';
|
|
685
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
686
|
+
path.setAttribute('d', iconPath);
|
|
687
|
+
iconSvg.appendChild(path);
|
|
688
|
+
button.appendChild(iconSvg);
|
|
689
|
+
return button;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private applyFiltersAndSorting(
|
|
693
|
+
columns: string[],
|
|
694
|
+
rows: TableRowData[],
|
|
695
|
+
state: { search: string; sortKey: string; sortDir: 'asc' | 'desc'; hasUserSort: boolean },
|
|
696
|
+
): TableRowData[] {
|
|
697
|
+
const filtered = state.search
|
|
698
|
+
? rows.filter((row) => matchesFilterQuery(columns, row.cells, state.search))
|
|
699
|
+
: rows.slice();
|
|
700
|
+
if (!state.hasUserSort) {
|
|
701
|
+
return filtered;
|
|
702
|
+
}
|
|
703
|
+
const columnIndex = columns.indexOf(state.sortKey);
|
|
704
|
+
if (columnIndex < 0) {
|
|
705
|
+
return filtered;
|
|
706
|
+
}
|
|
707
|
+
const dir = state.sortDir === 'asc' ? 1 : -1;
|
|
708
|
+
return filtered.sort((left, right) => {
|
|
709
|
+
const a = left.cells[columnIndex] ?? '';
|
|
710
|
+
const b = right.cells[columnIndex] ?? '';
|
|
711
|
+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) * dir;
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private applyStylesheet(
|
|
716
|
+
cellElement: HTMLElement,
|
|
717
|
+
valueElement: HTMLElement | undefined,
|
|
718
|
+
rules: ReadonlyArray<CompiledTableStyleRule>,
|
|
719
|
+
contexts: ReadonlyArray<SelectorContext>
|
|
720
|
+
): void {
|
|
721
|
+
for (const rule of rules) {
|
|
722
|
+
if (rule.target !== 'value') {
|
|
723
|
+
if (!this.matchesRule(rule, contexts)) {
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
for (const [property, cssValue] of Object.entries(rule.style)) {
|
|
727
|
+
cellElement.style.setProperty(property, cssValue);
|
|
728
|
+
}
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (!valueElement) {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const valueTargets = this.resolveValueTargets(valueElement);
|
|
736
|
+
for (const target of valueTargets) {
|
|
737
|
+
const valueOverride = target.dataset.value ?? target.textContent ?? '';
|
|
738
|
+
if (!this.matchesRule(rule, contexts, valueOverride)) {
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
for (const [property, cssValue] of Object.entries(rule.style)) {
|
|
742
|
+
target.style.setProperty(property, cssValue);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private matchesRule(
|
|
749
|
+
rule: CompiledTableStyleRule,
|
|
750
|
+
contexts: ReadonlyArray<SelectorContext>,
|
|
751
|
+
cellValueOverride?: string
|
|
752
|
+
): boolean {
|
|
753
|
+
return rule.selectors.some((selector) => {
|
|
754
|
+
const contextEntry = contexts.find((entry) => entry.kind === selector.kind);
|
|
755
|
+
if (!contextEntry) {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
if (selector.kind !== 'cell' || cellValueOverride === undefined) {
|
|
759
|
+
return evaluateSelectorCondition(selector.condition, contextEntry.context);
|
|
760
|
+
}
|
|
761
|
+
return evaluateSelectorCondition(selector.condition, {
|
|
762
|
+
...contextEntry.context,
|
|
763
|
+
value: cellValueOverride,
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private resolveValueTargets(valueElement: HTMLElement): HTMLElement[] {
|
|
769
|
+
const parts = Array.from(valueElement.querySelectorAll<HTMLElement>('.table-value-part'));
|
|
770
|
+
if (parts.length > 0) {
|
|
771
|
+
return parts;
|
|
772
|
+
}
|
|
773
|
+
return [valueElement];
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private buildGridTemplate(columnCount: number, columnWidths: ReadonlyArray<number | undefined>): string {
|
|
777
|
+
if (columnCount <= 0) {
|
|
778
|
+
return 'minmax(0, 1fr)';
|
|
779
|
+
}
|
|
780
|
+
const lastColumnIndex = columnCount - 1;
|
|
781
|
+
return columnWidths
|
|
782
|
+
.slice(0, columnCount)
|
|
783
|
+
.map((width, columnIndex) => {
|
|
784
|
+
if (columnIndex === lastColumnIndex) {
|
|
785
|
+
if (width && width > 0) {
|
|
786
|
+
return `minmax(${Math.round(width)}px, 1fr)`;
|
|
787
|
+
}
|
|
788
|
+
return 'minmax(0, 1fr)';
|
|
789
|
+
}
|
|
790
|
+
return width && width > 0 ? `${Math.round(width)}px` : 'minmax(0, 1fr)';
|
|
791
|
+
})
|
|
792
|
+
.join(' ');
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private createInitialColumnWidths(columns: string[], rows: TableRowData[], root: HTMLElement): Array<number | undefined> {
|
|
796
|
+
if (columns.length === 0) {
|
|
797
|
+
return [];
|
|
798
|
+
}
|
|
799
|
+
const minWidth = 72;
|
|
800
|
+
const valueReference = root.querySelector<HTMLElement>('.table-cell') ?? root;
|
|
801
|
+
const headerReference = root.querySelector<HTMLElement>('.table-header-label') ?? root;
|
|
802
|
+
const valueFont = this.resolveFont(valueReference);
|
|
803
|
+
const headerFont = this.resolveFont(headerReference);
|
|
804
|
+
return columns.map((_, columnIndex) => Math.max(minWidth, this.autosizeColumnWidth(columnIndex, columns, rows, valueFont, headerFont)));
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
private scheduleInitialAutosize(params: {
|
|
808
|
+
root: HTMLElement;
|
|
809
|
+
columns: string[];
|
|
810
|
+
rows: TableRowData[];
|
|
811
|
+
state: { columnWidths: Array<number | undefined>; initialAutosizeApplied: boolean };
|
|
812
|
+
rerender: () => void;
|
|
813
|
+
}): void {
|
|
814
|
+
const tryAutosize = () => {
|
|
815
|
+
if (params.state.initialAutosizeApplied) {
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (!params.root.isConnected) {
|
|
819
|
+
requestAnimationFrame(tryAutosize);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
params.state.columnWidths = this.createInitialColumnWidths(params.columns, params.rows, params.root);
|
|
823
|
+
params.state.initialAutosizeApplied = true;
|
|
824
|
+
params.rerender();
|
|
825
|
+
};
|
|
826
|
+
requestAnimationFrame(tryAutosize);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private installColumnResizeHandle(params: {
|
|
830
|
+
handleHost: HTMLElement;
|
|
831
|
+
columnIndex: number;
|
|
832
|
+
state: { columnWidths: Array<number | undefined> };
|
|
833
|
+
root: HTMLElement;
|
|
834
|
+
columns: string[];
|
|
835
|
+
rows: TableRowData[];
|
|
836
|
+
referenceCell: HTMLElement;
|
|
837
|
+
rerender: () => void;
|
|
838
|
+
}): void {
|
|
839
|
+
const handle = document.createElement('span');
|
|
840
|
+
handle.className = 'table-column-resize-handle';
|
|
841
|
+
handle.title = 'Drag to resize. Double-click to autosize.';
|
|
842
|
+
handle.setAttribute('aria-hidden', 'true');
|
|
843
|
+
params.handleHost.appendChild(handle);
|
|
844
|
+
handle.addEventListener('click', (event) => {
|
|
845
|
+
event.preventDefault();
|
|
846
|
+
event.stopPropagation();
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const minWidth = 72;
|
|
850
|
+
handle.addEventListener('mousedown', (event) => {
|
|
851
|
+
event.preventDefault();
|
|
852
|
+
event.stopPropagation();
|
|
853
|
+
const currentWidth = params.handleHost.getBoundingClientRect().width;
|
|
854
|
+
const startWidth = params.state.columnWidths[params.columnIndex] ?? currentWidth;
|
|
855
|
+
const startX = event.clientX;
|
|
856
|
+
params.state.columnWidths[params.columnIndex] = Math.max(minWidth, Math.round(startWidth));
|
|
857
|
+
params.root.classList.add('table-resizing');
|
|
858
|
+
|
|
859
|
+
const onMouseMove = (moveEvent: MouseEvent) => {
|
|
860
|
+
const delta = moveEvent.clientX - startX;
|
|
861
|
+
params.state.columnWidths[params.columnIndex] = Math.max(minWidth, Math.round(startWidth + delta));
|
|
862
|
+
params.rerender();
|
|
863
|
+
};
|
|
864
|
+
const onMouseUp = () => {
|
|
865
|
+
params.root.classList.remove('table-resizing');
|
|
866
|
+
window.removeEventListener('mousemove', onMouseMove);
|
|
867
|
+
window.removeEventListener('mouseup', onMouseUp);
|
|
868
|
+
};
|
|
869
|
+
window.addEventListener('mousemove', onMouseMove);
|
|
870
|
+
window.addEventListener('mouseup', onMouseUp);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
handle.addEventListener('dblclick', (event) => {
|
|
874
|
+
event.preventDefault();
|
|
875
|
+
event.stopPropagation();
|
|
876
|
+
const width = this.autosizeColumnWidth(params.columnIndex, params.columns, params.rows, params.referenceCell);
|
|
877
|
+
params.state.columnWidths[params.columnIndex] = Math.max(minWidth, width);
|
|
878
|
+
params.rerender();
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private autosizeColumnWidth(
|
|
883
|
+
columnIndex: number,
|
|
884
|
+
columns: string[],
|
|
885
|
+
rows: TableRowData[],
|
|
886
|
+
referenceCellOrFont: HTMLElement | string,
|
|
887
|
+
headerFontOverride?: string
|
|
888
|
+
): number {
|
|
889
|
+
const headerText = `${columns[columnIndex] ?? ''} ▲`;
|
|
890
|
+
const values = rows.map((row) => formatCellDisplayText(row.cells[columnIndex] ?? ''));
|
|
891
|
+
const valueFont = typeof referenceCellOrFont === 'string'
|
|
892
|
+
? referenceCellOrFont
|
|
893
|
+
: this.resolveFont(referenceCellOrFont);
|
|
894
|
+
const headerFont = headerFontOverride ?? valueFont;
|
|
895
|
+
const widestValue = values.reduce((max, text) => {
|
|
896
|
+
const width = this.measureTextWidth(text, valueFont);
|
|
897
|
+
return Math.max(max, width);
|
|
898
|
+
}, 0);
|
|
899
|
+
const headerWidth = this.measureTextWidth(headerText, headerFont);
|
|
900
|
+
const widestText = Math.max(headerWidth, widestValue);
|
|
901
|
+
const horizontalPadding = 20;
|
|
902
|
+
const handleSpace = 12;
|
|
903
|
+
const treePrefixSpace = this.measureTreePrefixWidth(columnIndex, rows);
|
|
904
|
+
return Math.ceil(widestText + horizontalPadding + handleSpace + treePrefixSpace);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private measureTreePrefixWidth(columnIndex: number, rows: TableRowData[]): number {
|
|
908
|
+
if (columnIndex !== 0) {
|
|
909
|
+
return 0;
|
|
910
|
+
}
|
|
911
|
+
const treeRows = rows.filter((row) => row.treeDepth !== undefined || row.treeNodeId !== undefined);
|
|
912
|
+
if (treeRows.length === 0) {
|
|
913
|
+
return 0;
|
|
914
|
+
}
|
|
915
|
+
const treeIndent = 16;
|
|
916
|
+
const toggleWidth = 16;
|
|
917
|
+
const toggleGap = 6;
|
|
918
|
+
const toggleBorderAllowance = 2;
|
|
919
|
+
return treeRows.reduce((max, row) => {
|
|
920
|
+
const depth = Math.max(0, row.treeDepth ?? 0);
|
|
921
|
+
const prefix = (depth * treeIndent) + toggleWidth + toggleGap + toggleBorderAllowance;
|
|
922
|
+
return Math.max(max, prefix);
|
|
923
|
+
}, 0);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
private resolveFont(referenceCell: HTMLElement): string {
|
|
927
|
+
const computed = window.getComputedStyle(referenceCell);
|
|
928
|
+
const lineHeight = computed.lineHeight && computed.lineHeight !== 'normal' ? computed.lineHeight : computed.fontSize;
|
|
929
|
+
return `${computed.fontStyle} ${computed.fontVariant} ${computed.fontWeight} ${computed.fontSize} / ${lineHeight} ${computed.fontFamily}`;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
private measureTextWidth(text: string, font: string): number {
|
|
933
|
+
const canvas = document.createElement('canvas');
|
|
934
|
+
const context = canvas.getContext('2d');
|
|
935
|
+
if (!context) {
|
|
936
|
+
return text.length * 8;
|
|
937
|
+
}
|
|
938
|
+
context.font = font;
|
|
939
|
+
return context.measureText(text).width;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
protected formatCellDisplayValue(
|
|
943
|
+
raw: string,
|
|
944
|
+
_context: {
|
|
945
|
+
columnIndex: number;
|
|
946
|
+
columns: string[];
|
|
947
|
+
blockSource?: string;
|
|
948
|
+
options?: Record<string, unknown>;
|
|
949
|
+
}
|
|
950
|
+
): string {
|
|
951
|
+
return raw;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
private renderValue(raw: string): HTMLElement {
|
|
955
|
+
const value = document.createElement('span');
|
|
956
|
+
value.className = 'table-value';
|
|
957
|
+
appendTokenizedValueParts(value, raw, 'table-value-part');
|
|
958
|
+
return value;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private requestCsvDownload(columns: string[], rows: string[][]): void {
|
|
962
|
+
if (columns.length === 0) {
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const escapeCell = (value: string) => `"${value.replace(/"/g, '""')}"`;
|
|
966
|
+
const lines = [
|
|
967
|
+
columns.map(escapeCell).join(','),
|
|
968
|
+
...rows.map((row) => row.map(escapeCell).join(',')),
|
|
969
|
+
];
|
|
970
|
+
this.requestTextFileDownload(lines.join('\n'), 'table', 'csv');
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
private requestTreeJsonDownload(columns: string[], model: TreeModel, rows: TableRowData[]): void {
|
|
974
|
+
if (columns.length === 0) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const keyMap = this.createTreeJsonKeys(columns);
|
|
978
|
+
const idColumn = columns[0] ?? '';
|
|
979
|
+
const idKey = keyMap.get(idColumn) ?? idColumn;
|
|
980
|
+
const includeIds = new Set(
|
|
981
|
+
rows
|
|
982
|
+
.map((row) => row.treeNodeId ?? '')
|
|
983
|
+
.filter((id) => id.length > 0)
|
|
984
|
+
);
|
|
985
|
+
const serializeNode = (nodeId: string, path: Set<string>): Record<string, unknown> | undefined => {
|
|
986
|
+
if (!includeIds.has(nodeId) || path.has(nodeId)) {
|
|
987
|
+
return undefined;
|
|
988
|
+
}
|
|
989
|
+
const row = model.rowsById.get(nodeId);
|
|
990
|
+
if (!row) {
|
|
991
|
+
return undefined;
|
|
992
|
+
}
|
|
993
|
+
const node: Record<string, unknown> = {};
|
|
994
|
+
columns.forEach((column, index) => {
|
|
995
|
+
const key = keyMap.get(column) ?? column;
|
|
996
|
+
const value = row.cells[index] ?? '';
|
|
997
|
+
if (index === 0) {
|
|
998
|
+
node['@id'] = value;
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (value.length > 0) {
|
|
1002
|
+
node[key] = value;
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
const nextPath = new Set(path);
|
|
1006
|
+
nextPath.add(nodeId);
|
|
1007
|
+
const children = (model.childrenByParent.get(nodeId) ?? [])
|
|
1008
|
+
.map((childId) => serializeNode(childId, nextPath))
|
|
1009
|
+
.filter((entry): entry is Record<string, unknown> => !!entry);
|
|
1010
|
+
if (children.length > 0) {
|
|
1011
|
+
node.children = children;
|
|
1012
|
+
}
|
|
1013
|
+
return node;
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
const graph = model.roots
|
|
1017
|
+
.map((rootId) => serializeNode(rootId, new Set<string>()))
|
|
1018
|
+
.filter((entry): entry is Record<string, unknown> => !!entry);
|
|
1019
|
+
const context: Record<string, string> = {};
|
|
1020
|
+
context[idKey] = idColumn;
|
|
1021
|
+
for (let columnIndex = 1; columnIndex < columns.length; columnIndex += 1) {
|
|
1022
|
+
const column = columns[columnIndex];
|
|
1023
|
+
const key = keyMap.get(column) ?? column;
|
|
1024
|
+
context[key] = column;
|
|
1025
|
+
}
|
|
1026
|
+
context.children = 'children';
|
|
1027
|
+
const payload = {
|
|
1028
|
+
'@context': context,
|
|
1029
|
+
'@graph': graph,
|
|
1030
|
+
};
|
|
1031
|
+
this.requestTextFileDownload(JSON.stringify(payload, null, 2), 'tree', 'json');
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
private createTreeJsonKeys(columns: string[]): Map<string, string> {
|
|
1035
|
+
const keys = new Map<string, string>();
|
|
1036
|
+
const counts = new Map<string, number>();
|
|
1037
|
+
for (const column of columns) {
|
|
1038
|
+
const base = shortLabelFromIri(column.trim()) || column;
|
|
1039
|
+
const currentCount = counts.get(base) ?? 0;
|
|
1040
|
+
counts.set(base, currentCount + 1);
|
|
1041
|
+
const key = currentCount === 0 ? base : `${base}_${currentCount + 1}`;
|
|
1042
|
+
keys.set(column, key);
|
|
1043
|
+
}
|
|
1044
|
+
return keys;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
type TableRowData = {
|
|
1049
|
+
index: number;
|
|
1050
|
+
cells: string[];
|
|
1051
|
+
treeNodeId?: string;
|
|
1052
|
+
treeDepth?: number;
|
|
1053
|
+
treeHasChildren?: boolean;
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
type TreeModel = {
|
|
1057
|
+
rowsById: Map<string, TableRowData>;
|
|
1058
|
+
childrenByParent: Map<string, string[]>;
|
|
1059
|
+
parentsByChild: Map<string, string[]>;
|
|
1060
|
+
roots: string[];
|
|
1061
|
+
expandableNodes: string[];
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
type TableRuleKind = 'row' | 'column' | 'cell' | 'header';
|
|
1065
|
+
type TableRuleTarget = 'cell' | 'value';
|
|
1066
|
+
type TableSelector = {
|
|
1067
|
+
kind: TableRuleKind;
|
|
1068
|
+
condition?: string;
|
|
1069
|
+
};
|
|
1070
|
+
type CompiledTableStyleRule = {
|
|
1071
|
+
selectors: TableSelector[];
|
|
1072
|
+
target: TableRuleTarget;
|
|
1073
|
+
style: Record<string, string>;
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
type SelectorContext =
|
|
1077
|
+
| { kind: 'row'; context: RowSelectorContext }
|
|
1078
|
+
| { kind: 'column'; context: ColumnSelectorContext }
|
|
1079
|
+
| { kind: 'cell'; context: CellSelectorContext }
|
|
1080
|
+
| { kind: 'header'; context: HeaderSelectorContext };
|
|
1081
|
+
|
|
1082
|
+
type RowSelectorContext = {
|
|
1083
|
+
index: number;
|
|
1084
|
+
get: (key?: unknown) => string | undefined;
|
|
1085
|
+
any: (expected?: unknown) => boolean;
|
|
1086
|
+
all: (expected?: unknown) => boolean;
|
|
1087
|
+
count: (expected?: unknown) => number;
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
type ColumnSelectorContext = {
|
|
1091
|
+
name: string;
|
|
1092
|
+
get: (index?: unknown) => string | undefined;
|
|
1093
|
+
any: (expected?: unknown) => boolean;
|
|
1094
|
+
all: (expected?: unknown) => boolean;
|
|
1095
|
+
count: (expected?: unknown) => number;
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
type CellSelectorContext = {
|
|
1099
|
+
row: CellRowSelectorContext;
|
|
1100
|
+
rowIndex: number;
|
|
1101
|
+
col: string;
|
|
1102
|
+
value: string;
|
|
1103
|
+
datatype?: string;
|
|
1104
|
+
lang?: string;
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
type CellRowSelectorContext = {
|
|
1108
|
+
index: number;
|
|
1109
|
+
get: (key?: unknown) => string | undefined;
|
|
1110
|
+
any: (expected?: unknown) => boolean;
|
|
1111
|
+
all: (expected?: unknown) => boolean;
|
|
1112
|
+
count: (expected?: unknown) => number;
|
|
1113
|
+
valueOf: () => number;
|
|
1114
|
+
toString: () => string;
|
|
1115
|
+
[Symbol.toPrimitive]: (hint: string) => number | string;
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
type HeaderSelectorContext = {
|
|
1119
|
+
name: string;
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
function compileTableStylesheet(options: Record<string, unknown> | undefined): CompiledTableStyleRule[] {
|
|
1123
|
+
const rawStylesheet = options?.stylesheet;
|
|
1124
|
+
if (!Array.isArray(rawStylesheet)) {
|
|
1125
|
+
return [];
|
|
1126
|
+
}
|
|
1127
|
+
const compiled: CompiledTableStyleRule[] = [];
|
|
1128
|
+
for (const rawRule of rawStylesheet) {
|
|
1129
|
+
if (!isRecord(rawRule)) {
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
const selectors = parseSelectors(rawRule.selector);
|
|
1133
|
+
if (selectors.length === 0) {
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
const style = normalizeStyle(rawRule.style);
|
|
1137
|
+
if (Object.keys(style).length === 0) {
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
const target = rawRule.target === 'value' ? 'value' : 'cell';
|
|
1141
|
+
compiled.push({ selectors, target, style });
|
|
1142
|
+
}
|
|
1143
|
+
return compiled;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function parseSelectors(rawSelector: unknown): TableSelector[] {
|
|
1147
|
+
if (typeof rawSelector !== 'string') {
|
|
1148
|
+
return [];
|
|
1149
|
+
}
|
|
1150
|
+
const parts = splitSelectors(rawSelector);
|
|
1151
|
+
const selectors: TableSelector[] = [];
|
|
1152
|
+
for (const part of parts) {
|
|
1153
|
+
const parsed = parseSelector(part);
|
|
1154
|
+
if (parsed) {
|
|
1155
|
+
selectors.push(parsed);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
return selectors;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function splitSelectors(rawSelector: string): string[] {
|
|
1162
|
+
const parts: string[] = [];
|
|
1163
|
+
let current = '';
|
|
1164
|
+
let bracketDepth = 0;
|
|
1165
|
+
let quote: '"' | '\'' | undefined;
|
|
1166
|
+
for (let i = 0; i < rawSelector.length; i += 1) {
|
|
1167
|
+
const char = rawSelector[i];
|
|
1168
|
+
if (quote) {
|
|
1169
|
+
current += char;
|
|
1170
|
+
if (char === quote && rawSelector[i - 1] !== '\\') {
|
|
1171
|
+
quote = undefined;
|
|
1172
|
+
}
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
if (char === '"' || char === '\'') {
|
|
1176
|
+
quote = char;
|
|
1177
|
+
current += char;
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
if (char === '[') {
|
|
1181
|
+
bracketDepth += 1;
|
|
1182
|
+
current += char;
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
if (char === ']' && bracketDepth > 0) {
|
|
1186
|
+
bracketDepth -= 1;
|
|
1187
|
+
current += char;
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
if (char === ',' && bracketDepth === 0) {
|
|
1191
|
+
parts.push(current.trim());
|
|
1192
|
+
current = '';
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
current += char;
|
|
1196
|
+
}
|
|
1197
|
+
if (current.trim()) {
|
|
1198
|
+
parts.push(current.trim());
|
|
1199
|
+
}
|
|
1200
|
+
return parts;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function parseSelector(selector: string): TableSelector | undefined {
|
|
1204
|
+
const trimmed = selector.trim();
|
|
1205
|
+
if (!trimmed) {
|
|
1206
|
+
return undefined;
|
|
1207
|
+
}
|
|
1208
|
+
const bracketStart = trimmed.indexOf('[');
|
|
1209
|
+
if (bracketStart === -1) {
|
|
1210
|
+
return toSelector(trimmed, undefined);
|
|
1211
|
+
}
|
|
1212
|
+
if (!trimmed.endsWith(']')) {
|
|
1213
|
+
return undefined;
|
|
1214
|
+
}
|
|
1215
|
+
const kind = trimmed.slice(0, bracketStart).trim();
|
|
1216
|
+
const condition = trimmed.slice(bracketStart + 1, -1).trim();
|
|
1217
|
+
return toSelector(kind, condition || undefined);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function toSelector(kind: string, condition: string | undefined): TableSelector | undefined {
|
|
1221
|
+
const normalized = kind.trim().toLowerCase();
|
|
1222
|
+
if (normalized !== 'row' && normalized !== 'column' && normalized !== 'cell' && normalized !== 'header') {
|
|
1223
|
+
return undefined;
|
|
1224
|
+
}
|
|
1225
|
+
return { kind: normalized, condition };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function normalizeStyle(rawStyle: unknown): Record<string, string> {
|
|
1229
|
+
if (!isRecord(rawStyle)) {
|
|
1230
|
+
return {};
|
|
1231
|
+
}
|
|
1232
|
+
const style: Record<string, string> = {};
|
|
1233
|
+
for (const [property, rawValue] of Object.entries(rawStyle)) {
|
|
1234
|
+
if (!property.trim()) {
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
if (rawValue === undefined || rawValue === null) {
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
style[property] = String(rawValue);
|
|
1241
|
+
}
|
|
1242
|
+
return style;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function createRowContext(columns: string[], row: TableRowData): RowSelectorContext {
|
|
1246
|
+
const values = row.cells;
|
|
1247
|
+
const getByKey = (key?: unknown): string | undefined => {
|
|
1248
|
+
if (typeof key === 'number' && Number.isInteger(key)) {
|
|
1249
|
+
return values[key];
|
|
1250
|
+
}
|
|
1251
|
+
if (typeof key === 'string') {
|
|
1252
|
+
const index = columns.indexOf(key);
|
|
1253
|
+
return index >= 0 ? values[index] : undefined;
|
|
1254
|
+
}
|
|
1255
|
+
return undefined;
|
|
1256
|
+
};
|
|
1257
|
+
const matches = (cellValue: string, expected?: unknown): boolean => {
|
|
1258
|
+
if (expected === undefined) {
|
|
1259
|
+
return cellValue.length > 0;
|
|
1260
|
+
}
|
|
1261
|
+
return cellValue === String(expected);
|
|
1262
|
+
};
|
|
1263
|
+
return {
|
|
1264
|
+
index: row.index,
|
|
1265
|
+
get: (key?: unknown) => getByKey(key),
|
|
1266
|
+
any: (expected?: unknown) => values.some((value) => matches(value, expected)),
|
|
1267
|
+
all: (expected?: unknown) => values.every((value) => matches(value, expected)),
|
|
1268
|
+
count: (expected?: unknown) => expected === undefined
|
|
1269
|
+
? values.length
|
|
1270
|
+
: values.filter((value) => matches(value, expected)).length,
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function createColumnContexts(columns: string[], rows: TableRowData[]): ColumnSelectorContext[] {
|
|
1275
|
+
return columns.map((name, columnIndex) => {
|
|
1276
|
+
const values = rows.map((row) => row.cells[columnIndex] ?? '');
|
|
1277
|
+
const matches = (cellValue: string, expected?: unknown): boolean => {
|
|
1278
|
+
if (expected === undefined) {
|
|
1279
|
+
return cellValue.length > 0;
|
|
1280
|
+
}
|
|
1281
|
+
return cellValue === String(expected);
|
|
1282
|
+
};
|
|
1283
|
+
return {
|
|
1284
|
+
name,
|
|
1285
|
+
get: (index?: unknown) => {
|
|
1286
|
+
if (typeof index !== 'number' || !Number.isInteger(index)) {
|
|
1287
|
+
return undefined;
|
|
1288
|
+
}
|
|
1289
|
+
return values[index];
|
|
1290
|
+
},
|
|
1291
|
+
any: (expected?: unknown) => values.some((value) => matches(value, expected)),
|
|
1292
|
+
all: (expected?: unknown) => values.every((value) => matches(value, expected)),
|
|
1293
|
+
count: (expected?: unknown) => expected === undefined
|
|
1294
|
+
? values.length
|
|
1295
|
+
: values.filter((value) => matches(value, expected)).length,
|
|
1296
|
+
};
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function createCellContext(row: RowSelectorContext | number, columnName: string, value: string): CellSelectorContext {
|
|
1301
|
+
const rowContext = toCellRowSelectorContext(row);
|
|
1302
|
+
return {
|
|
1303
|
+
row: rowContext,
|
|
1304
|
+
rowIndex: rowContext.index,
|
|
1305
|
+
col: columnName,
|
|
1306
|
+
value,
|
|
1307
|
+
datatype: undefined,
|
|
1308
|
+
lang: undefined,
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function toCellRowSelectorContext(row: RowSelectorContext | number): CellRowSelectorContext {
|
|
1313
|
+
if (typeof row === 'number') {
|
|
1314
|
+
const index = row;
|
|
1315
|
+
return {
|
|
1316
|
+
index,
|
|
1317
|
+
get: () => undefined,
|
|
1318
|
+
any: () => false,
|
|
1319
|
+
all: () => false,
|
|
1320
|
+
count: () => 0,
|
|
1321
|
+
valueOf: () => index,
|
|
1322
|
+
toString: () => String(index),
|
|
1323
|
+
[Symbol.toPrimitive]: (hint: string) => hint === 'string' ? String(index) : index,
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const base = row;
|
|
1328
|
+
const index = base.index;
|
|
1329
|
+
return {
|
|
1330
|
+
index,
|
|
1331
|
+
get: (key?: unknown) => base.get(key),
|
|
1332
|
+
any: (expected?: unknown) => base.any(expected),
|
|
1333
|
+
all: (expected?: unknown) => base.all(expected),
|
|
1334
|
+
count: (expected?: unknown) => base.count(expected),
|
|
1335
|
+
valueOf: () => index,
|
|
1336
|
+
toString: () => String(index),
|
|
1337
|
+
[Symbol.toPrimitive]: (hint: string) => hint === 'string' ? String(index) : index,
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function createHeaderContext(name: string): HeaderSelectorContext {
|
|
1342
|
+
return { name };
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function resolveHiddenColumns(
|
|
1346
|
+
columns: string[],
|
|
1347
|
+
columnContexts: ReadonlyArray<ColumnSelectorContext>,
|
|
1348
|
+
rules: ReadonlyArray<CompiledTableStyleRule>
|
|
1349
|
+
): Set<string> {
|
|
1350
|
+
const hidden = new Set<string>();
|
|
1351
|
+
for (let index = 0; index < columns.length; index += 1) {
|
|
1352
|
+
const columnName = columns[index];
|
|
1353
|
+
const columnContext = columnContexts[index];
|
|
1354
|
+
const headerContext = createHeaderContext(columnName);
|
|
1355
|
+
const cellContext = createCellContext(0, columnName, '');
|
|
1356
|
+
for (const rule of rules) {
|
|
1357
|
+
if (rule.target === 'value') {
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
const display = rule.style.display?.trim().toLowerCase();
|
|
1361
|
+
if (display !== 'none') {
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
const matches = rule.selectors.some((selector) => {
|
|
1365
|
+
if (selector.kind === 'header') {
|
|
1366
|
+
return evaluateSelectorCondition(selector.condition, headerContext);
|
|
1367
|
+
}
|
|
1368
|
+
if (selector.kind === 'column') {
|
|
1369
|
+
return evaluateSelectorCondition(selector.condition, columnContext);
|
|
1370
|
+
}
|
|
1371
|
+
if (selector.kind === 'cell') {
|
|
1372
|
+
return evaluateSelectorCondition(selector.condition, cellContext);
|
|
1373
|
+
}
|
|
1374
|
+
return false;
|
|
1375
|
+
});
|
|
1376
|
+
if (matches) {
|
|
1377
|
+
hidden.add(columnName);
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
return hidden;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function formatCellDisplayText(raw: string): string {
|
|
1386
|
+
const parts = raw.split(/[\s,]+/).filter((entry) => entry.length > 0);
|
|
1387
|
+
if (parts.length === 0) {
|
|
1388
|
+
return raw;
|
|
1389
|
+
}
|
|
1390
|
+
return parts.map((part) => isIriValue(part) ? shortLabelFromIri(part) : part).join(' ');
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
type ParsedFilterTerm =
|
|
1394
|
+
| { kind: 'global'; value: string }
|
|
1395
|
+
| { kind: 'scoped'; columns: string[]; value: string };
|
|
1396
|
+
|
|
1397
|
+
function matchesFilterQuery(columns: string[], cells: string[], rawQuery: string): boolean {
|
|
1398
|
+
const terms = parseFilterTerms(rawQuery);
|
|
1399
|
+
if (terms.length === 0) {
|
|
1400
|
+
return true;
|
|
1401
|
+
}
|
|
1402
|
+
const lowerColumns = columns.map((column) => column.toLowerCase());
|
|
1403
|
+
const lowerCells = cells.map((cell) => cell.toLowerCase());
|
|
1404
|
+
return terms.every((term) => {
|
|
1405
|
+
if (term.kind === 'global') {
|
|
1406
|
+
return lowerCells.some((cell) => cell.includes(term.value));
|
|
1407
|
+
}
|
|
1408
|
+
const indexes = term.columns
|
|
1409
|
+
.map((columnName) => lowerColumns.indexOf(columnName))
|
|
1410
|
+
.filter((index) => index >= 0);
|
|
1411
|
+
if (indexes.length === 0) {
|
|
1412
|
+
return false;
|
|
1413
|
+
}
|
|
1414
|
+
return indexes.some((index) => (lowerCells[index] ?? '').includes(term.value));
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function parseFilterTerms(rawQuery: string): ParsedFilterTerm[] {
|
|
1419
|
+
const tokens = tokenizeFilterQuery(rawQuery);
|
|
1420
|
+
const terms: ParsedFilterTerm[] = [];
|
|
1421
|
+
for (const token of tokens) {
|
|
1422
|
+
const scoped = parseScopedFilterToken(token);
|
|
1423
|
+
if (scoped) {
|
|
1424
|
+
terms.push(scoped);
|
|
1425
|
+
continue;
|
|
1426
|
+
}
|
|
1427
|
+
const value = normalizeFilterValue(token);
|
|
1428
|
+
if (!value) {
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
terms.push({ kind: 'global', value });
|
|
1432
|
+
}
|
|
1433
|
+
return terms;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function tokenizeFilterQuery(rawQuery: string): string[] {
|
|
1437
|
+
const tokens: string[] = [];
|
|
1438
|
+
let current = '';
|
|
1439
|
+
let quote: '"' | '\'' | undefined;
|
|
1440
|
+
for (let index = 0; index < rawQuery.length; index += 1) {
|
|
1441
|
+
const char = rawQuery[index];
|
|
1442
|
+
if (quote) {
|
|
1443
|
+
if (char === quote && rawQuery[index - 1] !== '\\') {
|
|
1444
|
+
quote = undefined;
|
|
1445
|
+
}
|
|
1446
|
+
current += char;
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
if (char === '"' || char === '\'') {
|
|
1450
|
+
quote = char;
|
|
1451
|
+
current += char;
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
if (/\s/.test(char)) {
|
|
1455
|
+
if (current.trim()) {
|
|
1456
|
+
tokens.push(current.trim());
|
|
1457
|
+
}
|
|
1458
|
+
current = '';
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
current += char;
|
|
1462
|
+
}
|
|
1463
|
+
if (current.trim()) {
|
|
1464
|
+
tokens.push(current.trim());
|
|
1465
|
+
}
|
|
1466
|
+
return tokens;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function parseScopedFilterToken(token: string): ParsedFilterTerm | undefined {
|
|
1470
|
+
const separator = token.indexOf(':');
|
|
1471
|
+
if (separator <= 0) {
|
|
1472
|
+
return undefined;
|
|
1473
|
+
}
|
|
1474
|
+
const rawScope = token.slice(0, separator).trim();
|
|
1475
|
+
const rawValue = token.slice(separator + 1).trim();
|
|
1476
|
+
const value = normalizeFilterValue(rawValue);
|
|
1477
|
+
if (!value) {
|
|
1478
|
+
return undefined;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const multiScope = /^\((.+)\)$/.exec(rawScope);
|
|
1482
|
+
const scopeColumns = multiScope
|
|
1483
|
+
? multiScope[1].split('|').map((entry) => normalizeFilterValue(entry)).filter((entry): entry is string => !!entry)
|
|
1484
|
+
: [normalizeFilterValue(rawScope)].filter((entry): entry is string => !!entry);
|
|
1485
|
+
if (scopeColumns.length === 0) {
|
|
1486
|
+
return undefined;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
return {
|
|
1490
|
+
kind: 'scoped',
|
|
1491
|
+
columns: scopeColumns,
|
|
1492
|
+
value
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function normalizeFilterValue(raw: string): string | undefined {
|
|
1497
|
+
const trimmed = raw.trim();
|
|
1498
|
+
if (!trimmed) {
|
|
1499
|
+
return undefined;
|
|
1500
|
+
}
|
|
1501
|
+
const unquoted = unquoteFilterValue(trimmed);
|
|
1502
|
+
const normalized = unquoted.trim().toLowerCase();
|
|
1503
|
+
return normalized || undefined;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function unquoteFilterValue(value: string): string {
|
|
1507
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
|
1508
|
+
return value.slice(1, -1);
|
|
1509
|
+
}
|
|
1510
|
+
return value;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function resolveContainmentColumns(columns: string[], options: Record<string, unknown> | undefined): number[] {
|
|
1514
|
+
const containment = options?.containment;
|
|
1515
|
+
if (!Array.isArray(containment) || containment.length === 0) {
|
|
1516
|
+
return [];
|
|
1517
|
+
}
|
|
1518
|
+
const names = new Set<string>();
|
|
1519
|
+
for (const entry of containment) {
|
|
1520
|
+
if (typeof entry !== 'string') {
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
const normalized = normalizeContainmentName(entry);
|
|
1524
|
+
if (normalized) {
|
|
1525
|
+
names.add(normalized);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
if (names.size === 0) {
|
|
1529
|
+
return [];
|
|
1530
|
+
}
|
|
1531
|
+
const indexes: number[] = [];
|
|
1532
|
+
columns.forEach((column, index) => {
|
|
1533
|
+
if (names.has(normalizeContainmentName(column))) {
|
|
1534
|
+
indexes.push(index);
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
return indexes;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
function normalizeContainmentName(raw: string): string {
|
|
1541
|
+
const trimmed = raw.trim().toLowerCase();
|
|
1542
|
+
if (!trimmed) {
|
|
1543
|
+
return '';
|
|
1544
|
+
}
|
|
1545
|
+
const colon = trimmed.lastIndexOf(':');
|
|
1546
|
+
if (colon >= 0 && colon < trimmed.length - 1) {
|
|
1547
|
+
return trimmed.slice(colon + 1);
|
|
1548
|
+
}
|
|
1549
|
+
const hash = trimmed.lastIndexOf('#');
|
|
1550
|
+
if (hash >= 0 && hash < trimmed.length - 1) {
|
|
1551
|
+
return trimmed.slice(hash + 1);
|
|
1552
|
+
}
|
|
1553
|
+
const slash = trimmed.lastIndexOf('/');
|
|
1554
|
+
if (slash >= 0 && slash < trimmed.length - 1) {
|
|
1555
|
+
return trimmed.slice(slash + 1);
|
|
1556
|
+
}
|
|
1557
|
+
return trimmed;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function evaluateSelectorCondition(condition: string | undefined, context: object): boolean {
|
|
1561
|
+
if (!condition) {
|
|
1562
|
+
return true;
|
|
1563
|
+
}
|
|
1564
|
+
try {
|
|
1565
|
+
return Boolean(evaluateExpression(condition, context));
|
|
1566
|
+
} catch {
|
|
1567
|
+
return false;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
type TokenType = 'identifier' | 'number' | 'string' | 'symbol' | 'eof';
|
|
1572
|
+
type Token = {
|
|
1573
|
+
type: TokenType;
|
|
1574
|
+
value: string;
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
class ExpressionParser {
|
|
1578
|
+
private index = 0;
|
|
1579
|
+
|
|
1580
|
+
constructor(
|
|
1581
|
+
private readonly tokens: ReadonlyArray<Token>,
|
|
1582
|
+
private readonly scope: Record<string, unknown>
|
|
1583
|
+
) {}
|
|
1584
|
+
|
|
1585
|
+
parse(): unknown {
|
|
1586
|
+
const value = this.parseOr();
|
|
1587
|
+
this.expectType('eof');
|
|
1588
|
+
return value;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
private parseOr(): unknown {
|
|
1592
|
+
let left = this.parseAnd();
|
|
1593
|
+
while (this.matchSymbol('||')) {
|
|
1594
|
+
const right = this.parseAnd();
|
|
1595
|
+
left = Boolean(left) || Boolean(right);
|
|
1596
|
+
}
|
|
1597
|
+
return left;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
private parseAnd(): unknown {
|
|
1601
|
+
let left = this.parseEquality();
|
|
1602
|
+
while (this.matchSymbol('&&')) {
|
|
1603
|
+
const right = this.parseEquality();
|
|
1604
|
+
left = Boolean(left) && Boolean(right);
|
|
1605
|
+
}
|
|
1606
|
+
return left;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
private parseEquality(): unknown {
|
|
1610
|
+
let left = this.parseComparison();
|
|
1611
|
+
while (true) {
|
|
1612
|
+
if (this.matchSymbol('===')) {
|
|
1613
|
+
left = left === this.parseComparison();
|
|
1614
|
+
continue;
|
|
1615
|
+
}
|
|
1616
|
+
if (this.matchSymbol('!==')) {
|
|
1617
|
+
left = left !== this.parseComparison();
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
if (this.matchSymbol('==')) {
|
|
1621
|
+
left = left == this.parseComparison();
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
if (this.matchSymbol('!=')) {
|
|
1625
|
+
left = left != this.parseComparison();
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
break;
|
|
1629
|
+
}
|
|
1630
|
+
return left;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
private parseComparison(): unknown {
|
|
1634
|
+
let left = this.parseAdditive();
|
|
1635
|
+
while (true) {
|
|
1636
|
+
if (this.matchSymbol('>=')) {
|
|
1637
|
+
left = Number(left) >= Number(this.parseAdditive());
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
if (this.matchSymbol('<=')) {
|
|
1641
|
+
left = Number(left) <= Number(this.parseAdditive());
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
if (this.matchSymbol('>')) {
|
|
1645
|
+
left = Number(left) > Number(this.parseAdditive());
|
|
1646
|
+
continue;
|
|
1647
|
+
}
|
|
1648
|
+
if (this.matchSymbol('<')) {
|
|
1649
|
+
left = Number(left) < Number(this.parseAdditive());
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
break;
|
|
1653
|
+
}
|
|
1654
|
+
return left;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
private parseAdditive(): unknown {
|
|
1658
|
+
let left = this.parseMultiplicative();
|
|
1659
|
+
while (true) {
|
|
1660
|
+
if (this.matchSymbol('+')) {
|
|
1661
|
+
const right = this.parseMultiplicative();
|
|
1662
|
+
left = typeof left === 'string' || typeof right === 'string'
|
|
1663
|
+
? `${left ?? ''}${right ?? ''}`
|
|
1664
|
+
: Number(left) + Number(right);
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
if (this.matchSymbol('-')) {
|
|
1668
|
+
left = Number(left) - Number(this.parseMultiplicative());
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
break;
|
|
1672
|
+
}
|
|
1673
|
+
return left;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
private parseMultiplicative(): unknown {
|
|
1677
|
+
let left = this.parseUnary();
|
|
1678
|
+
while (true) {
|
|
1679
|
+
if (this.matchSymbol('*')) {
|
|
1680
|
+
left = Number(left) * Number(this.parseUnary());
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
if (this.matchSymbol('/')) {
|
|
1684
|
+
left = Number(left) / Number(this.parseUnary());
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
if (this.matchSymbol('%')) {
|
|
1688
|
+
left = Number(left) % Number(this.parseUnary());
|
|
1689
|
+
continue;
|
|
1690
|
+
}
|
|
1691
|
+
break;
|
|
1692
|
+
}
|
|
1693
|
+
return left;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
private parseUnary(): unknown {
|
|
1697
|
+
if (this.matchSymbol('!')) {
|
|
1698
|
+
return !Boolean(this.parseUnary());
|
|
1699
|
+
}
|
|
1700
|
+
if (this.matchSymbol('-')) {
|
|
1701
|
+
return -Number(this.parseUnary());
|
|
1702
|
+
}
|
|
1703
|
+
return this.parseCallChain();
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
private parseCallChain(): unknown {
|
|
1707
|
+
let current = this.parsePrimary();
|
|
1708
|
+
let receiver: unknown = undefined;
|
|
1709
|
+
while (true) {
|
|
1710
|
+
if (this.matchSymbol('.')) {
|
|
1711
|
+
const propertyToken = this.expectType('identifier');
|
|
1712
|
+
if (current === null || current === undefined) {
|
|
1713
|
+
current = undefined;
|
|
1714
|
+
receiver = undefined;
|
|
1715
|
+
} else {
|
|
1716
|
+
const obj = Object(current) as Record<string, unknown>;
|
|
1717
|
+
receiver = current;
|
|
1718
|
+
current = obj[propertyToken.value];
|
|
1719
|
+
}
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
if (this.matchSymbol('(')) {
|
|
1723
|
+
const args: unknown[] = [];
|
|
1724
|
+
if (!this.matchSymbol(')')) {
|
|
1725
|
+
do {
|
|
1726
|
+
args.push(this.parseOr());
|
|
1727
|
+
} while (this.matchSymbol(','));
|
|
1728
|
+
this.expectSymbol(')');
|
|
1729
|
+
}
|
|
1730
|
+
if (typeof current !== 'function') {
|
|
1731
|
+
current = undefined;
|
|
1732
|
+
} else {
|
|
1733
|
+
const fn = current as (...params: unknown[]) => unknown;
|
|
1734
|
+
current = receiver !== undefined ? fn.apply(receiver, args) : fn(...args);
|
|
1735
|
+
}
|
|
1736
|
+
receiver = undefined;
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
break;
|
|
1740
|
+
}
|
|
1741
|
+
return current;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
private parsePrimary(): unknown {
|
|
1745
|
+
const token = this.current();
|
|
1746
|
+
if (token.type === 'number') {
|
|
1747
|
+
this.index += 1;
|
|
1748
|
+
return Number(token.value);
|
|
1749
|
+
}
|
|
1750
|
+
if (token.type === 'string') {
|
|
1751
|
+
this.index += 1;
|
|
1752
|
+
return token.value;
|
|
1753
|
+
}
|
|
1754
|
+
if (token.type === 'identifier') {
|
|
1755
|
+
this.index += 1;
|
|
1756
|
+
if (token.value === 'true') return true;
|
|
1757
|
+
if (token.value === 'false') return false;
|
|
1758
|
+
if (token.value === 'null') return null;
|
|
1759
|
+
if (token.value === 'undefined') return undefined;
|
|
1760
|
+
return this.scope[token.value];
|
|
1761
|
+
}
|
|
1762
|
+
if (this.matchSymbol('(')) {
|
|
1763
|
+
const value = this.parseOr();
|
|
1764
|
+
this.expectSymbol(')');
|
|
1765
|
+
return value;
|
|
1766
|
+
}
|
|
1767
|
+
throw new Error('Expected expression');
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
private current(): Token {
|
|
1771
|
+
return this.tokens[this.index];
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
private matchSymbol(symbol: string): boolean {
|
|
1775
|
+
const token = this.current();
|
|
1776
|
+
if (token.type === 'symbol' && token.value === symbol) {
|
|
1777
|
+
this.index += 1;
|
|
1778
|
+
return true;
|
|
1779
|
+
}
|
|
1780
|
+
return false;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
private expectType(type: TokenType): Token {
|
|
1784
|
+
const token = this.current();
|
|
1785
|
+
if (token.type !== type) {
|
|
1786
|
+
throw new Error(`Expected ${type}`);
|
|
1787
|
+
}
|
|
1788
|
+
this.index += 1;
|
|
1789
|
+
return token;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
private expectSymbol(symbol: string): void {
|
|
1793
|
+
if (!this.matchSymbol(symbol)) {
|
|
1794
|
+
throw new Error(`Expected ${symbol}`);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function evaluateExpression(expression: string, scope: object): unknown {
|
|
1800
|
+
const parser = new ExpressionParser(tokenizeExpression(expression), scope as Record<string, unknown>);
|
|
1801
|
+
return parser.parse();
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function tokenizeExpression(expression: string): Token[] {
|
|
1805
|
+
const tokens: Token[] = [];
|
|
1806
|
+
let index = 0;
|
|
1807
|
+
const operators = ['===', '!==', '>=', '<=', '&&', '||', '==', '!=', '(', ')', ',', '.', '+', '-', '*', '/', '%', '!', '>', '<'];
|
|
1808
|
+
|
|
1809
|
+
while (index < expression.length) {
|
|
1810
|
+
const char = expression[index];
|
|
1811
|
+
if (/\s/.test(char)) {
|
|
1812
|
+
index += 1;
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const operator = operators.find((op) => expression.startsWith(op, index));
|
|
1817
|
+
if (operator) {
|
|
1818
|
+
tokens.push({ type: 'symbol', value: operator });
|
|
1819
|
+
index += operator.length;
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
if (char === '"' || char === '\'') {
|
|
1824
|
+
const quote = char;
|
|
1825
|
+
let value = '';
|
|
1826
|
+
index += 1;
|
|
1827
|
+
while (index < expression.length) {
|
|
1828
|
+
const current = expression[index];
|
|
1829
|
+
if (current === quote) {
|
|
1830
|
+
index += 1;
|
|
1831
|
+
break;
|
|
1832
|
+
}
|
|
1833
|
+
if (current === '\\' && index + 1 < expression.length) {
|
|
1834
|
+
value += expression[index + 1];
|
|
1835
|
+
index += 2;
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1838
|
+
value += current;
|
|
1839
|
+
index += 1;
|
|
1840
|
+
}
|
|
1841
|
+
tokens.push({ type: 'string', value });
|
|
1842
|
+
continue;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
const numberMatch = /^[0-9]+(?:\.[0-9]+)?/.exec(expression.slice(index));
|
|
1846
|
+
if (numberMatch) {
|
|
1847
|
+
tokens.push({ type: 'number', value: numberMatch[0] });
|
|
1848
|
+
index += numberMatch[0].length;
|
|
1849
|
+
continue;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
const identifierMatch = /^[A-Za-z_][A-Za-z0-9_]*/.exec(expression.slice(index));
|
|
1853
|
+
if (identifierMatch) {
|
|
1854
|
+
tokens.push({ type: 'identifier', value: identifierMatch[0] });
|
|
1855
|
+
index += identifierMatch[0].length;
|
|
1856
|
+
continue;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
throw new Error(`Unexpected token '${char}'`);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
tokens.push({ type: 'eof', value: '' });
|
|
1863
|
+
return tokens;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1867
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1868
|
+
}
|