PMA.UI Documentation by Pathomation

components/js/imageUpload.js

import { checkBrowserCompatibility } from '../../view/helpers';
import { Events } from './components';
import { Resources } from '../../resources/resources';
import $ from 'jquery';

const singleFileFormatExts = ["tif", "tiff", "jpeg", "jpg", "jp2",
    "png", "ndpi", "dcm", "svs", "scn", "svslide", "lif", "zvi", "czi",
    "lsm", "nd2", "mds", "zif", "sws", "qptiff", "tmap", "jxr", "kfb",
    "isyntax", "rts", "bif", "oir", "szi", "mdsx"];

const multiFileFormatExts = ["ndpis", "mrxs", "afi", "vsi"];

function beforeLast(value, delimiter) {
    value = value || ''

    if (delimiter === '') {
        return value
    }

    const substrings = value.split(delimiter)

    return substrings.length === 1
        ? value // delimiter is not part of the string
        : substrings.slice(0, -1).join(delimiter)
}

function removePathInitialSlash(path) {
    path = path || '';

    if (!path.startsWith("/")) {
        return path;
    }

    return path.slice(1);
}

function getRandomId() {
    return Math.random().toString(36).slice(2).toUpperCase();
}

function parseAfiNode(node) {
    const childNodes = node.childNodes;
    if (childNodes.length === 0) {
        return node.nodeValue;
    } else if (childNodes.length === 1 && childNodes[0].nodeType === Node.TEXT_NODE) {
        return childNodes[0].nodeValue;
    } else {
        const obj = {};
        childNodes.forEach(childNode => {
            const childName = childNode.nodeName;
            const childValue = obj[childName];
            if (childValue !== undefined) {
                if (Array.isArray(childValue)) {
                    childValue.push(parseAfiNode(childNode));
                } else {
                    obj[childName] = [childValue, parseAfiNode(childNode)];
                }
            } else {
                obj[childName] = parseAfiNode(childNode);
            }
        });
        return obj;
    }
}

function parseAfi(str) {
    str = str.replace(/ {4}|[\t\n\r]/gm, '');
    str = str.replace(/>\s*/g, '>');
    str = str.replace(/\s*</g, '<');
    const dom = (new DOMParser()).parseFromString(str, 'text/xml')
    const result = { [dom.nodeName]: parseAfiNode(dom) };
    return result;
}

function readFileAsText(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.addEventListener('load', (event) => {
            const result = event.target.result;
            resolve(result);
        });

        reader.onerror = function (e) {
            reject(e);
        };

        reader.readAsText(file, "UTF-8");
    });
}

function parseIni(iniString) {
    let regex = {
        section: /^\s*\[\s*([^\]]*)\s*\]\s*$/,
        param: /^\s*([^=]+?)\s*=\s*(.*?)\s*$/,
        comment: /^\s*;.*$/
    };
    let value = {};
    let lines = iniString.split(/[\r\n]+/);
    let section = null;
    lines.forEach(function (line) {
        if (regex.comment.test(line)) {
            return;
        } else if (regex.param.test(line)) {
            let match = line.match(regex.param);
            if (section) {
                value[section][match[1]] = match[2];
            } else {
                value[match[1]] = match[2];
            }
        } else if (regex.section.test(line)) {
            let match = line.match(regex.section);
            value[match[1]] = {};
            section = match[1];
        } else if (line.length == 0 && section) {
            section = null;
        }
    });

    return value;
}

// Validate MRXS file
async function mrxsChecker(file, files, existing = false) {
    let includedFiles = [file];
    let missingFiles = [];

    files = files.filter(x => x);

    if (files.length === 0) {
        return { status: ImageUpload.SlideStatus.MissingFiles, errors: ["No additional files provided"], files: includedFiles };
    }

    // Find ini file
    const directoryToCheck = existing ?
        beforeLast(files[0].path ? removePathInitialSlash(files[0].path || files[0].name) : (files[0].webkitRelativePath || files[0].name), '.') :
        beforeLast(file.path ? removePathInitialSlash(file.path || file.name) : (file.webkitRelativePath || file.name), '.');

    const iniFile = files.find(x => removePathInitialSlash(x.path) === directoryToCheck + "/Slidedat.ini" || x.webkitRelativePath === directoryToCheck + "/Slidedat.ini");
    if (!iniFile) {
        return { status: ImageUpload.SlideStatus.MissingFiles, errors: [`Missing folder: ${file.name.split('.').shift()}`], files: includedFiles };
    }

    //Read ini file
    let iniData = await readFileAsText(iniFile);
    if (!iniData) {
        return { status: ImageUpload.SlideStatus.MissingFiles, errors: ["Slidedat.ini couldn't be opened"], files: includedFiles };
    }

    //Parse ini file
    let ini = parseIni(iniData);
    if (!ini) {
        return { status: ImageUpload.SlideStatus.MissingFiles, errors: ["Slidedat.ini couldn't be parsed"], files: includedFiles };
    }

    includedFiles.push(iniFile);

    //Check if index file included
    const indexDatName = ini.HIERARCHICAL.INDEXFILE;
    const indexDatFile = files.find(x => removePathInitialSlash(x.path) === directoryToCheck + '/' + indexDatName || x.webkitRelativePath === directoryToCheck + '/' + indexDatName);
    if (!indexDatFile) {
        missingFiles.push(indexDatName + " missing");
    } else {
        includedFiles.push(indexDatFile);
    }

    //Check if all other files included
    const noOfDats = parseInt(ini.DATAFILE.FILE_COUNT);

    for (let i = 0; i < noOfDats; i++) {
        const datName = ini.DATAFILE["FILE_" + i];
        const datFile = files.find(x => removePathInitialSlash(x.path) === directoryToCheck + '/' + datName || x.webkitRelativePath === directoryToCheck + '/' + datName);
        if (!datFile) {
            missingFiles.push(datName + " missing");
        } else {
            includedFiles.push(datFile);
        }
    }

    return { status: missingFiles.length === 0 ? ImageUpload.SlideStatus.Queued : ImageUpload.SlideStatus.MissingFiles, errors: missingFiles, files: includedFiles, childFolder: directoryToCheck.split('/').pop() };
}

// Validate VSI file
async function vsiChecker(file, files, existing = false) {
    let includedFiles = [file];
    let childFolders = [""];

    files = files.filter(x => x);

    if (files.length === 0) {
        return { status: ImageUpload.SlideStatus.MissingFiles, errors: ["No additional files provided"], files: includedFiles };
    }

    // const directoryToCheck = existing ?
    //     beforeLast(files[0].path ? removePathInitialSlash(files[0].path) : files[0].webkitRelativePath, '/') :
    //     beforeLast(file.path ? removePathInitialSlash(file.path) : file.webkitRelativePath, '/');

    const fileName = beforeLast(existing ? files[0].name : file.name, '.');

    const etsFiles = files.filter(x => {
        const testPath = x.path ? x.path : x.webkitRelativePath;
        if (!testPath.includes(".ets")) {
            return false;
        }

        return testPath.includes(fileName);
    });

    if (etsFiles.length === 0) {
        return { status: ImageUpload.SlideStatus.MissingFiles, errors: [`Missing folder: ${file.name.split('.').shift()}`], files: includedFiles };
    }

    for (let i = 0; i < etsFiles.length; i++) {
        childFolders.push(beforeLast(etsFiles[i].path ? removePathInitialSlash(etsFiles[i].path) : etsFiles[i].webkitRelativePath, '/'))
    }

    return { status: ImageUpload.SlideStatus.Queued, errors: [], files: includedFiles.concat(etsFiles), childFolder: childFolders };
}

// Validate AFI file
async function afiChecker(file, files, existing = false) {
    let includedFiles = [file];
    let missingFiles = [];

    files = files.filter(x => x);

    //Read afi file
    let afiData = await readFileAsText(file);
    if (!afiData) {
        return { status: ImageUpload.SlideStatus.MissingFiles, errors: [file.name + " couldn't be opened"], files: includedFiles };
    }

    //Parse afi file
    let afi = parseAfi(afiData);
    if (!afi) {
        return { status: ImageUpload.SlideStatus.MissingFiles, errors: [file.name + " couldn't be parsed"], files: includedFiles };
    }

    //Check if all other files included
    const svses = (afi["#document"] && afi["#document"].ImageList && afi["#document"].ImageList.Image) ? afi["#document"].ImageList.Image : null;
    const noOfSvs = (svses && svses.length) ? svses.length : 0;

    if (files.length === 0) {
        missingFiles = svses.map(({ Path }) => Path);
        return { status: ImageUpload.SlideStatus.MissingFiles, errors: missingFiles, files: includedFiles };
    }

    const directoryToCheck = existing ?
        beforeLast(files[0].path ? removePathInitialSlash(files[0].path) : files[0].webkitRelativePath, '/') :
        beforeLast(file.path ? removePathInitialSlash(file.path) : file.webkitRelativePath, '/');

    for (let i = 0; i < noOfSvs; i++) {
        const svsName = svses[i].Path;
        const svsFile = files.find(x => removePathInitialSlash(x.path) === directoryToCheck + '/' + svsName || x.webkitRelativePath === directoryToCheck + '/' + svsName || x.name.toLowerCase() === svsName.toLowerCase());
        if (!svsFile) {
            missingFiles.push(svsName + " missing");
        } else {
            includedFiles.push(svsFile);
        }
    }

    return { status: missingFiles.length === 0 ? ImageUpload.SlideStatus.Queued : ImageUpload.SlideStatus.MissingFiles, errors: missingFiles, files: includedFiles, childFolder: "" };
}

// Validate NDPIS file
async function ndpisChecker(file, files, existing = false) {
    let includedFiles = [file];
    let missingFiles = [];

    files = files.filter(x => x);

    //Read ndpis file
    let ndpisData = await readFileAsText(file);
    if (!ndpisData) {
        return { status: ImageUpload.SlideStatus.MissingFiles, errors: [file.name + " couldn't be opened"], files: includedFiles };
    }

    //Parse ndpis file
    let ndpis = parseIni(ndpisData);
    if (!ndpis) {
        return { status: ImageUpload.SlideStatus.MissingFiles, errors: [file.name + " couldn't be parsed"], files: includedFiles };
    }

    //Check if all other files included
    const noOfNdpisFiles = ndpis["NanoZoomer Digital Pathology Image Set"].NoImages;

    if (files.length === 0) {
        for (let i = 0; i < noOfNdpisFiles; i++) {
            missingFiles.push(ndpis["NanoZoomer Digital Pathology Image Set"]["Image" + i]);
        }
        return { status: ImageUpload.SlideStatus.MissingFiles, errors: missingFiles, files: includedFiles };
    }

    for (let i = 0; i < noOfNdpisFiles; i++) {
        const ndpiName = ndpis["NanoZoomer Digital Pathology Image Set"]["Image" + i];
        const ndpiFile = files.find(({ name }) => name === ndpiName);
        if (!ndpiFile) {
            missingFiles.push(ndpiName + " missing");
        } else {
            includedFiles.push(ndpiFile);
        }
    }

    return { status: missingFiles.length === 0 ? ImageUpload.SlideStatus.Queued : ImageUpload.SlideStatus.MissingFiles, errors: missingFiles, files: includedFiles, childFolder: "" };
}

async function multiFileSlideChecker(ext, file, files, existing = false) {
    switch (ext.toLowerCase()) {
        case "mrxs":
            return await mrxsChecker(file, files, existing);
        case "vsi":
            return await vsiChecker(file, files, existing);
        case "afi":
            return await afiChecker(file, files, existing);
        case "ndpis":
            return await ndpisChecker(file, files, existing);
        default:
            return { status: ImageUpload.SlideStatus.NotSupported, errors: ["Not supported format"], files: [] };
    }
}

function beforeUnloadListener(e) {
    e.preventDefault();
    let confirmationMessage = Resources.translate("Uploading is in progess. If you leave now, your uploads will be lost.");

    (e || window.event).returnValue = confirmationMessage;
    return confirmationMessage;
}

async function startUploading() {
    let that = this;
    if (this.lastLoadedImages.length === 0) {
        return;
    }

    this.uploading = true;
    if (this.notifyOnExit) {
        window.addEventListener("beforeunload", beforeUnloadListener);
    }

    for (; ;) {
        const slide = this.lastLoadedImages.find(x => x.status === ImageUpload.SlideStatus.Queued);
        if (!slide || !this.uploadingStatus) {
            break;
        }

        await this.context.uploadSlide({
            serverUrl: this.serverUrl,
            targetPath: slide.targetPath,
            targetChildPath: slide.targetChildPath,
            slide: slide,
            progress: (s, p) => {
                let slideIndex = that.lastLoadedImages.findIndex(x => x.id === s.id);
                if (slideIndex === -1) {
                    return;
                }
                that.lastLoadedImages[slideIndex].uploadProgress = p;
                that.lastLoadedImages[slideIndex].status = ImageUpload.SlideStatus.Uploading;
                that.fireEvent(Events.OnProgress, {
                    type: "uploadProgress",
                    slide: that.lastLoadedImages[slideIndex],
                    slides: that.lastLoadedImages,
                });
                updateResults.call(that);
            },
            success: (s) => {
                let slideIndex = that.lastLoadedImages.findIndex(x => x.id === s.id);
                if (slideIndex === -1) {
                    return;
                }
                that.lastLoadedImages[slideIndex].uploadProgress = 100;
                that.lastLoadedImages[slideIndex].status = ImageUpload.SlideStatus.Done;
                that.fireEvent(Events.OnProgress, {
                    type: "uploadSuccess",
                    slide: that.lastLoadedImages[slideIndex],
                    slides: that.lastLoadedImages,
                });
                updateResults.call(that);
            },
            error: (slide, errorMessage) => {
                let slideIndex = that.lastLoadedImages.findIndex(x => x.id === slide.id);
                if (slideIndex === -1) {
                    return;
                }
                that.lastLoadedImages[slideIndex].uploadProgress = 100;
                that.lastLoadedImages[slideIndex].status = ImageUpload.SlideStatus.Error;
                that.lastLoadedImages[slideIndex].error = errorMessage;
                that.fireEvent(Events.OnProgress, {
                    type: "uploadError",
                    slide: that.lastLoadedImages[slideIndex],
                    slides: that.lastLoadedImages,
                });
                updateResults.call(that);
            }
        });

        updateResults.call(this);
    }

    this.uploading = false;
    if (this.notifyOnExit) {
        window.removeEventListener("beforeunload", beforeUnloadListener);
    }
}

function getStatus(status, msg) {
    const popover = '<span class="image-upload-tooltip" data-tip="' + msg + '" tabindex="1"><i style="cursor: pointer;" class="fa fa-question-circle-o" /></span>';
    switch (status) {
        case ImageUpload.SlideStatus.MissingFiles:
            return '<span style="color: purple;">' + Resources.translate("Files missing") + '&nbsp;&nbsp;' + popover + '</span>';
        case ImageUpload.SlideStatus.Queued:
            return '<span style="color: #ffa700;">' + Resources.translate("Queued") + '&nbsp;&nbsp;<i class="fa fa-clock-o"/></span>';
        case ImageUpload.SlideStatus.Error:
            return '<span style="color: red;">' + Resources.translate("Error") + '&nbsp;&nbsp;' + popover + '</span>';
        case ImageUpload.SlideStatus.Done:
            return '<span style="color: green;">' + Resources.translate("Uploaded") + '&nbsp;&nbsp;<i class="fa fa-check-circle-o"/></span>';
        case ImageUpload.SlideStatus.Uploading:
            return '<span style="color: blue;">' + Resources.translate("Uploading") + '&nbsp;&nbsp;<i class="fa fa-upload"/></span>';
        case ImageUpload.SlideStatus.NotSupported:
            return '<span>' + Resources.translate("Not supported") + '&nbsp;&nbsp;' + popover + '</span>';
    }
}

function getProgress(status, progress) {
    let html = '<div class="meter ';
    switch (status) {
        case ImageUpload.SlideStatus.MissingFiles:
        case ImageUpload.SlideStatus.Error:
        case ImageUpload.SlideStatus.NotSupported:
            html += "danger";
            progress = 100;
            break;
        case ImageUpload.SlideStatus.Queued:
            html += "warning";
            progress = 100;
            break;
        case ImageUpload.SlideStatus.Done:
            html += "success";
            progress = 100;
            break;
        case ImageUpload.SlideStatus.Uploading:
            html += "info";
            break;
    }
    html += '"><span style="width: ' + progress + '%"></span></div>';
    return html;
}

function updateResults() {
    let that = this;
    if (!this.statusElement) {
        return;
    }

    const resultsDiv = this.statusElement;
    while (resultsDiv.firstChild) {
        resultsDiv.removeChild(resultsDiv.firstChild);
    }

    if (this.lastLoadedImages.length === 0) {
        return;
    }

    const resultsMain = document.createElement('div');
    const resultsTable = document.createElement('table');
    const resultsHeader = document.createElement('tr');
    let resultsHeaderCell = document.createElement('th');
    resultsHeaderCell.className = "text-center";
    resultsHeaderCell.innerText = "#";
    resultsHeader.appendChild(resultsHeaderCell);
    resultsHeaderCell = document.createElement('th');
    resultsHeaderCell.innerText = Resources.translate("Name");
    resultsHeader.appendChild(resultsHeaderCell);
    // resultsHeaderCell = document.createElement('th');
    // resultsHeaderCell.innerText = Resources.translate("Relative Path");
    // resultsHeader.appendChild(resultsHeaderCell);
    resultsHeaderCell = document.createElement('th');
    resultsHeaderCell.className = "text-right";
    resultsHeaderCell.innerText = Resources.translate("Size (in bytes)");
    resultsHeader.appendChild(resultsHeaderCell);
    resultsHeaderCell = document.createElement('th');
    resultsHeaderCell.innerText = Resources.translate("Target Path");
    resultsHeader.appendChild(resultsHeaderCell);
    resultsHeaderCell = document.createElement('th');
    resultsHeaderCell.className = "text-center";
    resultsHeaderCell.innerText = Resources.translate("Status");
    resultsHeader.appendChild(resultsHeaderCell);
    resultsHeaderCell = document.createElement('th');
    resultsHeaderCell.className = "text-center";
    resultsHeaderCell.innerText = Resources.translate("Progress");
    resultsHeader.appendChild(resultsHeaderCell);
    resultsHeaderCell = document.createElement('th');
    resultsHeaderCell.className = "text-center";
    resultsHeader.appendChild(resultsHeaderCell);
    resultsTable.appendChild(resultsHeader);
    this.lastLoadedImages.forEach((x, i) => {
        let resultsTableRow = document.createElement('tr');
        let resultsTableRowCell = document.createElement('th');
        resultsTableRowCell.className = "text-center";
        resultsTableRowCell.innerText = i + 1;
        resultsTableRow.appendChild(resultsTableRowCell);
        resultsTableRowCell = document.createElement('td');
        resultsTableRowCell.innerText = x.name;
        resultsTableRow.appendChild(resultsTableRowCell);
        // resultsTableRowCell = document.createElement('td'); 
        resultsTableRowCell = document.createElement('td');
        resultsTableRowCell.className = "text-right";
        resultsTableRowCell.innerText = x.size.toLocaleString("en");
        resultsTableRow.appendChild(resultsTableRowCell);
        resultsTableRowCell = document.createElement('td');
        resultsTableRowCell.innerText = x.targetPath;
        resultsTableRow.appendChild(resultsTableRowCell);
        resultsTableRowCell = document.createElement('td');
        resultsTableRowCell.className = "text-center";
        resultsTableRowCell.innerHTML = getStatus(x.status, x.error);
        resultsTableRow.appendChild(resultsTableRowCell);
        resultsTableRowCell = document.createElement('td');
        resultsTableRowCell.className = "text-center";
        resultsTableRowCell.innerHTML = getProgress(x.status, x.uploadProgress);
        resultsTableRow.appendChild(resultsTableRowCell);
        resultsTableRowCell = document.createElement('td');
        resultsTableRowCell.className = "text-center";
        let removeBtn = document.createElement("button");
        removeBtn.title = Resources.translate("Remove slide from list");
        removeBtn.innerHTML = '<i class="fa fa-trash-o" aria-hidden="true"></i>';
        removeBtn.onclick = function () {
            that.removeSlide(i);
        };
        removeBtn.disabled = x.status === ImageUpload.SlideStatus.Uploading;
        resultsTableRowCell.appendChild(removeBtn);
        resultsTableRow.appendChild(resultsTableRowCell);
        resultsTable.appendChild(resultsTableRow);
    });
    resultsMain.appendChild(resultsTable);
    resultsDiv.appendChild(resultsMain);
}

/**
 * Organizes a list of files into a tree structure.
 * @param {File[]} fileList 
 * @return {ImageUpload~TreeNode[]}
 * @ignore
 */
function buildTree(fileList) {
    const tree = {};

    for (let i = 0; i < fileList.length; i++) {
        // for each file in the provided list
        const file = fileList[i];
        const pathParts = file.path ? removePathInitialSlash(file.path).split('/') : file.webkitRelativePath.split('/'); // Split the relative path into an array of parts

        let currentNode = tree;
        // for each sub-directory
        for (let j = 0; j < pathParts.length; j++) {
            const part = pathParts[j];
            if (!currentNode.children) {
                // Create an empty array for children if it doesn't exist
                currentNode.children = [];
            }

            let childNode = currentNode.children.find(child => child.name === part);
            if (!childNode) {
                // Create a new child node if it doesn't exist
                childNode = { name: part, children: [] };
                currentNode.children.push(childNode);
            }

            if (j === pathParts.length - 1) {
                // Set the leaf node's properties
                childNode.file = file;
                childNode.path = file.path ? removePathInitialSlash(file.path) : file.webkitRelativePath;
                childNode.isLeaf = true;
                delete childNode.children;
            } else {
                childNode.isLeaf = false;
            }

            currentNode = childNode; // Traverse to the next level
        }
    }

    return tree.children[0];
}

function getAllFiles(treeNode) {
    let files = [];

    function traverse(node) {
        if (node.isLeaf && node.path) {
            files.push(node.file);
        }

        if (node.children && Array.isArray(node.children)) {
            node.children.forEach(child => {
                traverse(child);
            });
        }
    }

    traverse(treeNode);

    return files;
}

async function checkSlidesForMissingFiles(treeNode, filesArray) {
    if (!treeNode) {
        return;
    }

    let mffSlideIndex = -1;
    if (!treeNode.name) {
        if (treeNode.isLeaf) {
            let indexesToDelete = [];
            for (let i = 0; i < filesArray.length; i++) {
                const f = filesArray[i];
                mffSlideIndex = this.lastLoadedImages.findIndex(x => x.error.toLowerCase().includes(f.name.toLowerCase()) && x.status === ImageUpload.SlideStatus.MissingFiles);
                if (mffSlideIndex != -1) {
                    const mffSlide = this.lastLoadedImages[mffSlideIndex];
                    const ext = mffSlide.name.split('.').pop();
                    const checkResult = await multiFileSlideChecker(ext, mffSlide.files[0], [...mffSlide.files].concat(f), true);
                    this.lastLoadedImages[mffSlideIndex] = {
                        id: mffSlide.id,
                        name: mffSlide.name,
                        files: checkResult.files,
                        status: checkResult.status,
                        error: checkResult.errors.join(", "),
                        targetPath: mffSlide.targetPath,
                        relativePath: mffSlide.relativePath,
                        targetChildPath: checkResult.childFolder,
                        size: checkResult.files.map(f => (f && f.size) ? f.size : 0).reduce((accumulator, current) => accumulator + current, 0),
                        uploadProgress: 0,
                    };
                    indexesToDelete.push(filesArray.findIndex(({ name }) => name.toLowerCase() === f.name.toLowerCase()));
                }
            }

            indexesToDelete.filter(x => x !== -1).forEach(x => {
                filesArray[x] = undefined;
            });

            filesArray = filesArray.filter(x => x !== undefined);
        }
    } else {
        const folderName = treeNode.name;
        mffSlideIndex = this.lastLoadedImages.findIndex(x => folderName.toLowerCase().includes(x.name.toLowerCase().split('.').shift()) && x.status === ImageUpload.SlideStatus.MissingFiles);
        if (mffSlideIndex != -1) {
            const mffSlide = this.lastLoadedImages[mffSlideIndex];
            const ext = mffSlide.name.split('.').pop();
            const checkResult = await multiFileSlideChecker(ext, mffSlide.files[0], ([...mffSlide.files]).concat(getAllFiles(treeNode)), true);
            this.lastLoadedImages[mffSlideIndex] = {
                id: mffSlide.id,
                name: mffSlide.name,
                files: checkResult.files,
                status: checkResult.status,
                error: checkResult.errors.join(", "),
                targetPath: mffSlide.targetPath,
                relativePath: mffSlide.relativePath,
                targetChildPath: checkResult.childFolder,
                size: checkResult.files.map(f => (f && f.size) ? f.size : 0).reduce((accumulator, current) => accumulator + current, 0),
                uploadProgress: 0,
            };
        }
    }
}

async function checkSlidesAdded() {
    if (this.lastLoadedImages.length === 0) {
        return;
    }

    const brokenSlides = this.lastLoadedImages.filter(({ status }) => status === ImageUpload.SlideStatus.MissingFiles);
    let indexesToDelete = [];

    for (let i = 0; i < brokenSlides.length; i++) {
        for (let j = 0; j < this.lastLoadedImages.length; j++) {
            const s = this.lastLoadedImages[j];
            const mffSlide = brokenSlides[i];
            if (mffSlide.error.toLowerCase().includes(s.name.toLowerCase())) {
                const ext = mffSlide.name.split('.').pop();
                const mffSlideIndex = this.lastLoadedImages.findIndex(({ id }) => id === mffSlide.id);
                if (mffSlideIndex !== -1) {
                    const checkResult = await multiFileSlideChecker(ext, mffSlide.files[0], [...mffSlide.files].concat(...s.files), true);
                    this.lastLoadedImages[mffSlideIndex] = {
                        id: mffSlide.id,
                        name: mffSlide.name,
                        files: checkResult.files,
                        status: checkResult.status,
                        error: checkResult.errors.join(", "),
                        targetPath: mffSlide.targetPath,
                        relativePath: mffSlide.relativePath,
                        targetChildPath: checkResult.childFolder,
                        size: checkResult.files.map(f => (f && f.size) ? f.size : 0).reduce((accumulator, current) => accumulator + current, 0),
                        uploadProgress: 0,
                    };
                    brokenSlides[i] = this.lastLoadedImages[mffSlideIndex];
                    indexesToDelete.push(j);
                }
            }
        }
    }


    indexesToDelete.forEach(x => {
        this.lastLoadedImages[x] = undefined;
    });

    this.lastLoadedImages = this.lastLoadedImages.filter(x => x !== undefined);
}

async function removeAuxilliarySlides() {
    if (this.lastLoadedImages.length === 0) {
        return;
    }

    const normalMFFSlides = this.lastLoadedImages.filter(x => x.status === ImageUpload.SlideStatus.Queued && x.files.length > 1);
    let indexesToDelete = [];

    for (let i = 0; i < normalMFFSlides.length; i++) {
        const mffSlide = normalMFFSlides[i];
        for (let j = 1; j < mffSlide.files.length; j++) {
            const mffFile = mffSlide.files[j];
            const sffSlideIndex = this.lastLoadedImages.findIndex(({ name }) => name === mffFile.name);
            if (sffSlideIndex !== -1) {
                indexesToDelete.push(sffSlideIndex);
            }
        }
    }

    indexesToDelete.forEach(x => {
        this.lastLoadedImages[x] = undefined;
    });

    this.lastLoadedImages = this.lastLoadedImages.filter(x => x !== undefined);
}

async function checkFile(file, filesArray, targetPath) {
    if (!file) {
        return
    }

    const ext = file.name.split('.').pop();
    if (file.type === "image/jpeg") {
        return {
            id: getRandomId(),
            name: file.name,
            files: [file],
            status: ImageUpload.SlideStatus.Queued,
            error: "",
            targetPath: targetPath,
            relativePath: file.path ? beforeLast(removePathInitialSlash(file.path), '/') + "/" : beforeLast(file.webkitRelativePath, '/') + "/",
            size: file.size,
            uploadProgress: 0,
        };
    } else if (singleFileFormatExts.includes(ext)) {
        return {
            id: getRandomId(),
            name: file.name,
            files: [file],
            status: ImageUpload.SlideStatus.Queued,
            error: "",
            targetPath: targetPath,
            relativePath: file.path ? beforeLast(removePathInitialSlash(file.path), '/') + "/" : beforeLast(file.webkitRelativePath, '/') + "/",
            size: file.size,
            uploadProgress: 0,
        };
    } else if (multiFileFormatExts.includes(ext)) {
        const checkResult = await multiFileSlideChecker(ext, file, filesArray);
        return {
            id: getRandomId(),
            name: file.name,
            files: checkResult.files,
            status: checkResult.status,
            error: checkResult.errors.join(", "),
            targetPath: targetPath,
            relativePath: file.path ? beforeLast(removePathInitialSlash(file.path), '/') + "/" : beforeLast(file.webkitRelativePath, '/') + "/",
            targetChildPath: checkResult.childFolder,
            size: checkResult.files.map(f => (f && f.size) ? f.size : 0).reduce((accumulator, current) => accumulator + current, 0),
            uploadProgress: 0,
        };
    }
}

/**
 * Traverses the tree of file objects breadth first and finds slides, which are coupled with their auxiliary files if provided.
 * @param {ImageUpload~TreeNode} root - The tree top node
 * @param {string} targetPath - The path the slides must be uploaded to
 * @returns {ImageUpload~Slide[] | undefined}
 * @ignore
 */
async function findSlides(root, targetPath) {
    // Check if the tree is empty
    if (!root) {
        console.Error('Tree is empty');
        return;
    }

    // Initialize an array to store results and an object to keep track of nodes by level
    const result = [];
    const nodesByLevel = {};

    // Define a recursive function to traverse the tree
    async function traverse(parentNode, currentNode, currentLevel, targetPath) {
        // Base case: if the node is null, return
        if (!currentNode) {
            return;
        }

        // Add the current node's data to the result array for the current level
        if (!nodesByLevel[currentLevel]) {
            nodesByLevel[currentLevel] = [];
            result.push(nodesByLevel[currentLevel]);
        }

        if (!currentNode.isLeaf) {
            if (currentNode.parsed) {
                return;
            }
            // Recursively traverse the children of the current node
            const children = currentNode.children.sort((a, b) => b.isLeaf ? 1 : -1);
            for (let i = 0; i < children.length; i++) {
                const childNode = currentNode.children[i];
                await traverse(currentNode, childNode, currentLevel + 1, targetPath);
            }
        } else {
            currentNode.parsed = true;
            if (parentNode) {
                const folderName = beforeLast(currentNode.name, '.');
                const child = parentNode.children.find(c => c.name === folderName);
                if (child) {
                    const files = child.children.map(x => x.file);
                    nodesByLevel[currentLevel].push(await checkFile.call(this, currentNode.file, files, targetPath));
                    child.parsed = true;
                } else {
                    nodesByLevel[currentLevel].push(await checkFile.call(this, currentNode.file, [], targetPath));
                }
            } else {
                nodesByLevel[currentLevel].push(await checkFile.call(this, currentNode.file, [], targetPath));
            }
        }
    }

    // Start the traversal with the root node at level 0
    await traverse(null, root, 0, targetPath);

    // Return the result array
    return result;
}

export
    /**
     * Represents a UI component that allows the uploading of single- and multi-file format slides to PMA.core servers. Provides events to handle uploading progress, as well as ready to use upload and status components.
     * @memberof PMA.UI.Components
     * @alias ImageUpload
     * @param {Context} context
     * @param {object} options - Configuration options
     * @param {string} options.serverUrl - A string indicating the PMA.core server to upload to.
     * @param {string|HTMLElement} [options.uploadElement] - Optional selector or HTML element where the UI for browsing and dragging files & folders will be rendered. If not provided, no UI will be rendered.
     * @param {string|HTMLElement} [options.statusElement] - Optional selector or HTML element where each image’s status will be displayed. This element will display a list of slides and also give the option to the user to remove slides.
     * @param {boolean} [options.notifyOnExit] - A boolean indicating whether or not an alert should appear on leaving page while uploading.
     * @fires PMA.UI.Components.Events.OnProgress
     */
    class ImageUpload {
    constructor(context, options) {
        if (!checkBrowserCompatibility()) {
            return;
        }

        if (!options.serverUrl) {
            console.error("No server url defined in options");
        }

        if (options.uploadElement) {
            if (options.uploadElement instanceof HTMLElement) {
                this.uploadElement = options.uploadElement;
            }
            else if (typeof options.uploadElement == "string") {
                let el = document.querySelector(options.uploadElement);
                if (!el) {
                    console.error("Invalid selector for upload element");
                }
                else {
                    this.uploadElement = el;
                }
            }
            else {
                console.error("Invalid upload element");
                return;
            }
        }

        if (options.statusElement) {
            if (options.statusElement instanceof HTMLElement) {
                this.statusElement = options.statusElement;
            }
            else if (typeof options.statusElement == "string") {
                let el = document.querySelector(options.statusElement);
                if (!el) {
                    console.error("Invalid selector for status element");
                }
                else {
                    this.statusElement = el;
                }
            }
            else {
                console.error("Invalid status element");
                return;
            }
        }

        this.listeners = {};
        this.listeners[Events.OnProgress] = [];

        this.serverUrl = options.serverUrl;
        this.notifyOnExit = options.notifyOnExit ? true : false;
        this.context = context;

        // a helper array that holds the last loaded images as objects  { serverUrl, sessionId, imageArray }
        this.lastLoadedImages = [];
        this.uploadingStatus = false;
        this.uploading = false;

        $(this.uploadElement).addClass("pma-ui-image-upload");
        $(this.statusElement).addClass("pma-ui-image-upload-status");
    }

    /**
     * Attaches an event listener
     * @param {PMA.UI.Components.Events} eventName - The name of the event to listen to
     * @param {function} callback - The function to call when the event occurs
     */
    listen(eventName, callback) {
        // if (!this.listeners.hasOwnProperty(eventName)) {
        if (!Object.prototype.hasOwnProperty.call(this.listeners, eventName)) {
            console.error(eventName + " is not a valid event");
        }

        this.listeners[eventName].push(callback);
    }

    // fires an event
    fireEvent(eventName, eventArgs) {
        // if (!this.listeners.hasOwnProperty(eventName)) {
        if (!Object.prototype.hasOwnProperty.call(this.listeners, eventName)) {
            console.error(eventName + " does not exist");
            return;
        }

        for (let i = 0, max = this.listeners[eventName].length; i < max; i++) {
            this.listeners[eventName][i].call(this, eventArgs);
        }
    }

    /**
     * 
     * @param  {File[]} files - The array of File objects to add to upload list
     * @param  {string} targetPath - The path to upload images to
     * @fires PMA.UI.Components.Events.OnProgress
     */
    async addFiles(files, targetPath) {
        if (targetPath === null || targetPath === undefined || targetPath.length === 0) return;

        const filesArray = Array.from(files);
        if (filesArray.length === 0) {
            return;
        }

        const tree = buildTree(filesArray);

        let slidesToAdd = [];
        if (this.lastLoadedImages.length > 0) {
            await checkSlidesForMissingFiles.call(this, tree, filesArray);
        }

        // if (tree.children) {
        //     slidesToAdd = (await findSlides(tree, targetPath)).filter(x => x).flatMap(x => x);
        // } else {
        for (let i = 0; i < filesArray.length; i++) {
            slidesToAdd.push(await checkFile.call(this, filesArray[i], filesArray, targetPath));
        }
        // }

        this.lastLoadedImages = this.lastLoadedImages.concat(slidesToAdd.filter(x => x));

        await checkSlidesAdded.call(this);
        await removeAuxilliarySlides.call(this);

        this.fireEvent(Events.OnProgress, {
            type: "uploadAdded",
            slide: null,
            slides: this.lastLoadedImages,
        });
        updateResults.call(this);
        if (this.uploadingStatus === true && this.uploading === false) {
            startUploading.call(this);
        }
    }

    /**
     * Returns the list of processed slides, or null
     * @return {ImageUpload~Slide}
     */
    getSlides() {
        return this.lastLoadedImages;
    }

    /**
     * Removes a slide from the list
     * @param  {Number} index - The index of the slide to remove
     * @fires PMA.UI.Components.Events.OnProgress
     */
    removeSlide(index) {
        if (index === null || index === undefined || index < 0 || index >= this.lastLoadedImages.length) {
            return;
        }
        let slideToRemove = this.lastLoadedImages[index];
        this.lastLoadedImages.splice(index, 1);
        this.fireEvent(Events.OnProgress, {
            type: "uploadRemoved",
            slide: slideToRemove,
            slides: this.lastLoadedImages,
        });
        updateResults.call(this);
    }

    /**
     * Pauses or resumes uploading.
     * @param  {boolean} enable - Resumes uploading if true, else pauses uploading
     * @fires PMA.UI.Components.Events.OnProgress
     */
    setUploadingStatus(enable) {
        this.uploadingStatus = enable;
        this.fireEvent(Events.OnProgress, {
            type: enable ? "uploadingEnabled" : "uploadingDisabled",
            slide: null,
            slides: this.lastLoadedImages,
        });
        if (enable && this.uploading === false) {
            startUploading.call(this);
        }
    }

    /**
     * Returns the uploading status
     * @return {boolean}
     */
    getUploadingStatus() {
        return this.uploadingStatus;
    }
}

/**
 *
 * @typedef ImageUpload~Slide
 * @property {string} id - Upload id
 * @property {Array<File>} files - Files contained in slide
 * @property {ImageUpload.SlideStatus} status - Current slide uploadsing status
 * @property {string} error - Errors returned from slide checking
 * @property {string} targetPath - Virtual path slide will be uploaded to
 * @property {string} relativePath - Relative path of slide in local filesystem
 * @property {number} size - Total size of slide
 * @property {number} uploadProgress - Upload progress
 */

/**
 * ImageUpload slide status
 * @readonly
 * @enum {number}
 */
ImageUpload.SlideStatus = {
    /**
     * Slide is missing files
     */
    MissingFiles: 0,

    /**
     * Slide is queued for upload
     */
    Queued: 1,

    /**
     * Slide is uploading
     */
    Uploading: 2,

    /**
     * Slide uploading was unsuccessful
     */
    Error: 3,

    /**
     * Slide uploaded successfully
     */
    Done: 4,

    /**
     * Slide not supported
     */
    NotSupported: 5,
};


// /**
//  * @typedef ImageUpload~TreeNode
//  * @property {string} name - File or directory name
//  * @property {ImageUpload~TreeNode[]} children - Child nodes
//  * @property {File} file - The file object for file nodes
//  * @property {string} path - Relative path
//  * @property {boolean} isLeaf
//  */