@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.
Files changed (90) hide show
  1. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  2. package/dist/auto-ui/beam/routes/api-config.js +29 -8
  3. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  4. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
  5. package/dist/auto-ui/beam/routes/api-marketplace.js +3 -0
  6. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
  7. package/dist/auto-ui/beam.d.ts.map +1 -1
  8. package/dist/auto-ui/beam.js +167 -48
  9. package/dist/auto-ui/beam.js.map +1 -1
  10. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  11. package/dist/auto-ui/bridge/index.js +578 -0
  12. package/dist/auto-ui/bridge/index.js.map +1 -1
  13. package/dist/auto-ui/bridge/renderers.d.ts.map +1 -1
  14. package/dist/auto-ui/bridge/renderers.js +7 -3
  15. package/dist/auto-ui/bridge/renderers.js.map +1 -1
  16. package/dist/auto-ui/bridge/types.d.ts +6 -0
  17. package/dist/auto-ui/bridge/types.d.ts.map +1 -1
  18. package/dist/auto-ui/frontend/pure-view.html +289 -0
  19. package/dist/auto-ui/photon-bridge.d.ts +11 -0
  20. package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
  21. package/dist/auto-ui/photon-bridge.js +75 -1
  22. package/dist/auto-ui/photon-bridge.js.map +1 -1
  23. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  24. package/dist/auto-ui/streamable-http-transport.js +29 -3
  25. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  26. package/dist/beam-form.bundle.js +5707 -0
  27. package/dist/beam-form.bundle.js.map +7 -0
  28. package/dist/beam.bundle.js +1947 -523
  29. package/dist/beam.bundle.js.map +4 -4
  30. package/dist/cli/commands/info.d.ts.map +1 -1
  31. package/dist/cli/commands/info.js +15 -2
  32. package/dist/cli/commands/info.js.map +1 -1
  33. package/dist/daemon/client.d.ts +5 -0
  34. package/dist/daemon/client.d.ts.map +1 -1
  35. package/dist/daemon/client.js +50 -0
  36. package/dist/daemon/client.js.map +1 -1
  37. package/dist/daemon/manager.d.ts +15 -0
  38. package/dist/daemon/manager.d.ts.map +1 -1
  39. package/dist/daemon/manager.js +142 -11
  40. package/dist/daemon/manager.js.map +1 -1
  41. package/dist/deploy/cloudflare.d.ts.map +1 -1
  42. package/dist/deploy/cloudflare.js +10 -2
  43. package/dist/deploy/cloudflare.js.map +1 -1
  44. package/dist/loader.d.ts.map +1 -1
  45. package/dist/loader.js +50 -3
  46. package/dist/loader.js.map +1 -1
  47. package/dist/marketplace-manager.d.ts +9 -0
  48. package/dist/marketplace-manager.d.ts.map +1 -1
  49. package/dist/marketplace-manager.js +115 -42
  50. package/dist/marketplace-manager.js.map +1 -1
  51. package/dist/meta.d.ts +51 -0
  52. package/dist/meta.d.ts.map +1 -0
  53. package/dist/meta.js +320 -0
  54. package/dist/meta.js.map +1 -0
  55. package/dist/photon-cli-runner.d.ts.map +1 -1
  56. package/dist/photon-cli-runner.js +30 -5
  57. package/dist/photon-cli-runner.js.map +1 -1
  58. package/dist/photon-doc-extractor.d.ts +1 -0
  59. package/dist/photon-doc-extractor.d.ts.map +1 -1
  60. package/dist/photon-doc-extractor.js +33 -21
  61. package/dist/photon-doc-extractor.js.map +1 -1
  62. package/dist/photons/docs/ui/docs.html +441 -0
  63. package/dist/photons/docs.photon.d.ts +237 -0
  64. package/dist/photons/docs.photon.d.ts.map +1 -0
  65. package/dist/photons/docs.photon.js +483 -0
  66. package/dist/photons/docs.photon.js.map +1 -0
  67. package/dist/photons/docs.photon.ts +536 -0
  68. package/dist/photons/slides.photon.d.ts +212 -0
  69. package/dist/photons/slides.photon.d.ts.map +1 -0
  70. package/dist/photons/slides.photon.js +355 -0
  71. package/dist/photons/slides.photon.js.map +1 -0
  72. package/dist/photons/slides.photon.ts +370 -0
  73. package/dist/photons/spreadsheet/ui/spreadsheet.html +779 -0
  74. package/dist/photons/spreadsheet.photon.d.ts +554 -0
  75. package/dist/photons/spreadsheet.photon.d.ts.map +1 -0
  76. package/dist/photons/spreadsheet.photon.js +1050 -0
  77. package/dist/photons/spreadsheet.photon.js.map +1 -0
  78. package/dist/photons/spreadsheet.photon.ts +1239 -0
  79. package/dist/server.d.ts.map +1 -1
  80. package/dist/server.js +17 -57
  81. package/dist/server.js.map +1 -1
  82. package/dist/shared/error-handler.d.ts +8 -0
  83. package/dist/shared/error-handler.d.ts.map +1 -1
  84. package/dist/shared/error-handler.js +50 -0
  85. package/dist/shared/error-handler.js.map +1 -1
  86. package/dist/shared-utils.d.ts +3 -2
  87. package/dist/shared-utils.d.ts.map +1 -1
  88. package/dist/shared-utils.js +4 -3
  89. package/dist/shared-utils.js.map +1 -1
  90. 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