diff --git a/src/app/npg-lite/page.tsx b/src/app/npg-lite/page.tsx index 6267f48..cdf19d8 100644 --- a/src/app/npg-lite/page.tsx +++ b/src/app/npg-lite/page.tsx @@ -47,7 +47,8 @@ import { BatteryLow, BatteryMedium, BatteryFull, - BatteryWarning + BatteryWarning, + Loader2 } from "lucide-react"; import { lightThemeColors, darkThemeColors, getCustomColor } from '@/components/Colors'; import { useTheme } from "next-themes"; @@ -104,6 +105,13 @@ const NPG_Ble = () => { const [deviceName, setDeviceName] = useState(""); // Store device name for UI const [refreshKey, setRefreshKey] = useState(0); // Force re-render when config changes + // Loading states for various operations + const [isProcessingRecording, setIsProcessingRecording] = useState(false); + const [isDownloadingAll, setIsDownloadingAll] = useState(false); + const [isDownloadingFile, setIsDownloadingFile] = useState<{ [key: string]: boolean }>({}); + const [isDeletingFile, setIsDeletingFile] = useState<{ [key: string]: boolean }>({}); + const [isDeletingAll, setIsDeletingAll] = useState(false); + // Derive channel names from device config const channelNames = useMemo(() => Array.from({ length: deviceConfig.maxChannels }, (_, i) => `CH${i + 1}`), @@ -198,7 +206,6 @@ const NPG_Ble = () => { } container.appendChild(canvasWrapper); - // Create canvasElements for each selected channel selectedChannels.forEach((channelNumber) => { const canvasWrapper = document.createElement("div"); @@ -223,7 +230,6 @@ const NPG_Ble = () => { newWglPlots.push(wglp); wglp.gScaleY = Zoom; - const line = new WebglLine(getLineColor(channelNumber, theme), dataPointCountRef.current); wglp.gOffsetY = 0; line.offsetY = 0; @@ -252,7 +258,6 @@ const NPG_Ble = () => { return new ColorRGBA(r, g, b, alpha); }; - const handleSelectAllToggle = () => { const enabledChannels = Array.from({ length: deviceConfig.maxChannels }, (_, i) => i + 1); @@ -278,7 +283,6 @@ const NPG_Ble = () => { const [refresh, setRefresh] = useState(0); - useEffect(() => { createCanvasElements(); setRefresh(r => r + 1); @@ -483,7 +487,6 @@ const NPG_Ble = () => { channelData.push(sampleCounter); // Process the correct number of channels based on device configuration - for (let channel = 0; channel < config.maxChannels; channel++) { const sample = dataView.getInt16(1 + (channel * 2), false); channelData.push( @@ -618,7 +621,7 @@ const NPG_Ble = () => { dataChar.addEventListener("characteristicvaluechanged", handleNotification); setIsConnected(true); setIsLoading(false); - + getFileCountFromIndexedDB(); // Clear any existing interval first if (disconnectIntervalRef.current) { clearInterval(disconnectIntervalRef.current); @@ -644,7 +647,6 @@ const NPG_Ble = () => { } } - async function disconnect(): Promise { try { setIsLoading(true); // Show loading while disconnecting @@ -706,6 +708,7 @@ const NPG_Ble = () => { setIsConnected(false); setBatteryLevel(null); setDeviceName(""); + getFileCountFromIndexedDB(); // Reset device configuration to defaults deviceConfigRef.current = defaultConfig; @@ -753,6 +756,64 @@ const NPG_Ble = () => { workerRef.current = new Worker(new URL('../../../workers/indexedDBWorker.ts', import.meta.url), { type: 'module', }); + + // Set up worker message handler + workerRef.current.onmessage = (event) => { + const { action, filename, success, blob, error, allData } = event.data; + + switch (action) { + case 'writeComplete': + console.log(`Write completed for ${filename}: ${success}`); + break; + + case 'getFileCountFromIndexedDB': + if (allData) { + setDatasets(allData); + } + break; + + case 'saveDataByFilename': + if (blob) { + saveAs(blob, filename); + toast.success(`File "${filename}" downloaded successfully.`); + setIsDownloadingFile(prev => ({ ...prev, [filename]: false })); + } else if (error) { + console.error("Download error:", error); + toast.error(`Error downloading file: ${error}`); + setIsDownloadingFile(prev => ({ ...prev, [filename]: false })); + } + break; + + case 'saveAsZip': + if (blob) { + saveAs(blob, 'ChordsWeb.zip'); + toast.success("All files downloaded successfully as ZIP."); + setIsDownloadingAll(false); + } else if (error) { + console.error("ZIP creation error:", error); + toast.error(`Error creating ZIP file: ${error}`); + setIsDownloadingAll(false); + } + break; + + case 'deleteFile': + if (success) { + toast.success(`File '${filename}' deleted successfully.`); + setIsDeletingFile(prev => ({ ...prev, [filename]: false })); + // Refresh datasets after deletion + getFileCountFromIndexedDB(); + } + break; + + case 'deleteAll': + if (success) { + toast.success(`All files deleted successfully.`); + setIsDeletingAll(false); + setDatasets([]); + } + break; + } + }; } }; @@ -766,131 +827,95 @@ const NPG_Ble = () => { action: 'setSelectedChannels', selectedChannels: selectedChannels, }); - }; useEffect(() => { setSelectedChannelsInWorker(selectedChannels); }, [selectedChannels]); - const processBuffer = async (bufferIndex: number, canvasCount: number, selectChannel: number[]) => { - if (!workerRef.current) { - initializeWorker(); - } + const processBuffer = async (bufferIndex: number, canvasCount: number, selectChannel: number[]): Promise => { + return new Promise((resolve) => { + if (!workerRef.current) { + initializeWorker(); + } - // If the buffer is empty, return early - if (recordingBuffers[bufferIndex].length === 0) return; + // If the buffer is empty, return early + if (recordingBuffers[bufferIndex].length === 0) { + resolve(); + return; + } - const data = recordingBuffers[bufferIndex]; - const filename = currentFileNameRef.current; + const data = recordingBuffers[bufferIndex]; + const filename = currentFileNameRef.current; - if (filename) { - // Check if the record already exists - workerRef.current?.postMessage({ action: 'checkExistence', filename, canvasCount, selectChannel }); - writeToIndexedDB(data, filename, canvasCount, selectChannel); - } - }; + if (filename) { + const handleMessage = (event: MessageEvent) => { + const { action: msgAction, success: msgSuccess, filename: completedFilename } = event.data; + if (msgAction === 'writeComplete' && completedFilename === filename) { + workerRef.current?.removeEventListener('message', handleMessage); + resolve(); + } + }; - const writeToIndexedDB = (data: number[][], filename: string, canvasCount: number, selectChannel: number[]) => { - workerRef.current?.postMessage({ action: 'write', data, filename, canvasCount, selectChannel }); + workerRef.current?.addEventListener('message', handleMessage); + workerRef.current?.postMessage({ + action: 'write', + data, + filename, + canvasCount, + selectChannel + }); + } else { + resolve(); + } + }); }; const saveAllDataAsZip = async () => { try { + setIsDownloadingAll(true); if (workerRef.current) { workerRef.current.postMessage({ action: 'saveAsZip', - canvasElementCount: canvasElementCountRef.current, // Assign with a key + canvasElementCount: canvasElementCountRef.current, selectedChannels }); - - workerRef.current.onmessage = async (event) => { - const { zipBlob, error } = event.data; - - if (zipBlob) { - saveAs(zipBlob, 'ChordsWeb.zip'); - } else if (error) { - console.error(error); - } - }; } } catch (error) { console.error('Error while saving ZIP file:', error); + toast.error('Error creating ZIP file'); + setIsDownloadingAll(false); } }; // Function to handle saving data by filename const saveDataByFilename = async (filename: string, canvasCount: number, selectChannel: number[]) => { if (workerRef.current) { - workerRef.current.postMessage({ action: "saveDataByFilename", filename, canvasCount, selectChannel }); - workerRef.current.onmessage = (event) => { - const { blob, error } = event.data; - - if (blob) { - saveAs(blob, filename); // FileSaver.js - toast.success("File downloaded successfully."); - } else (error: any) => { - console.error("Worker error:", error); - toast.error(`Error during file download: ${error.message}`); - } - }; - - workerRef.current.onerror = (error) => { - console.error("Worker error:", error); - toast.error("An unexpected worker error occurred."); - }; + setIsDownloadingFile(prev => ({ ...prev, [filename]: true })); + workerRef.current.postMessage({ + action: "saveDataByFilename", + filename, + canvasCount, + selectChannel + }); } else { console.error("Worker reference is null."); toast.error("Worker is not available."); } - }; const deleteFileByFilename = async (filename: string) => { if (!workerRef.current) initializeWorker(); - return new Promise((resolve, reject) => { - workerRef.current?.postMessage({ action: 'deleteFile', filename }); - - workerRef.current!.onmessage = (event) => { - const { success, action, error } = event.data; - - if (action === 'deleteFile') { - if (success) { - toast.success(`File '${filename}' deleted successfully.`); - - setDatasets((prev) => prev.filter((file) => file !== filename)); // Update datasets - resolve(); - } else { - console.error(`Failed to delete file '${filename}': ${error}`); - reject(new Error(error)); - } - } - }; - }); + setIsDeletingFile(prev => ({ ...prev, [filename]: true })); + workerRef.current?.postMessage({ action: 'deleteFile', filename }); }; const deleteAllDataFromIndexedDB = async () => { if (!workerRef.current) initializeWorker(); - return new Promise((resolve, reject) => { - workerRef.current?.postMessage({ action: 'deleteAll' }); - - workerRef.current!.onmessage = (event) => { - const { success, action, error } = event.data; - - if (action === 'deleteAll') { - if (success) { - toast.success(`All files deleted successfully.`); - setDatasets([]); // Clear all datasets from state - resolve(); - } else { - console.error('Failed to delete all files:', error); - reject(new Error(error)); - } - } - }; - }); + setIsDeletingAll(true); + workerRef.current?.postMessage({ action: 'deleteAll' }); }; const handleTimeSelection = (minutes: number | null) => { @@ -914,8 +939,7 @@ const NPG_Ble = () => { const handleRecord = async () => { if (isRecordingRef.current) { // Stop the recording if it is currently active - stopRecording(); - + await stopRecording(); } else { // Start a new recording session isRecordingRef.current = true; @@ -935,47 +959,50 @@ const NPG_Ble = () => { toast.error("Recording start time was not captured."); return; } + isRecordingRef.current = false; setRecordingElapsedTime(0); setIsrecord(true); + setIsProcessingRecording(true); recordingStartTimeRef.current = 0; existingRecordRef.current = undefined; - // Re-fetch datasets from IndexedDB after recording stops - const fetchData = async () => { - const data = await getFileCountFromIndexedDB(); - setDatasets(data); // Update datasets with the latest data - }; - // Call fetchData after stopping the recording - fetchData(); + + // Process any remaining data in the buffer + if (fillingindex.current > 0) { + // Create a copy of the current buffer data + const remainingData = recordingBuffers[activeBufferIndex].slice(0, fillingindex.current); + recordingBuffers[activeBufferIndex] = remainingData; + + // Process the remaining buffer + await processBuffer(activeBufferIndex, canvasElementCountRef.current, selectedChannels); + } + + // Clear buffers after processing + recordingBuffers.forEach(buffer => buffer.length = 0); + activeBufferIndex = 0; + fillingindex.current = 0; + + // Fetch updated datasets + await getFileCountFromIndexedDB(); + + setIsProcessingRecording(false); + toast.success("Recording saved successfully!"); }; - const getFileCountFromIndexedDB = async (): Promise => { + const getFileCountFromIndexedDB = async (): Promise => { if (!workerRef.current) { initializeWorker(); } - return new Promise((resolve, reject) => { - if (workerRef.current) { - workerRef.current.postMessage({ action: 'getFileCountFromIndexedDB' }); - - workerRef.current.onmessage = (event) => { - if (event.data.allData) { - resolve(event.data.allData); - } else if (event.data.error) { - reject(event.data.error); - } - }; - - workerRef.current.onerror = (error) => { - reject(`Error in worker: ${error.message}`); - }; - } else { - reject('Worker is not initialized'); - } - }); + workerRef.current?.postMessage({ action: 'getFileCountFromIndexedDB' }); }; + // Initial load of datasets + useEffect(() => { + getFileCountFromIndexedDB(); + }, []); + const handlecustomTimeInputChange = (e: React.ChangeEvent) => { // Update custom time input with only numeric values setCustomTimeInput(e.target.value.replace(/\D/g, "")); @@ -1003,7 +1030,6 @@ const NPG_Ble = () => { return `${hours}:${minutes}:${seconds}`; }; - useEffect(() => { const enabledChannels = Array.from({ length: deviceConfig.maxChannels }, (_, i) => i + 1); @@ -1123,19 +1149,17 @@ const NPG_Ble = () => { } }, [wglPlots, pauseRef.current]); - useEffect(() => { requestAnimationFrame(animate); - }, [animate, Zoom]); // Function to get battery icon based on level const getBatteryIcon = (level: number | null) => { if (level === null) return ; - if (level <= 10) return ; if (level <= 20.0 && level > 10.0) return ; if (level <= 70.0 && level > 20.0) return ; @@ -1172,7 +1196,6 @@ const NPG_Ble = () => { clearInterval(intervalId); } }; - }, [batteryLevel]); // Function to get battery color based on level @@ -1187,7 +1210,6 @@ const NPG_Ble = () => { return (
-
@@ -1269,8 +1291,6 @@ const NPG_Ble = () => {
)} - - {/* Center-aligned buttons */} @@ -1303,7 +1323,6 @@ const NPG_Ble = () => { )} - @@ -1313,7 +1332,6 @@ const NPG_Ble = () => {
- @@ -1333,7 +1351,6 @@ const NPG_Ble = () => { -
{/* Record button with tooltip */} @@ -1343,10 +1360,11 @@ const NPG_Ble = () => {
+ {/* Processing indicator for recording */} + {isProcessingRecording && ( +
+ + Saving recording... +
+ )} + {/* List each file with download and delete actions */} {datasets.length > 0 ? ( datasets.map((dataset) => (
- {/* Display the filename directly */} - + {dataset} @@ -1389,40 +1415,74 @@ const NPG_Ble = () => { {/* Delete file by filename */} -
)) ) : ( -

No datasets available

+

No datasets available

)} + {/* Download all as ZIP and delete all options */} {datasets.length > 0 && (
)} @@ -1431,6 +1491,7 @@ const NPG_Ble = () => { + {/* filters */} { {/* Battery display when connected and has battery */} - - - {!isConnected ? (

Device Not Connected!

@@ -1855,11 +1912,10 @@ const NPG_Ble = () => { {isOldfirmwareRef.current ? (

Old Firmware Detected

-

Update firmware using @@ -1891,16 +1947,12 @@ const NPG_Ble = () => { )} )} - - -

); - } export default NPG_Ble; \ No newline at end of file diff --git a/workers/indexedDBWorker.ts b/workers/indexedDBWorker.ts index 16d694a..dfd5403 100644 --- a/workers/indexedDBWorker.ts +++ b/workers/indexedDBWorker.ts @@ -3,409 +3,611 @@ import JSZip from 'jszip'; // Global variables let canvasCount = 0; let selectedChannels: number[] = []; +const CHUNK_SIZE = 1000; // Store data in chunks of 1000 arrays self.onmessage = async (event) => { - const { action, data, filename, selectedChannels: channels } = event.data; - - // Open IndexedDB - const db = await openIndexedDB(); - - const handlePostMessage = (message: any) => { - self.postMessage(message); - }; - - const handleError = (error: string) => { - handlePostMessage({ error }); - }; - - switch (action) { - case 'setCanvasCount': - canvasCount = event.data.canvasCount; - handlePostMessage({ success: true, message: 'Canvas count updated' }); - break; - - case 'setSelectedChannels': - if (Array.isArray(channels) && channels.every((ch) => typeof ch === 'number')) { - selectedChannels = channels; - handlePostMessage({ success: true, message: 'Selected channels updated' }); - } else { - console.error('Invalid selectedChannels received:', channels); - handlePostMessage({ success: false, message: 'Invalid selectedChannels format' }); - } - break; - - case 'write': - try { - const success = await writeToIndexedDB(db, data, filename); - handlePostMessage({ success }); - } catch (error) { - handleError('Failed to write data to IndexedDB'); - } - break; - - case 'getFileCountFromIndexedDB': - try { - const dataMethod = action === 'getAllData' ? getAllDataFromIndexedDB : getFileCountFromIndexedDB; - const allData = await dataMethod(db); - handlePostMessage({ allData }); - } catch (error) { - handleError('Failed to retrieve data from IndexedDB'); - } - break; - - case 'saveAsZip': - try { - const zipBlob = await saveAllDataAsZip(canvasCount, selectedChannels); - handlePostMessage({ zipBlob }); - } catch (error) { - handleError('Failed to create ZIP file'); - } - break; - - case 'saveDataByFilename': - try { - const blob = await saveDataByFilename(filename, canvasCount, selectedChannels); - handlePostMessage({ blob }); - } catch (error) { - handleError(error instanceof Error ? error.message : 'Unknown error'); - } - - break; - - case 'deleteFile': - if (!filename) { - throw new Error('Filename is required for deleteFile action.'); - } - await deleteFilesByFilename(filename); - handlePostMessage({ success: true, action: 'deleteFile' }); - break; - - case 'deleteAll': - await deleteAllDataFromIndexedDB(); - handlePostMessage({ success: true, action: 'deleteAll' }); - break; - - - - default: - handlePostMessage({ error: 'Invalid action' }); - } -}; + const { action, data, filename, selectedChannels: channels, canvasElementCount } = event.data; -// Function to open IndexedDB -const openIndexedDB = async (): Promise => { - return new Promise((resolve, reject) => { - const request = indexedDB.open("ChordsRecordings", 2); + // Open IndexedDB + const db = await openIndexedDB(); + + const handlePostMessage = (message: any) => { + self.postMessage(message); + }; - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - const store = db.createObjectStore("ChordsRecordings", { keyPath: "filename" }); - store.createIndex("filename", "filename", { unique: true }); + const handleError = (error: string) => { + handlePostMessage({ error }); }; - request.onsuccess = (event) => resolve((event.target as IDBOpenDBRequest).result); - request.onerror = (event) => reject((event.target as IDBOpenDBRequest).error); - }); + switch (action) { + case 'setCanvasCount': + canvasCount = event.data.canvasCount; + handlePostMessage({ success: true, message: 'Canvas count updated' }); + break; + + case 'setSelectedChannels': + if (Array.isArray(channels) && channels.every((ch) => typeof ch === 'number')) { + selectedChannels = channels; + handlePostMessage({ success: true, message: 'Selected channels updated' }); + } else { + console.error('Invalid selectedChannels received:', channels); + handlePostMessage({ success: false, message: 'Invalid selectedChannels format' }); + } + break; + + case 'write': + try { + const success = await writeToIndexedDB(db, data, filename); + handlePostMessage({ + action: 'writeComplete', + filename, + success + }); + } catch (error) { + handleError('Failed to write data to IndexedDB'); + } + break; + + case 'getFileCountFromIndexedDB': + try { + const filenames = await getFileCountFromIndexedDB(db); + handlePostMessage({ + action: 'getFileCountFromIndexedDB', + allData: filenames + }); + } catch (error) { + handleError('Failed to retrieve data from IndexedDB'); + } + break; + + case 'saveAsZip': + try { + const zipBlob = await saveAllDataAsZip(canvasElementCount || canvasCount, selectedChannels); + handlePostMessage({ + action: 'saveAsZip', + blob: zipBlob + }); + } catch (error) { + handleError('Failed to create ZIP file'); + } + break; + + case 'saveDataByFilename': + try { + const blob = await saveDataByFilename(filename, canvasCount, selectedChannels); + handlePostMessage({ + action: 'saveDataByFilename', + blob, + filename + }); + } catch (error) { + handleError(error instanceof Error ? error.message : 'Unknown error'); + } + break; + + case 'deleteFile': + if (!filename) { + throw new Error('Filename is required for deleteFile action.'); + } + await deleteFilesByFilename(filename); + handlePostMessage({ + success: true, + action: 'deleteFile', + filename + }); + break; + + case 'deleteAll': + await deleteAllDataFromIndexedDB(); + handlePostMessage({ + success: true, + action: 'deleteAll' + }); + break; + + default: + handlePostMessage({ error: 'Invalid action' }); + } +}; + +// Interface for metadata +interface FileMetadata { + filename: string; + totalChunks: number; + totalRecords: number; + lastUpdated: Date; + created: Date; +} + +// Function to open IndexedDB +const openIndexedDB = async (): Promise => { + return new Promise((resolve, reject) => { + const request = indexedDB.open("ChordsRecordings", 3); // Version bump + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Create metadata store + if (!db.objectStoreNames.contains("FileMetadata")) { + const metadataStore = db.createObjectStore("FileMetadata", { keyPath: "filename" }); + metadataStore.createIndex("filename", "filename", { unique: true }); + } + + // Create data chunks store with composite key + if (!db.objectStoreNames.contains("DataChunks")) { + const chunksStore = db.createObjectStore("DataChunks", { + keyPath: ["filename", "chunkIndex"] + }); + chunksStore.createIndex("byFilename", "filename", { unique: false }); + } + }; + + request.onsuccess = (event) => resolve((event.target as IDBOpenDBRequest).result); + request.onerror = (event) => reject((event.target as IDBOpenDBRequest).error); + }); }; // Helper function for IndexedDB transactions const performIndexDBTransaction = async ( - db: IDBDatabase, - storeName: string, - mode: IDBTransactionMode, - callback: (store: IDBObjectStore) => Promise + db: IDBDatabase, + storeName: string, + mode: IDBTransactionMode, + callback: (store: IDBObjectStore) => Promise ): Promise => { - const tx = db.transaction(storeName, mode); - const store = tx.objectStore(storeName); - - try { - return await callback(store); // Await the callback directly - } catch (error) { - throw new Error(`Transaction failed: ${error}`); - } + const tx = db.transaction(storeName, mode); + const store = tx.objectStore(storeName); + + try { + return await callback(store); + } catch (error) { + throw new Error(`Transaction failed: ${error}`); + } }; -// Function to write data to IndexedDB -const writeToIndexedDB = async ( - db: IDBDatabase, - data: number[][], - filename: string -): Promise => { - try { - const existingRecord = await performIndexDBTransaction(db, "ChordsRecordings", "readwrite", (store) => { - return new Promise((resolve, reject) => { - const getRequest = store.get(filename); - getRequest.onsuccess = () => resolve(getRequest.result); - getRequest.onerror = () => reject(new Error("Error retrieving record")); - }); +// Function to get or create file metadata +const getFileMetadata = async (db: IDBDatabase, filename: string): Promise => { + return performIndexDBTransaction(db, "FileMetadata", "readonly", (store) => { + return new Promise((resolve, reject) => { + const request = store.get(filename); + request.onsuccess = () => { + if (request.result) { + resolve(request.result); + } else { + // Create default metadata + resolve({ + filename, + totalChunks: 0, + totalRecords: 0, + lastUpdated: new Date(), + created: new Date() + }); + } + }; + request.onerror = () => reject(request.error); + }); }); +}; - if (existingRecord) { - existingRecord.content.push(...data); - await performIndexDBTransaction(db, "ChordsRecordings", "readwrite", (store) => { +// Function to update file metadata +const updateFileMetadata = async (db: IDBDatabase, metadata: FileMetadata): Promise => { + return performIndexDBTransaction(db, "FileMetadata", "readwrite", (store) => { return new Promise((resolve, reject) => { - const putRequest = store.put(existingRecord); - putRequest.onsuccess = () => resolve(); - putRequest.onerror = () => reject(new Error("Error updating record")); + const request = store.put(metadata); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); }); - }); - } else { - const newRecord = { filename, content: [...data] }; - await performIndexDBTransaction(db, "ChordsRecordings", "readwrite", (store) => { - return new Promise((resolve, reject) => { - const putRequest = store.put(newRecord); - putRequest.onsuccess = () => resolve(); - putRequest.onerror = () => reject(new Error("Error inserting record")); - }); - }); + }); +}; + +// Function to write data to IndexedDB (optimized with chunking) +const writeToIndexedDB = async ( + db: IDBDatabase, + data: number[][], + filename: string +): Promise => { + try { + // Get or create metadata + const metadata = await getFileMetadata(db, filename); + + // Calculate which chunks we need to write + const startIndex = metadata.totalRecords; + const endIndex = startIndex + data.length; + const startChunk = Math.floor(startIndex / CHUNK_SIZE); + const endChunk = Math.floor((endIndex - 1) / CHUNK_SIZE); + + // Process each chunk + for (let chunkIndex = startChunk; chunkIndex <= endChunk; chunkIndex++) { + const chunkStart = chunkIndex * CHUNK_SIZE; + const chunkEnd = chunkStart + CHUNK_SIZE; + + // Calculate what portion of data goes into this chunk + const dataStart = Math.max(0, chunkStart - startIndex); + const dataEnd = Math.min(data.length, chunkEnd - startIndex); + + if (dataStart >= dataEnd) continue; + + const chunkData = data.slice(dataStart, dataEnd); + + // Get existing chunk or create new + const existingChunk = await performIndexDBTransaction( + db, + "DataChunks", + "readwrite", + (store) => { + return new Promise((resolve, reject) => { + const key = [filename, chunkIndex]; + const request = store.get(key); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + ); + + if (existingChunk) { + // Append to existing chunk + existingChunk.data.push(...chunkData); + + await performIndexDBTransaction( + db, + "DataChunks", + "readwrite", + (store) => { + return new Promise((resolve, reject) => { + const request = store.put(existingChunk); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + ); + } else { + // Create new chunk + const newChunk = { + filename, + chunkIndex, + data: chunkData + }; + + await performIndexDBTransaction( + db, + "DataChunks", + "readwrite", + (store) => { + return new Promise((resolve, reject) => { + const request = store.put(newChunk); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + ); + } + } + + // Update metadata + metadata.totalRecords += data.length; + metadata.totalChunks = Math.ceil(metadata.totalRecords / CHUNK_SIZE); + metadata.lastUpdated = new Date(); + + await updateFileMetadata(db, metadata); + + return true; + } catch (error) { + console.error("Error writing to IndexedDB:", error); + return false; } +}; - return true; - } catch (error) { - console.error("Error writing to IndexedDB:", error); - return false; - } +// Function to read all data for a file +// Helper: merge two data arrays +const mergeArrays = (a: number[][], b: number[][]): number[][] => { + if (!a || a.length === 0) return b || []; + if (!b || b.length === 0) return a || []; + return [...a, ...b]; }; +// Function to read all data for a file (tree-style merging) +const readFileData = async ( + db: IDBDatabase, + filename: string +): Promise => { + const metadata = await getFileMetadata(db, filename); -// Function to get all data from IndexedDB -const getAllDataFromIndexedDB = async (db: IDBDatabase): Promise => { - try { - return await performIndexDBTransaction(db, "ChordsRecordings", "readonly", (store) => { - return new Promise((resolve, reject) => { - const request = store.getAll(); - request.onsuccess = () => resolve(request.result); - request.onerror = (error) => reject(new Error(`Error retrieving data: ${error}`)); - }); - }); - } catch (error) { - console.error("Error retrieving data from IndexedDB:", error); - throw error; - } + if (!metadata || metadata.totalChunks === 0) { + return []; + } + + // Step 1: Load all chunks into an array + let chunkBuffers: number[][][] = []; + + for (let chunkIndex = 0; chunkIndex < metadata.totalChunks; chunkIndex++) { + const chunk = await performIndexDBTransaction( + db, + "DataChunks", + "readonly", + (store) => { + return new Promise((resolve, reject) => { + const key = [filename, chunkIndex]; + const request = store.get(key); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + ); + + if (chunk && Array.isArray(chunk.data)) { + chunkBuffers.push(chunk.data); + } + } + + // Step 2: Tree-style pairwise merge + while (chunkBuffers.length > 1) { + const nextLevel: number[][][] = []; + + for (let i = 0; i < chunkBuffers.length; i += 2) { + if (i + 1 < chunkBuffers.length) { + // merge pairs + const merged = mergeArrays(chunkBuffers[i], chunkBuffers[i + 1]); + nextLevel.push(merged); + } else { + // odd chunk, carry forward + nextLevel.push(chunkBuffers[i]); + } + } + + chunkBuffers = nextLevel; + } + + // Final merged result + return chunkBuffers[0] || []; }; // Function to convert data to CSV const convertToCSV = (data: any[], canvasCount: number, selectedChannels: number[]): string => { - if (!Array.isArray(data) || data.length === 0) return ""; + if (!Array.isArray(data) || data.length === 0) return ""; - // Generate the header dynamically for the selected channels - const header = ["Counter", ...selectedChannels.map((channel) => `Channel${channel}`)]; + // Generate the header dynamically for the selected channels + const header = ["Counter", ...selectedChannels.map((channel) => `Channel${channel}`)]; - // Create rows by filtering and mapping valid data const rows = data - .filter((item, index) => { - // Ensure each item is an array and has valid data - if (!item || !Array.isArray(item) || item.length === 0) { - console.warn(`Skipping invalid data at index ${index}:`, item); - return false; - } - return true; - }) - .map((item, index) => { - // Generate filtered row with Counter and selected channel data - const filteredRow = [ - item[0], // Counter - ...selectedChannels.map((channel, i) => { - if (channel) { - - return item[i + 1]; - } else { - console.warn(`Missing data for channel ${channel} in item ${index}:`, item); - return ""; // Default empty value for missing data - } - }), - ]; - - return filteredRow - .map((field) => (field !== undefined && field !== null ? JSON.stringify(field) : "")) // Ensure proper formatting - .join(","); - }); - - // Combine header and rows into a CSV format - const csvContent = [header.join(","), ...rows].join("\n"); + .filter((item, index) => { + if (!item || !Array.isArray(item) || item.length === 0) { + console.warn(`Skipping invalid data at index ${index}:`, item); + return false; + } + return true; + }) + .map((item, index) => { + const filteredRow = [ + item[0], // Counter + ...selectedChannels.map((channel, i) => { + if (channel && item[i + 1] !== undefined) { + return item[i + 1]; + } else { + console.warn(`Missing data for channel ${channel} in item ${index}:`, item); + return ""; + } + }), + ]; + + return filteredRow + .map((field) => (field !== undefined && field !== null ? JSON.stringify(field) : "")) + .join(","); + }); - return csvContent; + const csvContent = [header.join(","), ...rows].join("\n"); + return csvContent; }; // Function to save all data as a ZIP file const saveAllDataAsZip = async (canvasCount: number, selectedChannels: number[]): Promise => { - try { - const db = await openIndexedDB(); - - const allData = await performIndexDBTransaction(db, "ChordsRecordings", "readonly", (store) => { - return new Promise((resolve, reject) => { - const request = store.getAll(); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - }); - - if (!allData || allData.length === 0) { - throw new Error("No data available to download."); - } - - const zip = new JSZip(); + try { + const db = await openIndexedDB(); + + const allMetadata = await performIndexDBTransaction( + db, + "FileMetadata", + "readonly", + (store) => { + return new Promise((resolve, reject) => { + const request = store.getAll(); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + ); + + if (!allMetadata || allMetadata.length === 0) { + throw new Error("No data available to download."); + } - allData.forEach((record) => { - try { - const csvData = convertToCSV(record.content, canvasCount, selectedChannels); - zip.file(record.filename, csvData); - } catch (error) { - console.error(`Error processing record ${record.filename}:`, error); - } - }); + const zip = new JSZip(); - // Worker must not access UI. Return the blob to the main thread instead. + for (const metadata of allMetadata) { + try { + const content = await readFileData(db, metadata.filename); + const csvData = convertToCSV(content, canvasCount, selectedChannels); + zip.file(metadata.filename, csvData); + } catch (error) { + console.error(`Error processing record ${metadata.filename}:`, error); + } + } - const content = await zip.generateAsync({ type: "blob" }); - return content; - } catch (error) { - console.error("Error creating ZIP file:", error); - throw error; - } + const content = await zip.generateAsync({ type: "blob" }); + return content; + } catch (error) { + console.error("Error creating ZIP file:", error); + throw error; + } }; // Function to save data by filename const saveDataByFilename = async ( - filename: string, - canvasCount: number, - selectedChannels: number[] + filename: string, + canvasCount: number, + selectedChannels: number[] ): Promise => { - try { - const db = await openIndexedDB(); - - const record = await performIndexDBTransaction(db, "ChordsRecordings", "readonly", (store) => { - return new Promise((resolve, reject) => { - const index = store.index("filename"); - const getRequest = index.get(filename); - - getRequest.onsuccess = () => resolve(getRequest.result); - getRequest.onerror = () => reject(new Error("Error retrieving record")); - }); - }); + try { + const db = await openIndexedDB(); + + // Check if file exists + const metadata = await performIndexDBTransaction( + db, + "FileMetadata", + "readonly", + (store) => { + return new Promise((resolve, reject) => { + const request = store.get(filename); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + ); + + if (!metadata) { + throw new Error("No data found for the given filename."); + } - if (!record || !Array.isArray(record.content)) { - throw new Error("No data found for the given filename or invalid data format."); - } + // Read all data for this file + const content = await readFileData(db, filename); - // Validate the content structure - if (!record.content.every((item: any) => Array.isArray(item))) { - throw new Error("Content data contains invalid or non-array elements."); - } + if (!Array.isArray(content)) { + throw new Error("Invalid data format."); + } - try { - const csvData = convertToCSV(record.content, canvasCount, selectedChannels); - const blob = new Blob([csvData], { type: "text/csv;charset=utf-8" }); - return blob; - } catch (conversionError) { - console.error("Error converting data to CSV:", conversionError); - throw new Error("Failed to convert data to CSV format."); + try { + const csvData = convertToCSV(content, canvasCount, selectedChannels); + const blob = new Blob([csvData], { type: "text/csv;charset=utf-8" }); + return blob; + } catch (conversionError) { + console.error("Error converting data to CSV:", conversionError); + throw new Error("Failed to convert data to CSV format."); + } + } catch (error) { + console.error("Error during file download:", error); + throw new Error("Error occurred during file download."); } - } catch (error) { - console.error("Error during file download:", error); - throw new Error("Error occurred during file download."); - } }; // Function to get file count from IndexedDB const getFileCountFromIndexedDB = async (db: IDBDatabase): Promise => { - return performIndexDBTransaction(db, "ChordsRecordings", "readonly", (store) => { - return new Promise((resolve, reject) => { - const filenames: string[] = []; - const cursorRequest = store.openCursor(); - - cursorRequest.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result as IDBCursorWithValue | null; - if (cursor) { - filenames.push(cursor.value.filename); - cursor.continue(); - } else { - resolve(filenames); - } - }; + return performIndexDBTransaction(db, "FileMetadata", "readonly", (store) => { + return new Promise((resolve, reject) => { + const filenames: string[] = []; + const cursorRequest = store.openCursor(); + + cursorRequest.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result as IDBCursorWithValue | null; + if (cursor) { + filenames.push(cursor.value.filename); + cursor.continue(); + } else { + resolve(filenames); + } + }; - cursorRequest.onerror = (event) => { - const error = (event.target as IDBRequest).error; - console.error("Error retrieving filenames from IndexedDB:", error); - reject(error); - }; + cursorRequest.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error("Error retrieving filenames from IndexedDB:", error); + reject(error); + }; + }); }); - }); }; const deleteFilesByFilename = async (filename: string) => { - const dbRequest = indexedDB.open("ChordsRecordings"); - - return new Promise((resolve, reject) => { - dbRequest.onsuccess = async (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - try { - await performIndexDBTransaction(db, "ChordsRecordings", "readwrite", async (store) => { - if (!store.indexNames.contains("filename")) { - throw new Error("Index 'filename' does not exist."); - } - - const index = store.index("filename"); - const cursorRequest = index.openCursor(IDBKeyRange.only(filename)); - - return new Promise((resolveCursor, rejectCursor) => { - cursorRequest.onsuccess = (cursorEvent) => { - const cursor = (cursorEvent.target as IDBRequest).result; - if (cursor) { - cursor.delete(); - resolveCursor(); - } else { - resolveCursor(); // No file found, still resolve - } - }; - - cursorRequest.onerror = () => rejectCursor(new Error("Error during cursor operation.")); - }); - }); - - resolve(); - } catch (error) { - reject(error); - } - }; - - dbRequest.onerror = () => reject(new Error("Failed to open IndexedDB database.")); - }); + const dbRequest = indexedDB.open("ChordsRecordings", 3); + + return new Promise((resolve, reject) => { + dbRequest.onsuccess = async (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + try { + // Delete metadata + await performIndexDBTransaction(db, "FileMetadata", "readwrite", (store) => { + return new Promise((resolveMeta, rejectMeta) => { + const request = store.delete(filename); + request.onsuccess = () => resolveMeta(); + request.onerror = () => rejectMeta(request.error); + }); + }); + + // Delete all chunks for this file + await performIndexDBTransaction(db, "DataChunks", "readwrite", (store) => { + return new Promise((resolveChunks, rejectChunks) => { + const index = store.index("byFilename"); + const cursorRequest = index.openCursor(IDBKeyRange.only(filename)); + + cursorRequest.onsuccess = (cursorEvent) => { + const cursor = (cursorEvent.target as IDBRequest).result; + if (cursor) { + cursor.delete(); + cursor.continue(); + } else { + resolveChunks(); + } + }; + + cursorRequest.onerror = () => rejectChunks(new Error("Error deleting chunks.")); + }); + }); + + resolve(); + } catch (error) { + reject(error); + } + }; + + dbRequest.onerror = () => reject(new Error("Failed to open IndexedDB database.")); + }); }; const deleteAllDataFromIndexedDB = async () => { - const dbRequest = indexedDB.open("ChordsRecordings", 2); - - return new Promise((resolve, reject) => { - dbRequest.onsuccess = async (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - try { - await performIndexDBTransaction(db, "ChordsRecordings", "readwrite", async (store) => { - const clearRequest = store.clear(); - - return new Promise((resolveClear, rejectClear) => { - clearRequest.onsuccess = () => resolveClear(); - clearRequest.onerror = () => rejectClear(new Error("Failed to clear IndexedDB store.")); - }); - }); - - resolve(); - } catch (error) { - reject(error); - } - }; - - dbRequest.onerror = () => reject(new Error("Failed to open IndexedDB.")); - dbRequest.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - if (!db.objectStoreNames.contains("ChordsRecordings")) { - const store = db.createObjectStore("ChordsRecordings", { keyPath: "filename" }); - store.createIndex("filename", "filename", { unique: false }); - } - }; - }); -}; - + const dbRequest = indexedDB.open("ChordsRecordings", 3); + + return new Promise((resolve, reject) => { + dbRequest.onsuccess = async (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + try { + // Clear metadata + await performIndexDBTransaction(db, "FileMetadata", "readwrite", (store) => { + return new Promise((resolveMeta, rejectMeta) => { + const request = store.clear(); + request.onsuccess = () => resolveMeta(); + request.onerror = () => rejectMeta(request.error); + }); + }); + + // Clear data chunks + await performIndexDBTransaction(db, "DataChunks", "readwrite", (store) => { + return new Promise((resolveChunks, rejectChunks) => { + const request = store.clear(); + request.onsuccess = () => resolveChunks(); + request.onerror = () => rejectChunks(request.error); + }); + }); + + resolve(); + } catch (error) { + reject(error); + } + }; + + dbRequest.onerror = () => reject(new Error("Failed to open IndexedDB.")); + dbRequest.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Create stores if they don't exist + if (!db.objectStoreNames.contains("FileMetadata")) { + const metadataStore = db.createObjectStore("FileMetadata", { keyPath: "filename" }); + metadataStore.createIndex("filename", "filename", { unique: true }); + } + + if (!db.objectStoreNames.contains("DataChunks")) { + const chunksStore = db.createObjectStore("DataChunks", { + keyPath: ["filename", "chunkIndex"] + }); + chunksStore.createIndex("byFilename", "filename", { unique: false }); + } + }; + }); +}; \ No newline at end of file