@portel/photon 1.14.0 → 1.16.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/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-config.js +29 -8
- package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
- package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
- package/dist/auto-ui/beam/routes/api-marketplace.js +3 -0
- package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +167 -48
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/bridge/index.d.ts.map +1 -1
- package/dist/auto-ui/bridge/index.js +578 -0
- package/dist/auto-ui/bridge/index.js.map +1 -1
- package/dist/auto-ui/bridge/renderers.d.ts.map +1 -1
- package/dist/auto-ui/bridge/renderers.js +7 -3
- package/dist/auto-ui/bridge/renderers.js.map +1 -1
- package/dist/auto-ui/bridge/types.d.ts +6 -0
- package/dist/auto-ui/bridge/types.d.ts.map +1 -1
- package/dist/auto-ui/frontend/pure-view.html +289 -0
- package/dist/auto-ui/photon-bridge.d.ts +11 -0
- package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
- package/dist/auto-ui/photon-bridge.js +75 -1
- package/dist/auto-ui/photon-bridge.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +29 -3
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/beam-form.bundle.js +5707 -0
- package/dist/beam-form.bundle.js.map +7 -0
- package/dist/beam.bundle.js +1947 -523
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli/commands/info.d.ts.map +1 -1
- package/dist/cli/commands/info.js +15 -2
- package/dist/cli/commands/info.js.map +1 -1
- package/dist/daemon/client.d.ts +5 -0
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +50 -0
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/manager.d.ts +15 -0
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +142 -11
- package/dist/daemon/manager.js.map +1 -1
- package/dist/deploy/cloudflare.d.ts.map +1 -1
- package/dist/deploy/cloudflare.js +10 -2
- package/dist/deploy/cloudflare.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +50 -3
- package/dist/loader.js.map +1 -1
- package/dist/marketplace-manager.d.ts +9 -0
- package/dist/marketplace-manager.d.ts.map +1 -1
- package/dist/marketplace-manager.js +115 -42
- package/dist/marketplace-manager.js.map +1 -1
- package/dist/meta.d.ts +51 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +320 -0
- package/dist/meta.js.map +1 -0
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +30 -5
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/photon-doc-extractor.d.ts +1 -0
- package/dist/photon-doc-extractor.d.ts.map +1 -1
- package/dist/photon-doc-extractor.js +33 -21
- package/dist/photon-doc-extractor.js.map +1 -1
- package/dist/photons/docs/ui/docs.html +441 -0
- package/dist/photons/docs.photon.d.ts +237 -0
- package/dist/photons/docs.photon.d.ts.map +1 -0
- package/dist/photons/docs.photon.js +483 -0
- package/dist/photons/docs.photon.js.map +1 -0
- package/dist/photons/docs.photon.ts +536 -0
- package/dist/photons/slides.photon.d.ts +212 -0
- package/dist/photons/slides.photon.d.ts.map +1 -0
- package/dist/photons/slides.photon.js +355 -0
- package/dist/photons/slides.photon.js.map +1 -0
- package/dist/photons/slides.photon.ts +370 -0
- package/dist/photons/spreadsheet/ui/spreadsheet.html +779 -0
- package/dist/photons/spreadsheet.photon.d.ts +554 -0
- package/dist/photons/spreadsheet.photon.d.ts.map +1 -0
- package/dist/photons/spreadsheet.photon.js +1050 -0
- package/dist/photons/spreadsheet.photon.js.map +1 -0
- package/dist/photons/spreadsheet.photon.ts +1239 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +17 -57
- package/dist/server.js.map +1 -1
- package/dist/shared/error-handler.d.ts +8 -0
- package/dist/shared/error-handler.d.ts.map +1 -1
- package/dist/shared/error-handler.js +50 -0
- package/dist/shared/error-handler.js.map +1 -1
- package/dist/shared-utils.d.ts +3 -2
- package/dist/shared-utils.d.ts.map +1 -1
- package/dist/shared-utils.js +4 -3
- package/dist/shared-utils.js.map +1 -1
- package/package.json +7 -2
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spreadsheet — CSV-backed spreadsheet with formulas
|
|
3
|
+
*
|
|
4
|
+
* A spreadsheet engine that works on plain CSV files. Formulas (=SUM, =AVG, etc.)
|
|
5
|
+
* are stored directly in CSV cells and evaluated at runtime. Named instances map
|
|
6
|
+
* to CSV files: `_use('budget')` → `budget.csv` in your spreadsheets folder.
|
|
7
|
+
* Pass a full path to open any CSV: `_use('/path/to/data.csv')`.
|
|
8
|
+
*
|
|
9
|
+
* @version 1.1.0
|
|
10
|
+
* @runtime ^1.14.0
|
|
11
|
+
* @tags spreadsheet, csv, formulas, data
|
|
12
|
+
* @icon 📊
|
|
13
|
+
* @stateful
|
|
14
|
+
* @dependencies @portel/csv@^1.0.0, alasql@^4.0.0
|
|
15
|
+
* @ui dashboard ./ui/spreadsheet.html
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'fs/promises';
|
|
18
|
+
import { createReadStream, existsSync, mkdirSync, statSync, watch as fsWatch, } from 'fs';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
import * as os from 'os';
|
|
21
|
+
import * as readline from 'readline';
|
|
22
|
+
// Lazy-loaded: installed by @dependencies at runtime
|
|
23
|
+
let CsvEngine;
|
|
24
|
+
let cellToIndex;
|
|
25
|
+
class ConcurrencyLock {
|
|
26
|
+
promise = Promise.resolve();
|
|
27
|
+
async acquire() {
|
|
28
|
+
let release;
|
|
29
|
+
const next = new Promise((resolve) => {
|
|
30
|
+
release = resolve;
|
|
31
|
+
});
|
|
32
|
+
const current = this.promise;
|
|
33
|
+
this.promise = next;
|
|
34
|
+
await current;
|
|
35
|
+
return release;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export default class Spreadsheet {
|
|
39
|
+
engine;
|
|
40
|
+
loaded = false;
|
|
41
|
+
_lastLoadSize = -1;
|
|
42
|
+
_lock = new ConcurrencyLock();
|
|
43
|
+
_isLargeFileThreshold = 5 * 1024 * 1024; // 5MB
|
|
44
|
+
_engines = new Map();
|
|
45
|
+
// File watcher state
|
|
46
|
+
_watcher = null;
|
|
47
|
+
_lastFileSize = 0;
|
|
48
|
+
_watchDebounce = null;
|
|
49
|
+
// SQL watch state
|
|
50
|
+
_watches = new Map();
|
|
51
|
+
_watcherAutoStarted = false;
|
|
52
|
+
settings = {
|
|
53
|
+
/** @property Directory where spreadsheet CSV files are stored */
|
|
54
|
+
folder: path.join(os.homedir(), 'Documents', 'Spreadsheets'),
|
|
55
|
+
/** @property Save format row: true (always), false (never), auto (if original had one or formatting was customized) */
|
|
56
|
+
formatting: 'auto',
|
|
57
|
+
/** @property Row count for paging in view() */
|
|
58
|
+
pageSize: 100,
|
|
59
|
+
};
|
|
60
|
+
async onInitialize() {
|
|
61
|
+
const csvId = '@portel/csv';
|
|
62
|
+
const csvModule = await import(/* webpackIgnore: true */ csvId);
|
|
63
|
+
CsvEngine = csvModule.CsvEngine || csvModule.default?.CsvEngine;
|
|
64
|
+
cellToIndex = csvModule.cellToIndex || csvModule.default?.cellToIndex;
|
|
65
|
+
this.engine = new CsvEngine();
|
|
66
|
+
}
|
|
67
|
+
get defaultFolder() {
|
|
68
|
+
return this.settings?.folder || path.join(os.homedir(), 'Documents', 'Spreadsheets');
|
|
69
|
+
}
|
|
70
|
+
get csvPath() {
|
|
71
|
+
const name = this.instanceName || 'default';
|
|
72
|
+
if (path.isAbsolute(name)) {
|
|
73
|
+
return name.endsWith('.csv') ? name : name + '.csv';
|
|
74
|
+
}
|
|
75
|
+
if (name.includes('/') || name.includes('\\')) {
|
|
76
|
+
const resolved = path.resolve(name);
|
|
77
|
+
return resolved.endsWith('.csv') ? resolved : resolved + '.csv';
|
|
78
|
+
}
|
|
79
|
+
const dir = this.defaultFolder;
|
|
80
|
+
if (!existsSync(dir))
|
|
81
|
+
mkdirSync(dir, { recursive: true });
|
|
82
|
+
return path.join(dir, name.endsWith('.csv') ? name : name + '.csv');
|
|
83
|
+
}
|
|
84
|
+
// --- Optimized Reading (Generator Pattern) ---
|
|
85
|
+
/**
|
|
86
|
+
* Memory-efficient line-by-line generator
|
|
87
|
+
*/
|
|
88
|
+
async *getLineGenerator() {
|
|
89
|
+
const csvPath = this.csvPath;
|
|
90
|
+
if (!existsSync(csvPath))
|
|
91
|
+
return;
|
|
92
|
+
const fileStream = createReadStream(csvPath);
|
|
93
|
+
const rl = readline.createInterface({
|
|
94
|
+
input: fileStream,
|
|
95
|
+
crlfDelay: Infinity,
|
|
96
|
+
});
|
|
97
|
+
for await (const line of rl) {
|
|
98
|
+
yield line;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Helper to get or load an engine for a specific table name
|
|
103
|
+
*/
|
|
104
|
+
async _getCsvEngine(name) {
|
|
105
|
+
if (name === this.instanceName || (name === 'default' && !this.instanceName)) {
|
|
106
|
+
await this.load();
|
|
107
|
+
return this.engine;
|
|
108
|
+
}
|
|
109
|
+
if (this._engines.has(name))
|
|
110
|
+
return this._engines.get(name);
|
|
111
|
+
const csvPath = path.join(this.defaultFolder, name.endsWith('.csv') ? name : name + '.csv');
|
|
112
|
+
if (existsSync(csvPath)) {
|
|
113
|
+
const csv = await fs.readFile(csvPath, 'utf-8');
|
|
114
|
+
const engine = CsvEngine.fromCSV(csv);
|
|
115
|
+
this._engines.set(name, engine);
|
|
116
|
+
return engine;
|
|
117
|
+
}
|
|
118
|
+
throw new Error(`Table "${name}" not found in ${this.defaultFolder}`);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Pre-process Airtable-style formulas ({Field} -> "Field")
|
|
122
|
+
*/
|
|
123
|
+
_processAirtableFormula(formula) {
|
|
124
|
+
// Replace {Field Name} with "Field Name" for SQL/Expression compatibility
|
|
125
|
+
return formula.replace(/\{([^}]+)\}/g, '"$1"');
|
|
126
|
+
}
|
|
127
|
+
// --- Persistence ---
|
|
128
|
+
async load() {
|
|
129
|
+
const csvPath = this.csvPath;
|
|
130
|
+
const exists = existsSync(csvPath);
|
|
131
|
+
const currentSize = exists ? statSync(csvPath).size : -1;
|
|
132
|
+
if (this.loaded && currentSize === this._lastLoadSize)
|
|
133
|
+
return;
|
|
134
|
+
// For very large files, we only load headers and formatting into the engine
|
|
135
|
+
// and use streaming for data operations.
|
|
136
|
+
if (exists && currentSize > this._isLargeFileThreshold) {
|
|
137
|
+
await this.loadHeadersOnly();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const release = await this._lock.acquire();
|
|
141
|
+
try {
|
|
142
|
+
if (exists) {
|
|
143
|
+
const csv = await fs.readFile(csvPath, 'utf-8');
|
|
144
|
+
if (csv.trim().length > 0) {
|
|
145
|
+
this.engine = CsvEngine.fromCSV(csv);
|
|
146
|
+
this.loaded = true;
|
|
147
|
+
this._lastLoadSize = currentSize;
|
|
148
|
+
this._lastFileSize = currentSize;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
this.engine = new CsvEngine();
|
|
153
|
+
this.loaded = true;
|
|
154
|
+
this._lastLoadSize = currentSize;
|
|
155
|
+
this._lastFileSize = exists ? currentSize : 0;
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
release();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async loadHeadersOnly() {
|
|
162
|
+
const release = await this._lock.acquire();
|
|
163
|
+
try {
|
|
164
|
+
const generator = this.getLineGenerator();
|
|
165
|
+
const headersLine = await generator.next();
|
|
166
|
+
const formatLine = await generator.next();
|
|
167
|
+
let csv = (headersLine.value || '') + '\n';
|
|
168
|
+
if (formatLine.value?.startsWith('#fmt:')) {
|
|
169
|
+
csv += formatLine.value + '\n';
|
|
170
|
+
}
|
|
171
|
+
this.engine = CsvEngine.fromCSV(csv);
|
|
172
|
+
this.loaded = true;
|
|
173
|
+
this._lastLoadSize = statSync(this.csvPath).size;
|
|
174
|
+
this._lastFileSize = this._lastLoadSize;
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
release();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async save() {
|
|
181
|
+
const release = await this._lock.acquire();
|
|
182
|
+
try {
|
|
183
|
+
const dir = path.dirname(this.csvPath);
|
|
184
|
+
if (!existsSync(dir))
|
|
185
|
+
mkdirSync(dir, { recursive: true });
|
|
186
|
+
const formatRow = this.shouldWriteFormatRow();
|
|
187
|
+
const csv = this.engine.toCSV({ formatRow });
|
|
188
|
+
await fs.writeFile(this.csvPath, csv, 'utf-8');
|
|
189
|
+
// Update size to prevent immediate reload
|
|
190
|
+
this._lastLoadSize = statSync(this.csvPath).size;
|
|
191
|
+
this._lastFileSize = this._lastLoadSize;
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
release();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Optimized append: writes directly to end of file without rewriting
|
|
199
|
+
*/
|
|
200
|
+
async appendRows(rows) {
|
|
201
|
+
const release = await this._lock.acquire();
|
|
202
|
+
try {
|
|
203
|
+
const csvPath = this.csvPath;
|
|
204
|
+
const csvData = rows.map((r) => r.join(',')).join('\n') + '\n';
|
|
205
|
+
// Ensure file ends with newline before appending
|
|
206
|
+
const stats = statSync(csvPath);
|
|
207
|
+
if (stats.size > 0) {
|
|
208
|
+
const fd = await fs.open(csvPath, 'r+');
|
|
209
|
+
const buffer = Buffer.alloc(1);
|
|
210
|
+
await fd.read(buffer, 0, 1, stats.size - 1);
|
|
211
|
+
if (buffer.toString() !== '\n') {
|
|
212
|
+
await fd.write('\n', stats.size);
|
|
213
|
+
}
|
|
214
|
+
await fd.close();
|
|
215
|
+
}
|
|
216
|
+
await fs.appendFile(csvPath, csvData, 'utf-8');
|
|
217
|
+
this._lastLoadSize = statSync(csvPath).size;
|
|
218
|
+
this._lastFileSize = this._lastLoadSize;
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
release();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
shouldWriteFormatRow() {
|
|
225
|
+
const fmt = this.settings?.formatting || 'auto';
|
|
226
|
+
if (fmt === 'true')
|
|
227
|
+
return true;
|
|
228
|
+
if (fmt === 'false')
|
|
229
|
+
return false;
|
|
230
|
+
return this.engine.getHasFormatRow() || this.engine.getMetaCustomized();
|
|
231
|
+
}
|
|
232
|
+
// --- Response formatting ---
|
|
233
|
+
buildResponse(message) {
|
|
234
|
+
const snap = this.engine.snapshot(message);
|
|
235
|
+
return { ...snap, file: this.csvPath };
|
|
236
|
+
}
|
|
237
|
+
// --- Tool methods ---
|
|
238
|
+
/**
|
|
239
|
+
* Open spreadsheet UI
|
|
240
|
+
*
|
|
241
|
+
* @ui spreadsheet
|
|
242
|
+
* @autorun
|
|
243
|
+
*/
|
|
244
|
+
async main() {
|
|
245
|
+
await this.load();
|
|
246
|
+
return this.buildResponse(`Opened ${path.basename(this.csvPath)}`);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* List all tables (CSV files) in the spreadsheets folder
|
|
250
|
+
* @title List Tables
|
|
251
|
+
*/
|
|
252
|
+
async list_tables() {
|
|
253
|
+
const dir = this.defaultFolder;
|
|
254
|
+
if (!existsSync(dir))
|
|
255
|
+
return { tables: [], message: 'No tables found' };
|
|
256
|
+
const files = await fs.readdir(dir);
|
|
257
|
+
const tables = files
|
|
258
|
+
.filter((f) => f.endsWith('.csv'))
|
|
259
|
+
.map((f) => ({
|
|
260
|
+
name: f.replace('.csv', ''),
|
|
261
|
+
file: f,
|
|
262
|
+
size: statSync(path.join(dir, f)).size,
|
|
263
|
+
modified: statSync(path.join(dir, f)).mtime.toISOString(),
|
|
264
|
+
}));
|
|
265
|
+
return {
|
|
266
|
+
tables,
|
|
267
|
+
count: tables.length,
|
|
268
|
+
message: `Found ${tables.length} table(s) in ${dir}`,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* View records (Airtable-compatible)
|
|
273
|
+
*
|
|
274
|
+
* @param maxRecords Max records to return (default 100)
|
|
275
|
+
* @param offset Row offset for pagination
|
|
276
|
+
* @param filterByFormula Optional formula to filter records (e.g. "{Age} > 25")
|
|
277
|
+
* @title List Records
|
|
278
|
+
* @format table
|
|
279
|
+
*/
|
|
280
|
+
async list_records(params) {
|
|
281
|
+
await this.load();
|
|
282
|
+
if (params.filterByFormula) {
|
|
283
|
+
const sqlWhere = this._processAirtableFormula(params.filterByFormula);
|
|
284
|
+
// Try to use the engine's query capability with the processed formula
|
|
285
|
+
try {
|
|
286
|
+
const results = await this.engine.query(sqlWhere, params.maxRecords);
|
|
287
|
+
return results;
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
console.warn('Filter formula failed, returning all records:', err.message);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return this.view({
|
|
294
|
+
limit: params.maxRecords,
|
|
295
|
+
offset: params.offset,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Lookup a value from another table
|
|
300
|
+
*
|
|
301
|
+
* Mimics the relational "Lookup" field in Airtable/Google Sheets.
|
|
302
|
+
*
|
|
303
|
+
* @param table Other table name (e.g. "Products")
|
|
304
|
+
* @param matchField Field in the other table to match against (e.g. "SKU")
|
|
305
|
+
* @param matchValue Value to search for (e.g. "A101")
|
|
306
|
+
* @param resultField Field to return from the matching row (e.g. "Price")
|
|
307
|
+
* @example lookup({ table: 'Products', matchField: 'SKU', matchValue: 'A101', resultField: 'Price' })
|
|
308
|
+
*/
|
|
309
|
+
async lookup(params) {
|
|
310
|
+
const otherEngine = await this._getCsvEngine(params.table);
|
|
311
|
+
const where = `${params.matchField} = "${params.matchValue}"`;
|
|
312
|
+
const result = await otherEngine.query(where, 1);
|
|
313
|
+
if (result && result.data && result.data.length > 0) {
|
|
314
|
+
const headers = otherEngine.getHeaders();
|
|
315
|
+
const resIdx = headers.indexOf(params.resultField);
|
|
316
|
+
if (resIdx !== -1) {
|
|
317
|
+
return { value: result.data[0][resIdx], message: 'Found match' };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { value: null, message: 'No match found' };
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Get a single record by its row number
|
|
324
|
+
* @param recordId Row number (1-indexed)
|
|
325
|
+
* @title Get Record
|
|
326
|
+
*/
|
|
327
|
+
async get_record(params) {
|
|
328
|
+
await this.load();
|
|
329
|
+
const row = this.engine.getRow(params.recordId);
|
|
330
|
+
if (!row)
|
|
331
|
+
throw new Error(`Record ${params.recordId} not found`);
|
|
332
|
+
return { id: params.recordId, fields: row };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Create a new record (Airtable-compatible)
|
|
336
|
+
* @param fields Key-value pairs for the new record
|
|
337
|
+
* @title Create Record
|
|
338
|
+
*/
|
|
339
|
+
async create_record(params) {
|
|
340
|
+
return this.add({ values: params.fields });
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Update an existing record (Airtable-compatible)
|
|
344
|
+
* @param recordId Row number (1-indexed)
|
|
345
|
+
* @param fields Key-value pairs to update
|
|
346
|
+
* @title Update Record
|
|
347
|
+
*/
|
|
348
|
+
async update_record(params) {
|
|
349
|
+
return this.update({ row: params.recordId, values: params.fields });
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Delete a record (Airtable-compatible)
|
|
353
|
+
* @param recordId Row number (1-indexed)
|
|
354
|
+
* @title Delete Record
|
|
355
|
+
*/
|
|
356
|
+
async delete_record(params) {
|
|
357
|
+
return this.remove({ row: params.recordId });
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Search for records (Airtable-compatible)
|
|
361
|
+
* @param query Text to search for
|
|
362
|
+
* @title Search Records
|
|
363
|
+
*/
|
|
364
|
+
async search_records(params) {
|
|
365
|
+
return this.search({ query: params.query });
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* View the spreadsheet grid
|
|
369
|
+
*
|
|
370
|
+
* Returns the full spreadsheet or a specific range as a formatted table.
|
|
371
|
+
*
|
|
372
|
+
* @param range Optional cell range to view (e.g., "A1:D10")
|
|
373
|
+
* @param offset Row offset for pagination
|
|
374
|
+
* @param limit Max rows to return (defaults to settings.pageSize)
|
|
375
|
+
* @format table
|
|
376
|
+
*/
|
|
377
|
+
async view(params) {
|
|
378
|
+
await this.load();
|
|
379
|
+
if (params?.range) {
|
|
380
|
+
return {
|
|
381
|
+
...this.buildResponse(`Viewing range ${params.range}`),
|
|
382
|
+
table: this.engine.toTable(params.range),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
const limit = params?.limit || this.settings.pageSize;
|
|
386
|
+
const offset = params?.offset || 0;
|
|
387
|
+
// For the UI, we still return the snapshot, but we can respect the pagination hints
|
|
388
|
+
return {
|
|
389
|
+
...this.buildResponse(path.basename(this.csvPath)),
|
|
390
|
+
offset,
|
|
391
|
+
limit,
|
|
392
|
+
total: this.engine.rowCount,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Get a cell value
|
|
397
|
+
*
|
|
398
|
+
* Returns the evaluated value and raw content (formula if any) for a single cell.
|
|
399
|
+
*
|
|
400
|
+
* @param cell Cell reference in A1 notation (e.g., "B3")
|
|
401
|
+
*/
|
|
402
|
+
async get(params) {
|
|
403
|
+
await this.load();
|
|
404
|
+
const { row, col } = cellToIndex(params.cell);
|
|
405
|
+
if (row >= this.engine.rowCount || col >= this.engine.colCount) {
|
|
406
|
+
return { cell: params.cell, value: '', raw: '', message: 'Cell is empty' };
|
|
407
|
+
}
|
|
408
|
+
const raw = this.engine.getRawCell(row, col);
|
|
409
|
+
const value = this.engine.evaluate(row, col);
|
|
410
|
+
return {
|
|
411
|
+
cell: params.cell,
|
|
412
|
+
value,
|
|
413
|
+
raw,
|
|
414
|
+
message: raw.startsWith('=')
|
|
415
|
+
? `${params.cell} = ${value} (${raw})`
|
|
416
|
+
: `${params.cell} = ${value || '(empty)'}`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Set a cell value or formula
|
|
421
|
+
*
|
|
422
|
+
* Set a cell to a plain value or a formula starting with "=".
|
|
423
|
+
* Formulas support: SUM, AVG, MAX, MIN, COUNT, IF, LEN, ABS, ROUND, CONCAT.
|
|
424
|
+
* Cell references use A1 notation. Ranges use A1:B2 notation.
|
|
425
|
+
*
|
|
426
|
+
* @param cell Cell reference in A1 notation (e.g., "B3")
|
|
427
|
+
* @param value Value or formula (e.g., "42" or "=SUM(A1:A5)")
|
|
428
|
+
* @example set({ cell: 'B3', value: '=SUM(B1:B2)' })
|
|
429
|
+
*/
|
|
430
|
+
async set(params) {
|
|
431
|
+
await this.load();
|
|
432
|
+
const { row, col } = cellToIndex(params.cell);
|
|
433
|
+
const oldRaw = this.engine.getRawCell(row, col);
|
|
434
|
+
const oldDisplay = oldRaw.startsWith('=') ? this.engine.evaluate(row, col) : oldRaw;
|
|
435
|
+
this.engine.set(params.cell, String(params.value));
|
|
436
|
+
await this.save();
|
|
437
|
+
const evaluated = this.engine.evaluate(row, col);
|
|
438
|
+
const msg = oldRaw
|
|
439
|
+
? `Set ${params.cell} = ${evaluated} (was: ${oldDisplay})`
|
|
440
|
+
: `Set ${params.cell} = ${evaluated}`;
|
|
441
|
+
await this._emitAndWatch(msg);
|
|
442
|
+
return this.buildResponse(msg);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Add a row of data
|
|
446
|
+
*
|
|
447
|
+
* Add a new row to the bottom of the spreadsheet. Pass column values by header name.
|
|
448
|
+
*
|
|
449
|
+
* @param values Key-value pairs mapping column names to values (e.g., {"Name": "Alice", "Age": "30"})
|
|
450
|
+
* @example add({ values: { Name: 'Alice', Age: '30' } })
|
|
451
|
+
*/
|
|
452
|
+
async add(params) {
|
|
453
|
+
await this.load();
|
|
454
|
+
// If file is large and we are just adding a simple row, use optimized append
|
|
455
|
+
const isLarge = statSync(this.csvPath).size > this._isLargeFileThreshold;
|
|
456
|
+
const canAppend = !this.engine.getMetaCustomized(); // Simple append if no formula-heavy meta
|
|
457
|
+
if (isLarge && canAppend) {
|
|
458
|
+
const rowValues = this.engine.getHeaders().map((h) => params.values[h] || '');
|
|
459
|
+
await this.appendRows([rowValues]);
|
|
460
|
+
// Update engine state in memory without full reload if possible
|
|
461
|
+
this.engine.add(params.values);
|
|
462
|
+
const msg = `Appended row to large CSV`;
|
|
463
|
+
await this._emitAndWatch(msg);
|
|
464
|
+
return this.buildResponse(msg);
|
|
465
|
+
}
|
|
466
|
+
const rowNum = this.engine.add(params.values);
|
|
467
|
+
await this.save();
|
|
468
|
+
const msg = `Added row ${rowNum}`;
|
|
469
|
+
await this._emitAndWatch(msg);
|
|
470
|
+
return this.buildResponse(msg);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Remove a row
|
|
474
|
+
*
|
|
475
|
+
* @param row Row number to remove (1-indexed)
|
|
476
|
+
*/
|
|
477
|
+
async remove(params) {
|
|
478
|
+
await this.load();
|
|
479
|
+
this.engine.remove(params.row);
|
|
480
|
+
await this.save();
|
|
481
|
+
const msg = `Removed row ${params.row}`;
|
|
482
|
+
await this._emitAndWatch(msg);
|
|
483
|
+
return this.buildResponse(msg);
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Remove a column
|
|
487
|
+
*
|
|
488
|
+
* @param column Column letter or header name to remove
|
|
489
|
+
*/
|
|
490
|
+
async removeColumn(params) {
|
|
491
|
+
await this.load();
|
|
492
|
+
const oldName = this.engine.removeColumn(params.column);
|
|
493
|
+
await this.save();
|
|
494
|
+
const msg = `Removed column "${oldName}"`;
|
|
495
|
+
await this._emitAndWatch(msg);
|
|
496
|
+
return this.buildResponse(msg);
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Insert a row at a specific position
|
|
500
|
+
*
|
|
501
|
+
* @param row Row number to insert before (1-indexed)
|
|
502
|
+
* @param values Optional: Key-value pairs for the new row
|
|
503
|
+
*/
|
|
504
|
+
async insertRow(params) {
|
|
505
|
+
await this.load();
|
|
506
|
+
this.engine.insertRow(params.row, params.values);
|
|
507
|
+
await this.save();
|
|
508
|
+
const msg = `Inserted row at ${params.row}`;
|
|
509
|
+
await this._emitAndWatch(msg);
|
|
510
|
+
return this.buildResponse(msg);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Insert a column at a specific position
|
|
514
|
+
*
|
|
515
|
+
* @param column Column letter or header name to insert before
|
|
516
|
+
* @param name Name for the new column
|
|
517
|
+
*/
|
|
518
|
+
async insertColumn(params) {
|
|
519
|
+
await this.load();
|
|
520
|
+
this.engine.insertColumn(params.column, params.name);
|
|
521
|
+
await this.save();
|
|
522
|
+
const msg = `Inserted column "${params.name}" before ${params.column}`;
|
|
523
|
+
await this._emitAndWatch(msg);
|
|
524
|
+
return this.buildResponse(msg);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Update or insert a row (Database Upsert)
|
|
528
|
+
*
|
|
529
|
+
* Finds a row matching the `search` criteria. If found, updates it with `values`.
|
|
530
|
+
* If not found, adds a new row with `values`.
|
|
531
|
+
*
|
|
532
|
+
* @param search Key-value pairs to match an existing row (e.g., {"ID": "123"})
|
|
533
|
+
* @param values Key-value pairs to set in the row
|
|
534
|
+
* @example upsert({ search: { ID: '101' }, values: { Name: 'Alice', Status: 'Active' } })
|
|
535
|
+
*/
|
|
536
|
+
async upsert(params) {
|
|
537
|
+
await this.load();
|
|
538
|
+
const result = this.engine.upsert(params.search, params.values);
|
|
539
|
+
await this.save();
|
|
540
|
+
const msg = result.type === 'update'
|
|
541
|
+
? `Updated matching row ${result.row}`
|
|
542
|
+
: `Added new row ${result.row}`;
|
|
543
|
+
await this._emitAndWatch(msg);
|
|
544
|
+
return { ...this.buildResponse(msg), ...result };
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Fuzzy search across columns
|
|
548
|
+
*
|
|
549
|
+
* Finds rows containing the query string in any of the specified columns.
|
|
550
|
+
* Uses streaming generator for memory efficiency on large files.
|
|
551
|
+
*
|
|
552
|
+
* @param query Search string
|
|
553
|
+
* @param columns Optional: array of column names to search (searches all if omitted)
|
|
554
|
+
* @param limit Max results to return
|
|
555
|
+
* @format table
|
|
556
|
+
*/
|
|
557
|
+
async search(params) {
|
|
558
|
+
const csvPath = this.csvPath;
|
|
559
|
+
const isLarge = existsSync(csvPath) && statSync(csvPath).size > this._isLargeFileThreshold;
|
|
560
|
+
const limit = params.limit || 50;
|
|
561
|
+
if (isLarge) {
|
|
562
|
+
const results = [];
|
|
563
|
+
const query = params.query.toLowerCase();
|
|
564
|
+
const generator = this.getLineGenerator();
|
|
565
|
+
const headersResult = await generator.next();
|
|
566
|
+
const headers = headersResult.value?.split(',') || [];
|
|
567
|
+
const searchIndices = params.columns
|
|
568
|
+
? params.columns.map((c) => headers.indexOf(c)).filter((i) => i !== -1)
|
|
569
|
+
: headers.map((_, i) => i);
|
|
570
|
+
for await (const line of generator) {
|
|
571
|
+
if (line.startsWith('#fmt:'))
|
|
572
|
+
continue;
|
|
573
|
+
const cells = line.split(',');
|
|
574
|
+
const match = searchIndices.some((i) => cells[i]?.toLowerCase().includes(query));
|
|
575
|
+
if (match) {
|
|
576
|
+
const row = {};
|
|
577
|
+
headers.forEach((h, i) => (row[h] = cells[i]));
|
|
578
|
+
results.push(row);
|
|
579
|
+
if (results.length >= limit)
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return results;
|
|
584
|
+
}
|
|
585
|
+
await this.load();
|
|
586
|
+
return this.engine.search(params.query, params.columns, params.limit);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Update fields in a row
|
|
590
|
+
*
|
|
591
|
+
* @param row Row number to update (1-indexed)
|
|
592
|
+
* @param values Key-value pairs mapping column names to new values
|
|
593
|
+
* @example update({ row: 3, values: { Age: '42' } })
|
|
594
|
+
*/
|
|
595
|
+
async update(params) {
|
|
596
|
+
await this.load();
|
|
597
|
+
const changes = this.engine.update(params.row, params.values);
|
|
598
|
+
await this.save();
|
|
599
|
+
const msg = `Updated row ${params.row}: ${changes.join(', ')}`;
|
|
600
|
+
await this._emitAndWatch(msg);
|
|
601
|
+
return this.buildResponse(msg);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Query rows by condition
|
|
605
|
+
*
|
|
606
|
+
* Filter rows where a column matches a condition. Supports: =, !=, >, <, >=, <=, contains.
|
|
607
|
+
*
|
|
608
|
+
* @param where Condition string (e.g., "Age > 25", "Name contains Ali")
|
|
609
|
+
* @param limit Max rows to return
|
|
610
|
+
* @example query({ where: 'Age > 25' })
|
|
611
|
+
*/
|
|
612
|
+
async query(params) {
|
|
613
|
+
await this.load();
|
|
614
|
+
return this.engine.query(params.where, params.limit);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Sort by column
|
|
618
|
+
*
|
|
619
|
+
* Sorts all data rows by the specified column.
|
|
620
|
+
*
|
|
621
|
+
* @param column Column name or letter to sort by
|
|
622
|
+
* @param order Sort order: "asc" or "desc" (default: "asc")
|
|
623
|
+
* @example sort({ column: 'Age', order: 'desc' })
|
|
624
|
+
*/
|
|
625
|
+
async sort(params) {
|
|
626
|
+
await this.load();
|
|
627
|
+
this.engine.sort(params.column, params.order);
|
|
628
|
+
await this.save();
|
|
629
|
+
const msg = `Sorted by ${params.column} (${params.order || 'asc'})`;
|
|
630
|
+
await this._emitAndWatch(msg);
|
|
631
|
+
return this.buildResponse(msg);
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Fill a range with values or a pattern
|
|
635
|
+
*
|
|
636
|
+
* @param range Cell range (e.g., "A1:A10")
|
|
637
|
+
* @param pattern Comma-separated values to repeat (e.g., "1,2,3")
|
|
638
|
+
* @example fill({ range: 'A1:A10', pattern: '1,2,3' })
|
|
639
|
+
*/
|
|
640
|
+
async fill(params) {
|
|
641
|
+
await this.load();
|
|
642
|
+
this.engine.fill(params.range, params.pattern);
|
|
643
|
+
await this.save();
|
|
644
|
+
const msg = `Filled ${params.range} with pattern [${params.pattern}]`;
|
|
645
|
+
await this._emitAndWatch(msg);
|
|
646
|
+
return this.buildResponse(msg);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Show column headers and detected types
|
|
650
|
+
*
|
|
651
|
+
* @format table
|
|
652
|
+
*/
|
|
653
|
+
async schema() {
|
|
654
|
+
await this.load();
|
|
655
|
+
const schema = this.engine.schema();
|
|
656
|
+
const table = '| Column | Type | Non-empty | Total |\n|--------|------|-----------|-------|\n' +
|
|
657
|
+
schema.map((s) => `| ${s.column} | ${s.type} | ${s.nonEmpty} | ${s.total} |`).join('\n');
|
|
658
|
+
return {
|
|
659
|
+
table,
|
|
660
|
+
schema,
|
|
661
|
+
headers: this.engine.getHeaders(),
|
|
662
|
+
message: `${this.engine.colCount} columns, ${this.engine.rowCount} rows`,
|
|
663
|
+
file: this.csvPath,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Resize the spreadsheet grid
|
|
668
|
+
*
|
|
669
|
+
* @param rows New number of rows
|
|
670
|
+
* @param cols New number of columns
|
|
671
|
+
*/
|
|
672
|
+
async resize(params) {
|
|
673
|
+
await this.load();
|
|
674
|
+
this.engine.resize(params.rows, params.cols);
|
|
675
|
+
await this.save();
|
|
676
|
+
return this.buildResponse(`Resized to ${this.engine.rowCount} rows x ${this.engine.colCount} cols`);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Import CSV data
|
|
680
|
+
*
|
|
681
|
+
* Load data from a CSV file path or raw CSV text. The first row is treated as headers.
|
|
682
|
+
*
|
|
683
|
+
* @param file Path to a CSV file to import
|
|
684
|
+
* @param csv Raw CSV text to import (alternative to file)
|
|
685
|
+
* @example ingest({ csv: 'Name,Age\\nAlice,30\\nBob,25' })
|
|
686
|
+
*/
|
|
687
|
+
async ingest(params) {
|
|
688
|
+
let csvText;
|
|
689
|
+
if (params.file) {
|
|
690
|
+
csvText = await fs.readFile(params.file, 'utf-8');
|
|
691
|
+
}
|
|
692
|
+
else if (params.csv) {
|
|
693
|
+
csvText = params.csv;
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
throw new Error('Provide either "file" path or "csv" text');
|
|
697
|
+
}
|
|
698
|
+
this.engine = CsvEngine.fromCSV(csvText);
|
|
699
|
+
this.loaded = true;
|
|
700
|
+
await this.save();
|
|
701
|
+
const msg = `Imported ${this.engine.rowCount} rows x ${this.engine.colCount} cols`;
|
|
702
|
+
await this._emitAndWatch(msg);
|
|
703
|
+
return this.buildResponse(msg);
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Export as CSV
|
|
707
|
+
*
|
|
708
|
+
* Returns the raw CSV content (with formulas preserved), or saves to a file.
|
|
709
|
+
*
|
|
710
|
+
* @param file Optional file path to save CSV to
|
|
711
|
+
*/
|
|
712
|
+
async dump(params) {
|
|
713
|
+
await this.load();
|
|
714
|
+
const csvText = this.engine.toCSV({ formatRow: this.shouldWriteFormatRow() });
|
|
715
|
+
if (params?.file) {
|
|
716
|
+
const dir = path.dirname(params.file);
|
|
717
|
+
if (!existsSync(dir))
|
|
718
|
+
mkdirSync(dir, { recursive: true });
|
|
719
|
+
await fs.writeFile(params.file, csvText, 'utf-8');
|
|
720
|
+
return { message: `Exported to ${params.file}`, file: params.file };
|
|
721
|
+
}
|
|
722
|
+
const lineCount = csvText.split('\n').filter((l) => l.length > 0).length - 1;
|
|
723
|
+
return { csv: csvText, message: `CSV export (${lineCount} rows)` };
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Clear cells
|
|
727
|
+
*
|
|
728
|
+
* Clear all cells or a specific range.
|
|
729
|
+
*
|
|
730
|
+
* @param range Optional range to clear (e.g., "B:B" or "A1:C5"). Clears all if omitted.
|
|
731
|
+
*/
|
|
732
|
+
async clear(params) {
|
|
733
|
+
await this.load();
|
|
734
|
+
this.engine.clear(params?.range);
|
|
735
|
+
await this.save();
|
|
736
|
+
const msg = params?.range ? `Cleared range ${params.range}` : 'Cleared all cells';
|
|
737
|
+
await this._emitAndWatch(msg);
|
|
738
|
+
return this.buildResponse(msg);
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Rename a column header
|
|
742
|
+
*
|
|
743
|
+
* @param column Column letter or current header name
|
|
744
|
+
* @param name New header name
|
|
745
|
+
* @example rename({ column: 'A', name: 'Product' })
|
|
746
|
+
*/
|
|
747
|
+
async rename(params) {
|
|
748
|
+
await this.load();
|
|
749
|
+
const old = this.engine.rename(params.column, params.name);
|
|
750
|
+
await this.save();
|
|
751
|
+
return this.buildResponse(`Renamed column: "${old}" -> "${params.name}"`);
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Set column formatting
|
|
755
|
+
*
|
|
756
|
+
* Set alignment, type, or width for a column. This creates a format row in the CSV
|
|
757
|
+
* when formatting is set to "auto" (default).
|
|
758
|
+
*
|
|
759
|
+
* @param column Column letter or header name
|
|
760
|
+
* @param align Alignment: "left", "right", or "center"
|
|
761
|
+
* @param type Column type: "text", "number", "currency", "percent", "date", "bool", "select", "formula", "markdown", "longtext"
|
|
762
|
+
* @param width Column width in pixels
|
|
763
|
+
* @param wrap Enable text wrapping for this column
|
|
764
|
+
* @example format({ column: 'B', align: 'right', type: 'number' })
|
|
765
|
+
* @example format({ column: 'C', type: 'markdown', wrap: true })
|
|
766
|
+
*/
|
|
767
|
+
async format(params) {
|
|
768
|
+
await this.load();
|
|
769
|
+
this.engine.format(params.column, {
|
|
770
|
+
align: params.align,
|
|
771
|
+
type: params.type,
|
|
772
|
+
width: params.width,
|
|
773
|
+
wrap: params.wrap,
|
|
774
|
+
});
|
|
775
|
+
await this.save();
|
|
776
|
+
const changes = [
|
|
777
|
+
params.align && `align=${params.align}`,
|
|
778
|
+
params.type && `type=${params.type}`,
|
|
779
|
+
params.width !== undefined && `width=${params.width}`,
|
|
780
|
+
params.wrap !== undefined && `wrap=${params.wrap}`,
|
|
781
|
+
]
|
|
782
|
+
.filter(Boolean)
|
|
783
|
+
.join(', ');
|
|
784
|
+
const msg = `Formatted ${params.column}: ${changes}`;
|
|
785
|
+
await this._emitAndWatch(msg);
|
|
786
|
+
return this.buildResponse(msg);
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Watch the CSV file for appended rows
|
|
790
|
+
*
|
|
791
|
+
* Starts watching the underlying CSV file. When external processes append rows,
|
|
792
|
+
* the spreadsheet updates in real-time. Use `unwatch` to stop.
|
|
793
|
+
*/
|
|
794
|
+
async tail() {
|
|
795
|
+
await this.load();
|
|
796
|
+
if (this._watcher) {
|
|
797
|
+
return {
|
|
798
|
+
message: `Already watching ${path.basename(this.csvPath)}`,
|
|
799
|
+
file: this.csvPath,
|
|
800
|
+
watching: true,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
this._startFileWatching();
|
|
804
|
+
this._watcherAutoStarted = false;
|
|
805
|
+
const msg = `Watching ${path.basename(this.csvPath)} for changes`;
|
|
806
|
+
return { ...this.buildResponse(msg), watching: true };
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Stop watching the CSV file
|
|
810
|
+
*
|
|
811
|
+
* Stops the file watcher started by `tail`.
|
|
812
|
+
*/
|
|
813
|
+
async untail() {
|
|
814
|
+
this._watcherAutoStarted = false;
|
|
815
|
+
this._stopFileWatching();
|
|
816
|
+
return { message: 'Stopped watching', watching: false };
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Append rows to the spreadsheet
|
|
820
|
+
*
|
|
821
|
+
* Batch-append one or more rows. Each row is a list of values matching
|
|
822
|
+
* the column order, or a key-value object mapping column names to values.
|
|
823
|
+
* Emits after all rows are added so charts and UIs update once.
|
|
824
|
+
*
|
|
825
|
+
* @param rows Array of rows to append. Each row is either an array of values or a {column: value} object.
|
|
826
|
+
* @example push({ rows: [["Alice", "30"], ["Bob", "25"]] })
|
|
827
|
+
* @example push({ rows: [{"Name": "Alice", "Age": "30"}] })
|
|
828
|
+
*/
|
|
829
|
+
async push(params) {
|
|
830
|
+
await this.load();
|
|
831
|
+
const isLarge = statSync(this.csvPath).size > this._isLargeFileThreshold;
|
|
832
|
+
if (isLarge) {
|
|
833
|
+
const headers = this.engine.getHeaders();
|
|
834
|
+
const rowsToAppend = params.rows.map((row) => {
|
|
835
|
+
if (Array.isArray(row))
|
|
836
|
+
return row;
|
|
837
|
+
return headers.map((h) => row[h] || '');
|
|
838
|
+
});
|
|
839
|
+
await this.appendRows(rowsToAppend);
|
|
840
|
+
// Synchronize engine row count
|
|
841
|
+
for (const r of params.rows)
|
|
842
|
+
this.engine.add(typeof r === 'object' && !Array.isArray(r) ? r : {});
|
|
843
|
+
const msg = `Stream-pushed ${rowsToAppend.length} rows`;
|
|
844
|
+
await this._emitAndWatch(msg, { autoScroll: true });
|
|
845
|
+
return this.buildResponse(msg);
|
|
846
|
+
}
|
|
847
|
+
const added = this.engine.push(params.rows);
|
|
848
|
+
await this.save();
|
|
849
|
+
const msg = `Pushed ${added} row(s) (${this.engine.rowCount} total)`;
|
|
850
|
+
await this._emitAndWatch(msg, { autoScroll: true });
|
|
851
|
+
return this.buildResponse(msg);
|
|
852
|
+
}
|
|
853
|
+
/** Handle file change detected by watcher */
|
|
854
|
+
async _onFileChanged() {
|
|
855
|
+
try {
|
|
856
|
+
const csvPath = this.csvPath;
|
|
857
|
+
if (!existsSync(csvPath))
|
|
858
|
+
return;
|
|
859
|
+
const currentSize = statSync(csvPath).size;
|
|
860
|
+
if (currentSize <= this._lastFileSize) {
|
|
861
|
+
if (currentSize < this._lastFileSize) {
|
|
862
|
+
this.loaded = false;
|
|
863
|
+
await this.load();
|
|
864
|
+
this._lastFileSize = currentSize;
|
|
865
|
+
await this._emitAndWatch('File reloaded (truncated)', { autoScroll: false });
|
|
866
|
+
}
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
// Read only the new bytes appended since last check
|
|
870
|
+
const fd = await fs.open(csvPath, 'r');
|
|
871
|
+
const newBytes = Buffer.alloc(currentSize - this._lastFileSize);
|
|
872
|
+
await fd.read(newBytes, 0, newBytes.length, this._lastFileSize);
|
|
873
|
+
await fd.close();
|
|
874
|
+
this._lastFileSize = currentSize;
|
|
875
|
+
const newText = newBytes.toString('utf-8');
|
|
876
|
+
const newLines = newText.split('\n').filter((l) => l.trim().length > 0);
|
|
877
|
+
if (newLines.length === 0)
|
|
878
|
+
return;
|
|
879
|
+
const added = this.engine.appendCSVLines(newLines);
|
|
880
|
+
if (added > 0) {
|
|
881
|
+
const msg = `+${added} row(s) from file (${this.engine.rowCount} total)`;
|
|
882
|
+
await this._emitAndWatch(msg, { autoScroll: true });
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
catch (err) {
|
|
886
|
+
console.error('File watch handler error:', err);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
// --- SQL & Watch tools ---
|
|
890
|
+
/**
|
|
891
|
+
* Run a SQL query on the spreadsheet data
|
|
892
|
+
*
|
|
893
|
+
* Query the spreadsheet using SQL syntax. Table name is `data`.
|
|
894
|
+
* Column names with spaces or special characters need double quotes.
|
|
895
|
+
*
|
|
896
|
+
* @param query SQL query string (e.g., "SELECT * FROM data WHERE Age > 25")
|
|
897
|
+
* @example sql({ query: "SELECT Name, Age FROM data WHERE Age > 25 ORDER BY Age DESC" })
|
|
898
|
+
* @example sql({ query: "SELECT COUNT(*) as total FROM data" })
|
|
899
|
+
*/
|
|
900
|
+
async sql(params) {
|
|
901
|
+
await this.load();
|
|
902
|
+
return await this.engine.sql(params.query);
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Create a live SQL watch
|
|
906
|
+
*
|
|
907
|
+
* Registers a named SQL query that re-runs after every data change.
|
|
908
|
+
* When the query returns rows, an alert is emitted. Optionally triggers
|
|
909
|
+
* a cross-photon action (e.g., "slack.send").
|
|
910
|
+
*
|
|
911
|
+
* @param name Unique watch name (e.g., "price-alert")
|
|
912
|
+
* @param query SQL query — fires when it returns rows (e.g., "SELECT * FROM data WHERE Price < 50")
|
|
913
|
+
* @param action Optional cross-photon call target (e.g., "slack.send")
|
|
914
|
+
* @param actionParams Optional parameters passed to the action
|
|
915
|
+
* @param once If true, auto-removes after first trigger
|
|
916
|
+
* @example watch({ name: "big-orders", query: "SELECT * FROM data WHERE Amount > 1000" })
|
|
917
|
+
* @example watch({ name: "notify", query: "SELECT * FROM data WHERE Status = 'critical'", action: "slack.send", actionParams: { text: "Critical row found!" }, once: true })
|
|
918
|
+
*/
|
|
919
|
+
async watch(params) {
|
|
920
|
+
await this.load();
|
|
921
|
+
const def = {
|
|
922
|
+
name: params.name,
|
|
923
|
+
query: params.query,
|
|
924
|
+
action: params.action,
|
|
925
|
+
actionParams: params.actionParams,
|
|
926
|
+
once: params.once ?? false,
|
|
927
|
+
triggerCount: 0,
|
|
928
|
+
};
|
|
929
|
+
this._watches.set(params.name, def);
|
|
930
|
+
if (!this._watcher) {
|
|
931
|
+
this._startFileWatching();
|
|
932
|
+
this._watcherAutoStarted = true;
|
|
933
|
+
}
|
|
934
|
+
// Run query immediately to check current state
|
|
935
|
+
let currentMatches = 0;
|
|
936
|
+
try {
|
|
937
|
+
const result = await this.engine.sql(params.query);
|
|
938
|
+
currentMatches = Array.isArray(result.result) ? result.result.length : 0;
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
/* validation happens on first real run */
|
|
942
|
+
}
|
|
943
|
+
return {
|
|
944
|
+
message: `Watch "${params.name}" created${currentMatches > 0 ? ` (${currentMatches} rows match now)` : ''}`,
|
|
945
|
+
watch: params.name,
|
|
946
|
+
currentMatches,
|
|
947
|
+
watches: this._watches.size,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Remove a live SQL watch
|
|
952
|
+
*
|
|
953
|
+
* @param name Watch name to remove
|
|
954
|
+
*/
|
|
955
|
+
async unwatch(params) {
|
|
956
|
+
const existed = this._watches.delete(params.name);
|
|
957
|
+
if (this._watches.size === 0 && this._watcherAutoStarted) {
|
|
958
|
+
this._stopFileWatching();
|
|
959
|
+
this._watcherAutoStarted = false;
|
|
960
|
+
}
|
|
961
|
+
return {
|
|
962
|
+
message: existed ? `Removed watch "${params.name}"` : `Watch "${params.name}" not found`,
|
|
963
|
+
watches: this._watches.size,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* List active SQL watches
|
|
968
|
+
*
|
|
969
|
+
* Shows all registered watches with their trigger counts and status.
|
|
970
|
+
*/
|
|
971
|
+
async watches() {
|
|
972
|
+
const list = Array.from(this._watches.values()).map((w) => ({
|
|
973
|
+
name: w.name,
|
|
974
|
+
query: w.query,
|
|
975
|
+
action: w.action,
|
|
976
|
+
once: w.once,
|
|
977
|
+
triggerCount: w.triggerCount,
|
|
978
|
+
lastTriggered: w.lastTriggered || null,
|
|
979
|
+
}));
|
|
980
|
+
return {
|
|
981
|
+
watches: list,
|
|
982
|
+
count: list.length,
|
|
983
|
+
message: list.length > 0 ? `${list.length} active watch(es)` : 'No active watches',
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
// --- File watching helpers ---
|
|
987
|
+
_startFileWatching() {
|
|
988
|
+
if (this._watcher)
|
|
989
|
+
return;
|
|
990
|
+
const csvPath = this.csvPath;
|
|
991
|
+
if (!existsSync(csvPath))
|
|
992
|
+
return;
|
|
993
|
+
this._lastFileSize = statSync(csvPath).size;
|
|
994
|
+
this._watcher = fsWatch(csvPath, () => {
|
|
995
|
+
if (this._watchDebounce)
|
|
996
|
+
clearTimeout(this._watchDebounce);
|
|
997
|
+
this._watchDebounce = setTimeout(() => {
|
|
998
|
+
void this._onFileChanged();
|
|
999
|
+
}, 200);
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
_stopFileWatching() {
|
|
1003
|
+
if (this._watcher) {
|
|
1004
|
+
this._watcher.close();
|
|
1005
|
+
this._watcher = null;
|
|
1006
|
+
if (this._watchDebounce) {
|
|
1007
|
+
clearTimeout(this._watchDebounce);
|
|
1008
|
+
this._watchDebounce = null;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
// --- Emit + watch pipeline ---
|
|
1013
|
+
async _emitAndWatch(msg, opts = {}) {
|
|
1014
|
+
this.emit({ emit: 'data', ...this.buildResponse(msg), ...opts });
|
|
1015
|
+
await this._rerunWatches();
|
|
1016
|
+
}
|
|
1017
|
+
async _rerunWatches() {
|
|
1018
|
+
if (this._watches.size === 0)
|
|
1019
|
+
return;
|
|
1020
|
+
for (const [name, watch] of this._watches) {
|
|
1021
|
+
try {
|
|
1022
|
+
const result = await this.engine.sql(watch.query);
|
|
1023
|
+
if (Array.isArray(result.result) && result.result.length > 0) {
|
|
1024
|
+
watch.triggerCount++;
|
|
1025
|
+
watch.lastTriggered = new Date().toISOString();
|
|
1026
|
+
this.emit({
|
|
1027
|
+
emit: 'alert',
|
|
1028
|
+
watch: name,
|
|
1029
|
+
rows: result.result,
|
|
1030
|
+
count: result.result.length,
|
|
1031
|
+
});
|
|
1032
|
+
if (watch.action && this.call) {
|
|
1033
|
+
try {
|
|
1034
|
+
await this.call(watch.action, { ...watch.actionParams, _matchedRows: result.result });
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
console.error(`Watch "${name}" action failed:`, err);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (watch.once)
|
|
1041
|
+
this._watches.delete(name);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
catch (err) {
|
|
1045
|
+
console.error(`Watch "${name}" query error:`, err);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
//# sourceMappingURL=spreadsheet.photon.js.map
|