diff --git a/js/ui.js b/js/ui.js index 2a50313b..9db8c26c 100644 --- a/js/ui.js +++ b/js/ui.js @@ -849,14 +849,14 @@ const displayLocationAddress = async (where) => { latEl = document.getElementById('customLat'); lonEl = document.getElementById('customLon'); placeEl = document.getElementById('customPlace'); - address = await fetchLocationAddress(latEl.value, lonEl.value, false).catch(error => console.warn(error)); + address = await fetchLocationAddress(latEl.value, lonEl.value, false); if (address === false) return placeEl.value = address || 'Location not available'; } else { latEl = document.getElementById('latitude'); lonEl = document.getElementById('longitude'); placeEl = document.getElementById('place'); - address = await fetchLocationAddress(latEl.value, lonEl.value, false).catch(error => console.warn(error));; + address = await fetchLocationAddress(latEl.value, lonEl.value, false); if (address === false) return const content = 'fmd_good ' + address; placeEl.innerHTML = content; @@ -1095,7 +1095,7 @@ function postAnalyseMessage(args) { circleClicked: args.fromDB }); } else { - generateToast({message: 'An analysis is underway. Press Esc to cancel it before running a new analysis.'}) + generateToast({type: 'warning', message: 'An analysis is underway. Press Esc to cancel it before running a new analysis.'}) } } @@ -1103,7 +1103,7 @@ let openStreetMapTimer; function fetchLocationAddress(lat, lon) { if (isNaN(lat) || isNaN(lon) || !lat || !lon){ - generateToast({ message:'Both lat and lon values need to be numbers between 180 and -180'}) + generateToast({type: 'warning', message:'Both lat and lon values need to be numbers between 180 and -180'}) return false } return new Promise((resolve, reject) => { @@ -1199,7 +1199,7 @@ function hideAll() { async function batchExportAudio() { const species = isSpeciesViewFiltered(true); - species ? exportData('audio', species, 1000) : generateToast({message: "Filter results by species to export audio files"}); + species ? exportData('audio', species, 1000) : generateToast({type: 'warning', message: "Filter results by species to export audio files"}); } const export2CSV = () => exportData('text', isSpeciesViewFiltered(true), Infinity); @@ -1911,7 +1911,7 @@ const setUpWorkerMessaging = () => { ` } - generateToast({ message: args.message}); + generateToast({ type: args.type, message: args.message}); // This is how we know the database update has completed if (args.database && config.archive.auto) document.getElementById('compress-and-organise').click(); break; @@ -1989,7 +1989,7 @@ const setUpWorkerMessaging = () => { if (!config.hasNode && config[config.model].backend !== 'webgpu'){ // No node? Not using webgpu? Force webgpu handleBackendChange('webgpu'); - generateToast({ message: 'The standard backend could not be loaded on this machine. An experimental backend (webGPU) has been used instead.'}); + generateToast({type: 'warning', message: 'The standard backend could not be loaded on this machine. An experimental backend (webGPU) has been used instead.'}); console.warn('tfjs-node could not be loaded, webGPU backend forced. CPU is', DIAGNOSTICS['CPU']) } modelSettingsDisplay(); @@ -2016,7 +2016,7 @@ const setUpWorkerMessaging = () => { onWorkerLoadedAudio(args); break; } - default: {generateToast({ message:`Unrecognised message from worker:${args.event}`}); + default: {generateToast({type: 'error', message:`Unrecognised message from worker:${args.event}`}); } } }) @@ -2953,7 +2953,7 @@ function centreSpec(){ seconds = Math.min(parseFloat(timeArray[2]), 59.999); } else { // Invalid input - generateToast({ message:'Invalid time format. Please enter time in one of the following formats: \n1. Float (for seconds) \n2. Two numbers separated by a colon (for minutes and seconds) \n3. Three numbers separated by colons (for hours, minutes, and seconds)'}); + generateToast({type: 'warning', message:'Invalid time format. Please enter time in one of the following formats: \n1. Float (for seconds) \n2. Two numbers separated by a colon (for minutes and seconds) \n3. Three numbers separated by colons (for hours, minutes, and seconds)'}); return; } let start = hours * 3600 + minutes * 60 + seconds; @@ -4539,7 +4539,7 @@ DOM.gain.addEventListener('input', () => { } case 'clear-call-cache': { const data = fs.rm(p.join(appPath, 'XCcache.json'), err =>{ - if (err) generateToast({message: 'No call cache was found.'}) && config.debug && console.log('No XC cache found', err); + if (err) generateToast({type: 'error', message: 'No call cache was found.'}) && config.debug && console.log('No XC cache found', err); else generateToast({message: 'The call cache was successfully cleared.'}) }) break; @@ -4576,7 +4576,7 @@ DOM.gain.addEventListener('input', () => { switch (target) { case 'species-frequency-threshold' : { if (isNaN(element.value) || element.value === '') { - generateToast({ message:'The threshold must be a number between 0.001 and 1'}); + generateToast({type: 'warning', message:'The threshold must be a number between 0.001 and 1'}); return false } config.speciesThreshold = element.value; @@ -4631,7 +4631,7 @@ DOM.gain.addEventListener('input', () => { if (element.value === 'custom'){ labelFile = config.customListFile[config.model]; if (! labelFile) { - generateToast({message: 'You must select a label file in the list settings to use the custom language option.'}); + generateToast({type: 'warning', message: 'You must select a label file in the list settings to use the custom language option.'}); return; } } else { @@ -4826,7 +4826,7 @@ function setListUIState(list){ } else if (list === 'custom') { DOM.customListContainer.classList.remove('d-none'); if (!config.customListFile[config.model]) { - generateToast({message: 'You need to upload a custom list for the model before using the custom list option.'}) + generateToast({type: 'warning', message: 'You need to upload a custom list for the model before using the custom list option.'}) return } readLabels(config.customListFile[config.model], 'list'); @@ -4839,7 +4839,7 @@ async function readLabels(labelFile, updating){ return response.text(); }).catch(error =>{ if (error.message === 'Failed to fetch') { - generateToast({message: 'The custom list could not be found, no detections will be shown.'}) + generateToast({type: 'error', message: 'The custom list could not be found, no detections will be shown.'}) DOM.customListSelector.classList.add('btn-outline-danger'); document.getElementById('navbarSettings').click(); document.getElementById('list-file-selector').focus(); @@ -5258,7 +5258,8 @@ async function readLabels(labelFile, updating){ } } - function generateToast({message}) { + + function generateToast({message = '', type = 'info'} ={}) { const domEl = document.getElementById('toastContainer'); const wrapper = document.createElement('div'); @@ -5268,16 +5269,45 @@ async function readLabels(labelFile, updating){ wrapper.setAttribute("aria-live", "assertive"); wrapper.setAttribute("aria-atomic", "true"); - wrapper.innerHTML = ` -
- - Notice - just now - -
-
- ${message} -
` + // Create elements + const toastHeader = document.createElement('div'); + toastHeader.className = 'toast-header'; + + const iconSpan = document.createElement('span'); + iconSpan.classList.add('material-symbols-outlined', 'pe-2'); + iconSpan.textContent = type; // The icon name + const typeColours = { info: 'text-primary', warning: 'text-warning', error: 'text-danger'}; + const typeText = { info: 'Notice', warning: 'Warning', error: 'Error'}; + iconSpan.classList.add(typeColours[type]); + const strong = document.createElement('strong'); + strong.className = 'me-auto'; + strong.textContent = typeText[type]; + + const small = document.createElement('small'); + small.className = 'text-muted'; + small.textContent = 'just now'; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'btn-close'; + button.setAttribute('data-bs-dismiss', 'toast'); + button.setAttribute('aria-label', 'Close'); + + // Append elements to toastHeader + toastHeader.appendChild(iconSpan); + toastHeader.appendChild(strong); + toastHeader.appendChild(small); + toastHeader.appendChild(button); + + // Create toast body + const toastBody = document.createElement('div'); + toastBody.className = 'toast-body'; + toastBody.textContent = message; // Assuming message is defined + + // Append header and body to the wrapper + wrapper.appendChild(toastHeader); + wrapper.appendChild(toastBody); + domEl.appendChild(wrapper) const toast = new bootstrap.Toast(wrapper) @@ -5372,7 +5402,7 @@ async function getXCComparisons(){ .then(response =>{ if (! response.ok) { loading.classList.add('d-none'); - return generateToast({message: 'The Xeno-canto API is not responding'}) + return generateToast({type: 'error', message: 'The Xeno-canto API is not responding'}) } return response.json() }) @@ -5423,7 +5453,7 @@ async function getXCComparisons(){ } }); if (songCount === 0 && callCount === 0 && flightCallCount === 0 && nocturnalFlightCallCount === 0) { - generateToast({message: 'The Xeno-canto site has no comparisons available'}) + generateToast({type: 'warning', message: 'The Xeno-canto site has no comparisons available'}) return } else { // Let's cache the result, 'cos the XC API is quite slow diff --git a/js/worker.js b/js/worker.js index de5b03ce..a78e25c3 100644 --- a/js/worker.js +++ b/js/worker.js @@ -63,7 +63,7 @@ console.error = function() { // Implement error handling in the worker self.onerror = function(message, file, lineno, colno, error) { STATE.track && trackEvent(STATE.UUID, 'Unhandled Worker Error', message, customURLEncode(error?.stack)); - if (message.includes('dynamic link library')) UI.postMessage({event: 'generate-alert', message: 'There has been an error loading the model. This may be due to missing AVX support. Chirpity AI models require the AVX2 instructions set to run. If you have AVX2 enabled and still see this notice, please refer to this issue on Github.'}) + if (message.includes('dynamic link library')) UI.postMessage({event: 'generate-alert', type: 'error', message: 'There has been an error loading the model. This may be due to missing AVX support. Chirpity AI models require the AVX2 instructions set to run. If you have AVX2 enabled and still see this notice, please refer to this issue on Github.'}) // Return false not to inhibit the default error handling return false; }; @@ -520,7 +520,7 @@ function savedFileCheck(fileList) { } }); } else { - UI.postMessage({event: 'generate-alert', message: 'The database has not finished loading. The saved file check was skipped'}) + UI.postMessage({event: 'generate-alert', type: 'error', message: 'The database has not finished loading. The saved file check was skipped'}) return undefined } } @@ -951,7 +951,7 @@ const getDuration = async (src) => { resolve(duration); }); audio.addEventListener('error', (error) => { - UI.postMessage({event: 'generate-alert', message: 'Unable to decode file metatada'}) + UI.postMessage({event: 'generate-alert', type: 'error', message: 'Unable to decode file metatada'}) reject(error) }) }); @@ -1024,7 +1024,7 @@ async function notifyMissingFile(file) { const row = await diskDB.getAsync('SELECT * FROM FILES WHERE name = ?', file); if (row?.id) missingFile = file UI.postMessage({ - event: 'generate-alert', + event: 'generate-alert', type: 'error', message: `Unable to locate source file with any supported file extension: ${file}`, file: missingFile }) @@ -1272,7 +1272,7 @@ const getWavePredictBuffers = async ({ try { wav.fromBuffer(chunk); } catch (e) { - UI.postMessage({event: 'generate-alert', message: `Cannot parse ${file}, it has an invalid wav header.`}); + UI.postMessage({event: 'generate-alert', type: 'error', message: `Cannot parse ${file}, it has an invalid wav header.`}); console.warn('GetWavePredictBuffers failed: ', e) headerStream.close(); updateFilesBeingProcessed(file); @@ -1533,7 +1533,7 @@ const fetchAudioBuffer = async ({ const stream = command.pipe(); command.on('error', error => { - UI.postMessage({event: 'generate-alert', message: error}) + UI.postMessage({event: 'generate-alert', type: 'error', message: error}) reject(new Error('fetchAudioBuffer: Error extracting audio segment:', error)); }); command.on('start', function (commandLine) { @@ -2562,7 +2562,7 @@ const getResults = async ({ filename += format == 'Raven' ? `_selections.txt` : '_detections.csv'; const filePath = p.join(directory, filename); writeToPath(filePath, formattedValues, {headers: true, delimiter: format === 'Raven' ? '\t' : ','}) - .on('error', err => UI.postMessage({event: 'generate-alert', message: `Cannot save file ${filePath}\nbecause it is open in another application`})) + .on('error', err => UI.postMessage({event: 'generate-alert', type: 'warning', message: `Cannot save file ${filePath}\nbecause it is open in another application`})) .on('finish', () => { UI.postMessage({event: 'generate-alert', message: filePath + ' has been written successfully.'}); }); @@ -2783,7 +2783,7 @@ const getSavedFileInfo = async (file) => { } return row } else { - UI.postMessage({event: 'generate-alert', message: 'The database has not finished loading. The check for the presence of the file in the archive has been skipped'}) + UI.postMessage({event: 'generate-alert', type: 'error', message: 'The database has not finished loading. The check for the presence of the file in the archive has been skipped'}) return undefined } }; @@ -3282,20 +3282,20 @@ async function onFileUpdated(oldName, newName){ }); } else { UI.postMessage({ - event: 'generate-alert', message: 'No changes made. The selected file has a different duration to the original file.' + event: 'generate-alert', type: 'error', message: 'No changes made. The selected file has a different duration to the original file.' }); } } catch (err) { if (err.code === 'SQLITE_CONSTRAINT' && err.message.includes('UNIQUE')) { // Unique constraint violation, show specific error message UI.postMessage({ - event: 'generate-alert', + event: 'generate-alert', type: 'warning', message: 'No changes made. The selected file already exists in the database.' }); } else { // Other types of errors UI.postMessage({ - event: 'generate-alert', + event: 'generate-alert', type: 'error', message: `An error occurred while updating the file: ${err.message}` }); } @@ -3508,7 +3508,7 @@ async function setIncludedIDs(lat, lon, week) { if (STATE.included === undefined) STATE.included = {} STATE.included = merge(STATE.included, includedObject); - messages.forEach(message => UI.postMessage({event: 'generate-alert', message: message} )) + messages.forEach(message => UI.postMessage({event: 'generate-alert', type: 'warning', message: message} )) return STATE.included; })(); @@ -3689,7 +3689,7 @@ async function convertAndOrganiseFiles(threadLimit) { if (!mkkDirFailed){ mkkDirFailed = true; UI.postMessage({ - event: 'generate-alert', + event: 'generate-alert', type: 'error', message: `Failed to create directory: ${fullPath}
Error: ${err.message}` }); } @@ -3760,7 +3760,7 @@ async function convertFile(inputFilePath, fullFilePath, row, db, dbArchiveName, let scaleFactor = 1; if (STATE.detect.nocmig) { if (boundaries.length > 1) { - UI.postMessage({event: 'generate-alert', message: `Multi-day operations are not yet supported: ${inputFilePath} will not be trimmed`}); + UI.postMessage({event: 'generate-alert', type: 'warning', message: `Multi-day operations are not yet supported: ${inputFilePath} will not be trimmed`}); } else { const {start, end} = boundaries[0]; if (start === end) return; @@ -3788,7 +3788,7 @@ async function convertFile(inputFilePath, fullFilePath, row, db, dbArchiveName, }) .on('error', (err) => { DEBUG && console.error(`Error converting file ${inputFilePath}:`, err); - UI.postMessage({event: 'generate-alert', message: `File not found: ${inputFilePath}`, file: inputFilePath}); + UI.postMessage({event: 'generate-alert', type: 'error', message: `File not found: ${inputFilePath}`, file: inputFilePath}); reject(err); }) .on('progress', (progress) => {