diff --git a/.nocache.htaccess b/.nocache.htaccess deleted file mode 100644 index 5b9061fe1..000000000 --- a/.nocache.htaccess +++ /dev/null @@ -1,9 +0,0 @@ - - FileETag None - - Header unset ETag - Header set Cache-Control "max-age=0, no-cache, no-store, must-revalidate" - Header set Pragma "no-cache" - Header set Expires "Wed, 11 Jan 1984 05:00:00 GMT" - - diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 000000000..db6716e43 --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,67 @@ +### caMicroscope [?.?.?](https://github.com/camicroscope/camicroscope/compare/v3.7.3...camicroscope:develop) +###### ????-??-?? + +### caMicroscope [3.7.3](https://github.com/camicroscope/camicroscope/compare/v3.7.2...camicroscope:v3.7.3) +###### 2020-05-01 +* Start this changelog. +* Use Friendlier Alerts in Viewer (#383) +* Batch Slide Loader (#385, #389) +* Filter Selector in Tables (#391) +* High-volume Render Optimization +* Temporarily Disabled Security Integration (#388) +* Readme Visual Documentation (#394) +* Details Button in Information Dashboard (#393) + + +### caMicroscope [3.7.2](https://github.com/camicroscope/camicroscope/compare/v3.7.1...camicroscope:v3.7.2) +###### 2020-04-17 +* Documentation Improvements (#314, #315) +* User Creation Workflow (#371) +* Slide Deletion Workflow (#303, #367, #369) +* Table and Loader Improvements (#340, #345, #356, #358) +* Model and Segment App Improvements (#317, #375 #327, #330, #348, #351, #354, #360, #362, #382) +* Heatmap Color Changes (#322, #381) +* Viewer Tool Tour (#334) + + +### caMicroscope [3.7.1](https://github.com/camicroscope/camicroscope/compare/v3.7.0...camicroscope:v3.7.1) +###### 2020-04-03 +* Bugfix: POST instead of UPDATE tables +* Sanitize user input (#301) +* Responsive tables (#306, #308) +* Style: camel case checks (#278) + + +### caMicroscope [3.7.0](https://github.com/camicroscope/camicroscope/compare/v3.6.2...camicroscope:v3.7.0) +###### 2020-04-02 +* Use new backend (#291, https://github.com/camicroscope/Caracal/) +* Adopted a code style guide (#270, #282, #281) +* Table/loader improvements (#273, #276) +* Faster UI transitions (#284, #288) +* Spyglass Behavior Improvements (#275, #297) + +### caMicroscope [3.6.2](https://github.com/camicroscope/camicroscope/compare/v3.6.1...camicroscope:v3.6.2) +###### 2020-03-29 +* Add Custom colors for heatmap (#253) +* Model Summary for Segmentation and Prediction Apps (#255, #250) +* Keyboard shortcut upgrades (#262, #265) +* Segmentation Memory Cleanup (#261) +* Edit Prediction Classes (#263) +* Loader Slide Name Checking (#266) +* Panel Text Wrapping (#264) +* Selector Fix #260 + +### caMicroscope [3.6.1](https://github.com/camicroscope/camicroscope/compare/v3.6.0...camicroscope:v3.6.1) +###### 2020-03-26 +* Bugfix: Upload button with no slides (#249) +* Testing Updates +* UI Improvements (#256, #252, #251, #246) + +### caMicroscope [3.6.0](https://github.com/camicroscope/camicroscope/compare/v3.5.10...camicroscope:v3.6.0) +###### 2020-03-22 +* Removal of Package System (breaking) (#243) +* Table, Slide Loader, and Signup UX Improvements (#239, #241, #227, #226) +* Model Improvements (#223, #231) + +### Older Versions +see the [release log](https://github.com/camicroscope/caMicroscope/releases) diff --git a/README.md b/README.md index fc89bfb63..95b6603a2 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,16 @@ camicroscope: a web-based image viewer optimized for large bio-medical image data viewing -caMicroscope is a web-based image viewer optimized for large bio-medical image data viewing, with a strong emphasis on cancer pathology. +caMicroscope is a web-based biomedical image and data viewer, with a strong emphasis on cancer pathology WSI (Whole Slide Imaging). This guide has sections for different kinds of use of the platform. The [User Guide](#user-guide) covers the basics on how to use caMicroscope viewer. [nanoBorb](#nanoborb) covers nanoBorb, the version of caMicroscope designed as a standalone application for individual users without a server. [Hosted Setup](#hosted-setup) covers how to set up caMicroscope for multiple users on a server. [Developer Guide](#developer-guide) covers the broad strokes on how to add new functionality to caMicroscope. +![View Slides](docs/View.gif) +![Measure Features](docs/Measure.gif) +![Annotate Areas of Interest](docs/Draw.gif) +![Alternate Annotation Method](docs/Paint.gif) +![Automatic Object Detection](docs/Segment.gif) +![Test Classification Models](docs/Predict.gif) + # User Guide ## Selecting an Image diff --git a/apps/Info.html b/apps/Info.html index b82830229..69d555e45 100644 --- a/apps/Info.html +++ b/apps/Info.html @@ -83,17 +83,72 @@

caMicroscope

Digital pathology image viewer with support for human/machine generated annotations and markups.

+ + +

Information Dashboard

+
+
+
+
+ + + +
- - + +
@@ -105,20 +160,22 @@

Infor diff --git a/apps/batchloader/batchLoader.js b/apps/batchloader/batchLoader.js new file mode 100644 index 000000000..ca9bc29a9 --- /dev/null +++ b/apps/batchloader/batchLoader.js @@ -0,0 +1,564 @@ +let existingFiles = []; +let existingSlides = []; +let tokens = null; +let files= null; +let fileNames = null; +let slideNames = null; +let originalFileNames = null; +let startUrl = '../../loader/upload/start'; +let continueUrl = '../../loader/upload/continue/'; +let finishUrl = '../../loader/upload/finish/'; +let checkUrl = '../../loader/data/one/'; +let chunkSize = 5*1024*1024; +let finishUploadSuccess = false; +const allowedExtensions = ['svs', 'tif', 'tiff', 'vms', 'vmu', 'ndpi', 'scn', 'mrxs', 'bif', 'svslide']; + + +$(document).ready(function() { + $('#files').show(400); + let store = new Store('../../data/'); + store.findSlide().then((response) => { + for (i=0; i { + console.log(error); + }); + + $('#filesInput').change(function() { + let files = $(this).prop('files'); + let fileNames = $.map(files, function(val) { + return val.name; + }); + $('#filesInputLabel').html(fileNames.length + ' files selected'); + if (fileNames.length>0) { + $('#filesDetails').show(500); + } else { + $('#filesDetails').css('display', 'none'); + $('#table').css('display', 'none'); + } + $('#start').css('display', 'none'); + $('#finish').css('display', 'none'); + $('#check').css('display', 'none'); + $('#post').css('display', 'none'); + $('#complete').css('display', 'none'); + $('#table').css('display', 'none'); + }); + + $('#fileNameSwitch').change(function() { + if ($(this).is(':checked')) { + $('.fileName').css('opacity', '1'); + $('#fileNamesInput').prop('disabled', false); + $('#fileNamesInput').prop('required', true); + $('#fswitch').prop('title', 'Toggle to keep original file names'); + } else { + $('#fileNamesInput').prop('disabled', true); + $('#fswitch').prop('title', 'Toggle to select a generalised file name'); + $('#fileNamesInput').prop('required', false); + $('.fileName').css('opacity', '0.4'); + } + }); + + $('.modal').on('shown.bs.modal', function() { + let input = $(this).find('input:text:visible:first'); + input.focus(); + }); + + $('#fileNamesInput').change(function() { + $(this).val($(this).val().split(' ').join('_')); + }); +}); + +function addbody(rowData) { + let table = $('table tbody'); + let markup = ''+rowData.serial+''+rowData.fileName+ + '   '+rowData.slideName+ + '   '+ + ''+rowData.token+ + ''+ + ' '+ + ''+ + ''; + + table.append(markup); +} + +function startTable() { + slideNames = []; + fileNames = []; + originalFileNames = []; + tokens = []; + let genSlideName = $('#slideNamesInput').val(); + let genFileName=$('#fileNamesInput').val(); + $('table tbody').html(''); + if (genSlideName!='') { + files = $('#filesInput').prop('files'); + let slidesNum = files.length; + originalFileNames = $.map(files, function(val) { + return val.name; + }); + if ($('#fileNameSwitch').is(':checked') && genFileName!='') { + for (i=0; i   `; + if ($('.fileNameEdit:eq('+i+')').prev().prev('#fileNameError').length == 0) { + $('.fileNameEdit:eq('+i+')').parent().prepend(errorIcon); + } + numErrors++; + } else if (!allowedExtensions.includes(fileNames[i].substring(fileNames[i].lastIndexOf('.')+1, + fileNames[i].length))) { + let errorIcon = `   `; + if ($('.fileNameEdit:eq('+i+')').prev().prev('#fileNameError').length == 0) { + $('.fileNameEdit:eq('+i+')').parent().prepend(errorIcon); + } + numErrors++; + } else { + $('.fileNameEdit:eq('+i+')').parent().find('#fileNameError').remove(); + } + if (existingSlides.includes(slideNames[i])) { + let errorIcon = `   `; + if ($('.slideNameEdit:eq('+i+')').prev().prev('#slideNameError').length == 0) { + $('.slideNameEdit:eq('+i+')').parent().prepend(errorIcon); + } + numErrors++; + } else { + $('.slideNameEdit:eq('+i+')').parent().find('#slideNameError').remove(); + } + } + if (numErrors > 0) { + $('#start').hide(); + } else { + $('#start').show(300); + } +} + +function showSlideInfo1(index) { + $('#slideInfoContent').html(`Original Filename: `+originalFileNames[index]+`
Filename: `+fileNames[index]+ + `
Slide name: `+slideNames[index]+`
Status: Pending Initial Upload`); +} +function showSlideInfo2(index) { + $('#slideInfoContent').html(`Original Filename: `+originalFileNames[index]+`
Filename: `+fileNames[index]+ + `
Slide name: `+slideNames[index]+`
Token: `+ + tokens[index]+`
Status: Initial Upload done | Token Generated | Final Upload Pending`); +} +function showSlideInfo3(index) { + $('#slideInfoContent').html(`Original Filename: `+originalFileNames[index]+`
Filename: `+fileNames[index]+ + `
Slide name: `+slideNames[index]+`
Token: `+ + tokens[index]+`
Status: Final Upload Done | Check Pending | Post Pending`); +} +function showSlideInfo4(index) { + $('#slideInfoContent').html(`Original Filename: `+originalFileNames[index]+`
Filename: `+fileNames[index]+ + `
Slide name: `+slideNames[index]+`
Token: `+ + tokens[index]+`
Status: Check successfull | Post Pending`); +} +function showSlideInfo5(index) { + $('#slideInfoContent').html(`Original Filename: `+originalFileNames[index]+`
Filename: `+fileNames[index]+ + `
Slide name: `+slideNames[index]+`
Token: `+ + tokens[index]+`
Status: Posted Successfully`); +} + +function updateSlideName(oldSlideName) { + $('#confirmUpdateSlideContent').html('Enter the new name for:
'+oldSlideName+ + '

'); + let input = document.getElementById('newSlideName'); + input.select(); + $('#confirmUpdateSlide').unbind('click'); + $('#confirmUpdateSlide').click(function() { + let newSlideName = $('#newSlideName'); + let newName = newSlideName.val(); + + if (newName!='') { + if (slideNames.includes(newName) || existingSlides.includes(newName)) { + newSlideName.addClass('is-invalid'); + if (newSlideName.parent().children().length === 1) { + newSlideName.parent().append(`
+ Slide with given name already exists.
`); + } + } else { + newSlideName.removeClass('is-invalid'); + let index = slideNames.indexOf(oldSlideName); + slideNames[index] = newName; + $('tr:eq('+(index+1)+') td:nth-child(3) span').html(newName); + $('#slideNameChangeModal').modal('hide'); + checkNames(); + } + } + }); +} + +function updateFileName(oldfileName) { + $('#confirmUpdateFileContent').html('Enter the new name for:
'+oldfileName+ + '

'); + let input = document.getElementById('newFileName'); + let value = input.value; + input.setSelectionRange(0, value.lastIndexOf('.')); + + $('#newFileName').change(function() { + $(this).val($(this).val().split(' ').join('_')); + }); + $('#confirmUpdateFile').unbind('click'); + $('#confirmUpdateFile').click(function() { + let newFileName = $('#newFileName'); + let newName = newFileName.val(); + let fileExtension = newName.toLowerCase().split('.').reverse()[0]; + + if (newName!='') { + if (fileNames.includes(newName) || existingFiles.includes(newName)) { + newFileName.addClass('is-invalid'); + if (newFileName.parent().children().length === 1) { + newFileName.parent().append(`
+ File with given name already exists
`); + } else { + $('#filename-feedback0').html(`File with given name already exists`); + } + } else if (!allowedExtensions.includes(fileExtension)) { + newFileName.addClass('is-invalid'); + if (newFileName.parent().children().length === 1) { + newFileName.parent().append(`
+ .${fileExtension} files are not compatible
`); + } else { + $('#filename-feedback0').html(`.${fileExtension} files are not compatible`); + } + } else { + newFileName.removeClass('is-invalid'); + let index = fileNames.indexOf(oldfileName); + fileNames[index] = newName; + $('tr:eq('+(index+1)+') td:nth-child(2) span').html(newName); + $('#fileNameChangeModal').modal('hide'); + checkNames(); + } + } + }); +} + + +function startBatch() { + tokens = []; + for (i=0; i'); + $('.slideInfo:eq('+i+')').unbind('click'); + $('.slideInfo:eq('+i+')').click(function() { + showSlideInfo2(i); + }); + + continueUpload(token); + readFileChunks(selectedFile, token); +} + +async function startUpload(filename) { + const body = {filename: filename}; + const token = fetch(startUrl, {method: 'POST', body: JSON.stringify(body), headers: { + 'Content-Type': 'application/json; charset=utf-8', + }}).then((x)=>x.json()); + try { + const a = await token; + return a['upload_token']; + } catch (e) { + console.log(e); + } +} + +function continueUpload(token) { + return async function(body) { + return await fetch(continueUrl + token, {method: 'POST', body: JSON.stringify(body), headers: { + 'Content-Type': 'application/json; charset=utf-8', + }}); + }; +} + +function finishUpload(token, filename, i) { +// let reset = true; + + const body = {filename: filename}; + // changeStatus('UPLOAD', 'Finished Reading File, Posting'); + const regReq = fetch(finishUrl + token, {method: 'POST', body: JSON.stringify(body), headers: { + 'Content-Type': 'application/json; charset=utf-8', + }}); + regReq.then((x)=>x.json()).then((a)=>{ + // changeStatus('UPLOAD | Finished', a, reset); reset = false; + console.log(a); + if (typeof a === 'object' && a.error) { + finishUploadSuccess = false; + // $('#check_btn').hide(); + // $('#post_btn').hide(); + } else { + finishUploadSuccess=true; + $('#check').show(); + $('#finish').css('display', 'none'); + + let status = $('.status:eq('+i+')'); + $('i:nth-child(2)', status).after(''); + $('i:nth-child(2)', status).remove(); + + $('table').find('tr').each(function() { + $('td:nth-child(2)', this).unbind('mouseenter mouseleave'); + }); + + $('.slideInfo:eq('+i+')').unbind('click'); + $('.slideInfo:eq('+i+')').click(function() { + showSlideInfo3(i); + }); + } + }); + regReq.then((e)=> { + if (e['ok']===false) { + finishUploadSuccess = false; + // $('#check_btn').hide(); + // $('#post_btn').hide(); + // changeStatus('UPLOAD | ERROR;', e); + // reset = true; + console.log(e); + } + }); +} + + +async function readFileChunks(file, token) { + let part = 0; + let complete = false; + while (!complete) { + try { + const data = await promiseChunkFileReader(file, part); + const body = {chunkSize: chunkSize, offset: part*chunkSize, data: data}; + const res = await continueUpload(token)(body); + part++; + console.log(part); + } catch (e) { + console.log(e); + complete = true; + } + } +} + +// read a chunk of the file +function promiseChunkFileReader(file, part) { + return new Promise((resolve, reject)=>{ + let fr = new FileReader(); + fr.onload = (evt)=>{ + if (evt.target.error == null) { + const d = evt.target.result.split(',')[1]; + if (d) { + resolve(d); + } else { + reject(new Error('Done Reading') ); + } + } else { + reject(evt.target.error); + } + }; + let blob = file.slice(part*chunkSize, (part+1)*chunkSize); + fr.readAsDataURL(blob); + }); +} + +function checkBatch() { + for (i=0; i response.json(), // if the response is a JSON object + ).then( + (success) => { + $('#post').show(300); + let status = $('.status:eq('+i+')'); + $('i:nth-child(3)', status).remove(); + $(status).append(''); + $('.slideInfo:eq('+i+')').unbind('click'); + $('.slideInfo:eq('+i+')').click(function() { + showSlideInfo4(i); + }); + // Add the filename, to be able to fetch the thumbnail. + success['preview'] = filename; + }, // Handle the success response object + ).catch( + (error) => console.log(error), // Handle the error response object + ); +} + +function postBatch() { + for (i=0; i response.json(), // if the response is a JSON object + ).then( + (data) => { + data['upload_date'] = new Date(Date.now()).toLocaleString(); + data.name = slidename; + data.location = '/images/' + filename; + data.study = ''; + data.specimen = ''; + data.mpp = parseFloat(data['mpp-x']) || parseFloat(data['mpp-y']) || 0; + data.mpp_x = parseFloat(data['mpp-x']); + data.mpp_y = parseFloat(data['mpp-y']); + let store = new Store('../../data/'); + store.post('Slide', data).then( + (success) => { + $('#post').css('display', 'none'); + $('#check').css('display', 'none'); + $('#complete').show(400); + $('form').trigger('reset'); + $('#fileNameSwitch').trigger('change'); + $('#filesInputLabel').html('Choose Files'); + + let status = $('.status:eq('+i+')'); + $(status).append(' '); + console.log(success); + + $('table').find('tr').each(function() { + $('td:nth-child(3)', this).unbind('mouseenter mouseleave'); + }); + $('.slideInfo:eq('+i+')').unbind('click'); + $('.slideInfo:eq('+i+')').click(function() { + showSlideInfo5(i); + }); + $('.slideDelete').css('display', 'none'); + // initialize(); + // $('#upload-dialog').modal('hide'); + // showSuccessPopup('Slide uploaded successfully'); + // return changeStatus('POST', success.result, reset); + }, // Handle the success response object + ).catch( + (error) => console.log(error), // Handle the error response object + ); + }, + ).catch( + (error) => console.log(error), // Handle the error response object + ); +} + +function sanitize(string) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + // eslint-disable-next-line quotes + "'": ''', + // eslint-disable-next-line quotes + "/": '/', + }; + const reg = /[&<>"'/]/ig; + return string.replace(reg, (match)=>(map[match])); +} diff --git a/apps/batchloader/batchloader.html b/apps/batchloader/batchloader.html new file mode 100644 index 000000000..d6d7d4660 --- /dev/null +++ b/apps/batchloader/batchloader.html @@ -0,0 +1,360 @@ + + + + + + + + + + + + + + + + + + + Batch Upload + + + +
+
+ + + +
+
+
+ + + + + + + + + + + + +
#FileSlideTokenStatus
+
+ + + + + + + + + + + + + diff --git a/apps/info.css b/apps/info.css index 34731a46b..b656aaf46 100644 --- a/apps/info.css +++ b/apps/info.css @@ -30,4 +30,53 @@ body { .footer { margin-top: auto !important; -} \ No newline at end of file + +} +#detailtable{ + margin:auto; + text-align: left; + +} +#detailtable th{ + padding: 6px; + padding-left: 15px; + padding-right: 15px; + text-align: right; + border-right-style: double; +} +#detailtable td{ + padding: 6px; + padding-left: 15px; + padding-left: 15px; + text-align: left; +} +#annotationtable, #heatmaptable{ + margin: auto; + text-align: left; + border-collapse: collapse; + width: 100%; + border-bottom-style: solid; + border-bottom-color: #0c5460; +} +#annotationtable th,#heatmaptable th{ + border: 1px solid #0c5460; + padding: 8px; + padding-top: 10px; + padding-bottom: 10px; + text-align: left; + background-color: #0c5460; + padding-left: 12px; + color: white; +} +#annotationtable td, #heatmaptable td{ + /* border: 1px solid #0c5460; */ + padding: 8px; + padding-left: 12px; + font-style: italic; +} +#annotationtable tr:nth-child(even), #heatmaptable tr:nth-child(even){ + background: #b9e2e8; +} +#annotationtable tr:hover, #heatmaptable tr:hover{ + background: #9cc7ce; +} diff --git a/apps/loader/chunked_upload.js b/apps/loader/chunked_upload.js index 4e766e949..bef309f22 100644 --- a/apps/loader/chunked_upload.js +++ b/apps/loader/chunked_upload.js @@ -176,7 +176,8 @@ function updateFormOnUpload(fileName, token) { tokentr.insertCell(-1).innerHTML = ``; slidetr.insertCell(-1).innerHTML = ``; - filtertr.insertCell(-1).innerHTML = ``; + filtertr.insertCell(-1).innerHTML = ``; document.getElementById('token'+0).value = token; } diff --git a/apps/model/model.js b/apps/model/model.js index 91925437d..4b44d017f 100644 --- a/apps/model/model.js +++ b/apps/model/model.js @@ -740,8 +740,8 @@ function uploadModel() { } async function deleteModel(name) { - modelName = name.split('/').pop().split('_').splice(2).join('_').slice(0, -3); - if (confirm('Are you sure you want to delete ' + modelName + ' model?')) { + deletedmodelName = name.split('/').pop().split('_').splice(2).join('_').slice(0, -3); + if (confirm('Are you sure you want to delete ' + deletedmodelName + ' model?')) { const res = await tf.io.removeModel(IDB_URL + name); console.log(res); const tx = db.transaction('models_store', 'readwrite'); @@ -758,7 +758,7 @@ async function deleteModel(name) { if (popups.childElementCount < 2) { let popupBox = document.createElement('div'); popupBox.classList.add('popup-msg', 'slide-in'); - popupBox.innerHTML = `info` + modelName + ` model deleted successfully`; + popupBox.innerHTML = `info` + deletedmodelName + ` model deleted successfully`; popups.insertBefore(popupBox, popups.childNodes[0]); setTimeout(function() { popups.removeChild(popups.lastChild); diff --git a/apps/segment/segment.js b/apps/segment/segment.js index e95664f34..ad1f70fe5 100644 --- a/apps/segment/segment.js +++ b/apps/segment/segment.js @@ -1120,7 +1120,8 @@ function watershed(inn, out, save=null, thresh) { M.delete(); } async function deleteModel(name) { - if (confirm('Are you sure you want to delete this model?')) { + deletedmodelName = name.split('/').pop().split('_').splice(2).join('_').slice(0, -3); + if (confirm('Are you sure you want to delete ' + deletedmodelName + ' model?')) { const res = await tf.io.removeModel(IDB_URL + name); console.log(res); const tx = db.transaction('models_store', 'readwrite'); @@ -1138,7 +1139,7 @@ async function deleteModel(name) { if (popups.childElementCount < 2) { let popupBox = document.createElement('div'); popupBox.classList.add('popup-msg', 'slide-in'); - popupBox.innerHTML = `info` + modelName + ` model deleted successfully`; + popupBox.innerHTML = `info` + deletedmodelName + ` model deleted successfully`; popups.insertBefore(popupBox, popups.childNodes[0]); setTimeout(function() { popups.removeChild(popups.lastChild); diff --git a/apps/signup/signup.html b/apps/signup/signup.html index 73e08b3bd..0f180645a 100644 --- a/apps/signup/signup.html +++ b/apps/signup/signup.html @@ -36,7 +36,8 @@ diff --git a/apps/table.css b/apps/table.css index 156a11a3c..fb95744e1 100644 --- a/apps/table.css +++ b/apps/table.css @@ -100,7 +100,7 @@ nav li:not(.active):hover a{ background-color: #eee; } @media (max-width: 640px) { - .dropdown-menu{ + #dropNot{ top: 50px; left: -16px; width: 290px; @@ -115,7 +115,7 @@ nav li:not(.active):hover a{ font-size: 13px; } } -.dropdown-menu{ +#dropNot{ top: 60px; left: 0px; right: unset; diff --git a/apps/table.html b/apps/table.html index 51077a7d9..b8a2d9723 100644 --- a/apps/table.html +++ b/apps/table.html @@ -78,6 +78,33 @@ changeStatus("UPLOAD", fileExtension + " files are not compatible"); return false; } + let filterInput = $("#filter0"); + if(filterInput.val()) + { + try + { + let filters = filterInput.val().replace(/'/g, '"') + filters=JSON.parse(filters); + if(!Array.isArray(filters)) + throw new Error("Filters should be an array.") + else + { + filterInput.removeClass('is-invalid'); + if (filterInput.parent().children().length !== 1) { + $('#filter-feedback0').remove(); + } + } + } + catch(err) + { + filterInput.addClass('is-invalid'); + if (filterInput.parent().children().length === 1) { + filterInput.parent().append(`
+ Filters should be an array.
`); + } + return false; + } + } callback(); } @@ -85,12 +112,11 @@ + + - @@ -135,7 +161,7 @@ -