@yeyuan98/opencode-bioresearcher-plugin 1.7.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tools/parser/pubmed/utils.js +3 -1
- package/dist/tools/table/backends/base.d.ts +8 -0
- package/dist/tools/table/backends/base.js +23 -0
- package/dist/tools/table/backends/csv/index.d.ts +64 -0
- package/dist/tools/table/backends/csv/index.js +660 -0
- package/dist/tools/table/backends/factory.d.ts +2 -0
- package/dist/tools/table/backends/factory.js +14 -0
- package/dist/tools/table/backends/interface.d.ts +68 -0
- package/dist/tools/table/backends/interface.js +1 -0
- package/dist/tools/table/backends/xlsx/index.d.ts +62 -0
- package/dist/tools/table/backends/xlsx/index.js +547 -0
- package/dist/tools/table/index.d.ts +6 -4
- package/dist/tools/table/tools.d.ts +6 -4
- package/dist/tools/table/tools.js +292 -459
- package/dist/tools/table/utils.d.ts +5 -5
- package/dist/tools/table/utils.js +18 -39
- package/dist/vendor/xlsx.d.mts +1 -0
- package/dist/vendor/xlsx.mjs +33989 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -4
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as crypto from 'crypto';
|
|
4
|
+
import { BaseTableBackend } from '../base';
|
|
5
|
+
import { iterativeMin, iterativeMax, DEFAULT_MAX_RANGE_CELLS } from '../../utils';
|
|
6
|
+
const CELL_ADDRESS_REGEX = /^[A-Za-z]+\d+$/;
|
|
7
|
+
function coerceCSVValue(field) {
|
|
8
|
+
if (field === '')
|
|
9
|
+
return undefined;
|
|
10
|
+
const num = Number(field);
|
|
11
|
+
if (!isNaN(num) && field.trim() !== '')
|
|
12
|
+
return num;
|
|
13
|
+
return field;
|
|
14
|
+
}
|
|
15
|
+
function deduplicateHeaders(rawHeaders) {
|
|
16
|
+
const headers = [];
|
|
17
|
+
const seen = new Map();
|
|
18
|
+
for (const raw of rawHeaders) {
|
|
19
|
+
const count = seen.get(raw) ?? 0;
|
|
20
|
+
seen.set(raw, count + 1);
|
|
21
|
+
headers.push(count > 0 ? `${raw}_${count}` : raw);
|
|
22
|
+
}
|
|
23
|
+
return headers;
|
|
24
|
+
}
|
|
25
|
+
function detectDelimiter(resolvedPath) {
|
|
26
|
+
const ext = path.extname(resolvedPath).toLowerCase();
|
|
27
|
+
if (ext === '.tsv')
|
|
28
|
+
return '\t';
|
|
29
|
+
if (ext === '.csv')
|
|
30
|
+
return ',';
|
|
31
|
+
const firstLine = fs.readFileSync(resolvedPath, 'utf-8').split('\n')[0] || '';
|
|
32
|
+
const commas = (firstLine.match(/,/g) || []).length;
|
|
33
|
+
const tabs = (firstLine.match(/\t/g) || []).length;
|
|
34
|
+
return tabs > commas ? '\t' : ',';
|
|
35
|
+
}
|
|
36
|
+
function parseCSVLine(line, delimiter) {
|
|
37
|
+
const fields = [];
|
|
38
|
+
let current = '';
|
|
39
|
+
let inQuotes = false;
|
|
40
|
+
for (let i = 0; i < line.length; i++) {
|
|
41
|
+
const ch = line[i];
|
|
42
|
+
if (inQuotes) {
|
|
43
|
+
if (ch === '"') {
|
|
44
|
+
if (i + 1 < line.length && line[i + 1] === '"') {
|
|
45
|
+
current += '"';
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
inQuotes = false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
current += ch;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
if (ch === '"') {
|
|
58
|
+
inQuotes = true;
|
|
59
|
+
}
|
|
60
|
+
else if (ch === delimiter) {
|
|
61
|
+
fields.push(current);
|
|
62
|
+
current = '';
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
current += ch;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
fields.push(current);
|
|
70
|
+
return fields;
|
|
71
|
+
}
|
|
72
|
+
function rowFieldsToObject(fields, headers) {
|
|
73
|
+
const obj = {};
|
|
74
|
+
for (let i = 0; i < headers.length; i++) {
|
|
75
|
+
const val = coerceCSVValue(fields[i] ?? '');
|
|
76
|
+
if (val !== undefined) {
|
|
77
|
+
obj[headers[i]] = val;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return obj;
|
|
81
|
+
}
|
|
82
|
+
function encodeColumn(col) {
|
|
83
|
+
let result = '';
|
|
84
|
+
let c = col;
|
|
85
|
+
while (c >= 0) {
|
|
86
|
+
result = String.fromCharCode(65 + (c % 26)) + result;
|
|
87
|
+
c = Math.floor(c / 26) - 1;
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
function decodeColumn(str) {
|
|
92
|
+
let col = 0;
|
|
93
|
+
for (let i = 0; i < str.length; i++) {
|
|
94
|
+
col = col * 26 + (str.charCodeAt(i) - 64);
|
|
95
|
+
}
|
|
96
|
+
return col - 1;
|
|
97
|
+
}
|
|
98
|
+
function decodeCellAddress(address) {
|
|
99
|
+
const match = address.match(/^([A-Za-z]+)(\d+)$/);
|
|
100
|
+
if (!match)
|
|
101
|
+
throw new Error(`Invalid cell address: ${address}`);
|
|
102
|
+
return { col: decodeColumn(match[1].toUpperCase()), row: parseInt(match[2], 10) - 1 };
|
|
103
|
+
}
|
|
104
|
+
function escapeCSVField(field, delimiter) {
|
|
105
|
+
const str = String(field ?? '');
|
|
106
|
+
if (str.includes(delimiter) || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
107
|
+
return '"' + str.replace(/"/g, '""') + '"';
|
|
108
|
+
}
|
|
109
|
+
return str;
|
|
110
|
+
}
|
|
111
|
+
export class CSVBackend extends BaseTableBackend {
|
|
112
|
+
type = 'csv';
|
|
113
|
+
delimiter;
|
|
114
|
+
_headers = null;
|
|
115
|
+
_totalDataRows = null;
|
|
116
|
+
constructor(resolvedPath, force) {
|
|
117
|
+
super(resolvedPath, force);
|
|
118
|
+
this.delimiter = detectDelimiter(resolvedPath);
|
|
119
|
+
}
|
|
120
|
+
readHeaders() {
|
|
121
|
+
if (this._headers)
|
|
122
|
+
return this._headers;
|
|
123
|
+
const firstLine = fs.readFileSync(this.resolvedPath, 'utf-8').split('\n')[0] || '';
|
|
124
|
+
const rawHeaders = parseCSVLine(firstLine, this.delimiter);
|
|
125
|
+
const processed = rawHeaders.map((h, i) => {
|
|
126
|
+
const trimmed = h.trim();
|
|
127
|
+
return (trimmed !== '') ? trimmed : `Column_${i}`;
|
|
128
|
+
});
|
|
129
|
+
this._headers = deduplicateHeaders(processed);
|
|
130
|
+
return this._headers;
|
|
131
|
+
}
|
|
132
|
+
countDataRows() {
|
|
133
|
+
if (this._totalDataRows !== null)
|
|
134
|
+
return this._totalDataRows;
|
|
135
|
+
const content = fs.readFileSync(this.resolvedPath, 'utf-8');
|
|
136
|
+
const lines = content.split('\n');
|
|
137
|
+
let count = 0;
|
|
138
|
+
for (let i = 1; i < lines.length; i++) {
|
|
139
|
+
if (lines[i].trim() === '' && i === lines.length - 1)
|
|
140
|
+
continue;
|
|
141
|
+
if (lines[i].trim() === '')
|
|
142
|
+
continue;
|
|
143
|
+
count++;
|
|
144
|
+
}
|
|
145
|
+
this._totalDataRows = count;
|
|
146
|
+
return this._totalDataRows;
|
|
147
|
+
}
|
|
148
|
+
listSheets() {
|
|
149
|
+
return [{ name: 'Sheet1', index: 0 }];
|
|
150
|
+
}
|
|
151
|
+
getHeaders(sheetIndex) {
|
|
152
|
+
this.ensureSheetIndex(sheetIndex);
|
|
153
|
+
const headers = this.readHeaders();
|
|
154
|
+
return { headers, columnCount: headers.length };
|
|
155
|
+
}
|
|
156
|
+
getTotalDataRows(sheetIndex) {
|
|
157
|
+
this.ensureSheetIndex(sheetIndex);
|
|
158
|
+
return this.countDataRows();
|
|
159
|
+
}
|
|
160
|
+
getCell(sheetIndex, cellAddress) {
|
|
161
|
+
this.ensureSheetIndex(sheetIndex);
|
|
162
|
+
if (!CELL_ADDRESS_REGEX.test(cellAddress)) {
|
|
163
|
+
throw new Error(`Invalid cell address "${cellAddress}". Expected format like "A1", "B5", "AA123".`);
|
|
164
|
+
}
|
|
165
|
+
const { row, col } = decodeCellAddress(cellAddress);
|
|
166
|
+
const headers = this.readHeaders();
|
|
167
|
+
const content = fs.readFileSync(this.resolvedPath, 'utf-8');
|
|
168
|
+
const lines = content.split('\n');
|
|
169
|
+
if (row < 0 || row >= lines.length)
|
|
170
|
+
return { value: '', type: 'unknown' };
|
|
171
|
+
const targetLine = lines[row];
|
|
172
|
+
if (!targetLine && row > 0)
|
|
173
|
+
return { value: '', type: 'unknown' };
|
|
174
|
+
const fields = parseCSVLine(targetLine || lines[0], this.delimiter);
|
|
175
|
+
const fieldVal = fields[col];
|
|
176
|
+
if (fieldVal === undefined)
|
|
177
|
+
return { value: '', type: 'unknown' };
|
|
178
|
+
if (row === 0)
|
|
179
|
+
return { value: fieldVal.trim(), type: 's' };
|
|
180
|
+
const coerced = coerceCSVValue(fieldVal);
|
|
181
|
+
return { value: coerced ?? '', type: typeof coerced === 'number' ? 'n' : 's' };
|
|
182
|
+
}
|
|
183
|
+
getRange(sheetIndex, range) {
|
|
184
|
+
this.ensureSheetIndex(sheetIndex);
|
|
185
|
+
const { s, e } = (() => {
|
|
186
|
+
const parts = range.split(':');
|
|
187
|
+
const start = decodeCellAddress(parts[0]);
|
|
188
|
+
const end = parts[1] ? decodeCellAddress(parts[1]) : { ...start };
|
|
189
|
+
return {
|
|
190
|
+
s: { r: start.row, c: start.col },
|
|
191
|
+
e: { r: end.row, c: end.col },
|
|
192
|
+
};
|
|
193
|
+
})();
|
|
194
|
+
const requestedCells = (e.r - s.r + 1) * (e.c - s.c + 1);
|
|
195
|
+
if (!this.force && requestedCells > DEFAULT_MAX_RANGE_CELLS) {
|
|
196
|
+
throw new Error(`Range "${range}" exceeds ${DEFAULT_MAX_RANGE_CELLS} cell limit (${requestedCells} cells). Set force: true to override.`);
|
|
197
|
+
}
|
|
198
|
+
const headers = this.readHeaders();
|
|
199
|
+
const totalCols = headers.length;
|
|
200
|
+
const totalRows = this.countDataRows() + 1;
|
|
201
|
+
const clampedS = { r: Math.max(s.r, 0), c: Math.max(s.c, 0) };
|
|
202
|
+
const clampedE = { r: Math.min(e.r, totalRows - 1), c: Math.min(e.c, totalCols - 1) };
|
|
203
|
+
if (clampedS.r > clampedE.r || clampedS.c > clampedE.c) {
|
|
204
|
+
return { data: [], rows: 0, columns: 0, out_of_bounds: true };
|
|
205
|
+
}
|
|
206
|
+
const content = fs.readFileSync(this.resolvedPath, 'utf-8');
|
|
207
|
+
const lines = content.split('\n');
|
|
208
|
+
const data = [];
|
|
209
|
+
for (let r = clampedS.r; r <= clampedE.r; r++) {
|
|
210
|
+
const line = lines[r];
|
|
211
|
+
if (!line) {
|
|
212
|
+
data.push(new Array(clampedE.c - clampedS.c + 1).fill(''));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const fields = parseCSVLine(line, this.delimiter);
|
|
216
|
+
const row = [];
|
|
217
|
+
for (let c = clampedS.c; c <= clampedE.c; c++) {
|
|
218
|
+
const fieldVal = fields[c];
|
|
219
|
+
if (r === 0) {
|
|
220
|
+
row.push(fieldVal?.trim() ?? '');
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
const coerced = fieldVal !== undefined ? coerceCSVValue(fieldVal) : '';
|
|
224
|
+
row.push(coerced ?? '');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
data.push(row);
|
|
228
|
+
}
|
|
229
|
+
const result = { data, rows: data.length, columns: data[0]?.length || 0 };
|
|
230
|
+
if (clampedS.r !== s.r || clampedS.c !== s.c || clampedE.r !== e.r || clampedE.c !== e.c) {
|
|
231
|
+
result.clamped_to = `${encodeColumn(clampedS.c)}${clampedS.r + 1}:${encodeColumn(clampedE.c)}${clampedE.r + 1}`;
|
|
232
|
+
}
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
getPreview(sheetIndex, maxRows) {
|
|
236
|
+
this.ensureSheetIndex(sheetIndex);
|
|
237
|
+
const headers = this.readHeaders();
|
|
238
|
+
const content = fs.readFileSync(this.resolvedPath, 'utf-8');
|
|
239
|
+
const lines = content.split('\n');
|
|
240
|
+
const previewRows = [];
|
|
241
|
+
const headerRow = headers.map(h => h);
|
|
242
|
+
previewRows.push(headerRow);
|
|
243
|
+
for (let i = 1; i < lines.length && previewRows.length < maxRows; i++) {
|
|
244
|
+
const line = lines[i];
|
|
245
|
+
if (line.trim() === '' && i === lines.length - 1)
|
|
246
|
+
continue;
|
|
247
|
+
if (line.trim() === '')
|
|
248
|
+
continue;
|
|
249
|
+
const fields = parseCSVLine(line, this.delimiter);
|
|
250
|
+
const row = headers.map((_, ci) => {
|
|
251
|
+
const val = coerceCSVValue(fields[ci] ?? '');
|
|
252
|
+
return val ?? '';
|
|
253
|
+
});
|
|
254
|
+
previewRows.push(row);
|
|
255
|
+
}
|
|
256
|
+
return { rows: previewRows, totalRows: this.countDataRows() + 1 };
|
|
257
|
+
}
|
|
258
|
+
search(sheetIndex, searchTerm, options) {
|
|
259
|
+
this.ensureSheetIndex(sheetIndex);
|
|
260
|
+
const headers = this.readHeaders();
|
|
261
|
+
const results = [];
|
|
262
|
+
let totalMatchesFound = 0;
|
|
263
|
+
let rowsScanned = 0;
|
|
264
|
+
const term = searchTerm.toLowerCase();
|
|
265
|
+
let searchColIdx = null;
|
|
266
|
+
if (options.column) {
|
|
267
|
+
searchColIdx = headers.indexOf(options.column);
|
|
268
|
+
if (searchColIdx === -1) {
|
|
269
|
+
throw new Error(`Column "${options.column}" not found. Available columns: ${headers.slice(0, 20).join(', ')}${headers.length > 20 ? ` ... (${headers.length} total)` : ''}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const content = fs.readFileSync(this.resolvedPath, 'utf-8');
|
|
273
|
+
const lines = content.split('\n');
|
|
274
|
+
let dataRowIdx = 0;
|
|
275
|
+
for (let i = 0; i < lines.length; i++) {
|
|
276
|
+
const line = lines[i];
|
|
277
|
+
if (line.trim() === '' && i === lines.length - 1)
|
|
278
|
+
continue;
|
|
279
|
+
if (line.trim() === '' && i > 0)
|
|
280
|
+
continue;
|
|
281
|
+
rowsScanned++;
|
|
282
|
+
if (rowsScanned > options.maxRowsScan + 1)
|
|
283
|
+
break;
|
|
284
|
+
const fields = parseCSVLine(line, this.delimiter);
|
|
285
|
+
if (searchColIdx !== null) {
|
|
286
|
+
const fieldVal = fields[searchColIdx] ?? '';
|
|
287
|
+
if (String(fieldVal).toLowerCase().includes(term)) {
|
|
288
|
+
totalMatchesFound++;
|
|
289
|
+
if (results.length < options.maxResults) {
|
|
290
|
+
const address = `${encodeColumn(searchColIdx)}${i + 1}`;
|
|
291
|
+
const coerced = coerceCSVValue(fieldVal);
|
|
292
|
+
results.push({ address, value: coerced ?? fieldVal, type: typeof coerced === 'number' ? 'n' : 's', row_index: i, column: options.column });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
for (let c = 0; c < fields.length; c++) {
|
|
298
|
+
const fieldVal = fields[c] ?? '';
|
|
299
|
+
if (String(fieldVal).toLowerCase().includes(term)) {
|
|
300
|
+
totalMatchesFound++;
|
|
301
|
+
if (results.length < options.maxResults) {
|
|
302
|
+
const address = `${encodeColumn(c)}${i + 1}`;
|
|
303
|
+
const coerced = coerceCSVValue(fieldVal);
|
|
304
|
+
results.push({ address, value: coerced ?? fieldVal, type: typeof coerced === 'number' ? 'n' : 's', row_index: i });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return { results, totalMatchesFound, rowsScanned };
|
|
311
|
+
}
|
|
312
|
+
filterRows(sheetIndex, column, operator, value, options) {
|
|
313
|
+
this.ensureSheetIndex(sheetIndex);
|
|
314
|
+
const headers = this.readHeaders();
|
|
315
|
+
const filterColIdx = headers.indexOf(column);
|
|
316
|
+
if (filterColIdx === -1) {
|
|
317
|
+
throw new Error(`Column "${column}" not found. Available columns: ${headers.slice(0, 20).join(', ')}${headers.length > 20 ? ` ... (${headers.length} total)` : ''}`);
|
|
318
|
+
}
|
|
319
|
+
const filteredRows = [];
|
|
320
|
+
let totalMatchesFound = 0;
|
|
321
|
+
const content = fs.readFileSync(this.resolvedPath, 'utf-8');
|
|
322
|
+
const lines = content.split('\n');
|
|
323
|
+
let scanned = 0;
|
|
324
|
+
for (let i = 1; i < lines.length; i++) {
|
|
325
|
+
const line = lines[i];
|
|
326
|
+
if (line.trim() === '' && i === lines.length - 1)
|
|
327
|
+
continue;
|
|
328
|
+
if (line.trim() === '')
|
|
329
|
+
continue;
|
|
330
|
+
scanned++;
|
|
331
|
+
if (scanned > options.maxRowsScan)
|
|
332
|
+
break;
|
|
333
|
+
const fields = parseCSVLine(line, this.delimiter);
|
|
334
|
+
let cellValue = coerceCSVValue(fields[filterColIdx] ?? '');
|
|
335
|
+
if (cellValue === undefined)
|
|
336
|
+
cellValue = '';
|
|
337
|
+
let normalizedCell = cellValue;
|
|
338
|
+
let normalizedComp = value;
|
|
339
|
+
if (typeof normalizedCell === 'string' && normalizedCell.trim() !== '' && !isNaN(Number(normalizedCell))) {
|
|
340
|
+
normalizedCell = Number(normalizedCell);
|
|
341
|
+
}
|
|
342
|
+
if (typeof normalizedComp === 'string' && normalizedComp.trim() !== '' && !isNaN(Number(normalizedComp))) {
|
|
343
|
+
normalizedComp = Number(normalizedComp);
|
|
344
|
+
}
|
|
345
|
+
let matches = false;
|
|
346
|
+
switch (operator) {
|
|
347
|
+
case '=':
|
|
348
|
+
matches = normalizedCell === normalizedComp;
|
|
349
|
+
break;
|
|
350
|
+
case '!=':
|
|
351
|
+
matches = normalizedCell !== normalizedComp;
|
|
352
|
+
break;
|
|
353
|
+
case '>':
|
|
354
|
+
matches = normalizedCell > normalizedComp;
|
|
355
|
+
break;
|
|
356
|
+
case '<':
|
|
357
|
+
matches = normalizedCell < normalizedComp;
|
|
358
|
+
break;
|
|
359
|
+
case '>=':
|
|
360
|
+
matches = normalizedCell >= normalizedComp;
|
|
361
|
+
break;
|
|
362
|
+
case '<=':
|
|
363
|
+
matches = normalizedCell <= normalizedComp;
|
|
364
|
+
break;
|
|
365
|
+
case 'contains':
|
|
366
|
+
matches = String(normalizedCell).toLowerCase().includes(String(normalizedComp).toLowerCase());
|
|
367
|
+
break;
|
|
368
|
+
case 'starts_with':
|
|
369
|
+
matches = String(normalizedCell).toLowerCase().startsWith(String(normalizedComp).toLowerCase());
|
|
370
|
+
break;
|
|
371
|
+
case 'ends_with':
|
|
372
|
+
matches = String(normalizedCell).toLowerCase().endsWith(String(normalizedComp).toLowerCase());
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
if (matches) {
|
|
376
|
+
totalMatchesFound++;
|
|
377
|
+
if (filteredRows.length < options.maxResults) {
|
|
378
|
+
filteredRows.push(rowFieldsToObject(fields, headers));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
matchedRows: filteredRows,
|
|
384
|
+
totalMatchesFound,
|
|
385
|
+
totalDataRows: this.countDataRows(),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
summarize(sheetIndex, columns, options) {
|
|
389
|
+
this.ensureSheetIndex(sheetIndex);
|
|
390
|
+
const headers = this.readHeaders();
|
|
391
|
+
const columnsToAnalyze = columns && columns.length > 0 ? columns : headers;
|
|
392
|
+
const colStats = new Map();
|
|
393
|
+
for (const col of columnsToAnalyze) {
|
|
394
|
+
colStats.set(col, { n: 0, mean: 0, m2: 0, min: Infinity, max: -Infinity, sum: 0 });
|
|
395
|
+
}
|
|
396
|
+
const content = fs.readFileSync(this.resolvedPath, 'utf-8');
|
|
397
|
+
const lines = content.split('\n');
|
|
398
|
+
let scanned = 0;
|
|
399
|
+
for (let i = 1; i < lines.length; i++) {
|
|
400
|
+
const line = lines[i];
|
|
401
|
+
if (line.trim() === '' && i === lines.length - 1)
|
|
402
|
+
continue;
|
|
403
|
+
if (line.trim() === '')
|
|
404
|
+
continue;
|
|
405
|
+
scanned++;
|
|
406
|
+
if (scanned > options.maxRowsScan)
|
|
407
|
+
break;
|
|
408
|
+
const fields = parseCSVLine(line, this.delimiter);
|
|
409
|
+
for (let ci = 0; ci < columnsToAnalyze.length; ci++) {
|
|
410
|
+
const colName = columnsToAnalyze[ci];
|
|
411
|
+
const colIdx = headers.indexOf(colName);
|
|
412
|
+
if (colIdx === -1)
|
|
413
|
+
continue;
|
|
414
|
+
const val = coerceCSVValue(fields[colIdx] ?? '');
|
|
415
|
+
if (typeof val !== 'number' || isNaN(val))
|
|
416
|
+
continue;
|
|
417
|
+
const s = colStats.get(colName);
|
|
418
|
+
s.n++;
|
|
419
|
+
s.sum += val;
|
|
420
|
+
if (val < s.min)
|
|
421
|
+
s.min = val;
|
|
422
|
+
if (val > s.max)
|
|
423
|
+
s.max = val;
|
|
424
|
+
const delta = val - s.mean;
|
|
425
|
+
s.mean += delta / s.n;
|
|
426
|
+
const delta2 = val - s.mean;
|
|
427
|
+
s.m2 += delta * delta2;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const summaries = {};
|
|
431
|
+
for (const col of columnsToAnalyze) {
|
|
432
|
+
const s = colStats.get(col);
|
|
433
|
+
if (s.n > 0) {
|
|
434
|
+
summaries[col] = { sum: s.sum, avg: s.mean, min: s.min, max: s.max, std_dev: Math.sqrt(s.m2 / s.n) };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return { summaries, rowCount: scanned };
|
|
438
|
+
}
|
|
439
|
+
groupBy(sheetIndex, groupColumn, aggColumn, aggType, options) {
|
|
440
|
+
this.ensureSheetIndex(sheetIndex);
|
|
441
|
+
const headers = this.readHeaders();
|
|
442
|
+
const groupColIdx = headers.indexOf(groupColumn);
|
|
443
|
+
const aggColIdx = headers.indexOf(aggColumn);
|
|
444
|
+
if (groupColIdx === -1)
|
|
445
|
+
throw new Error(`Group column "${groupColumn}" not found`);
|
|
446
|
+
if (aggColIdx === -1)
|
|
447
|
+
throw new Error(`Aggregation column "${aggColumn}" not found`);
|
|
448
|
+
const grouped = {};
|
|
449
|
+
const content = fs.readFileSync(this.resolvedPath, 'utf-8');
|
|
450
|
+
const lines = content.split('\n');
|
|
451
|
+
let scanned = 0;
|
|
452
|
+
for (let i = 1; i < lines.length; i++) {
|
|
453
|
+
const line = lines[i];
|
|
454
|
+
if (line.trim() === '' && i === lines.length - 1)
|
|
455
|
+
continue;
|
|
456
|
+
if (line.trim() === '')
|
|
457
|
+
continue;
|
|
458
|
+
scanned++;
|
|
459
|
+
if (scanned > options.maxRowsScan)
|
|
460
|
+
break;
|
|
461
|
+
const fields = parseCSVLine(line, this.delimiter);
|
|
462
|
+
const groupKey = String(coerceCSVValue(fields[groupColIdx] ?? '') || '');
|
|
463
|
+
const aggValue = coerceCSVValue(fields[aggColIdx] ?? '');
|
|
464
|
+
if (!grouped[groupKey])
|
|
465
|
+
grouped[groupKey] = [];
|
|
466
|
+
if (aggType === 'count') {
|
|
467
|
+
grouped[groupKey].push(1);
|
|
468
|
+
}
|
|
469
|
+
else if (typeof aggValue === 'number' && !isNaN(aggValue)) {
|
|
470
|
+
grouped[groupKey].push(aggValue);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const result = {};
|
|
474
|
+
for (const groupKey of Object.keys(grouped)) {
|
|
475
|
+
const values = grouped[groupKey];
|
|
476
|
+
if (values.length === 0) {
|
|
477
|
+
result[groupKey] = 0;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
switch (aggType) {
|
|
481
|
+
case 'sum':
|
|
482
|
+
result[groupKey] = values.reduce((a, b) => a + b, 0);
|
|
483
|
+
break;
|
|
484
|
+
case 'count':
|
|
485
|
+
result[groupKey] = values.length;
|
|
486
|
+
break;
|
|
487
|
+
case 'avg':
|
|
488
|
+
result[groupKey] = values.reduce((a, b) => a + b, 0) / values.length;
|
|
489
|
+
break;
|
|
490
|
+
case 'min': {
|
|
491
|
+
const m = iterativeMin(values);
|
|
492
|
+
result[groupKey] = m !== undefined ? m : 0;
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case 'max': {
|
|
496
|
+
const m = iterativeMax(values);
|
|
497
|
+
result[groupKey] = m !== undefined ? m : 0;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return { groups: result };
|
|
503
|
+
}
|
|
504
|
+
pivotSummary(sheetIndex, rowField, colField, valueField, agg, options) {
|
|
505
|
+
this.ensureSheetIndex(sheetIndex);
|
|
506
|
+
const headers = this.readHeaders();
|
|
507
|
+
const rowFieldIdx = headers.indexOf(rowField);
|
|
508
|
+
const colFieldIdx = headers.indexOf(colField);
|
|
509
|
+
const valueFieldIdx = headers.indexOf(valueField);
|
|
510
|
+
if (rowFieldIdx === -1)
|
|
511
|
+
throw new Error(`Row field "${rowField}" not found`);
|
|
512
|
+
if (colFieldIdx === -1)
|
|
513
|
+
throw new Error(`Column field "${colField}" not found`);
|
|
514
|
+
if (valueFieldIdx === -1)
|
|
515
|
+
throw new Error(`Value field "${valueField}" not found`);
|
|
516
|
+
const pivot = {};
|
|
517
|
+
const counts = {};
|
|
518
|
+
const allCols = new Set();
|
|
519
|
+
const content = fs.readFileSync(this.resolvedPath, 'utf-8');
|
|
520
|
+
const lines = content.split('\n');
|
|
521
|
+
let scanned = 0;
|
|
522
|
+
for (let i = 1; i < lines.length; i++) {
|
|
523
|
+
const line = lines[i];
|
|
524
|
+
if (line.trim() === '' && i === lines.length - 1)
|
|
525
|
+
continue;
|
|
526
|
+
if (line.trim() === '')
|
|
527
|
+
continue;
|
|
528
|
+
scanned++;
|
|
529
|
+
if (scanned > options.maxRowsScan)
|
|
530
|
+
break;
|
|
531
|
+
const fields = parseCSVLine(line, this.delimiter);
|
|
532
|
+
const rowKey = String(coerceCSVValue(fields[rowFieldIdx] ?? '') || '');
|
|
533
|
+
const colKey = String(coerceCSVValue(fields[colFieldIdx] ?? '') || '');
|
|
534
|
+
const value = coerceCSVValue(fields[valueFieldIdx] ?? '');
|
|
535
|
+
allCols.add(colKey);
|
|
536
|
+
const isNumeric = typeof value === 'number' && !isNaN(value);
|
|
537
|
+
if (!isNumeric)
|
|
538
|
+
continue;
|
|
539
|
+
if (!pivot[rowKey])
|
|
540
|
+
pivot[rowKey] = {};
|
|
541
|
+
if (!counts[rowKey])
|
|
542
|
+
counts[rowKey] = {};
|
|
543
|
+
if (!pivot[rowKey][colKey]) {
|
|
544
|
+
if (agg === 'min')
|
|
545
|
+
pivot[rowKey][colKey] = Infinity;
|
|
546
|
+
else if (agg === 'max')
|
|
547
|
+
pivot[rowKey][colKey] = -Infinity;
|
|
548
|
+
else
|
|
549
|
+
pivot[rowKey][colKey] = 0;
|
|
550
|
+
}
|
|
551
|
+
if (!counts[rowKey][colKey])
|
|
552
|
+
counts[rowKey][colKey] = 0;
|
|
553
|
+
counts[rowKey][colKey]++;
|
|
554
|
+
switch (agg) {
|
|
555
|
+
case 'sum':
|
|
556
|
+
case 'avg':
|
|
557
|
+
pivot[rowKey][colKey] += value;
|
|
558
|
+
break;
|
|
559
|
+
case 'min':
|
|
560
|
+
pivot[rowKey][colKey] = Math.min(pivot[rowKey][colKey], value);
|
|
561
|
+
break;
|
|
562
|
+
case 'max':
|
|
563
|
+
pivot[rowKey][colKey] = Math.max(pivot[rowKey][colKey], value);
|
|
564
|
+
break;
|
|
565
|
+
case 'count':
|
|
566
|
+
pivot[rowKey][colKey]++;
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (agg === 'avg') {
|
|
571
|
+
for (const rowKey of Object.keys(pivot)) {
|
|
572
|
+
for (const colKey of Object.keys(pivot[rowKey])) {
|
|
573
|
+
const count = counts[rowKey][colKey];
|
|
574
|
+
if (count > 0)
|
|
575
|
+
pivot[rowKey][colKey] /= count;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return { columns: Array.from(allCols).sort(), pivot };
|
|
580
|
+
}
|
|
581
|
+
appendRows(sheetIndex, rows) {
|
|
582
|
+
this.ensureSheetIndex(sheetIndex);
|
|
583
|
+
const headers = this.readHeaders();
|
|
584
|
+
const lines = [];
|
|
585
|
+
for (const row of rows) {
|
|
586
|
+
if (Array.isArray(row)) {
|
|
587
|
+
lines.push(row.map((v) => escapeCSVField(v, this.delimiter)).join(this.delimiter));
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
const fields = headers.map(h => escapeCSVField(row[h] ?? '', this.delimiter));
|
|
591
|
+
lines.push(fields.join(this.delimiter));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
fs.appendFileSync(this.resolvedPath, '\n' + lines.join('\n'));
|
|
595
|
+
this._totalDataRows = null;
|
|
596
|
+
}
|
|
597
|
+
updateCell(sheetIndex, cellAddress, value) {
|
|
598
|
+
this.ensureSheetIndex(sheetIndex);
|
|
599
|
+
if (!CELL_ADDRESS_REGEX.test(cellAddress)) {
|
|
600
|
+
throw new Error(`Invalid cell address "${cellAddress}". Expected format like "A1", "B5", "AA123".`);
|
|
601
|
+
}
|
|
602
|
+
const { row, col } = decodeCellAddress(cellAddress);
|
|
603
|
+
const content = fs.readFileSync(this.resolvedPath, 'utf-8');
|
|
604
|
+
const lines = content.split('\n');
|
|
605
|
+
while (lines.length <= row)
|
|
606
|
+
lines.push('');
|
|
607
|
+
const targetLine = lines[row];
|
|
608
|
+
const fields = parseCSVLine(targetLine, this.delimiter);
|
|
609
|
+
while (fields.length <= col)
|
|
610
|
+
fields.push('');
|
|
611
|
+
if (value === null) {
|
|
612
|
+
fields[col] = '';
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
fields[col] = String(value);
|
|
616
|
+
}
|
|
617
|
+
lines[row] = fields.map(f => escapeCSVField(f, this.delimiter)).join(this.delimiter);
|
|
618
|
+
const suffix = crypto.randomBytes(8).toString('hex');
|
|
619
|
+
const tmpPath = this.resolvedPath + `.tmp_${suffix}`;
|
|
620
|
+
fs.writeFileSync(tmpPath, lines.join('\n'));
|
|
621
|
+
fs.renameSync(tmpPath, this.resolvedPath);
|
|
622
|
+
this._headers = null;
|
|
623
|
+
this._totalDataRows = null;
|
|
624
|
+
}
|
|
625
|
+
createFile(sheetName, data) {
|
|
626
|
+
let parsedData = data;
|
|
627
|
+
if (typeof parsedData === 'string') {
|
|
628
|
+
try {
|
|
629
|
+
parsedData = JSON.parse(parsedData);
|
|
630
|
+
}
|
|
631
|
+
catch (e) {
|
|
632
|
+
throw new Error(`Failed to parse data string as JSON: ${e}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (!Array.isArray(parsedData))
|
|
636
|
+
throw new Error(`Data must be an array, received: ${typeof parsedData}`);
|
|
637
|
+
const lines = [];
|
|
638
|
+
if (parsedData.length > 0) {
|
|
639
|
+
if (Array.isArray(parsedData[0])) {
|
|
640
|
+
for (const row of parsedData) {
|
|
641
|
+
lines.push(row.map((v) => escapeCSVField(v, this.delimiter)).join(this.delimiter));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
const keys = Object.keys(parsedData[0]);
|
|
646
|
+
lines.push(keys.map(k => escapeCSVField(k, this.delimiter)).join(this.delimiter));
|
|
647
|
+
for (const row of parsedData) {
|
|
648
|
+
lines.push(keys.map(k => escapeCSVField(row[k], this.delimiter)).join(this.delimiter));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
fs.writeFileSync(this.resolvedPath, lines.join('\n'));
|
|
653
|
+
this._headers = null;
|
|
654
|
+
this._totalDataRows = null;
|
|
655
|
+
}
|
|
656
|
+
dispose() {
|
|
657
|
+
this._headers = null;
|
|
658
|
+
this._totalDataRows = null;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import { XLSXBackend } from './xlsx';
|
|
3
|
+
import { CSVBackend } from './csv';
|
|
4
|
+
export function createBackend(resolvedPath, force) {
|
|
5
|
+
const ext = path.extname(resolvedPath).toLowerCase();
|
|
6
|
+
switch (ext) {
|
|
7
|
+
case '.csv':
|
|
8
|
+
case '.tsv':
|
|
9
|
+
case '.txt':
|
|
10
|
+
return new CSVBackend(resolvedPath, force);
|
|
11
|
+
default:
|
|
12
|
+
return new XLSXBackend(resolvedPath, force);
|
|
13
|
+
}
|
|
14
|
+
}
|