gb/fullsheet.md
Rodrigo Rodriguez (Pragmatismo) f3bad05e76
Some checks failed
BotServer CI / build (push) Failing after 13s
Fix LXD socket handling in container mode
2026-03-15 18:19:22 -03:00

856 lines
No EOL
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# FullSheet - Enterprise Grade Online Spreadsheet
## Vision
Transform the current sheet.js into a full-featured Excel competitor with:
- **Virtual scrolling** for million+ row datasets
- **xlsx import/export** via SheetJS (xlsx library)
- **Real-time collaboration** with operational transformation
- **Enterprise features**: audit trail, permissions, version history
---
## Phase 1: Virtual Scrolling Engine (Priority: HIGH)
### Current Problem
- 26×100 = 2600 DOM elements rendered simultaneously
- No support for large datasets (Excel supports 1M+ rows)
- Poor performance with 10K+ cells
### Solution Architecture
```javascript
// NEW: VirtualGrid class - drop-in replacement for current grid
class VirtualGrid {
constructor(container, config) {
this.config = {
colCount: 16384, // Excel's max columns (XFD)
rowCount: 1048576, // Excel's max rows
colWidth: 100,
rowHeight: 24,
bufferSize: 20, // Render 20 extra rows/cols outside viewport
...config
};
this.visibleStartRow = 0;
this.visibleEndRow = 0;
this.visibleStartCol = 0;
this.visibleEndCol = 0;
this.cellCache = new Map(); // Only stores non-empty cells
this.renderedCells = new Map(); // Currently in DOM
this.initialize();
}
initialize() {
// Use CSS transform for virtual scrolling
this.viewport = document.createElement('div');
this.viewport.className = 'virtual-viewport';
this.viewport.style.overflow = 'auto';
this.viewport.style.position = 'relative';
this.content = document.createElement('div');
this.content.className = 'virtual-content';
this.content.style.position = 'absolute';
this.content.style.top = '0';
this.content.style.left = '0';
this.viewport.appendChild(this.content);
this.container.appendChild(this.viewport);
// Calculate dimensions
this.content.style.width = `${this.config.colCount * this.config.colWidth}px`;
this.content.style.height = `${this.config.rowCount * this.config.rowHeight}px`;
// Bind scroll events
this.viewport.addEventListener('scroll', () => this.onScroll());
this.resizeObserver = new ResizeObserver(() => this.onResize());
this.resizeObserver.observe(this.viewport);
// Initial render
this.onScroll();
}
onScroll() {
const scrollTop = this.viewport.scrollTop;
const scrollLeft = this.viewport.scrollLeft;
const viewHeight = this.viewport.clientHeight;
const viewWidth = this.viewport.clientWidth;
// Calculate visible range
this.visibleStartRow = Math.floor(scrollTop / this.config.rowHeight);
this.visibleEndRow = Math.min(
this.config.rowCount - 1,
Math.ceil((scrollTop + viewHeight) / this.config.rowHeight)
);
this.visibleStartCol = Math.floor(scrollLeft / this.config.colWidth);
this.visibleEndCol = Math.min(
this.config.colCount - 1,
Math.ceil((scrollLeft + viewWidth) / this.config.colWidth)
);
// Apply buffer
const buffer = this.config.bufferSize;
this.visibleStartRow = Math.max(0, this.visibleStartRow - buffer);
this.visibleEndRow = Math.min(this.config.rowCount - 1, this.visibleEndRow + buffer);
this.visibleStartCol = Math.max(0, this.visibleStartCol - buffer);
this.visibleEndCol = Math.min(this.config.colCount - 1, this.visibleEndCol + buffer);
this.renderVisibleCells();
}
renderVisibleCells() {
// Remove cells no longer visible
for (const [key, el] of this.renderedCells) {
const [r, c] = key.split(',').map(Number);
if (r < this.visibleStartRow || r > this.visibleEndRow ||
c < this.visibleStartCol || c > this.visibleEndCol) {
el.remove();
this.renderedCells.delete(key);
}
}
// Render visible cells
for (let row = this.visibleStartRow; row <= this.visibleEndRow; row++) {
for (let col = this.visibleStartCol; col <= this.visibleEndCol; col++) {
const key = `${row},${col}`;
if (!this.renderedCells.has(key)) {
const cellData = this.cellCache.get(key);
if (cellData) {
const cell = this.createCellElement(row, col, cellData);
cell.style.position = 'absolute';
cell.style.top = `${row * this.config.rowHeight}px`;
cell.style.left = `${col * this.config.colWidth}px`;
cell.style.width = `${this.config.colWidth}px`;
cell.style.height = `${this.config.rowHeight}px`;
this.content.appendChild(cell);
this.renderedCells.set(key, cell);
}
}
}
}
}
setCellValue(row, col, value) {
const key = `${row},${col}`;
if (value === null || value === undefined || value === '') {
this.cellCache.delete(key);
} else {
this.cellCache.set(key, value);
}
// Update if in visible range
if (row >= this.visibleStartRow && row <= this.visibleEndRow &&
col >= this.visibleStartCol && col <= this.visibleEndCol) {
const cell = this.renderedCells.get(key);
if (cell) {
if (value) {
cell.textContent = value;
} else {
cell.remove();
this.renderedCells.delete(key);
}
} else if (value) {
const el = this.createCellElement(row, col, value);
el.style.position = 'absolute';
el.style.top = `${row * this.config.rowHeight}px`;
el.style.left = `${col * this.config.colWidth}px`;
this.content.appendChild(el);
this.renderedCells.set(key, el);
}
}
}
getCellValue(row, col) {
return this.cellCache.get(`${row},${col}`);
}
scrollToCell(row, col) {
const scrollTop = row * this.config.rowHeight;
const scrollLeft = col * this.config.colWidth;
this.viewport.scrollTo(scrollLeft, scrollTop);
}
getViewportPosition() {
return {
top: this.viewport.scrollTop,
left: this.viewport.scrollLeft,
height: this.viewport.clientHeight,
width: this.viewport.clientWidth
};
}
}
```
### Integration Steps
1. **Add VirtualGrid to sheet.js** (keep existing functionality):
```javascript
// At top of sheet.js, after CONFIG
let virtualGrid = null;
function initVirtualGrid() {
const container = document.getElementById('cellsContainer');
virtualGrid = new VirtualGrid(container, {
colCount: CONFIG.COLS,
rowCount: CONFIG.ROWS,
colWidth: CONFIG.COL_WIDTH,
rowHeight: CONFIG.ROW_HEIGHT
});
// Copy existing data to virtual grid
const ws = state.worksheets[state.activeWorksheet];
for (const [key, data] of Object.entries(ws.data)) {
const [row, col] = key.split(',').map(Number);
virtualGrid.setCellValue(row, col, data.value || data.formula);
}
}
```
2. **Modify renderGrid()** to use virtual grid:
```javascript
function renderGrid() {
// Keep header rendering (not virtualized)
renderColumnHeaders();
renderRowHeaders();
// Initialize virtual grid for cells
if (!virtualGrid) {
initVirtualGrid();
}
}
```
---
## Phase 2: xlsx Import/Export (Priority: HIGH)
### Library Integration
Add SheetJS library to botui. Place in: `botui/ui/suite/js/vendor/xlsx.full.min.js`
### File Import
```javascript
// Add to sheet.js
async function importXlsx(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: 'array' });
// Convert to sheet format
const worksheets = workbook.SheetNames.map((name, index) => {
const sheet = workbook.Sheets[name];
const jsonData = XLSX.utils.sheet_to_json(sheet, {
header: 1, // Array of arrays
defval: ''
});
// Convert to sparse cell format
const cellData = {};
jsonData.forEach((row, rowIndex) => {
row.forEach((value, colIndex) => {
if (value !== '' && value !== null && value !== undefined) {
cellData[`${rowIndex},${colIndex}`] = { value: String(value) };
}
});
});
return { name, data: cellData };
});
state.worksheets = worksheets;
state.activeWorksheet = 0;
renderWorksheetTabs();
// Reinitialize virtual grid with new data
initVirtualGrid();
resolve();
} catch (err) {
reject(err);
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
```
### File Export
```javascript
async function exportXlsx() {
// Convert sheet format to array of arrays
const ws = state.worksheets[state.activeWorksheet];
const maxRow = findMaxRow(ws.data);
const maxCol = findMaxCol(ws.data);
const jsonData = [];
for (let r = 0; r <= maxRow; r++) {
const row = [];
for (let c = 0; c <= maxCol; c++) {
const key = `${r},${c}`;
const cell = ws.data[key];
row.push(cell?.value || cell?.formula || '');
}
jsonData.push(row);
}
// Create workbook
const worksheet = XLSX.utils.aoa_to_sheet(jsonData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, state.sheetName);
// Generate file
const xlsxData = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'array'
});
// Download
const blob = new Blob([xlsxData], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${state.sheetName}.xlsx`;
a.click();
URL.revokeObjectURL(url);
}
```
### UI Integration
Add import/export buttons to toolbar in sheet.html:
```html
<button class="btn-icon" id="importXlsxBtn" title="Import xlsx">
<svg>...</svg>
</button>
<button class="btn-icon" id="exportXlsxBtn" title="Export xlsx">
<svg>...</svg>
</button>
```
Add hidden file input:
```html
<input type="file" id="xlsxFileInput" accept=".xlsx,.xls" style="display:none" />
```
Bind events:
```javascript
document.getElementById('importXlsxBtn')?.addEventListener('click', () => {
document.getElementById('xlsxFileInput').click();
});
document.getElementById('xlsxFileInput')?.addEventListener('change', async (e) => {
if (e.target.files[0]) {
await importXlsx(e.target.files[0]);
}
});
document.getElementById('exportXlsxBtn')?.addEventListener('click', exportXlsx);
```
---
## Phase 3: Real-time Collaboration (Priority: HIGH)
### WebSocket Architecture
Current implementation has basic collaboration. Enhance with:
```javascript
// Enhanced collaboration state
const collabState = {
operations: [], // Operation log
revision: 0, // Current revision number
pendingOps: [], // Ops not yet acknowledged
cursorPositions: new Map(), // Other users' cursors
userColors: new Map() // Assign colors to users
};
// Operational Transformation for concurrent edits
function transformOperation(op1, op2) {
// Transform op1 against op2
if (op1.type === 'set' && op2.type === 'set') {
if (op1.row === op2.row && op1.col === op2.col) {
// Same cell - use op2 (later operation wins)
return null;
}
// Different cells - no transformation needed
return op1;
}
// Handle insert/delete row/column
// ... (OT logic)
}
function applyOperation(op) {
const ws = state.worksheets[state.activeWorksheet];
const key = `${op.row},${op.col}`;
switch (op.type) {
case 'set':
if (op.value === null || op.value === '') {
delete ws.data[key];
} else {
ws.data[key] = { value: op.value };
}
break;
case 'delete':
delete ws.data[key];
break;
}
// Update virtual grid
if (virtualGrid) {
virtualGrid.setCellValue(op.row, op.col, op.value);
}
}
```
### Conflict Resolution
```javascript
function resolveConflict(localOp, remoteOp) {
// If same cell, remote wins (last-write-wins for simplicity)
// For enterprise: use CRDT or OT
if (localOp.row === remoteOp.row && localOp.col === remoteOp.col) {
return remoteOp; // Remote takes precedence
}
// Different cells - apply both
return localOp;
}
```
---
## Phase 4: Enterprise Features (Priority: MEDIUM)
### 4.1 Audit Trail
```javascript
class AuditLog {
constructor() {
this.entries = [];
this.maxEntries = 10000;
}
log(action, details) {
const entry = {
timestamp: new Date().toISOString(),
userId: getUserId(),
action,
details,
sheetId: state.sheetId
};
this.entries.push(entry);
if (this.entries.length > this.maxEntries) {
this.entries.shift();
}
// Send to server for persistence
this.persistEntry(entry);
}
async persistEntry(entry) {
await fetch('/api/sheet/audit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry)
});
}
getHistory(startTime, endTime) {
return this.entries.filter(e => {
const time = new Date(e.timestamp);
return time >= startTime && time <= endTime;
});
}
}
const auditLog = new AuditLog();
// Wrap cell operations with audit
const originalSetCellValue = setCellValue;
setCellValue = function(row, col, value) {
const oldValue = getCellValue(row, col);
originalSetCellValue(row, col, value);
auditLog.log('cell_change', { row, col, oldValue, newValue: value });
};
```
### 4.2 Version History
```javascript
class VersionManager {
constructor() {
this.versions = [];
this.currentVersion = 0;
this.autoSaveInterval = null;
}
createSnapshot() {
const snapshot = {
timestamp: new Date().toISOString(),
worksheets: JSON.parse(JSON.stringify(state.worksheets)),
sheetName: state.sheetName
};
this.versions.push(snapshot);
this.currentVersion = this.versions.length - 1;
// Persist to server
this.persistVersion(snapshot);
// Keep last 100 versions in memory
if (this.versions.length > 100) {
this.versions.shift();
}
}
restoreVersion(versionIndex) {
if (versionIndex < 0 || versionIndex >= this.versions.length) return;
const version = this.versions[versionIndex];
state.worksheets = JSON.parse(JSON.stringify(version.worksheets));
state.sheetName = version.sheetName;
state.activeWorksheet = 0;
renderAllCells();
renderWorksheetTabs();
auditLog.log('version_restore', { versionIndex });
}
getVersionList() {
return this.versions.map((v, i) => ({
index: i,
timestamp: v.timestamp,
sheetName: v.sheetName
}));
}
}
```
### 4.3 Permissions System
```javascript
class PermissionManager {
constructor() {
this.permissions = new Map(); // userId -> permission level
}
setPermission(userId, level) {
this.permissions.set(userId, level);
}
canEdit() {
const userId = getUserId();
const level = this.permissions.get(userId);
return level === 'edit' || level === 'admin';
}
canDelete() {
const userId = getUserId();
const level = this.permissions.get(userId);
return level === 'admin';
}
canShare() {
const userId = getUserId();
const level = this.permissions.get(userId);
return level === 'admin';
}
}
const permissions = new PermissionManager();
// Check permissions before operations
function setCellValue(row, col, value) {
if (!permissions.canEdit()) {
showNotification('You do not have permission to edit', 'error');
return;
}
// ... original implementation
}
```
---
## Phase 5: Performance Optimization (Priority: MEDIUM)
### 5.1 Lazy Formula Evaluation
```javascript
class FormulaEngine {
constructor() {
this.dependencyGraph = new Map(); // cell -> dependent cells
this.evaluationQueue = new Set();
this.isEvaluating = false;
}
scheduleEvaluation(cell) {
this.evaluationQueue.add(cell);
this.processQueue();
}
async processQueue() {
if (this.isEvaluating) return;
this.isEvaluating = true;
while (this.evaluationQueue.size > 0) {
const cell = this.evaluationQueue.values().next().value;
this.evaluationQueue.delete(cell);
await this.evaluateCell(cell);
}
this.isEvaluating = false;
}
async evaluateCell(cell) {
const data = getCellData(cell.row, cell.col);
if (!data?.formula) return;
try {
const result = evaluateFormula(data.formula, cell.row, cell.col);
// Update display without full re-render
if (virtualGrid) {
virtualGrid.setCellValue(cell.row, cell.col, result);
}
} catch (e) {
// Handle formula error
}
}
}
```
### 5.2 Web Worker for Heavy Calculations
```javascript
// sheet.worker.js - separate worker file
self.onmessage = function(e) {
const { type, data } = e.data;
switch (type) {
case 'evaluate_formula':
const result = evaluateFormulaInWorker(data.formula, data.references);
self.postMessage({ type: 'formula_result', result });
break;
case 'batch_render':
const html = batchRenderCells(data.cells);
self.postMessage({ type: 'batch_result', html });
break;
}
};
```
### 5.3 Debounced Auto-save
```javascript
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
const debouncedSave = debounce(saveSheet, 2000);
function scheduleAutoSave() {
state.isDirty = true;
debouncedSave();
}
```
---
## Phase 6: Advanced Features (Priority: LOW)
### 6.1 Pivot Tables
```javascript
function createPivotTable(sourceRange, rowFields, colFields, valueField, aggregation) {
const data = extractRangeData(sourceRange);
// Group by row fields
const grouped = {};
data.forEach(row => {
const key = rowFields.map(f => row[f]).join('|');
if (!grouped[key]) grouped[key] = [];
grouped[key].push(row);
});
// Aggregate values
const result = {};
for (const [key, rows] of Object.entries(grouped)) {
const values = rows.map(r => parseFloat(r[valueField])).filter(v => !isNaN(v));
result[key] = aggregate(values, aggregation);
}
return result;
}
function aggregate(values, type) {
switch (type) {
case 'sum': return values.reduce((a, b) => a + b, 0);
case 'avg': return values.reduce((a, b) => a + b, 0) / values.length;
case 'count': return values.length;
case 'min': return Math.min(...values);
case 'max': return Math.max(...values);
default: return 0;
}
}
```
### 6.2 Macros/Scripts
```javascript
class MacroEngine {
constructor() {
this.macros = new Map();
}
registerMacro(name, fn) {
this.macros.set(name, fn);
}
runMacro(name) {
const macro = this.macros.get(name);
if (macro) {
macro();
auditLog.log('macro_run', { name });
}
}
}
// Built-in macros
const macroEngine = new MacroEngine();
macroEngine.registerMacro('clearAll', () => {
state.worksheets[state.activeWorksheet].data = {};
renderAllCells();
});
macroEngine.registerMacro('autoSum', () => {
// Auto-sum selected range
});
```
---
## Implementation Roadmap
### Step 1: Virtual Scrolling (Week 1) ✅ COMPLETED
- [x] Implement VirtualGrid class (sheet.js:253-448)
- [x] Update renderGrid() to use virtual grid (sheet.js:378-430)
- [x] Auto-enable for sheets > 500 rows
- [x] Ensure existing functionality works (backward compatible)
- [ ] Test with 10K+ rows
### Step 2: xlsx Support (Week 1-2) ✅ COMPLETED
- [x] Use existing backend APIs (/api/sheet/import, /api/sheet/export)
- [x] Implement importXlsx() (sheet.js:1893-1947)
- [x] Implement exportXlsx() (sheet.js:1949-1992)
- [x] Implement exportCsv() (sheet.js:1994-2021)
- [x] Add UI buttons (sheet.html:209-225)
- [ ] Test with real xlsx files
### Step 3: Collaboration (Week 2-3) 🔄 IN PROGRESS
- [x] Existing WebSocket handler (connectWebSocket)
- [ ] Enhance with operation transformation
- [ ] Add cursor tracking for remote users
- [ ] Test multi-user scenarios
### Step 4: Enterprise (Week 3-4) ✅ COMPLETED
- [x] Add audit logging (AuditLog class, sheet.js:299-338)
- [x] Implement version history (VersionManager class, sheet.js:340-418)
- [x] Add permission system (PermissionManager class, sheet.js:420-452)
- [ ] Add admin UI (version history dialog)
### Step 5: Performance (Week 4-5) 🔄 IN PROGRESS
- [x] Add debounced save (CONFIG.AUTOSAVE_DELAY)
- [ ] Add formula lazy evaluation
- [ ] Implement Web Worker
- [ ] Profile and optimize
---
## Backward Compatibility
All changes must maintain backward compatibility:
1. Keep existing CONFIG structure
2. Preserve current API (setCellValue, getCellValue, etc.)
3. VirtualGrid should wrap existing cell data
4. Feature flags for new functionality
```javascript
// Feature detection
const features = {
virtualScrolling: true, // Default on for large sheets
xlsxSupport: typeof XLSX !== 'undefined',
collaboration: state.sheetId !== null,
auditLog: true
};
// Use features to determine behavior
if (features.virtualScrolling && CONFIG.ROWS > 1000) {
// Use virtual grid
} else {
// Use traditional grid
}
```
---
## Testing Strategy
### Unit Tests
- VirtualGrid scroll behavior
- xlsx import/export round-trip
- Formula evaluation
- Permission checks
### Integration Tests
- Multi-user collaboration
- Large file import (100K+ rows)
- Version restore
- Audit log persistence
### Performance Tests
- Render time with 1M rows
- Memory usage
- Network bandwidth for collaboration
---
## Files Modified
1.**botui/ui/suite/sheet/sheet.js** - VirtualGrid, importXlsx/exportXlsx, AuditLog, VersionManager, PermissionManager
2.**botui/ui/suite/sheet/sheet.html** - Import/export buttons
3.**botui/ui/suite/sheet/sheet.css** - Virtual grid styles (needed)
4.**botserver/** - Use existing APIs (already have /api/sheet/import, /api/sheet/export, etc.)
---
## Success Metrics
- [x] Virtual scrolling implementation complete (auto-enable >500 rows)
- [x] xlsx import/export via backend APIs
- [ ] Support 1M+ rows with smooth scrolling (needs testing)
- [ ] Import/export xlsx files up to 50MB (needs testing)
- [ ] 10+ concurrent users with sub-100ms sync (needs enhancement)
- [ ] <100ms initial load time (needs profiling)
- [x] 0 compilation errors in botui (verified)
- [ ] 0 compilation errors in botserver (verifying) (verify)