Some checks failed
BotServer CI / build (push) Failing after 13s
856 lines
No EOL
22 KiB
Markdown
856 lines
No EOL
22 KiB
Markdown
# 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) |