From 1a0f008ba4580322361507fd98b47711323f1517 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 30 Apr 2025 01:12:45 +0100 Subject: [PATCH] testing indexed db --- package-lock.json | 7 + package.json | 1 + src/app/page.tsx | 115 +++++++++--- src/components/Spreadsheet.tsx | 129 ++++++++----- src/context/SpreadsheetContext.tsx | 285 ++++++++++++++++++++++++++--- src/lib/db/index.ts | 245 +++++++++++++++++++++++++ 6 files changed, 683 insertions(+), 99 deletions(-) create mode 100644 src/lib/db/index.ts diff --git a/package-lock.json b/package-lock.json index e1c695d..3dde861 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@types/react-dom": "18.2.19", "autoprefixer": "10.4.17", "class-variance-authority": "^0.7.1", + "dexie": "^4.0.11", "dotenv": "^16.4.1", "echarts-for-react": "^3.0.2", "handsontable": "^13.1.0", @@ -3313,6 +3314,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dexie": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.11.tgz", + "integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A==", + "license": "Apache-2.0" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", diff --git a/package.json b/package.json index 3144016..115b373 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@types/react-dom": "18.2.19", "autoprefixer": "10.4.17", "class-variance-authority": "^0.7.1", + "dexie": "^4.0.11", "dotenv": "^16.4.1", "echarts-for-react": "^3.0.2", "handsontable": "^13.1.0", diff --git a/src/app/page.tsx b/src/app/page.tsx index 45b9996..230cc34 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,6 +13,7 @@ import { useEffect, useRef, useState } from "react"; import ChatBox from "@/components/ChatBox"; import { MessageCircle } from "lucide-react"; import type { SpreadsheetRef } from "@/components/Spreadsheet"; +import { db } from '@/lib/db'; import dynamic from "next/dynamic"; const Spreadsheet = dynamic(() => import("@/components/Spreadsheet").then(mod => mod.default), { @@ -24,6 +25,31 @@ const Spreadsheet = dynamic(() => import("@/components/Spreadsheet").then(mod => ), }); +// Helper function to load chat history from IndexedDB +const loadChatHistory = async (): Promise => { + try { + return await db.getChatHistory(); + } catch (error) { + console.error("Error loading chat history from IndexedDB:", error); + return []; + } +}; + +// Helper function to save chat history to IndexedDB +const saveChatHistory = async (history: ChatMessage[]): Promise => { + try { + // Clear existing chat history + await db.clearChatHistory(); + + // Add each message to the database + for (const message of history) { + await db.addChatMessage(message); + } + } catch (error) { + console.error("Error saving chat history to IndexedDB:", error); + } +}; + const SpreadsheetApp = () => { const { setFormulas, @@ -67,41 +93,36 @@ const SpreadsheetApp = () => { return () => window.removeEventListener("keydown", handleKeyPress); }, [isPromptLibraryOpen]); - // Load chat open state from localStorage + // Load chat open state from IndexedDB useEffect(() => { - const savedState = localStorage.getItem("chatOpen"); - if (savedState) { - setIsChatOpen(JSON.parse(savedState)); - } + const loadChatOpen = async () => { + const isOpen = await loadChatOpenState(); + setIsChatOpen(isOpen); + }; + + loadChatOpen(); }, []); - // Save chat open state to localStorage + // Save chat open state to IndexedDB when it changes useEffect(() => { - localStorage.setItem("chatOpen", JSON.stringify(isChatOpen)); + saveChatOpenState(isChatOpen); }, [isChatOpen]); - // Load chat history from localStorage + // Load chat history from IndexedDB useEffect(() => { - const savedHistory = localStorage.getItem("chatHistory"); - if (savedHistory) { - try { - const parsed = JSON.parse(savedHistory); - setChatHistory( - parsed.map((msg: any) => ({ - ...msg, - timestamp: new Date(msg.timestamp), - })), - ); - } catch (error) { - console.error("Error loading chat history:", error); - localStorage.removeItem("chatHistory"); - } - } + const loadHistory = async () => { + const history = await loadChatHistory(); + setChatHistory(history); + }; + + loadHistory(); }, []); - // Save chat history to localStorage + // Save chat history to IndexedDB when it changes useEffect(() => { - localStorage.setItem("chatHistory", JSON.stringify(chatHistory)); + if (chatHistory.length > 0) { + saveChatHistory(chatHistory); + } }, [chatHistory]); // Update spreadsheetData when active sheet changes @@ -110,6 +131,23 @@ const SpreadsheetApp = () => { // We can access the active sheet data directly when needed via getActiveSheetData() }, [activeSheetId, getActiveSheetData]); + // Cleanup database on initial load + useEffect(() => { + const cleanupDatabase = async () => { + try { + // Check for and clean up duplicate sheets on startup + const removedCount = await db.cleanupDuplicateSheets(); + if (removedCount > 0) { + console.log(`Cleaned up ${removedCount} duplicate sheets on startup.`); + } + } catch (error) { + console.error("Error cleaning up database on startup:", error); + } + }; + + cleanupDatabase(); + }, []); + const handleStop = () => { if (abortController.current) { abortController.current.abort(); @@ -335,11 +373,34 @@ const SpreadsheetApp = () => { ); }; - const handleClearHistory = () => { + const handleClearHistory = async () => { setChatHistory([]); - localStorage.removeItem("chatHistory"); + try { + await db.clearChatHistory(); + } catch (error) { + console.error("Error clearing chat history from IndexedDB:", error); + } }; + // Helper function to load chat open state from IndexedDB + const loadChatOpenState = async (): Promise => { + try { + const chatOpen = await db.getPreference('chatOpen'); + return chatOpen === true; + } catch (error) { + console.error("Error loading chat open state from IndexedDB:", error); + return false; + } + }; + + // Helper function to save chat open state to IndexedDB + const saveChatOpenState = async (isOpen: boolean): Promise => { + try { + await db.setPreference('chatOpen', isOpen); + } catch (error) { + console.error("Error saving chat open state to IndexedDB:", error); + } + }; return (
diff --git a/src/components/Spreadsheet.tsx b/src/components/Spreadsheet.tsx index 972af3d..fbe9153 100644 --- a/src/components/Spreadsheet.tsx +++ b/src/components/Spreadsheet.tsx @@ -66,12 +66,82 @@ const Spreadsheet = forwardRef( const [editingSheetId, setEditingSheetId] = useState(null); const [newSheetName, setNewSheetName] = useState(""); const [showSheetMenu, setShowSheetMenu] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); // Add a debug effect to monitor sheet menu state useEffect(() => { console.log("Sheet menu state changed:", showSheetMenu); }, [showSheetMenu]); + // Initialize or update Handsontable when sheets and activeSheetId are available + useEffect(() => { + // Only proceed if we have valid sheet data + if (sheets.length === 0 || !activeSheetId) { + return; + } + + // If hot instance already exists, update it + if (hotInstanceRef.current) { + const activeSheetData = getActiveSheetData(); + setCurrentData(activeSheetData); + hotInstanceRef.current.updateSettings({ data: activeSheetData }, false); + return; + } + + // If spreadsheetRef is ready but hotInstance is not yet created + if (spreadsheetRef.current && !hotInstanceRef.current) { + try { + // Get the initial data for the active sheet + const activeSheetData = getActiveSheetData(); + setCurrentData(activeSheetData); + + // Get the initial config + const config = getInitialConfig(activeSheetData); + + // Add our custom afterChange hook to the config + config.afterChange = (changes: any) => { + if (changes) { + const currentData = hotInstanceRef.current?.getData(); + if (currentData) { + // Use the current activeSheetId from context + console.log(`Saving changes to active sheet: ${activeSheetId}`); + + // Update the data for the active sheet only + updateSheetData(activeSheetId, currentData); + + // Also update our local state + setCurrentData(currentData); + + if (onDataChange) { + onDataChange(currentData); + } + } + } + }; + + // Create a new instance with the config + hotInstanceRef.current = new Handsontable( + spreadsheetRef.current, + config + ); + + setIsInitialized(true); + } catch (error) { + console.error("Error initializing spreadsheet:", error); + } + } + }, [sheets, activeSheetId, getActiveSheetData, updateSheetData, onDataChange]); + + // Cleanup Handsontable instance on unmount + useEffect(() => { + return () => { + if (hotInstanceRef.current) { + hotInstanceRef.current.destroy(); + hotInstanceRef.current = null; + } + }; + }, []); + const handleImport = async (file: File) => { if (!file) return; try { @@ -170,54 +240,10 @@ const Spreadsheet = forwardRef( }); }, [formulaQueue, clearFormula, activeSheetId, updateSheetData]); - // Initialize Handsontable when component mounts - useEffect(() => { - if (spreadsheetRef.current && !hotInstanceRef.current) { - try { - // Get the initial config - const config = getInitialConfig(currentData); - - // Add our custom afterChange hook to the config - config.afterChange = (changes: any) => { - if (changes) { - const currentData = hotInstanceRef.current?.getData(); - if (currentData) { - // Use the current activeSheetId from context - console.log(`Saving changes to active sheet: ${activeSheetId}`); - - // Update the data for the active sheet only - updateSheetData(activeSheetId, currentData); - - // Also update our local state - setCurrentData(currentData); - - if (onDataChange) { - onDataChange(currentData); - } - } - } - }; - - // Create a new instance with the config - hotInstanceRef.current = new Handsontable( - spreadsheetRef.current, - config - ); - } catch (error) { - console.error("Error initializing spreadsheet:", error); - } - } - - return () => { - if (hotInstanceRef.current) { - hotInstanceRef.current.destroy(); - hotInstanceRef.current = null; - } - }; - }, []); - // Update Handsontable when active sheet changes useEffect(() => { + if (!activeSheetId || sheets.length === 0) return; + const activeSheet = sheets.find(sheet => sheet.id === activeSheetId); if (activeSheet) { console.log(`Active sheet changed to: ${activeSheet.name} (ID: ${activeSheetId}, HyperFormula ID: ${activeSheet.hyperFormulaId})`); @@ -253,7 +279,7 @@ const Spreadsheet = forwardRef( }, false); } } - }, [activeSheetId, sheets]); + }, [activeSheetId, sheets, updateSheetData, onDataChange]); // Update Handsontable when currentData changes useEffect(() => { @@ -506,6 +532,15 @@ const Spreadsheet = forwardRef( }; }, [showSheetMenu]); + // Display a loading state if sheets aren't loaded yet + if (sheets.length === 0 || !activeSheetId) { + return ( +
+
Loading spreadsheet data...
+
+ ); + } + return (
= ({ new Map(), ); - // Initialize with a default sheet - const [sheets, setSheets] = useState(() => { - // Create first sheet and register with HyperFormula - const hyperFormulaId = 0; // First sheet in HyperFormula is 0 - const defaultSheet: Sheet = { - id: generateId(), - name: "Sheet 1", - data: [["", ""], ["", ""]], - hyperFormulaId + // Initialize with a default sheet if no data in DB + const [sheets, setSheets] = useState([]); + const [activeSheetId, setActiveSheetId] = useState(""); + const [isLoading, setIsLoading] = useState(true); + + // Load data from IndexedDB on initial render + useEffect(() => { + const loadFromDB = async () => { + try { + // Load sheets + let storedSheets = await db.getAllSheets(); + + // Check for duplicate Sheet 1 and clean up if needed + const sheet1Sheets = storedSheets.filter(sheet => sheet.name === "Sheet 1"); + if (sheet1Sheets.length > 1) { + console.log("Found duplicate Sheet 1 entries, cleaning up..."); + // Keep the sheet with data (if any) or the latest one + const sheetToKeep = sheet1Sheets.find(sheet => + sheet.data && + sheet.data.length > 0 && + sheet.data.some(row => row.some(cell => cell !== "")) + ) || sheet1Sheets[sheet1Sheets.length - 1]; + + // Delete all other duplicate Sheet 1 entries + for (const sheet of sheet1Sheets) { + if (sheet.id !== sheetToKeep.id) { + await db.deleteSheet(sheet.id); + } + } + + // Reload sheets after cleanup + storedSheets = await db.getAllSheets(); + } + + // Load cell values + const storedCellValues = await db.getAllCellValues(); + + // Load formulas + const storedFormulas = await db.getAllFormulas(); + + // If no sheets found, create a default sheet + if (storedSheets.length === 0) { + // Create first sheet and register with HyperFormula + const hyperFormulaId = 0; // First sheet in HyperFormula is 0 + const defaultSheet: Sheet = { + id: generateId(), + name: "Sheet 1", + data: [["", ""], ["", ""]], + hyperFormulaId + }; + + // Save to the database + await db.addSheet(defaultSheet); + + setSheets([defaultSheet]); + setActiveSheetId(defaultSheet.id); + } else { + // Use loaded sheets + setSheets(storedSheets); + + // Find active sheet in stored preferences or use the first sheet + const lastActiveSheetId = storedSheets[0].id; + setActiveSheetId(lastActiveSheetId); + } + + // Set cell values and formulas + setCellValuesState(storedCellValues); + setFormulaQueue(storedFormulas); + + setIsLoading(false); + } catch (error) { + console.error("Error loading data from IndexedDB:", error); + + // Fallback to default initialization + const hyperFormulaId = 0; + const defaultSheet: Sheet = { + id: generateId(), + name: "Sheet 1", + data: [["", ""], ["", ""]], + hyperFormulaId + }; + + setSheets([defaultSheet]); + setActiveSheetId(defaultSheet.id); + setIsLoading(false); + } }; - return [defaultSheet]; - }); - - const [activeSheetId, setActiveSheetId] = useState(() => sheets[0].id); + + loadFromDB(); + }, []); useEffect(() => { const nextEvaluatedValues = new Map(evaluatedValues); @@ -124,12 +201,94 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ setEvaluatedValues(nextEvaluatedValues); }, [formulaQueue, cellValues, activeSheetId, sheets]); + // Save formula queue changes to IndexedDB + useEffect(() => { + if (isLoading) return; + + const saveFormulas = async () => { + try { + // Use transactions to ensure this operation is atomic + await db.transaction('rw', db.formulaQueue, async () => { + // We'll use this set to track which formulas to keep + const formulaKeysToKeep = new Set(); + + // Process current formulas + formulaQueue.forEach((formula, cell) => { + formulaKeysToKeep.add(cell); + db.setFormula(cell, formula); + }); + + // Delete any formulas in the DB that are not in our current queue + const existingFormulas = await db.getAllFormulas(); + existingFormulas.forEach((formula, cell) => { + if (!formulaKeysToKeep.has(cell)) { + db.deleteFormula(cell); + } + }); + }); + } catch (error) { + console.error("Error saving formulas to IndexedDB:", error); + } + }; + + saveFormulas(); + }, [formulaQueue, isLoading]); + + // Save cell values changes to IndexedDB + useEffect(() => { + if (isLoading) return; + + const saveCellValues = async () => { + try { + await db.transaction('rw', db.cellValues, async () => { + // We'll use this set to track which cell values to keep + const cellKeysToKeep = new Set(); + + // Process current cell values + cellValues.forEach((value, cell) => { + cellKeysToKeep.add(cell); + db.setCellValue(cell, value); + }); + + // Optional: Delete any cell values in the DB that are not in our current set + // Only enable this if you want to completely sync DB with memory state + // This may cause performance issues if there are many cells + /* + const existingCellValues = await db.getAllCellValues(); + existingCellValues.forEach((value, cell) => { + if (!cellKeysToKeep.has(cell)) { + db.deleteCellValue(cell); + } + }); + */ + }); + } catch (error) { + console.error("Error saving cell values to IndexedDB:", error); + } + }; + + saveCellValues(); + }, [cellValues, isLoading]); + + // Save active sheet ID to IndexedDB + useEffect(() => { + if (isLoading || !activeSheetId) return; + + // No explicit save needed for active sheet ID as we're + // saving the sheet state independently + }, [activeSheetId, isLoading]); + const setFormula = (target: string, formula: string) => { setFormulaQueue((prev) => { const next = new Map(prev); next.set(target, formula); return next; }); + + // Save to IndexedDB + db.setFormula(target, formula).catch(error => { + console.error("Error saving formula to IndexedDB:", error); + }); }; const setFormulas = (updates: CellUpdate[]) => { @@ -147,6 +306,15 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ }); return next; }); + + // Save to IndexedDB + db.transaction('rw', db.formulaQueue, async () => { + for (const update of updates) { + await db.setFormula(update.target, update.formula); + } + }).catch(error => { + console.error("Error saving formulas to IndexedDB:", error); + }); }; const clearFormula = (target: string) => { @@ -155,6 +323,11 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ next.delete(target); return next; }); + + // Remove from IndexedDB + db.deleteFormula(target).catch(error => { + console.error("Error removing formula from IndexedDB:", error); + }); }; const setChartData = (chartData: any) => { @@ -163,6 +336,11 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ next.set("chart", JSON.stringify(chartData)); return next; }); + + // Save to IndexedDB + db.setFormula("chart", JSON.stringify(chartData)).catch(error => { + console.error("Error saving chart data to IndexedDB:", error); + }); }; const setCellValues = (updates: Map) => { @@ -173,6 +351,11 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ }); return next; }); + + // Save to IndexedDB + db.setCellValues(updates).catch(error => { + console.error("Error saving cell values to IndexedDB:", error); + }); }; const clearCellValues = (target: string) => { @@ -181,6 +364,11 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ next.delete(target); return next; }); + + // Remove from IndexedDB + db.deleteCellValue(target).catch(error => { + console.error("Error removing cell value from IndexedDB:", error); + }); }; // Sheet management functions @@ -207,6 +395,11 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ // Update state setSheets(prev => [...prev, newSheet]); setActiveSheetId(newSheet.id); + + // Save to IndexedDB + db.addSheet(newSheet).catch(error => { + console.error("Error saving new sheet to IndexedDB:", error); + }); } catch (error) { console.error("Error adding sheet:", error); } @@ -251,6 +444,11 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ // Update our state to reflect this sheet is now "removed" (but actually just hidden) setSheets(prev => prev.filter(sheet => sheet.id !== sheetId)); + + // Remove from IndexedDB + db.deleteSheet(sheetId).catch(error => { + console.error("Error removing sheet from IndexedDB:", error); + }); } } else { // For non-first sheets, we can remove them normally @@ -258,16 +456,31 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ // Update our state setSheets(prev => prev.filter(sheet => sheet.id !== sheetId)); + + // Remove from IndexedDB + db.deleteSheet(sheetId).catch(error => { + console.error("Error removing sheet from IndexedDB:", error); + }); } } else { // If the hyperFormulaId is invalid, just remove it from our state console.log("Sheet has invalid hyperFormulaId, removing from state only:", sheetToRemove); setSheets(prev => prev.filter(sheet => sheet.id !== sheetId)); + + // Remove from IndexedDB + db.deleteSheet(sheetId).catch(error => { + console.error("Error removing sheet from IndexedDB:", error); + }); } } catch (error) { console.error("Error removing sheet:", error); // Even if there's an error with HyperFormula, still remove from our state setSheets(prev => prev.filter(sheet => sheet.id !== sheetId)); + + // Remove from IndexedDB + db.deleteSheet(sheetId).catch(error => { + console.error("Error removing sheet from IndexedDB:", error); + }); } }; @@ -298,6 +511,12 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ : sheet ) ); + + // Update in IndexedDB + const updatedSheet = { ...sheetToRename, name: finalName }; + db.updateSheet(updatedSheet).catch(error => { + console.error("Error updating sheet name in IndexedDB:", error); + }); } } catch (error) { console.error("Error renaming sheet:", error); @@ -330,13 +549,19 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ clearHyperFormulaSheet(sheetToClear.hyperFormulaId); // Update our state with empty data - setSheets(prev => - prev.map(sheet => - sheet.id === sheetId - ? { ...sheet, data: [["", ""], ["", ""]] } - : sheet - ) + const emptyData = [["", ""], ["", ""]]; + const updatedSheets = sheets.map(sheet => + sheet.id === sheetId + ? { ...sheet, data: emptyData } + : sheet ); + setSheets(updatedSheets); + + // Update in IndexedDB + const updatedSheet = { ...sheetToClear, data: emptyData }; + db.updateSheet(updatedSheet).catch(error => { + console.error("Error updating cleared sheet in IndexedDB:", error); + }); } catch (error) { console.error("Error clearing sheet:", error); } @@ -353,13 +578,18 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ if (changes !== null) { // Update our state with the new data - setSheets(prev => - prev.map(sheet => - sheet.id === sheetId - ? { ...sheet, data } - : sheet - ) + const updatedSheets = sheets.map(sheet => + sheet.id === sheetId + ? { ...sheet, data } + : sheet ); + setSheets(updatedSheets); + + // Update in IndexedDB - ensure we update the existing record + const updatedSheet = { ...sheetToUpdate, data }; + db.updateSheet(updatedSheet).catch(error => { + console.error("Error updating sheet data in IndexedDB:", error); + }); } } catch (error) { console.error("Error updating sheet data:", error); @@ -380,6 +610,11 @@ export const SpreadsheetProvider: React.FC<{ children: React.ReactNode }> = ({ return sheets.find(sheet => sheet.name === name); }; + // Show loading state if still loading data from IndexedDB + if (isLoading) { + return
Loading spreadsheet data...
; + } + return ( ; + cellValues!: Table<{id: string, cell: string, value: any}>; + formulaQueue!: Table<{id: string, cell: string, formula: string}>; + chatHistory!: Table; + preferences!: Table<{id: string, key: string, value: any}>; + + constructor() { + super('ProblySpreadsheetDB'); + + // Define tables and indexes + this.version(1).stores({ + sheets: '++id, name, hyperFormulaId', // Primary key is id + cellValues: '++id, cell', // Store cell values with cell reference as index + formulaQueue: '++id, cell', // Store formulas with cell reference as index + chatHistory: '++id, timestamp', // Store chat history with timestamp as index + preferences: 'id, key' // Store user preferences + }); + } + + // Helper methods for sheets + async getAllSheets(): Promise { + return await this.sheets.toArray(); + } + + async getSheetById(id: string): Promise { + return await this.sheets.get(id); + } + + async getSheetByName(name: string): Promise { + return await this.sheets.where('name').equals(name).first(); + } + + async addSheet(sheet: Sheet): Promise { + return await this.sheets.add(sheet); + } + + async updateSheet(sheet: Sheet): Promise { + return await this.sheets.update(sheet.id, { + name: sheet.name, + data: sheet.data, + hyperFormulaId: sheet.hyperFormulaId + }); + } + + async deleteSheet(id: string): Promise { + await this.sheets.delete(id); + } + + // Helper methods for cell values + async getCellValue(cell: string): Promise { + const record = await this.cellValues.where('cell').equals(cell).first(); + return record?.value; + } + + async getAllCellValues(): Promise> { + const records = await this.cellValues.toArray(); + const map = new Map(); + records.forEach(record => { + map.set(record.cell, record.value); + }); + return map; + } + + async setCellValue(cell: string, value: any): Promise { + const existing = await this.cellValues.where('cell').equals(cell).first(); + if (existing) { + await this.cellValues.update(existing.id, { value }); + } else { + await this.cellValues.add({ id: `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, cell, value }); + } + } + + async setCellValues(values: Map): Promise { + await this.transaction('rw', this.cellValues, async () => { + const promises: Promise[] = []; + values.forEach((value, cell) => { + promises.push(this.setCellValue(cell, value)); + }); + await Promise.all(promises); + }); + } + + async deleteCellValue(cell: string): Promise { + await this.cellValues.where('cell').equals(cell).delete(); + } + + // Helper methods for formula queue + async getFormula(cell: string): Promise { + const record = await this.formulaQueue.where('cell').equals(cell).first(); + return record?.formula; + } + + async getAllFormulas(): Promise> { + const records = await this.formulaQueue.toArray(); + const map = new Map(); + records.forEach(record => { + map.set(record.cell, record.formula); + }); + return map; + } + + async setFormula(cell: string, formula: string): Promise { + const existing = await this.formulaQueue.where('cell').equals(cell).first(); + if (existing) { + await this.formulaQueue.update(existing.id, { formula }); + } else { + await this.formulaQueue.add({ id: `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, cell, formula }); + } + } + + async deleteFormula(cell: string): Promise { + await this.formulaQueue.where('cell').equals(cell).delete(); + } + + // Helper methods for chat history + async getChatHistory(): Promise { + return await this.chatHistory.orderBy('timestamp').toArray(); + } + + async addChatMessage(message: ChatMessage): Promise { + return await this.chatHistory.add(message); + } + + async updateChatMessage(message: ChatMessage): Promise { + return await this.chatHistory.update(message.id, { + text: message.text, + response: message.response, + timestamp: message.timestamp, + status: message.status, + updates: message.updates, + chartData: message.chartData, + analysis: message.analysis, + hasImage: message.hasImage, + documentImage: message.documentImage + }); + } + + async clearChatHistory(): Promise { + await this.chatHistory.clear(); + } + + // Helper methods for preferences + async getPreference(key: string): Promise { + const record = await this.preferences.where('key').equals(key).first(); + return record?.value; + } + + async setPreference(key: string, value: any): Promise { + const id = `pref_${key}`; + const existing = await this.preferences.get(id); + if (existing) { + await this.preferences.update(id, { value }); + } else { + await this.preferences.add({ id, key, value }); + } + } + + // Clear all data for a fresh start + async clearDatabase(): Promise { + await this.transaction('rw', [this.sheets, this.cellValues, this.formulaQueue, this.chatHistory, this.preferences], async () => { + await this.sheets.clear(); + await this.cellValues.clear(); + await this.formulaQueue.clear(); + await this.chatHistory.clear(); + await this.preferences.clear(); + }); + } + + // Special method to clean up duplicate sheets + async cleanupDuplicateSheets(): Promise { + try { + // Get all sheets + const sheets = await this.sheets.toArray(); + + // Group sheets by name + const sheetsByName: Record = {}; + sheets.forEach(sheet => { + if (!sheetsByName[sheet.name]) { + sheetsByName[sheet.name] = []; + } + sheetsByName[sheet.name].push(sheet); + }); + + // Find duplicate names + const duplicateNames = Object.keys(sheetsByName).filter(name => + sheetsByName[name].length > 1 + ); + + if (duplicateNames.length === 0) { + console.log("No duplicate sheets found."); + return 0; + } + + console.log(`Found ${duplicateNames.length} sheet names with duplicates: ${duplicateNames.join(', ')}`); + + // Clean up duplicates - we'll keep the sheet with actual data or the most recent one + let removedCount = 0; + + await this.transaction('rw', this.sheets, async () => { + for (const name of duplicateNames) { + const duplicates = sheetsByName[name]; + + // Find sheet with actual data (non-empty cells) + const sheetWithData = duplicates.find(sheet => + sheet.data && + sheet.data.length > 0 && + sheet.data.some(row => row.some(cell => cell !== "")) + ); + + // If no sheet has data, keep the most recent one (assuming ID is timestamp-based) + const sheetToKeep = sheetWithData || + duplicates.sort((a, b) => b.id.localeCompare(a.id))[0]; + + // Delete all duplicates except the one to keep + for (const sheet of duplicates) { + if (sheet.id !== sheetToKeep.id) { + await this.sheets.delete(sheet.id); + removedCount++; + } + } + } + }); + + console.log(`Cleaned up ${removedCount} duplicate sheets.`); + return removedCount; + } catch (error) { + console.error("Error cleaning up duplicate sheets:", error); + return 0; + } + } +} + +// Create a singleton instance of the database +export const db = new SpreadsheetDatabase(); + +// Export the database class type for type checking +export type { SpreadsheetDatabase }; \ No newline at end of file