/*
 * MIT License
 * Copyright (c) 2008 Ishan Arora <ishan@qbittorrent.org> & Christophe Dumez <chris@qbittorrent.org>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

/**************************************************************

    Script      : Dynamic Table
    Version     : 0.5
    Authors     : Ishan Arora & Christophe Dumez
    Desc        : Programmable sortable table
    Licence     : Open Source MIT Licence

 **************************************************************/

"use strict";

window.qBittorrent ??= {};
window.qBittorrent.DynamicTable ??= (() => {
    const exports = () => {
        return {
            TorrentsTable: TorrentsTable,
            TorrentPeersTable: TorrentPeersTable,
            SearchResultsTable: SearchResultsTable,
            SearchPluginsTable: SearchPluginsTable,
            TorrentTrackersTable: TorrentTrackersTable,
            TorrentFilesTable: TorrentFilesTable,
            BulkRenameTorrentFilesTable: BulkRenameTorrentFilesTable,
            AddTorrentFilesTable: AddTorrentFilesTable,
            LogMessageTable: LogMessageTable,
            LogPeerTable: LogPeerTable,
            RssFeedTable: RssFeedTable,
            RssArticleTable: RssArticleTable,
            RssDownloaderRulesTable: RssDownloaderRulesTable,
            RssDownloaderFeedSelectionTable: RssDownloaderFeedSelectionTable,
            RssDownloaderArticlesTable: RssDownloaderArticlesTable,
            TorrentWebseedsTable: TorrentWebseedsTable,
            TorrentCreationTasksTable: TorrentCreationTasksTable,
        };
    };

    const compareNumbers = (val1, val2) => {
        if (val1 < val2)
            return -1;
        if (val1 > val2)
            return 1;
        return 0;
    };

    const localPreferences = new window.qBittorrent.LocalPreferences.LocalPreferences();
    const clientData = window.qBittorrent.ClientData ?? window.parent.qBittorrent.ClientData;

    class DynamicTable {
        #DynamicTableHeaderContextMenuClass = null;

        setup(dynamicTableDivId, dynamicTableFixedHeaderDivId, contextMenu) {
            this.dynamicTableDivId = dynamicTableDivId;
            this.dynamicTableFixedHeaderDivId = dynamicTableFixedHeaderDivId;
            this.dynamicTableDiv = document.getElementById(dynamicTableDivId);
            this.useVirtualList = clientData.get("use_virtual_list") === true;
            this.fixedTableHeader = document.querySelector(`#${dynamicTableFixedHeaderDivId} thead tr`);
            this.table = this.dynamicTableDiv.querySelector("table");
            this.colgroup = document.createElement("colgroup");
            this.table.prepend(this.colgroup);
            this.tableBody = this.dynamicTableDiv.querySelector("tbody");
            this.rowHeight = (clientData.get("display_density") === "compact") ? 22 : 26;
            this.rows = new Map();
            this.cachedElements = [];
            this.selectedRows = [];
            this.columns = [];
            this.contextMenu = contextMenu;
            this.sortedColumn = localPreferences.get(`sorted_column_${this.dynamicTableDivId}`, 0);
            this.reverseSort = localPreferences.get(`reverse_sort_${this.dynamicTableDivId}`, "0");
            this.initColumns();
            this.loadColumnsOrder();
            this.updateTableHeaders();
            this.setupCommonEvents();
            this.setupHeaderEvents();
            this.setupHeaderMenu();
            this.setupAltRow();
            this.setupVirtualList();
        }

        setupVirtualList() {
            if (!this.useVirtualList)
                return;
            this.table.style.position = "relative";

            this.renderedOffset = this.dynamicTableDiv.scrollTop;
            this.renderedHeight = this.dynamicTableDiv.offsetHeight;
            const resizeCallback = window.qBittorrent.Misc.createDebounceHandler(100, () => {
                const height = this.dynamicTableDiv.offsetHeight;
                const needRerender = this.renderedHeight < height;
                this.renderedHeight = height;
                if (needRerender)
                    this.rerender();
            });
            new ResizeObserver(resizeCallback).observe(this.dynamicTableDiv);
        }

        setupCommonEvents() {
            const tableFixedHeaderDiv = document.getElementById(this.dynamicTableFixedHeaderDivId);

            const tableElement = tableFixedHeaderDiv.querySelector("table");
            this.dynamicTableDiv.addEventListener("scroll", (e) => {
                tableElement.style.left = `${-this.dynamicTableDiv.scrollLeft}px`;
                // rerender on scroll
                if (this.useVirtualList) {
                    this.renderedOffset = this.dynamicTableDiv.scrollTop;
                    this.rerender();
                }
            });

            this.dynamicTableDiv.addEventListener("click", (e) => {
                const tr = e.target.closest("tr");
                if (!tr) {
                    // clicking on the table body deselects all rows
                    this.deselectAll();
                    this.setRowClass();
                    return;
                }

                if (e.ctrlKey || e.metaKey) {
                    // CTRL/CMD ⌘ key was pressed
                    if (this.isRowSelected(tr.rowId))
                        this.deselectRow(tr.rowId);
                    else
                        this.selectRow(tr.rowId);
                }
                else if (e.shiftKey && (this.selectedRows.length === 1)) {
                    // Shift key was pressed
                    this.selectRows(this.getSelectedRowId(), tr.rowId);
                }
                else {
                    // Simple selection
                    this.deselectAll();
                    this.selectRow(tr.rowId);
                }
            });

            this.dynamicTableDiv.addEventListener("contextmenu", (e) => {
                const tr = e.target.closest("tr");
                if (!tr)
                    return;

                if (!this.isRowSelected(tr.rowId)) {
                    this.deselectAll();
                    this.selectRow(tr.rowId);
                }
            }, true);

            this.dynamicTableDiv.addEventListener("touchstart", (e) => {
                const tr = e.target.closest("tr");
                if (!tr)
                    return;

                if (!this.isRowSelected(tr.rowId)) {
                    this.deselectAll();
                    this.selectRow(tr.rowId);
                }
            }, { passive: true });

            this.dynamicTableDiv.addEventListener("keydown", (e) => {
                const tr = e.target.closest("tr");
                if (!tr)
                    return;

                switch (e.key) {
                    case "ArrowUp":
                        e.preventDefault();
                        this.selectPreviousRow();
                        this.dynamicTableDiv.querySelector(".selected").scrollIntoView({ block: "nearest" });
                        break;

                    case "ArrowDown":
                        e.preventDefault();
                        this.selectNextRow();
                        this.dynamicTableDiv.querySelector(".selected").scrollIntoView({ block: "nearest" });
                        break;
                }
            });
        }

        setupHeaderEvents() {
            this.currentHeaderAction = "";
            this.canResize = false;

            const resetElementBorderStyle = (el, side) => {
                if ((side === "left") || (side !== "right"))
                    el.style.borderLeft = "";
                if ((side === "right") || (side !== "left"))
                    el.style.borderRight = "";
            };

            const mouseMoveFn = function(e) {
                const brect = e.target.getBoundingClientRect();
                const mouseXRelative = e.clientX - brect.left;
                if (this.currentHeaderAction === "") {
                    if ((brect.width - mouseXRelative) < 5) {
                        this.resizeTh = e.target;
                        this.canResize = true;
                        e.target.closest("tr").style.cursor = "col-resize";
                    }
                    else if ((mouseXRelative < 5) && e.target.getPrevious('[class=""]')) {
                        this.resizeTh = e.target.getPrevious('[class=""]');
                        this.canResize = true;
                        e.target.closest("tr").style.cursor = "col-resize";
                    }
                    else {
                        this.canResize = false;
                        e.target.closest("tr").style.cursor = "";
                    }
                }
                if (this.currentHeaderAction === "drag") {
                    const previousVisibleSibling = e.target.getPrevious('[class=""]');
                    let borderChangeElement = previousVisibleSibling;
                    let changeBorderSide = "right";

                    if (mouseXRelative > (brect.width / 2)) {
                        borderChangeElement = e.target;
                        this.dropSide = "right";
                    }
                    else {
                        this.dropSide = "left";
                    }

                    e.target.closest("tr").style.cursor = "move";

                    if (!previousVisibleSibling) { // right most column
                        borderChangeElement = e.target;

                        if (mouseXRelative <= (brect.width / 2))
                            changeBorderSide = "left";
                    }

                    const borderStyle = "solid #e60";
                    if (changeBorderSide === "left") {
                        borderChangeElement.style.borderLeft = borderStyle;
                        borderChangeElement.style.borderLeftWidth = "initial";
                    }
                    else {
                        borderChangeElement.style.borderRight = borderStyle;
                        borderChangeElement.style.borderRightWidth = "initial";
                    }

                    resetElementBorderStyle(borderChangeElement, ((changeBorderSide === "right") ? "left" : "right"));

                    borderChangeElement.getSiblings('[class=""]').each((el) => {
                        resetElementBorderStyle(el);
                    });
                }
                this.lastHoverTh = e.target;
                this.lastClientX = e.clientX;
            }.bind(this);

            const mouseOutFn = (e) => {
                resetElementBorderStyle(e.target);
            };

            const onBeforeStart = function(el) {
                this.clickedTh = el;
                this.currentHeaderAction = "start";
                this.dragMovement = false;
                this.dragStartX = this.lastClientX;
            }.bind(this);

            const onStart = function(el, event) {
                if (this.canResize) {
                    this.currentHeaderAction = "resize";
                    this.startWidth = Number.parseInt(this.resizeTh.style.width, 10);
                }
                else {
                    this.currentHeaderAction = "drag";
                    el.style.backgroundColor = "#C1D5E7";
                }
            }.bind(this);

            const onDrag = function(el, event) {
                if (this.currentHeaderAction === "resize") {
                    let width = this.startWidth + (event.event.pageX - this.dragStartX);
                    if (width < 16)
                        width = 16;

                    this.#setColumnWidth(this.resizeTh.columnName, width);
                }
            }.bind(this);

            const onComplete = function(el, event) {
                resetElementBorderStyle(this.lastHoverTh);
                el.style.backgroundColor = "";
                if (this.currentHeaderAction === "resize")
                    this.saveColumnWidth(this.resizeTh.columnName);
                if ((this.currentHeaderAction === "drag") && (el !== this.lastHoverTh)) {
                    this.saveColumnsOrder();
                    const val = localPreferences.get(`columns_order_${this.dynamicTableDivId}`).split(",");
                    val.erase(el.columnName);
                    let pos = val.indexOf(this.lastHoverTh.columnName);
                    if (this.dropSide === "right")
                        ++pos;
                    val.splice(pos, 0, el.columnName);
                    localPreferences.set(`columns_order_${this.dynamicTableDivId}`, val.join(","));
                    const oldIndexByName = new Map(this.columns.map((col, i) => [col.name, i]));
                    this.loadColumnsOrder();
                    this.updateTableHeaders();
                    for (const tr of this.cachedElements) {
                        if (!tr)
                            continue;
                        const tds = [...tr.children];
                        tr.replaceChildren(...this.columns.map(col => tds[oldIndexByName.get(col.name)]));
                    }
                    this.tableBody.replaceChildren();
                    this.updateTable(true);
                    this.reselectRows(this.selectedRowsIds());
                }
                if (this.currentHeaderAction === "drag") {
                    resetElementBorderStyle(el);
                    el.getSiblings('[class=""]').each((el) => {
                        resetElementBorderStyle(el);
                    });
                }
                this.currentHeaderAction = "";
            }.bind(this);

            const onCancel = function(el) {
                this.currentHeaderAction = "";

                // ignore click/touch events performed when on the column's resize area
                if (!this.canResize)
                    this.setSortedColumn(el.columnName);
            }.bind(this);

            const onTouch = function(e) {
                const column = e.target.columnName;
                this.currentHeaderAction = "";
                this.setSortedColumn(column);
            }.bind(this);

            const onDoubleClick = function(e) {
                e.preventDefault();
                this.currentHeaderAction = "";

                // only resize when hovering on the column's resize area
                if (this.canResize) {
                    this.currentHeaderAction = "resize";
                    this.autoResizeColumn(e.target.columnName);
                    onComplete(e.target);
                }
            }.bind(this);

            for (const th of this.getRowCells(this.fixedTableHeader)) {
                th.addEventListener("mousemove", mouseMoveFn);
                th.addEventListener("mouseout", mouseOutFn);
                th.addEventListener("touchend", onTouch, { passive: true });
                th.addEventListener("dblclick", onDoubleClick);
                th.makeResizable({
                    modifiers: {
                        x: "",
                        y: ""
                    },
                    onBeforeStart: onBeforeStart,
                    onStart: onStart,
                    onDrag: onDrag,
                    onComplete: onComplete,
                    onCancel: onCancel
                });
            }
        }

        setupDynamicTableHeaderContextMenuClass() {
            this.#DynamicTableHeaderContextMenuClass ??= class extends window.qBittorrent.ContextMenu.ContextMenu {
                updateMenuItems() {
                    for (let i = 0; i < this.dynamicTable.columns.length; ++i) {
                        if (this.dynamicTable.columns[i].caption === "")
                            continue;
                        if (this.dynamicTable.columns[i].visible !== "0")
                            this.setItemChecked(this.dynamicTable.columns[i].name, true);
                        else
                            this.setItemChecked(this.dynamicTable.columns[i].name, false);
                    }
                }
            };
        }

        showColumn(columnName, show) {
            this.columns[columnName].visible = show ? "1" : "0";
            localPreferences.set(`column_${columnName}_visible_${this.dynamicTableDivId}`, show ? "1" : "0");
            this.updateColumn(columnName);
            this.columns[columnName].onVisibilityChange?.(columnName);
        }

        #calculateColumnBodyWidth(column) {
            const columnIndex = this.getColumnPos(column.name);
            const bodyColumn = this.getRowCells(this.fixedTableHeader)[columnIndex];
            const canvas = document.createElement("canvas");
            const context = canvas.getContext("2d");
            context.font = window.getComputedStyle(bodyColumn, null).getPropertyValue("font");

            const longestTd = { value: "", width: 0 };
            for (const tr of this.getTrs()) {
                const tds = this.getRowCells(tr);
                const td = tds[columnIndex];

                const buffer = column.calculateBuffer(tr.rowId);
                const valueWidth = context.measureText(td.textContent).width;
                if ((valueWidth + buffer) > (longestTd.width)) {
                    longestTd.value = td.textContent;
                    longestTd.width = valueWidth + buffer;
                }
            }

            // slight buffer to prevent clipping
            return longestTd.width + 10;
        }

        #setColumnWidth(columnName, width) {
            const column = this.columns[columnName];
            column.width = width;

            const pos = this.getColumnPos(column.name);
            const style = `width: ${column.width}px; ${column.style}`;
            this.getRowCells(this.fixedTableHeader)[pos].style.cssText = style;
            this.colgroup.children[pos].style.width = `${column.width}px`;
            // rerender on column resize
            if (this.useVirtualList)
                this.rerender();

            column.onResize?.(column.name);
        }

        autoResizeColumn(columnName) {
            const column = this.columns[columnName];

            let width = column.staticWidth ?? 0;
            if (column.staticWidth === null) {
                // check required min body width
                const bodyTextWidth = this.#calculateColumnBodyWidth(column);

                // check required min header width
                const columnIndex = this.getColumnPos(column.name);
                const headColumn = document.getElementById(this.dynamicTableFixedHeaderDivId).querySelectorAll("tr>th")[columnIndex];
                const canvas = document.createElement("canvas");
                const context = canvas.getContext("2d");
                context.font = window.getComputedStyle(headColumn, null).getPropertyValue("font");
                const columnTitle = column.caption;
                const sortedIconWidth = 20;
                const headTextWidth = context.measureText(columnTitle).width + sortedIconWidth;

                width = Math.max(headTextWidth, bodyTextWidth);
            }

            this.#setColumnWidth(column.name, width);
            this.saveColumnWidth(column.name);
        }

        saveColumnWidth(columnName) {
            localPreferences.set(`column_${columnName}_width_${this.dynamicTableDivId}`, this.columns[columnName].width);
        }

        setupHeaderMenu() {
            this.setupDynamicTableHeaderContextMenuClass();

            const menuId = `${this.dynamicTableDivId}_headerMenu`;

            // reuse menu if already exists
            let ul = document.getElementById(menuId);
            if (ul === null) {
                ul = document.createElement("ul");
                ul.id = menuId;
                ul.className = "contextMenu scrollableMenu";
            }

            const createLi = (columnName, text) => {
                const anchor = document.createElement("a");
                anchor.href = `#${columnName}`;
                anchor.textContent = text;

                const img = document.createElement("img");
                img.src = "images/checked-completed.svg";
                anchor.prepend(img);

                const listItem = document.createElement("li");
                listItem.appendChild(anchor);

                return listItem;
            };

            const actions = {
                autoResizeAction: function(element, ref, action) {
                    this.autoResizeColumn(element.columnName);
                }.bind(this),

                autoResizeAllAction: function(element, ref, action) {
                    for (const { name } of this.columns)
                        this.autoResizeColumn(name);
                }.bind(this),
            };

            const onMenuItemClicked = function(element, ref, action) {
                this.showColumn(action, this.columns[action].visible === "0");
            }.bind(this);

            // recreate child elements when reusing (enables the context menu to work correctly)
            ul.replaceChildren();

            for (let i = 0; i < this.columns.length; ++i) {
                const text = this.columns[i].caption;
                if (text === "")
                    continue;
                ul.appendChild(createLi(this.columns[i].name, text));
                actions[this.columns[i].name] = onMenuItemClicked;
            }

            const createResizeElement = (text, href) => {
                const anchor = document.createElement("a");
                anchor.href = href;
                anchor.textContent = text;

                const spacer = document.createElement("span");
                spacer.style = "display: inline-block; width: calc(.5em + 16px);";
                anchor.prepend(spacer);

                const li = document.createElement("li");
                li.appendChild(anchor);
                return li;
            };

            const autoResizeAllElement = createResizeElement("QBT_TR(Resize All)QBT_TR[CONTEXT=ListWidget]", "#autoResizeAllAction");
            const autoResizeElement = createResizeElement("QBT_TR(Resize)QBT_TR[CONTEXT=ListWidget]", "#autoResizeAction");

            ul.firstElementChild.classList.add("separator");
            ul.insertBefore(autoResizeAllElement, ul.firstElementChild);
            ul.insertBefore(autoResizeElement, ul.firstElementChild);
            document.body.append(ul);

            this.headerContextMenu = new this.#DynamicTableHeaderContextMenuClass({
                targets: `#${this.dynamicTableFixedHeaderDivId} tr th`,
                actions: actions,
                menu: menuId,
                offsets: {
                    x: 0,
                    y: 2
                }
            });

            this.headerContextMenu.dynamicTable = this;
        }

        initColumns() {}

        newColumn(name, style, caption, defaultWidth, defaultVisible) {
            const column = {};
            column["name"] = name;
            column["title"] = name;
            column["visible"] = localPreferences.get(`column_${name}_visible_${this.dynamicTableDivId}`, (defaultVisible ? "1" : "0"));
            column["force_hide"] = false;
            column["caption"] = caption;
            column["style"] = style;
            if (defaultWidth !== -1)
                column["width"] = localPreferences.get(`column_${name}_width_${this.dynamicTableDivId}`, defaultWidth);
            column["dataProperties"] = [name];
            column["getRowValue"] = function(row, pos) {
                if (pos === undefined)
                    pos = 0;
                return row["full_data"][this.dataProperties[pos]];
            };
            column["compareRows"] = function(row1, row2) {
                const value1 = this.getRowValue(row1);
                const value2 = this.getRowValue(row2);
                if ((typeof(value1) === "number") && (typeof(value2) === "number"))
                    return compareNumbers(value1, value2);
                return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
            };
            column["updateTd"] = function(td, row) {
                const value = this.getRowValue(row);
                td.textContent = value;
                td.title = value;
            };
            column["isVisible"] = function() {
                return (this.visible === "1") && !this.force_hide;
            };
            column["onResize"] = null;
            column["onVisibilityChange"] = null;
            column["staticWidth"] = null;
            column["calculateBuffer"] = () => 0;
            this.columns.push(column);
            this.columns[name] = column;

            const col = document.createElement("col");
            col.style.width = `${column.width}px`;
            this.colgroup.append(col);
            this.fixedTableHeader.append(document.createElement("th"));
        }

        loadColumnsOrder() {
            const columnsOrder = [];
            const val = localPreferences.get(`columns_order_${this.dynamicTableDivId}`);
            if ((val === null) || (val === undefined))
                return;
            for (const v of val.split(",")) {
                if ((v in this.columns) && (!columnsOrder.contains(v)))
                    columnsOrder.push(v);
            }

            for (let i = 0; i < this.columns.length; ++i) {
                if (!columnsOrder.contains(this.columns[i].name))
                    columnsOrder.push(this.columns[i].name);
            }

            for (let i = 0; i < this.columns.length; ++i)
                this.columns[i] = this.columns[columnsOrder[i]];
        }

        saveColumnsOrder() {
            let val = "";
            for (let i = 0; i < this.columns.length; ++i) {
                if (i > 0)
                    val += ",";
                val += this.columns[i].name;
            }
            localPreferences.set(`columns_order_${this.dynamicTableDivId}`, val);
        }

        updateTableHeaders() {
            this.updateHeader(this.fixedTableHeader);
            const cols = this.colgroup.children;
            for (let i = 0; i < this.columns.length; ++i) {
                cols[i].style.width = `${this.columns[i].width}px`;
                cols[i].classList.toggle("invisible", !this.columns[i].isVisible());
            }
            this.setSortedColumnIcon(this.sortedColumn, null, (this.reverseSort === "1"));
        }

        updateHeader(header) {
            const ths = this.getRowCells(header);
            for (const [i, th] of ths.entries()) {
                if (th.columnName !== this.columns[i].name) {
                    th.title = this.columns[i].caption;
                    th.textContent = this.columns[i].caption;
                    th.style.cssText = `width: ${this.columns[i].width}px; ${this.columns[i].style}`;
                    th.columnName = this.columns[i].name;
                    th.className = `column_${th.columnName}`;
                    th.classList.toggle("invisible", ((this.columns[i].visible === "0") || this.columns[i].force_hide));
                }
            }
        }

        getColumnPos(columnName) {
            for (let i = 0; i < this.columns.length; ++i) {
                if (this.columns[i].name === columnName)
                    return i;
            }
            return -1;
        }

        updateColumn(columnName, updateCellData = false) {
            const column = this.columns[columnName];
            const pos = this.getColumnPos(columnName);
            const fths = this.getRowCells(this.fixedTableHeader);
            const action = column.isVisible() ? "remove" : "add";
            fths[pos].classList[action]("invisible");
            this.colgroup.children[pos].classList[action]("invisible");

            for (const tr of this.getTrs()) {
                const td = this.getRowCells(tr)[pos];
                td.classList[action]("invisible");
                if (updateCellData)
                    column.updateTd(td, this.rows.get(tr.rowId));
            }
        }

        getSortedColumn() {
            return localPreferences.get(`sorted_column_${this.dynamicTableDivId}`);
        }

        /**
         * @param {string} column name to sort by
         * @param {string|null} reverse defaults to implementation-specific behavior when not specified. Should only be passed when restoring previous state.
         */
        setSortedColumn(column, reverse = null) {
            if (column !== this.sortedColumn) {
                const oldColumn = this.sortedColumn;
                this.sortedColumn = column;
                this.reverseSort = reverse ?? "0";
                this.setSortedColumnIcon(column, oldColumn, false);
            }
            else {
                // Toggle sort order
                this.reverseSort = reverse ?? (this.reverseSort === "0" ? "1" : "0");
                this.setSortedColumnIcon(column, null, (this.reverseSort === "1"));
            }
            localPreferences.set(`sorted_column_${this.dynamicTableDivId}`, column);
            localPreferences.set(`reverse_sort_${this.dynamicTableDivId}`, this.reverseSort);
            this.updateTable(false);
        }

        setSortedColumnIcon(newColumn, oldColumn, isReverse) {
            const getCol = (headerDivId, colName) => {
                const colElem = document.querySelectorAll(`#${headerDivId} .column_${colName}`);
                if (colElem.length === 1)
                    return colElem[0];
                return null;
            };

            const colElem = getCol(this.dynamicTableFixedHeaderDivId, newColumn);
            if (colElem !== null) {
                colElem.classList.add("sorted");
                colElem.classList.toggle("reverse", isReverse);
            }
            const oldColElem = getCol(this.dynamicTableFixedHeaderDivId, oldColumn);
            if (oldColElem !== null)
                oldColElem.classList.remove("sorted", "reverse");
        }

        getSelectedRowId() {
            if (this.selectedRows.length > 0)
                return this.selectedRows[0];
            return "";
        }

        isRowSelected(rowId) {
            return this.selectedRows.contains(rowId);
        }

        setupAltRow() {
            const useAltRowColors = (clientData.get("use_alt_row_colors") ?? true) === true;
            if (useAltRowColors)
                document.getElementById(this.dynamicTableDivId).classList.add("altRowColors");
        }

        selectAll() {
            this.deselectAll();
            for (const row of this.getFilteredAndSortedRows())
                this.selectedRows.push(row.rowId);
            this.setRowClass();
        }

        deselectAll() {
            this.selectedRows.empty();
        }

        selectRow(rowId) {
            this.selectedRows.push(rowId);
            this.setRowClass();
            this.onSelectedRowChanged();
        }

        deselectRow(rowId) {
            this.selectedRows.erase(rowId);
            this.setRowClass();
            this.onSelectedRowChanged();
        }

        selectRows(rowId1, rowId2) {
            this.deselectAll();
            if (rowId1 === rowId2) {
                this.selectRow(rowId1);
                return;
            }

            let select = false;
            for (const row of this.getFilteredAndSortedRows()) {
                if ((row.rowId === rowId1) || (row.rowId === rowId2)) {
                    select = !select;
                    this.selectedRows.push(row.rowId);
                }
                else if (select) {
                    this.selectedRows.push(row.rowId);
                }
            }
            this.setRowClass();
            this.onSelectedRowChanged();
        }

        reselectRows(rowIds) {
            this.deselectAll();
            this.selectedRows = rowIds.slice();
            this.setRowClass();
        }

        setRowClass() {
            for (const tr of this.getTrs())
                tr.classList.toggle("selected", this.isRowSelected(tr.rowId));
        }

        onSelectedRowChanged() {}

        updateRowData(data) {
            // ensure rowId is a string
            const rowId = `${data["rowId"]}`;
            let row;

            if (!this.rows.has(rowId)) {
                row = {
                    full_data: {},
                    rowId: rowId
                };
                this.rows.set(rowId, row);
            }
            else {
                row = this.rows.get(rowId);
            }

            row["data"] = data;
            for (const x in data) {
                if (!Object.hasOwn(data, x))
                    continue;
                row["full_data"][x] = data[x];
            }
        }

        /**
         * Replaces all rows with the provided set.
         * More efficient than calling clear() + updateRowData() because existing <tr>
         * elements are reused.
         * @param {Object[]} rows - Array of row objects, each with a rowId property
         */
        setRows(rows) {
            const newRowIds = new Set();
            for (const row of rows) {
                this.updateRowData(row);
                newRowIds.add(`${row.rowId}`);
            }
            for (const rowId of this.rows.keys()) {
                if (!newRowIds.has(rowId))
                    this.rows.delete(rowId);
            }
        }

        getTrs() {
            return this.tableBody.querySelectorAll("tr");
        }

        getRowCells(tr) {
            return tr.querySelectorAll("td, th");
        }

        getRow(rowId) {
            return this.rows.get(rowId);
        }

        getFilteredAndSortedRows() {
            const filteredRows = [];

            for (const row of this.getRowValues()) {
                filteredRows.push(row);
                filteredRows[row.rowId] = row;
            }

            const column = this.columns[this.sortedColumn];
            const isReverseSort = (this.reverseSort === "0");
            filteredRows.sort((row1, row2) => {
                const result = column.compareRows(row1, row2);
                return isReverseSort ? result : -result;
            });
            return filteredRows;
        }

        getTrByRowId(rowId) {
            return Array.prototype.find.call(this.getTrs(), (tr => tr.rowId === rowId));
        }

        updateTable(fullUpdate = false) {
            const rows = this.getFilteredAndSortedRows();
            const rowIds = new Set(rows.map(row => row.rowId));
            for (let i = 0; i < this.selectedRows.length; ++i) {
                if (!rowIds.has(this.selectedRows[i])) {
                    this.selectedRows.splice(i, 1);
                    --i;
                }
            }

            if (this.useVirtualList) {
                // rerender on table update
                this.rerender(rows);
            }
            else {
                const trs = [...this.getTrs()];
                const trMap = new Map(trs.map(tr => [tr.rowId, tr]));

                for (const row of rows) {
                    const rowId = row.rowId;
                    const existingTr = trMap.get(rowId);
                    if (existingTr !== undefined) {
                        this.updateRow(existingTr, fullUpdate);
                    }
                    else {
                        const tr = this.createRowElement(row);

                        // TODO look into using DocumentFragment or appending all trs at once for add'l performance gains
                        // add to end of table - we'll move into the proper order later
                        this.tableBody.appendChild(tr);
                        trMap.set(rowId, tr);

                        this.updateRow(tr, true);
                    }
                }

                // reorder table rows
                let prevTr = null;
                for (const [rowPos, { rowId }] of rows.entries()) {
                    const tr = trMap.get(rowId);
                    trMap.delete(rowId);

                    const isInCorrectLocation = rowId === trs[rowPos]?.rowId;
                    if (!isInCorrectLocation) {
                        // move row into correct location
                        if (prevTr === null) {
                            // insert as first row in table
                            if (trs.length === 0)
                                this.tableBody.append(tr);
                            else
                                trs[0].before(tr);
                        }
                        else {
                            prevTr.after(tr);
                        }
                    }
                    prevTr = tr;
                }

                for (const tr of trMap.values())
                    tr.remove();
            }
        }

        rerender(rows = this.getFilteredAndSortedRows()) {
            // set the scrollable height
            const tableHeight = rows.length * this.rowHeight;
            if (tableHeight !== this.previousTableHeight) {
                this.previousTableHeight = tableHeight;
                this.table.style.height = `${tableHeight}px`;
            }

            if (this.renderedHeight === 0)
                return;
            // show extra rows at top/bottom to reduce flickering
            const extraRowCount = 20;
            // how many rows can be shown in the visible area
            const visibleRowCount = Math.ceil(this.renderedHeight / this.rowHeight) + (extraRowCount * 2);
            // start position of visible rows, offsetted by renderedOffset
            let startRow = Math.max((Math.trunc(this.renderedOffset / this.rowHeight) - extraRowCount), 0);
            // ensure startRow is even
            if ((startRow % 2) === 1)
                startRow = Math.max(0, startRow - 1);
            const endRow = Math.min((startRow + visibleRowCount), rows.length);

            const elements = [];
            for (let i = startRow; i < endRow; ++i) {
                const row = rows[i];
                if (row === undefined)
                    continue;
                const offset = i * this.rowHeight;
                const position = i - startRow;
                // reuse existing elements
                let element = this.cachedElements[position];
                if (element !== undefined)
                    this.updateRowElement(element, row.rowId, offset);
                else
                    element = this.cachedElements[position] = this.createRowElement(row, offset);
                elements.push(element);
            }
            const hadFocus = this.dynamicTableDiv.contains(document.activeElement);
            this.tableBody.replaceChildren(...elements);

            // update row classes
            this.setRowClass();

            // update visible rows
            for (const row of this.tableBody.children)
                this.updateRow(row, true);

            // restore focus so keyboard navigation continues working
            if (hadFocus)
                this.getTrByRowId(this.getSelectedRowId())?.focus({ preventScroll: true });
        }

        createRowElement(row, top = -1) {
            const tr = document.createElement("tr");
            // set tabindex so element receives keydown events
            // more info: https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event
            tr.tabIndex = -1;

            for (let k = 0; k < this.columns.length; ++k) {
                const td = document.createElement("td");
                if ((this.columns[k].visible === "0") || this.columns[k].force_hide)
                    td.classList.add("invisible");
                tr.append(td);
            }

            this.updateRowElement(tr, row.rowId, top);

            // update context menu
            this.contextMenu?.addTarget(tr);
            return tr;
        }

        updateRowElement(tr, rowId, top) {
            tr.dataset.rowId = rowId;
            tr.rowId = rowId;

            tr.className = "";

            if (this.useVirtualList) {
                tr.style.position = "absolute";
                tr.style.top = `${top}px`;
                tr.style.height = `${this.rowHeight}px`;
            }
        }

        getRowData(row, fullUpdate) {
            return row[fullUpdate ? "full_data" : "data"];
        }

        updateRow(tr, fullUpdate) {
            const row = this.rows.get(tr.rowId);
            const data = this.getRowData(row, fullUpdate);

            const tds = this.getRowCells(tr);
            for (let i = 0; i < this.columns.length; ++i) {
                // required due to position: absolute breaks table layout
                if (this.useVirtualList) {
                    tds[i].style.width = `${this.columns[i].width}px`;
                    tds[i].style.maxWidth = `${this.columns[i].width}px`;
                }
                if (this.columns[i].dataProperties.some(prop => Object.hasOwn(data, prop)))
                    this.columns[i].updateTd(tds[i], row);
            }
            row.data = {};
        }

        removeRow(rowId) {
            this.selectedRows.erase(rowId);
            this.rows.delete(rowId);
            if (this.useVirtualList) {
                this.rerender();
            }
            else {
                const tr = this.getTrByRowId(rowId);
                tr?.remove();
            }
        }

        clear() {
            this.deselectAll();
            this.rows.clear();
            if (this.useVirtualList) {
                this.rerender();
            }
            else {
                for (const tr of this.getTrs())
                    tr.remove();
            }
        }

        selectedRowsIds() {
            return this.selectedRows.slice();
        }

        getRowIds() {
            return this.rows.keys();
        }

        getRowValues() {
            return this.rows.values();
        }

        getRowItems() {
            return this.rows.entries();
        }

        getRowSize() {
            return this.rows.size;
        }

        selectNextRow() {
            const visibleRows = Array.prototype.filter.call(this.getTrs(), (tr => !tr.classList.contains("invisible") && (tr.style.display !== "none")));
            const selectedRowId = this.getSelectedRowId();

            const selectedIndex = visibleRows.findIndex(row => (row.getAttribute("data-row-id") === selectedRowId));

            const isLastRowSelected = (selectedIndex >= (visibleRows.length - 1));
            if (!isLastRowSelected) {
                this.deselectAll();
                const newRow = visibleRows[selectedIndex + 1];
                this.selectRow(newRow.getAttribute("data-row-id"));
                newRow.focus({ preventScroll: true });
            }
        }

        selectPreviousRow() {
            const visibleRows = Array.prototype.filter.call(this.getTrs(), (tr => !tr.classList.contains("invisible") && (tr.style.display !== "none")));
            const selectedRowId = this.getSelectedRowId();

            const selectedIndex = visibleRows.findIndex(row => (row.getAttribute("data-row-id") === selectedRowId));

            const isFirstRowSelected = selectedIndex <= 0;
            if (!isFirstRowSelected) {
                this.deselectAll();
                const newRow = visibleRows[selectedIndex - 1];
                this.selectRow(newRow.getAttribute("data-row-id"));
                newRow.focus({ preventScroll: true });
            }
        }
    }

    class TorrentsTable extends DynamicTable {
        setupVirtualList() {
            super.setupVirtualList();
            this.rowHeight = (clientData.get("display_density") === "compact") ? 18 : 22;
        }

        initColumns() {
            this.newColumn("priority", "", "#", 30, true);
            this.newColumn("state_icon", "", "QBT_TR(Status Icon)QBT_TR[CONTEXT=TransferListModel]", 30, false);
            this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TransferListModel]", 200, true);
            this.newColumn("size", "", "QBT_TR(Size)QBT_TR[CONTEXT=TransferListModel]", 100, true);
            this.newColumn("total_size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=TransferListModel]", 85, true);
            this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TransferListModel]", 100, true);
            this.newColumn("num_seeds", "", "QBT_TR(Seeds)QBT_TR[CONTEXT=TransferListModel]", 100, true);
            this.newColumn("num_leechs", "", "QBT_TR(Peers)QBT_TR[CONTEXT=TransferListModel]", 100, true);
            this.newColumn("dlspeed", "", "QBT_TR(Down Speed)QBT_TR[CONTEXT=TransferListModel]", 100, true);
            this.newColumn("upspeed", "", "QBT_TR(Up Speed)QBT_TR[CONTEXT=TransferListModel]", 100, true);
            this.newColumn("eta", "", "QBT_TR(ETA)QBT_TR[CONTEXT=TransferListModel]", 100, true);
            this.newColumn("ratio", "", "QBT_TR(Ratio)QBT_TR[CONTEXT=TransferListModel]", 100, true);
            this.newColumn("popularity", "", "QBT_TR(Popularity)QBT_TR[CONTEXT=TransferListModel]", 100, true);
            this.newColumn("category", "", "QBT_TR(Category)QBT_TR[CONTEXT=TransferListModel]", 100, true);
            this.newColumn("tags", "", "QBT_TR(Tags)QBT_TR[CONTEXT=TransferListModel]", 100, true);
            this.newColumn("creation_date", "", "QBT_TR(Created On)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("added_on", "", "QBT_TR(Added On)QBT_TR[CONTEXT=TransferListModel]", 100, true);
            this.newColumn("completion_on", "", "QBT_TR(Completed On)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("tracker", "", "QBT_TR(Tracker)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("dl_limit", "", "QBT_TR(Down Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("up_limit", "", "QBT_TR(Up Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("downloaded", "", "QBT_TR(Downloaded)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("uploaded", "", "QBT_TR(Uploaded)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("downloaded_session", "", "QBT_TR(Session Downloaded)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("uploaded_session", "", "QBT_TR(Session Uploaded)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("amount_left", "", "QBT_TR(Remaining)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("time_active", "", "QBT_TR(Time Active)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("save_path", "", "QBT_TR(Save path)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("completed", "", "QBT_TR(Completed)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("max_ratio", "", "QBT_TR(Ratio Limit)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("seen_complete", "", "QBT_TR(Last Seen Complete)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("last_activity", "", "QBT_TR(Last Activity)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("availability", "", "QBT_TR(Availability)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("download_path", "", "QBT_TR(Incomplete Save Path)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("infohash_v1", "", "QBT_TR(Info Hash v1)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("infohash_v2", "", "QBT_TR(Info Hash v2)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("reannounce", "", "QBT_TR(Reannounce In)QBT_TR[CONTEXT=TransferListModel]", 100, false);
            this.newColumn("private", "", "QBT_TR(Private)QBT_TR[CONTEXT=TransferListModel]", 100, false);

            this.columns["state_icon"].dataProperties[0] = "state";
            this.columns["name"].dataProperties.push("state");
            this.columns["num_seeds"].dataProperties.push("num_complete");
            this.columns["num_leechs"].dataProperties.push("num_incomplete");
            this.columns["time_active"].dataProperties.push("seeding_time");

            this.initColumnsFunctions();
        }

        initColumnsFunctions() {
            const getStateIconClasses = (state) => {
                let stateClass = "stateUnknown";
                // normalize states
                switch (state) {
                    case "forcedDL":
                    case "metaDL":
                    case "forcedMetaDL":
                    case "downloading":
                        stateClass = "stateDownloading";
                        break;
                    case "forcedUP":
                    case "uploading":
                        stateClass = "stateUploading";
                        break;
                    case "stalledUP":
                        stateClass = "stateStalledUP";
                        break;
                    case "stalledDL":
                        stateClass = "stateStalledDL";
                        break;
                    case "stoppedDL":
                        stateClass = "stateStoppedDL";
                        break;
                    case "stoppedUP":
                        stateClass = "stateStoppedUP";
                        break;
                    case "queuedDL":
                    case "queuedUP":
                        stateClass = "stateQueued";
                        break;
                    case "checkingDL":
                    case "checkingUP":
                    case "queuedForChecking":
                    case "checkingResumeData":
                        stateClass = "stateChecking";
                        break;
                    case "moving":
                        stateClass = "stateMoving";
                        break;
                    case "error":
                    case "unknown":
                    case "missingFiles":
                        stateClass = "stateError";
                        break;
                    default:
                        break; // do nothing
                }

                return `stateIcon ${stateClass}`;
            };

            // state_icon
            this.columns["state_icon"].updateTd = function(td, row) {
                const state = this.getRowValue(row);
                let div = td.firstElementChild;
                if (div === null) {
                    div = document.createElement("div");
                    td.append(div);
                }

                div.className = `${getStateIconClasses(state)} stateIconColumn`;
            };

            this.columns["state_icon"].onVisibilityChange = (columnName) => {
                // show state icon in name column only when standalone
                // state icon column is hidden
                this.updateColumn("name", true);
            };

            // name
            this.columns["name"].updateTd = function(td, row) {
                const name = this.getRowValue(row, 0);
                const state = this.getRowValue(row, 1);
                let span = td.firstElementChild;
                if (span === null) {
                    span = document.createElement("span");
                    td.append(span);
                }

                span.className = this.isStateIconShown() ? `${getStateIconClasses(state)}` : "";
                span.textContent = name;
                td.title = name;
            };

            this.columns["name"].isStateIconShown = () => !this.columns["state_icon"].isVisible();

            // status
            this.columns["status"].updateTd = function(td, row) {
                const state = this.getRowValue(row);
                if (!state)
                    return;

                let status;
                switch (state) {
                    case "downloading":
                        status = "QBT_TR(Downloading)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "stalledDL":
                        status = "QBT_TR(Stalled)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "metaDL":
                        status = "QBT_TR(Downloading metadata)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "forcedMetaDL":
                        status = "QBT_TR([F] Downloading metadata)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "forcedDL":
                        status = "QBT_TR([F] Downloading)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "uploading":
                    case "stalledUP":
                        status = "QBT_TR(Seeding)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "forcedUP":
                        status = "QBT_TR([F] Seeding)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "queuedDL":
                    case "queuedUP":
                        status = "QBT_TR(Queued)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "checkingDL":
                    case "checkingUP":
                        status = "QBT_TR(Checking)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "queuedForChecking":
                        status = "QBT_TR(Queued for checking)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "checkingResumeData":
                        status = "QBT_TR(Checking resume data)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "stoppedDL":
                        status = "QBT_TR(Stopped)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "stoppedUP":
                        status = "QBT_TR(Completed)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "moving":
                        status = "QBT_TR(Moving)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "missingFiles":
                        status = "QBT_TR(Missing Files)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    case "error":
                        status = "QBT_TR(Errored)QBT_TR[CONTEXT=TransferListDelegate]";
                        break;
                    default:
                        status = "QBT_TR(Unknown)QBT_TR[CONTEXT=HttpServer]";
                }

                td.textContent = status;
                td.title = status;
            };

            this.columns["status"].compareRows = (row1, row2) => {
                return compareNumbers(row1.full_data._statusOrder, row2.full_data._statusOrder);
            };

            // priority
            this.columns["priority"].updateTd = function(td, row) {
                const queuePos = this.getRowValue(row);
                const formattedQueuePos = (queuePos < 1) ? "*" : queuePos;
                td.textContent = formattedQueuePos;
                td.title = formattedQueuePos;
            };

            this.columns["priority"].compareRows = function(row1, row2) {
                let row1_val = this.getRowValue(row1);
                let row2_val = this.getRowValue(row2);
                if (row1_val < 1)
                    row1_val = 1000000;
                if (row2_val < 1)
                    row2_val = 1000000;
                return compareNumbers(row1_val, row2_val);
            };

            // name, category, tags
            this.columns["name"].compareRows = function(row1, row2) {
                const row1Val = this.getRowValue(row1);
                const row2Val = this.getRowValue(row2);
                return row1Val.localeCompare(row2Val, undefined, { numeric: true, sensitivity: "base" });
            };
            this.columns["category"].compareRows = this.columns["name"].compareRows;
            this.columns["tags"].compareRows = this.columns["name"].compareRows;

            // size, total_size
            this.columns["size"].updateTd = function(td, row) {
                const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
                td.textContent = size;
                td.title = size;
            };
            this.columns["total_size"].updateTd = this.columns["size"].updateTd;

            // progress
            this.columns["progress"].updateTd = function(td, row) {
                const progress = this.getRowValue(row);
                const progressFormatted = window.qBittorrent.Misc.toFixedPointString((progress * 100), 1);

                const div = td.firstElementChild;
                if (div !== null) {
                    if (div.getValue() !== progressFormatted)
                        div.setValue(progressFormatted);
                }
                else {
                    td.append(new window.qBittorrent.ProgressBar.ProgressBar(progressFormatted));
                }
            };
            this.columns["progress"].staticWidth = 100;

            // num_seeds
            this.columns["num_seeds"].updateTd = function(td, row) {
                const num_seeds = this.getRowValue(row, 0);
                const num_complete = this.getRowValue(row, 1);
                let value = num_seeds;
                if (num_complete !== -1)
                    value += ` (${num_complete})`;
                td.textContent = value;
                td.title = value;
            };
            this.columns["num_seeds"].compareRows = function(row1, row2) {
                const num_seeds1 = this.getRowValue(row1, 0);
                const num_complete1 = this.getRowValue(row1, 1);

                const num_seeds2 = this.getRowValue(row2, 0);
                const num_complete2 = this.getRowValue(row2, 1);

                const result = compareNumbers(num_complete1, num_complete2);
                if (result !== 0)
                    return result;
                return compareNumbers(num_seeds1, num_seeds2);
            };

            // num_leechs
            this.columns["num_leechs"].updateTd = this.columns["num_seeds"].updateTd;
            this.columns["num_leechs"].compareRows = this.columns["num_seeds"].compareRows;

            // dlspeed
            this.columns["dlspeed"].updateTd = function(td, row) {
                const speed = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), true);
                td.textContent = speed;
                td.title = speed;
            };

            // upspeed
            this.columns["upspeed"].updateTd = this.columns["dlspeed"].updateTd;

            // eta
            this.columns["eta"].updateTd = function(td, row) {
                const eta = window.qBittorrent.Misc.friendlyDuration(this.getRowValue(row), window.qBittorrent.Misc.MAX_ETA);
                td.textContent = eta;
                td.title = eta;
            };

            // ratio
            this.columns["ratio"].updateTd = function(td, row) {
                const ratio = this.getRowValue(row);
                const string = (ratio === -1) ? "∞" : window.qBittorrent.Misc.toFixedPointString(ratio, 2);
                td.textContent = string;
                td.title = string;
            };

            // popularity
            this.columns["popularity"].updateTd = function(td, row) {
                const value = this.getRowValue(row);
                const popularity = (value === -1) ? "∞" : window.qBittorrent.Misc.toFixedPointString(value, 2);
                td.textContent = popularity;
                td.title = popularity;
            };

            // creation_date
            this.columns["creation_date"].updateTd = function(td, row) {
                const val = this.getRowValue(row);
                if ((val === 0xffffffff) || (val < 0)) {
                    td.textContent = "";
                    td.title = "";
                }
                else {
                    const date = window.qBittorrent.Misc.formatDate(new Date(this.getRowValue(row) * 1000));
                    td.textContent = date;
                    td.title = date;
                }
            };

            // added on
            this.columns["added_on"].updateTd = function(td, row) {
                const date = window.qBittorrent.Misc.formatDate(new Date(this.getRowValue(row) * 1000));
                td.textContent = date;
                td.title = date;
            };

            // completion_on
            this.columns["completion_on"].updateTd = function(td, row) {
                const val = this.getRowValue(row);
                if ((val === 0xffffffff) || (val < 0)) {
                    td.textContent = "";
                    td.title = "";
                }
                else {
                    const date = window.qBittorrent.Misc.formatDate(new Date(this.getRowValue(row) * 1000));
                    td.textContent = date;
                    td.title = date;
                }
            };

            // tracker
            this.columns["tracker"].updateTd = function(td, row) {
                const value = this.getRowValue(row);
                const tracker = displayFullURLTrackerColumn ? value : window.qBittorrent.Misc.getHost(value);
                td.textContent = tracker;
                td.title = value;
            };

            //  dl_limit, up_limit
            this.columns["dl_limit"].updateTd = function(td, row) {
                const speed = this.getRowValue(row);
                if (speed === 0) {
                    td.textContent = "∞";
                    td.title = "∞";
                }
                else {
                    const formattedSpeed = window.qBittorrent.Misc.friendlyUnit(speed, true);
                    td.textContent = formattedSpeed;
                    td.title = formattedSpeed;
                }
            };

            this.columns["up_limit"].updateTd = this.columns["dl_limit"].updateTd;

            // downloaded, uploaded, downloaded_session, uploaded_session, amount_left
            this.columns["downloaded"].updateTd = this.columns["size"].updateTd;
            this.columns["uploaded"].updateTd = this.columns["size"].updateTd;
            this.columns["downloaded_session"].updateTd = this.columns["size"].updateTd;
            this.columns["uploaded_session"].updateTd = this.columns["size"].updateTd;
            this.columns["amount_left"].updateTd = this.columns["size"].updateTd;

            // time active
            this.columns["time_active"].updateTd = function(td, row) {
                const activeTime = this.getRowValue(row, 0);
                const seedingTime = this.getRowValue(row, 1);
                const time = (seedingTime > 0)
                    ? ("QBT_TR(%1 (seeded for %2))QBT_TR[CONTEXT=TransferListDelegate]"
                        .replace("%1", window.qBittorrent.Misc.friendlyDuration(activeTime))
                        .replace("%2", window.qBittorrent.Misc.friendlyDuration(seedingTime)))
                    : window.qBittorrent.Misc.friendlyDuration(activeTime);
                td.textContent = time;
                td.title = time;
            };

            // completed
            this.columns["completed"].updateTd = this.columns["size"].updateTd;

            // max_ratio
            this.columns["max_ratio"].updateTd = this.columns["ratio"].updateTd;

            // seen_complete
            this.columns["seen_complete"].updateTd = this.columns["completion_on"].updateTd;

            // last_activity
            this.columns["last_activity"].updateTd = function(td, row) {
                const val = this.getRowValue(row);
                if (val < 1) {
                    td.textContent = "∞";
                    td.title = "∞";
                }
                else {
                    const formattedVal = "QBT_TR(%1 ago)QBT_TR[CONTEXT=TransferListDelegate]".replace("%1", window.qBittorrent.Misc.friendlyDuration((Date.now() / 1000) - val));
                    td.textContent = formattedVal;
                    td.title = formattedVal;
                }
            };

            // availability
            this.columns["availability"].updateTd = function(td, row) {
                const value = window.qBittorrent.Misc.toFixedPointString(this.getRowValue(row), 3);
                td.textContent = value;
                td.title = value;
            };

            // infohash_v1
            this.columns["infohash_v1"].updateTd = function(td, row) {
                const sourceInfohashV1 = this.getRowValue(row);
                const infohashV1 = (sourceInfohashV1 !== "") ? sourceInfohashV1 : "QBT_TR(N/A)QBT_TR[CONTEXT=TransferListDelegate]";
                td.textContent = infohashV1;
                td.title = infohashV1;
            };

            // infohash_v2
            this.columns["infohash_v2"].updateTd = function(td, row) {
                const sourceInfohashV2 = this.getRowValue(row);
                const infohashV2 = (sourceInfohashV2 !== "") ? sourceInfohashV2 : "QBT_TR(N/A)QBT_TR[CONTEXT=TransferListDelegate]";
                td.textContent = infohashV2;
                td.title = infohashV2;
            };

            // reannounce
            this.columns["reannounce"].updateTd = function(td, row) {
                const time = window.qBittorrent.Misc.friendlyDuration(this.getRowValue(row));
                td.textContent = time;
                td.title = time;
            };

            // private
            this.columns["private"].updateTd = function(td, row) {
                const hasMetadata = row["full_data"].has_metadata;
                const isPrivate = this.getRowValue(row);
                const string = hasMetadata
                    ? (isPrivate
                        ? "QBT_TR(Yes)QBT_TR[CONTEXT=PropertiesWidget]"
                        : "QBT_TR(No)QBT_TR[CONTEXT=PropertiesWidget]")
                    : "QBT_TR(N/A)QBT_TR[CONTEXT=PropertiesWidget]";
                td.textContent = string;
                td.title = string;
            };
        }

        applyFilter(row, filterName, category, tag, trackerHost, filterTerms) {
            const { state, upspeed, dlspeed } = row["full_data"];
            let inactive = false;

            switch (filterName) {
                case "downloading":
                    if ((state !== "downloading") && !state.includes("DL"))
                        return false;
                    break;
                case "seeding":
                    if ((state !== "uploading") && (state !== "forcedUP") && (state !== "stalledUP") && (state !== "queuedUP") && (state !== "checkingUP"))
                        return false;
                    break;
                case "completed":
                    if ((state !== "uploading") && !state.includes("UP"))
                        return false;
                    break;
                case "stopped":
                    if (!state.includes("stopped"))
                        return false;
                    break;
                case "running":
                    if (state.includes("stopped"))
                        return false;
                    break;
                case "stalled":
                    if ((state !== "stalledUP") && (state !== "stalledDL"))
                        return false;
                    break;
                case "stalled_uploading":
                    if (state !== "stalledUP")
                        return false;
                    break;
                case "stalled_downloading":
                    if (state !== "stalledDL")
                        return false;
                    break;
                case "inactive":
                    inactive = true;
                    // fallthrough
                case "active": {
                    const r = (upspeed > 0) || (dlspeed > 0);
                    if (r === inactive)
                        return false;
                    break;
                }
                case "checking":
                    if ((state !== "checkingUP") && (state !== "checkingDL") && (state !== "checkingResumeData"))
                        return false;
                    break;
                case "moving":
                    if (state !== "moving")
                        return false;
                    break;
                case "errored":
                    if ((state !== "error") && (state !== "unknown") && (state !== "missingFiles"))
                        return false;
                    break;
            }

            switch (category) {
                case CATEGORIES_ALL:
                    break; // do nothing

                case CATEGORIES_UNCATEGORIZED:
                    if (row["full_data"].category.length > 0)
                        return false;
                    break; // do nothing

                default: {
                    const selectedCategory = window.qBittorrent.Client.categoryMap.get(category);
                    if (selectedCategory !== undefined) {
                        const selectedCategoryName = `${category}/`;
                        const torrentCategoryName = `${row["full_data"].category}/`;
                        if (!torrentCategoryName.startsWith(selectedCategoryName))
                            return false;
                    }
                    break;
                }
            }

            switch (tag) {
                case TAGS_ALL:
                    break; // do nothing

                case TAGS_UNTAGGED:
                    if (row["full_data"].tags.length > 0)
                        return false;
                    break; // do nothing

                default: {
                    const tags = row["full_data"].tags.split(", ");
                    if (!tags.contains(tag))
                        return false;
                    break;
                }
            }

            switch (trackerHost) {
                case TRACKERS_ALL:
                    break; // do nothing

                case TRACKERS_ANNOUNCE_ERROR:
                    if (!row["full_data"]["has_other_announce_error"])
                        return false;
                    break;

                case TRACKERS_ERROR:
                    if (!row["full_data"]["has_tracker_error"])
                        return false;
                    break;

                case TRACKERS_TRACKERLESS:
                    if (row["full_data"].trackers_count > 0)
                        return false;
                    break;

                case TRACKERS_WARNING:
                    if (!row["full_data"]["has_tracker_warning"])
                        return false;
                    break;

                default: {
                    const trackerTorrentMap = trackerMap.get(trackerHost);
                    if (trackerTorrentMap !== undefined) {
                        let found = false;
                        for (const torrents of trackerTorrentMap.values()) {
                            if (torrents.has(row["full_data"].rowId)) {
                                found = true;
                                break;
                            }
                        }
                        if (!found)
                            return false;
                    }
                    break;
                }
            }

            if ((filterTerms !== undefined) && (filterTerms !== null)) {
                const filterBy = document.getElementById("torrentsFilterSelect").value;
                const textToSearch = row["full_data"][filterBy].toLowerCase();
                if (filterTerms instanceof RegExp) {
                    if (!filterTerms.test(textToSearch))
                        return false;
                }
                else {
                    if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(textToSearch, filterTerms))
                        return false;
                }
            }

            return true;
        }

        getFilteredTorrentsNumber(filterName, category, tag, tracker) {
            let cnt = 0;

            for (const row of this.rows.values()) {
                if (this.applyFilter(row, filterName, category, tag, tracker, null))
                    ++cnt;
            }
            return cnt;
        }

        getFilteredTorrentsHashes(filterName, category, tag, tracker) {
            const rowsHashes = [];
            const useRegex = document.getElementById("torrentsFilterRegexBox").checked;
            const filterText = document.getElementById("torrentsFilterInput").value.trim().toLowerCase();
            let filterTerms;
            try {
                filterTerms = (filterText.length > 0)
                    ? (useRegex ? new RegExp(filterText) : filterText.split(" "))
                    : null;
            }
            catch (e) { // SyntaxError: Invalid regex pattern
                return filteredRows;
            }

            for (const row of this.rows.values()) {
                if (this.applyFilter(row, filterName, category, tag, tracker, filterTerms))
                    rowsHashes.push(row["rowId"]);
            }

            return rowsHashes;
        }

        getFilteredAndSortedRows() {
            const filteredRows = [];

            const useRegex = document.getElementById("torrentsFilterRegexBox").checked;
            const filterText = document.getElementById("torrentsFilterInput").value.trim().toLowerCase();
            let filterTerms;
            try {
                filterTerms = (filterText.length > 0)
                    ? (useRegex ? new RegExp(filterText) : filterText.split(" "))
                    : null;
            }
            catch (e) { // SyntaxError: Invalid regex pattern
                return filteredRows;
            }

            for (const row of this.rows.values()) {
                if (this.applyFilter(row, selectedStatus, selectedCategory, selectedTag, selectedTracker, filterTerms)) {
                    filteredRows.push(row);
                    filteredRows[row.rowId] = row;
                }
            }

            const column = this.columns[this.sortedColumn];
            const isReverseSort = (this.reverseSort === "0");
            filteredRows.sort((row1, row2) => {
                const result = column.compareRows(row1, row2);
                return isReverseSort ? result : -result;
            });
            return filteredRows;
        }

        setupCommonEvents() {
            super.setupCommonEvents();
            this.dynamicTableDiv.addEventListener("dblclick", (e) => {
                const tr = e.target.closest("tr");
                if (!tr)
                    return;

                this.deselectAll();
                this.selectRow(tr.rowId);
                const row = this.getRow(tr.rowId);
                const state = row["full_data"].state;

                const prefKey =
                    (state !== "uploading")
                    && (state !== "stoppedUP")
                    && (state !== "forcedUP")
                    && (state !== "stalledUP")
                    && (state !== "queuedUP")
                    && (state !== "checkingUP")
                    ? "dblclick_download"
                    : "dblclick_complete";

                if (clientData.get(prefKey) !== "1")
                    return true;

                if (state.includes("stopped"))
                    startFN();
                else
                    stopFN();
            });
        }

        getCurrentTorrentID() {
            return this.getSelectedRowId();
        }

        onSelectedRowChanged() {
            updatePropertiesPanel();
        }

        isStopped(hash) {
            const row = this.getRow(hash);
            return (row === undefined) ? true : row.full_data.state.includes("stopped");
        }
    }

    class TorrentPeersTable extends DynamicTable {
        initColumns() {
            this.newColumn("country", "", "QBT_TR(Country/Region)QBT_TR[CONTEXT=PeerListWidget]", 22, true);
            this.newColumn("ip", "", "QBT_TR(IP/Address)QBT_TR[CONTEXT=PeerListWidget]", 80, true);
            this.newColumn("port", "", "QBT_TR(Port)QBT_TR[CONTEXT=PeerListWidget]", 35, true);
            this.newColumn("connection", "", "QBT_TR(Connection)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
            this.newColumn("flags", "", "QBT_TR(Flags)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
            this.newColumn("client", "", "QBT_TR(Client)QBT_TR[CONTEXT=PeerListWidget]", 140, true);
            this.newColumn("peer_id_client", "", "QBT_TR(Peer ID Client)QBT_TR[CONTEXT=PeerListWidget]", 60, false);
            this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
            this.newColumn("dl_speed", "", "QBT_TR(Down Speed)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
            this.newColumn("up_speed", "", "QBT_TR(Up Speed)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
            this.newColumn("downloaded", "", "QBT_TR(Downloaded)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
            this.newColumn("uploaded", "", "QBT_TR(Uploaded)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
            this.newColumn("relevance", "", "QBT_TR(Relevance)QBT_TR[CONTEXT=PeerListWidget]", 30, true);
            this.newColumn("files", "", "QBT_TR(Files)QBT_TR[CONTEXT=PeerListWidget]", 100, true);

            this.columns["country"].dataProperties.push("country_code");
            this.columns["ip"].dataProperties.push("host_name");
            this.columns["flags"].dataProperties.push("flags_desc");
            this.initColumnsFunctions();
        }

        initColumnsFunctions() {
            // country
            this.columns["country"].updateTd = function(td, row) {
                const country = this.getRowValue(row, 0);
                const country_code = this.getRowValue(row, 1);

                let span = td.firstElementChild;
                if (span === null) {
                    span = document.createElement("span");
                    span.classList.add("flags");
                    td.append(span);
                }

                span.style.backgroundImage = `url('images/flags/${country_code || "xx"}.svg')`;
                span.textContent = country;
                td.title = country;
            };

            // ip
            this.columns["ip"].updateTd = function(td, row) {
                const ip = this.getRowValue(row, 0);
                const hostname = this.getRowValue(row, 1);
                const displayValue = (hostname?.length > 0) ? hostname : ip;
                td.textContent = displayValue;
                td.title = displayValue;
            };
            this.columns["ip"].compareRows = function(row1, row2) {
                const ip1 = this.getRowValue(row1);
                const ip2 = this.getRowValue(row2);

                return window.qBittorrent.Misc.naturalSortCollator.compare(ip1, ip2);
            };

            // flags
            this.columns["flags"].updateTd = function(td, row) {
                td.textContent = this.getRowValue(row, 0);
                td.title = this.getRowValue(row, 1);
            };

            // progress
            this.columns["progress"].updateTd = function(td, row) {
                const progress = this.getRowValue(row);
                const progressFormatted = `${window.qBittorrent.Misc.toFixedPointString((progress * 100), 1)}%`;
                td.textContent = progressFormatted;
                td.title = progressFormatted;
            };

            // dl_speed, up_speed
            this.columns["dl_speed"].updateTd = function(td, row) {
                const speed = this.getRowValue(row);
                if (speed === 0) {
                    td.textContent = "";
                    td.title = "";
                }
                else {
                    const formattedSpeed = window.qBittorrent.Misc.friendlyUnit(speed, true);
                    td.textContent = formattedSpeed;
                    td.title = formattedSpeed;
                }
            };
            this.columns["up_speed"].updateTd = this.columns["dl_speed"].updateTd;

            // downloaded, uploaded
            this.columns["downloaded"].updateTd = function(td, row) {
                const downloaded = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
                td.textContent = downloaded;
                td.title = downloaded;
            };
            this.columns["uploaded"].updateTd = this.columns["downloaded"].updateTd;

            // relevance
            this.columns["relevance"].updateTd = this.columns["progress"].updateTd;
            this.columns["relevance"].staticWidth = 100;

            // files
            this.columns["files"].updateTd = function(td, row) {
                const value = this.getRowValue(row, 0);
                td.textContent = value.replace(/\n/g, ";");
                td.title = value;
            };

        }
    }

    class SearchResultsTable extends DynamicTable {
        initColumns() {
            this.newColumn("fileName", "", "QBT_TR(Name)QBT_TR[CONTEXT=SearchResultsTable]", 500, true);
            this.newColumn("fileSize", "", "QBT_TR(Size)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
            this.newColumn("nbSeeders", "", "QBT_TR(Seeders)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
            this.newColumn("nbLeechers", "", "QBT_TR(Leechers)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
            this.newColumn("engineName", "", "QBT_TR(Engine)QBT_TR[CONTEXT=SearchResultsTable]", 100, true);
            this.newColumn("siteUrl", "", "QBT_TR(Engine URL)QBT_TR[CONTEXT=SearchResultsTable]", 250, true);
            this.newColumn("pubDate", "", "QBT_TR(Published On)QBT_TR[CONTEXT=SearchResultsTable]", 200, true);

            this.initColumnsFunctions();
        }

        initColumnsFunctions() {
            const displaySize = function(td, row) {
                const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
                td.textContent = size;
                td.title = size;
            };
            const displayNum = function(td, row) {
                const value = this.getRowValue(row);
                const formattedValue = (value === "-1") ? "Unknown" : value;
                td.textContent = formattedValue;
                td.title = formattedValue;
            };
            const displayDate = function(td, row) {
                const value = this.getRowValue(row) * 1000;
                const formattedValue = (Number.isNaN(value) || (value <= 0)) ? "" : window.qBittorrent.Misc.formatDate(new Date(value));
                td.textContent = formattedValue;
                td.title = formattedValue;
            };

            this.columns["fileSize"].updateTd = displaySize;
            this.columns["nbSeeders"].updateTd = displayNum;
            this.columns["nbLeechers"].updateTd = displayNum;
            this.columns["pubDate"].updateTd = displayDate;
        }

        getFilteredAndSortedRows() {
            const getSizeFilters = () => {
                let minSize = (window.qBittorrent.Search.searchSizeFilter.min > 0) ? (window.qBittorrent.Search.searchSizeFilter.min * Math.pow(1024, window.qBittorrent.Search.searchSizeFilter.minUnit)) : 0;
                let maxSize = (window.qBittorrent.Search.searchSizeFilter.max > 0) ? (window.qBittorrent.Search.searchSizeFilter.max * Math.pow(1024, window.qBittorrent.Search.searchSizeFilter.maxUnit)) : 0;

                if ((minSize > maxSize) && (maxSize > 0)) {
                    const tmp = minSize;
                    minSize = maxSize;
                    maxSize = tmp;
                }

                return {
                    min: minSize,
                    max: maxSize
                };
            };

            const getSeedsFilters = () => {
                let minSeeds = (window.qBittorrent.Search.searchSeedsFilter.min > 0) ? window.qBittorrent.Search.searchSeedsFilter.min : 0;
                let maxSeeds = (window.qBittorrent.Search.searchSeedsFilter.max > 0) ? window.qBittorrent.Search.searchSeedsFilter.max : 0;

                if ((minSeeds > maxSeeds) && (maxSeeds > 0)) {
                    const tmp = minSeeds;
                    minSeeds = maxSeeds;
                    maxSeeds = tmp;
                }

                return {
                    min: minSeeds,
                    max: maxSeeds
                };
            };

            let filteredRows = [];
            const searchTerms = window.qBittorrent.Search.searchText.pattern.toLowerCase().split(" ");
            const filterTerms = window.qBittorrent.Search.searchText.filterPattern.toLowerCase().split(" ");
            const sizeFilters = getSizeFilters();
            const seedsFilters = getSeedsFilters();
            const searchInTorrentName = document.getElementById("searchInTorrentName").value === "names";

            if (searchInTorrentName || (filterTerms.length > 0) || (window.qBittorrent.Search.searchSizeFilter.min > 0) || (window.qBittorrent.Search.searchSizeFilter.max > 0)) {
                for (const row of this.getRowValues()) {

                    if (searchInTorrentName && !window.qBittorrent.Misc.containsAllTerms(row.full_data.fileName, searchTerms))
                        continue;
                    if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.fileName, filterTerms))
                        continue;
                    if ((sizeFilters.min > 0) && (row.full_data.fileSize < sizeFilters.min))
                        continue;
                    if ((sizeFilters.max > 0) && (row.full_data.fileSize > sizeFilters.max))
                        continue;
                    if ((seedsFilters.min > 0) && (row.full_data.nbSeeders < seedsFilters.min))
                        continue;
                    if ((seedsFilters.max > 0) && (row.full_data.nbSeeders > seedsFilters.max))
                        continue;

                    filteredRows.push(row);
                }
            }
            else {
                filteredRows = [...this.getRowValues()];
            }

            const column = this.columns[this.sortedColumn];
            const isReverseSort = (this.reverseSort === "0");
            filteredRows.sort((row1, row2) => {
                const result = column.compareRows(row1, row2);
                return isReverseSort ? result : -result;
            });

            return filteredRows;
        }
    }

    class SearchPluginsTable extends DynamicTable {
        initColumns() {
            this.newColumn("fullName", "", "QBT_TR(Name)QBT_TR[CONTEXT=SearchPluginsTable]", 175, true);
            this.newColumn("version", "", "QBT_TR(Version)QBT_TR[CONTEXT=SearchPluginsTable]", 100, true);
            this.newColumn("url", "", "QBT_TR(Url)QBT_TR[CONTEXT=SearchPluginsTable]", 175, true);
            this.newColumn("enabled", "", "QBT_TR(Enabled)QBT_TR[CONTEXT=SearchPluginsTable]", 100, true);

            this.initColumnsFunctions();
        }

        initColumnsFunctions() {
            this.columns["enabled"].updateTd = function(td, row) {
                const value = this.getRowValue(row);
                if (value) {
                    td.textContent = "QBT_TR(Yes)QBT_TR[CONTEXT=SearchPluginsTable]";
                    td.title = "QBT_TR(Yes)QBT_TR[CONTEXT=SearchPluginsTable]";
                    td.closest("tr").classList.add("green");
                    td.closest("tr").classList.remove("red");
                }
                else {
                    td.textContent = "QBT_TR(No)QBT_TR[CONTEXT=SearchPluginsTable]";
                    td.title = "QBT_TR(No)QBT_TR[CONTEXT=SearchPluginsTable]";
                    td.closest("tr").classList.add("red");
                    td.closest("tr").classList.remove("green");
                }
            };
        }
    }

    class TorrentTrackersTable extends DynamicTable {
        collapseState = new Map(); // { rowId: String, isCollapsed: bool }

        isTrackerCollapsed(id) {
            return this.collapseState.get(id) ?? true;
        }

        toggleTrackerCollapsed(id) {
            this.collapseState.set(id, !this.isTrackerCollapsed(id));
            this.#updateTrackerRowState(id, this.isTrackerCollapsed(id));
        }

        #updateEndpointVisibility(endpoint, shouldHide) {
            const span = document.getElementById(`trackersTableTrackerUrl${endpoint}`);
            // span won't exist if row has been filtered out
            if (span === null)
                return;
            const tr = span.parentElement.parentElement;
            tr.classList.toggle("invisible", shouldHide);
        }

        #updateTrackerCollapseIcon(tracker, isCollapsed) {
            const span = document.getElementById(`trackersTableTrackerUrl${tracker}`);
            // span won't exist if row has been filtered out
            if (span === null)
                return;
            const td = span.parentElement;

            // rotate the collapse icon
            const collapseIcon = td.firstElementChild;
            collapseIcon.classList.toggle("rotate", isCollapsed);
        }

        #updateTrackerRowState(id, shouldCollapse) {
            // collapsed rows will be filtered out when using virtual list
            if (this.useVirtualList)
                return;

            this.#updateTrackerCollapseIcon(id, shouldCollapse);

            for (const row of this.getRowValues()) {
                const parentId = row.full_data._tracker;
                if (parentId === id)
                    this.#updateEndpointVisibility(row.rowId, shouldCollapse);
            }
        }

        clearCollapseState() {
            this.collapseState.clear();
        }

        initColumns() {
            this.newColumn("url", "", "QBT_TR(URL/Announce Endpoint)QBT_TR[CONTEXT=TrackerListWidget]", 250, true);
            this.newColumn("tier", "", "QBT_TR(Tier)QBT_TR[CONTEXT=TrackerListWidget]", 35, true);
            this.newColumn("btVersion", "", "QBT_TR(BT Protocol)QBT_TR[CONTEXT=TrackerListWidget]", 35, true);
            this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TrackerListWidget]", 125, true);
            this.newColumn("peers", "", "QBT_TR(Peers)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
            this.newColumn("seeds", "", "QBT_TR(Seeds)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
            this.newColumn("leeches", "", "QBT_TR(Leeches)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
            this.newColumn("downloaded", "", "QBT_TR(Times Downloaded)QBT_TR[CONTEXT=TrackerListWidget]", 100, true);
            this.newColumn("message", "", "QBT_TR(Message)QBT_TR[CONTEXT=TrackerListWidget]", 250, true);
            this.newColumn("nextAnnounce", "", "QBT_TR(Next Announce)QBT_TR[CONTEXT=TrackerListWidget]", 150, true);
            this.newColumn("minAnnounce", "", "QBT_TR(Min Announce)QBT_TR[CONTEXT=TrackerListWidget]", 150, true);

            this.initColumnsFunctions();
        }

        initColumnsFunctions() {
            const naturalSort = function(row1, row2) {
                if (!row1.full_data._sortable || !row2.full_data._sortable)
                    return 0;

                const value1 = this.getRowValue(row1);
                const value2 = this.getRowValue(row2);
                return window.qBittorrent.Misc.naturalSortCollator.compare(value1, value2);
            };

            this.columns["url"].updateTd = (td, row) => {
                const id = row.rowId;
                const data = row.full_data;

                let collapseIcon = td.firstElementChild;
                if (collapseIcon === null) {
                    collapseIcon = document.createElement("img");
                    collapseIcon.src = "images/go-down.svg";
                    collapseIcon.className = "filesTableCollapseIcon";
                    collapseIcon.addEventListener("click", (e) => {
                        const id = collapseIcon.dataset.id;
                        this.toggleTrackerCollapsed(id);
                        if (this.useVirtualList)
                            this.rerender();
                    });
                    td.append(collapseIcon);
                }
                if (data._isTracker) {
                    collapseIcon.style.display = "inline";
                    collapseIcon.style.visibility = data._hasEndpoints ? "visible" : "hidden";
                    collapseIcon.dataset.id = id;
                    collapseIcon.classList.toggle("rotate", this.isTrackerCollapsed(id));
                }
                else {
                    collapseIcon.style.display = "none";
                }

                let span = td.children[1];
                if (span === undefined) {
                    span = document.createElement("span");
                    td.append(span);
                }
                span.id = `trackersTableTrackerUrl${id}`;
                span.textContent = data.url;
                span.style.marginLeft = data._isTracker ? "0" : "20px";
            };

            this.columns["url"].compareRows = naturalSort;
            this.columns["status"].compareRows = naturalSort;
            this.columns["message"].compareRows = naturalSort;

            const sortNumbers = function(row1, row2) {
                if (!row1.full_data._sortable || !row2.full_data._sortable)
                    return 0;

                const value1 = this.getRowValue(row1);
                const value2 = this.getRowValue(row2);
                if (value1 === "")
                    return -1;
                if (value2 === "")
                    return 1;
                return compareNumbers(value1, value2);
            };

            this.columns["tier"].compareRows = sortNumbers;

            const sortMixed = function(row1, row2) {
                if (!row1.full_data._sortable || !row2.full_data._sortable)
                    return 0;

                const value1 = this.getRowValue(row1);
                const value2 = this.getRowValue(row2);
                if (value1 === "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]")
                    return -1;
                if (value2 === "QBT_TR(N/A)QBT_TR[CONTEXT=TrackerListWidget]")
                    return 1;
                return compareNumbers(value1, value2);
            };

            this.columns["peers"].compareRows = sortMixed;
            this.columns["seeds"].compareRows = sortMixed;
            this.columns["leeches"].compareRows = sortMixed;
            this.columns["downloaded"].compareRows = sortMixed;

            this.columns["status"].updateTd = function(td, row) {
                let statusClass = "trackerUnknown";
                const status = this.getRowValue(row);
                switch (status) {
                    case "QBT_TR(Disabled)QBT_TR[CONTEXT=TrackerListWidget]":
                        statusClass = "trackerDisabled";
                        break;
                    case "QBT_TR(Not contacted yet)QBT_TR[CONTEXT=TrackerListWidget]":
                        statusClass = "trackerNotContacted";
                        break;
                    case "QBT_TR(Working)QBT_TR[CONTEXT=TrackerListWidget]":
                        statusClass = "trackerWorking";
                        break;
                    case "QBT_TR(Updating...)QBT_TR[CONTEXT=TrackerListWidget]":
                        statusClass = "trackerUpdating";
                        break;
                    case "QBT_TR(Not working)QBT_TR[CONTEXT=TrackerListWidget]":
                    case "QBT_TR(Tracker error)QBT_TR[CONTEXT=TrackerListWidget]":
                    case "QBT_TR(Unreachable)QBT_TR[CONTEXT=TrackerListWidget]":
                        statusClass = "trackerNotWorking";
                        break;
                }

                for (const c of [...td.classList]) {
                    if (c.startsWith("tracker"))
                        td.classList.remove(c);
                }
                td.classList.add(statusClass);
                td.textContent = status;
                td.title = status;
            };

            const friendlyDuration = function(td, row) {
                const value = this.getRowValue(row) ?? 0;
                const seconds = Math.max(value - (Date.now() / 1000), 0);
                const duration = window.qBittorrent.Misc.friendlyDuration(seconds, window.qBittorrent.Misc.MAX_ETA);
                td.textContent = duration;
                td.title = duration;
            };

            this.columns["nextAnnounce"].updateTd = friendlyDuration;
            this.columns["minAnnounce"].updateTd = friendlyDuration;
        }

        getFilteredAndSortedRows() {
            const trackers = [];
            const trakcerEndpoints = new Map();

            for (const row of this.getRowValues()) {
                const tracker = row.full_data._tracker;
                if (tracker) {
                    if (this.useVirtualList && this.isTrackerCollapsed(tracker))
                        continue;
                    const endpoints = trakcerEndpoints.get(tracker);
                    if (endpoints === undefined)
                        trakcerEndpoints.set(tracker, [row]);
                    else
                        endpoints.push(row);
                }
                else {
                    trackers.push(row);
                }
            }

            const column = this.columns[this.sortedColumn];
            const isReverseSort = this.reverseSort === "0";
            const sortRows = (row1, row2) => {
                const result = column.compareRows(row1, row2);
                return isReverseSort ? result : -result;
            };

            const result = [];
            for (const tracker of trackers.sort(sortRows)) {
                result.push(tracker);
                const endpoints = trakcerEndpoints.get(tracker.rowId) || [];
                result.push(...endpoints.sort(sortRows));
            }

            return result;
        }

        updateTable(fullUpdate = false) {
            super.updateTable(fullUpdate);
            if (!this.useVirtualList) {
                for (const row of this.getRowValues()) {
                    if (row.full_data._isTracker)
                        continue;
                    this.#updateEndpointVisibility(row.rowId, this.isTrackerCollapsed(row.full_data._tracker));
                }
            }
        }

        setupCommonEvents() {
            super.setupCommonEvents();
            this.dynamicTableDiv.addEventListener("dblclick", (e) => {
                const tr = e.target.closest("tr");
                if (!tr || (tr.rowId.startsWith("** [") || tr.rowId.startsWith("endpoint|")))
                    return;

                window.qBittorrent.PropTrackers.editTracker(tr);
            });
        }
    }

    class TorrentFilesTable extends DynamicTable {
        filterTerms = [];
        prevFilterTerms = [];
        prevRowsString = null;
        prevFilteredRows = [];
        prevSortedColumn = null;
        prevReverseSort = null;
        fileTree = new window.qBittorrent.FileTree.FileTree();
        supportCollapsing = true;
        collapseState = new Map();
        fileNameColumn = "name";
        headerCheckboxClickHandler = null;
        headerCheckboxId = "tristate_cb";

        updateHeaderCheckbox() {
            const checkbox = document.getElementById(this.headerCheckboxId);
            if (checkbox === null)
                return;

            if (this.getRowSize() === 0)
                return;

            const TriState = window.qBittorrent.FileTree.TriState;
            if (this.isAllCheckboxesChecked()) {
                checkbox.state = TriState.Checked;
                checkbox.indeterminate = false;
                checkbox.checked = true;
            }
            else if (this.isAllCheckboxesUnchecked()) {
                checkbox.state = TriState.Unchecked;
                checkbox.indeterminate = false;
                checkbox.checked = false;
            }
            else {
                checkbox.state = TriState.Partial;
                checkbox.indeterminate = true;
            }
        }

        injectHeaderCheckbox() {
            document.getElementById(this.headerCheckboxId)?.remove();

            if (!this.headerCheckboxClickHandler)
                return;

            const checkboxTH = document.querySelector(`#${this.dynamicTableFixedHeaderDivId} .column_checked`);
            if (checkboxTH === null)
                return;

            const checkbox = document.createElement("input");
            checkbox.type = "checkbox";
            checkbox.id = this.headerCheckboxId;
            checkbox.addEventListener("click", this.headerCheckboxClickHandler);
            checkboxTH.appendChild(checkbox);

            this.updateHeaderCheckbox();
        }

        updateTableHeaders() {
            super.updateTableHeaders();
            this.injectHeaderCheckbox();
        }

        isCollapsed(id) {
            if (!this.supportCollapsing)
                return false;
            return this.collapseState.get(id)?.collapsed ?? false;
        }

        expandNode(id) {
            if (!this.supportCollapsing)
                return;

            const state = this.collapseState.get(id);
            if (state !== undefined)
                state.collapsed = false;
            this.#updateNodeState(id, false);
        }

        collapseNode(id) {
            if (!this.supportCollapsing)
                return;

            const state = this.collapseState.get(id);
            if (state !== undefined)
                state.collapsed = true;
            this.#updateNodeState(id, true);
        }

        expandAllNodes() {
            if (!this.supportCollapsing)
                return;

            for (const [key, _] of this.collapseState)
                this.expandNode(key);

            if (this.useVirtualList)
                this.rerender();
        }

        collapseAllNodes() {
            if (!this.supportCollapsing)
                return;

            for (const [key, state] of this.collapseState) {
                // collapse all nodes except root
                if (state.depth >= 1)
                    this.collapseNode(key);
            }

            if (this.useVirtualList)
                this.rerender();
        }

        #updateNodeVisibility(node, shouldHide) {
            const span = document.getElementById(`filesTablefileName${node.rowId}`);
            // span won't exist if row has been filtered out
            if (span === null)
                return;
            const tr = span.parentElement.parentElement;
            tr.classList.toggle("invisible", shouldHide);
        }

        #updateNodeCollapseIcon(node, isCollapsed) {
            const span = document.getElementById(`filesTablefileName${node.rowId}`);
            // span won't exist if row has been filtered out
            if (span === null)
                return;
            const td = span.parentElement;

            // rotate the collapse icon
            const collapseIcon = td.firstElementChild;
            collapseIcon.classList.toggle("rotate", isCollapsed);
        }

        #updateNodeState(id, shouldCollapse) {
            // collapsed rows will be filtered out when using virtual list
            if (this.useVirtualList)
                return;
            const node = this.getNode(id);
            if (!node.isFolder)
                return;

            this.#updateNodeCollapseIcon(node, shouldCollapse);

            this.#updateNodeChildVisibility(node, shouldCollapse);
        }

        #updateNodeChildVisibility(root, shouldHide) {
            const stack = [...root.children];
            while (stack.length > 0) {
                const node = stack.pop();

                this.#updateNodeVisibility(node, (shouldHide ? shouldHide : this.isCollapsed(node.root.rowId)));

                stack.push(...node.children);
            }
        }

        clear() {
            super.clear();
            this.collapseState.clear();
        }

        expandFolder(id) {
            if (!this.supportCollapsing)
                return;

            const node = this.getNode(id);
            if (node.isFolder) {
                this.expandNode(node.rowId);
                if (this.useVirtualList)
                    this.rerender();
            }
        }

        collapseFolder(id) {
            if (!this.supportCollapsing)
                return;

            const node = this.getNode(id);
            if (node.isFolder) {
                this.collapseNode(node.rowId);
                if (this.useVirtualList)
                    this.rerender();
            }
        }

        isAllCheckboxesChecked() {
            return this.fileTree.toArray().every((node) => node.checked === window.qBittorrent.FileTree.TriState.Checked);
        }

        isAllCheckboxesUnchecked() {
            return this.fileTree.toArray().every((node) => node.checked !== window.qBittorrent.FileTree.TriState.Checked);
        }

        populateTable(root) {
            this.fileTree.setRoot(root);
            for (const node of root.children)
                this.#addNodeToTable(node, 0, root);
        }

        #addNodeToTable(node, depth, parent) {
            node.depth = depth;
            node.parent = parent;

            if (node.isFolder && this.supportCollapsing && !this.collapseState.has(node.rowId))
                this.collapseState.set(node.rowId, { depth: depth, collapsed: false });

            this.updateRowData({
                rowId: node.rowId,
            });

            for (const child of node.children)
                this.#addNodeToTable(child, depth + 1, node);
        }

        getFileTreeArray() {
            return this.fileTree.toArray();
        }

        getRoot() {
            return this.fileTree.getRoot();
        }

        getNode(rowId) {
            return this.fileTree.getNode(rowId);
        }

        getRow(node) {
            const rowId = this.fileTree.getRowId(node).toString();
            return this.rows.get(rowId);
        }

        getRowFileId(rowId) {
            const node = this.getNode(rowId);
            return node.fileId;
        }

        getRowData(row, fullUpdate) {
            return this.getNode(row.rowId);
        }

        calculateRemaining() {
            this.fileTree.getRoot().calculateRemaining();
        }

        initColumns() {
            this.newColumn("checked", "", "", 50, true);
            this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TrackerListWidget]", 300, true);
            this.newColumn("size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
            this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=TrackerListWidget]", 100, true);
            this.newColumn("priority", "", "QBT_TR(Download Priority)QBT_TR[CONTEXT=TrackerListWidget]", 150, true);
            this.newColumn("remaining", "", "QBT_TR(Remaining)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
            this.newColumn("availability", "", "QBT_TR(Availability)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);

            this.initColumnsFunctions();
        }

        initColumnsFunctions() {
            const that = this;
            const displaySize = function(td, row) {
                const size = window.qBittorrent.Misc.friendlyUnit(this.getRowValue(row), false);
                td.textContent = size;
                td.title = size;
            };
            const displayPercentage = function(td, row) {
                const value = window.qBittorrent.Misc.friendlyPercentage(this.getRowValue(row));
                td.textContent = value;
                td.title = value;
            };

            // checked
            this.columns["checked"].updateTd = function(td, row) {
                const id = row.rowId;
                const value = this.getRowValue(row);
                const fileId = that.getRowFileId(id);

                if (td.firstElementChild === null) {
                    const treeImg = document.createElement("img");
                    treeImg.src = "images/L.svg";
                    treeImg.style.verticalAlign = "top";
                    td.append(treeImg);
                }

                const downloadCheckbox = td.children[1];
                if (downloadCheckbox === undefined)
                    td.append(window.qBittorrent.TorrentContent.createDownloadCheckbox(id, fileId, value));
                else
                    window.qBittorrent.TorrentContent.updateDownloadCheckbox(downloadCheckbox, id, fileId, value);

            };
            this.columns["checked"].staticWidth = 50;

            // name
            this.columns["name"].updateTd = function(td, row) {
                const id = row.rowId;
                const fileNameId = `filesTablefileName${id}`;
                const node = that.getNode(id);
                const value = this.getRowValue(row);

                let collapseIcon = td.firstElementChild;
                if (collapseIcon === null) {
                    collapseIcon = document.createElement("img");
                    collapseIcon.src = "images/go-down.svg";
                    collapseIcon.className = "filesTableCollapseIcon";
                    collapseIcon.addEventListener("click", (e) => {
                        const id = collapseIcon.dataset.id;
                        const node = that.getNode(id);
                        if (node !== null) {
                            if (that.isCollapsed(node.rowId))
                                that.expandNode(node.rowId);
                            else
                                that.collapseNode(node.rowId);
                            if (that.useVirtualList)
                                that.rerender();
                        }
                    });
                    td.append(collapseIcon);
                }
                if (node.isFolder) {
                    collapseIcon.style.marginLeft = `${node.depth * 20}px`;
                    collapseIcon.style.display = "inline";
                    collapseIcon.dataset.id = id;
                    collapseIcon.classList.toggle("rotate", that.isCollapsed(node.rowId));
                }
                else {
                    collapseIcon.style.display = "none";
                }

                let dirImg = td.children[1];
                if (dirImg === undefined) {
                    dirImg = document.createElement("img");
                    dirImg.src = "images/directory.svg";
                    dirImg.style.height = "18px";
                    dirImg.style.width = "18px";
                    dirImg.style.marginRight = "5px";
                    dirImg.style.verticalAlign = "top";
                    td.append(dirImg);
                }
                dirImg.style.display = node.isFolder ? "inline" : "none";

                let span = td.children[2];
                if (span === undefined) {
                    span = document.createElement("span");
                    td.append(span);
                }
                span.id = fileNameId;
                span.textContent = value;
                span.style.marginLeft = node.isFolder ? "0" : `${(node.depth + 1) * 20}px`;
            };
            this.columns["name"].calculateBuffer = (rowId) => {
                const node = that.getNode(rowId);
                // folders add 20px for folder icon and 15px for collapse icon
                const folderBuffer = node.isFolder ? 35 : 0;
                return (node.depth * 20) + folderBuffer;
            };

            // size
            this.columns["size"].updateTd = displaySize;

            // progress
            if (this.columns["progress"] !== undefined) {
                this.columns["progress"].updateTd = function(td, row) {
                    const value = Number(this.getRowValue(row));

                    const progressBar = td.firstElementChild;
                    if (progressBar === null)
                        td.append(new window.qBittorrent.ProgressBar.ProgressBar(value));
                    else
                        progressBar.setValue(value);
                };
                this.columns["progress"].staticWidth = 100;
            }

            // priority
            this.columns["priority"].updateTd = function(td, row) {
                const id = row.rowId;
                const value = this.getRowValue(row);
                const fileId = that.getRowFileId(id);

                const priorityCombo = td.firstElementChild;
                if (priorityCombo === null)
                    td.append(window.qBittorrent.TorrentContent.createPriorityCombo(id, fileId, value));
                else
                    window.qBittorrent.TorrentContent.updatePriorityCombo(priorityCombo, id, fileId, value);
            };
            this.columns["priority"].staticWidth = 140;

            // remaining, availability
            if (this.columns["remaining"] !== undefined)
                this.columns["remaining"].updateTd = displaySize;
            if (this.columns["availability"] !== undefined)
                this.columns["availability"].updateTd = displayPercentage;

            for (const column of this.columns) {
                column["getRowValue"] = function(row, pos = 0) {
                    const node = that.getNode(row.rowId);
                    return node[this.dataProperties[pos]];
                };
            }
        }

        #sortNodesByColumn(root, column) {
            const isColumnName = (column.name === this.fileNameColumn);
            const isReverseSort = (this.reverseSort === "0");

            const stack = [root];
            while (stack.length > 0) {
                const node = stack.pop();

                node.children.sort((node1, node2) => {
                    // list folders before files when sorting by name
                    if (isColumnName) {
                        if (node1.isFolder && !node2.isFolder)
                            return -1;
                        if (!node1.isFolder && node2.isFolder)
                            return 1;
                    }

                    const result = column.compareRows(node1, node2);
                    return isReverseSort ? result : -result;
                });

                stack.push(...node.children);
            }
        }

        #filterNodes(root, filterTerms) {
            const ret = [];
            const stack = [root];

            while (stack.length > 0) {
                const node = stack.at(-1);

                if (node.isFolder && (!this.useVirtualList || !this.isCollapsed(node.rowId))) {
                    if (node._visited === undefined) {
                        node._visited = true;
                        stack.push(...node.children);
                        continue;
                    }

                    // ready to check results from children at this point

                    if (node._hasChildrenAdded || window.qBittorrent.Misc.containsAllTerms(node.name, filterTerms)) {
                        ret.push(this.getRow(node));
                        delete node._hasChildrenAdded;

                        // propagate up
                        const parent = node.root;
                        if (parent !== undefined)
                            parent._hasChildrenAdded = true;
                    }

                    delete node._visited;
                }
                else {
                    if (window.qBittorrent.Misc.containsAllTerms(node[this.fileNameColumn], filterTerms)) {
                        ret.push(this.getRow(node));

                        const parent = node.root;
                        if (parent !== undefined)
                            parent._hasChildrenAdded = true;
                    }
                }

                stack.pop();
            }

            ret.reverse();
            return ret;
        }

        setFilter(text) {
            const filterTerms = text.trim().toLowerCase().split(" ");
            if ((filterTerms.length === 1) && (filterTerms[0] === ""))
                this.filterTerms = [];
            else
                this.filterTerms = filterTerms;
        }

        generateRowsSignature() {
            const rowsData = [];
            for (const { rowId } of this.getRowValues()) {
                const node = this.getNode(rowId);
                rowsData.push({ ...node.serialize(), collapsed: this.isCollapsed(node.rowId) });
            }
            return JSON.stringify(rowsData);
        }

        getFilteredAndSortedRows() {
            const root = this.getRoot();
            if (root === null)
                return [];

            const hasRowsChanged = function(rowsString, prevRowsStringString) {
                const rowsChanged = (rowsString !== prevRowsStringString);
                const isFilterTermsChanged = this.filterTerms.reduce((acc, term, index) => {
                    return (acc || (term !== this.prevFilterTerms[index]));
                }, false);
                const isFilterChanged = ((this.filterTerms.length !== this.prevFilterTerms.length)
                    || ((this.filterTerms.length > 0) && isFilterTermsChanged));
                const isSortedColumnChanged = (this.prevSortedColumn !== this.sortedColumn);
                const isReverseSortChanged = (this.prevReverseSort !== this.reverseSort);

                return (rowsChanged || isFilterChanged || isSortedColumnChanged || isReverseSortChanged);
            }.bind(this);

            const rowsString = this.generateRowsSignature();
            if (!hasRowsChanged(rowsString, this.prevRowsString))
                return this.prevFilteredRows;

            // sort, then filter
            this.#sortNodesByColumn(root, this.columns[this.sortedColumn]);
            const rows = (() => {
                if (!this.useVirtualList && (this.filterTerms.length === 0)) {
                    const nodeArray = this.fileTree.toArray();
                    const filteredRows = nodeArray.map(node => this.getRow(node));
                    return filteredRows;
                }

                return this.#filterNodes(root.children[0], this.filterTerms);
            })();

            this.prevFilterTerms = this.filterTerms;
            this.prevRowsString = rowsString;
            this.prevFilteredRows = rows;
            this.prevSortedColumn = this.sortedColumn;
            this.prevReverseSort = this.reverseSort;
            return rows;
        }

        setupCommonEvents() {
            super.setupCommonEvents();
            this.dynamicTableDiv.addEventListener("keydown", (e) => {
                const tr = e.target.closest("tr");
                if (!tr)
                    return;

                switch (e.key) {
                    case "ArrowLeft":
                        e.preventDefault();
                        this.collapseFolder(this.getSelectedRowId());
                        break;
                    case "ArrowRight":
                        e.preventDefault();
                        this.expandFolder(this.getSelectedRowId());
                        break;
                }
            });
        }
    }

    class BulkRenameTorrentFilesTable extends TorrentFilesTable {
        prevCheckboxNum = null;
        supportCollapsing = false;
        fileNameColumn = "original";
        headerCheckboxId = "rootMultiRename_cb";

        getSelectedRows() {
            const nodes = this.fileTree.toArray();
            return nodes.filter(x => x.checked === 0);
        }

        initColumns() {
            // Blocks saving header width (because window width isn't saved)
            localPreferences.remove(`column_checked_width_${this.dynamicTableDivId}`);
            localPreferences.remove(`column_original_width_${this.dynamicTableDivId}`);
            localPreferences.remove(`column_renamed_width_${this.dynamicTableDivId}`);
            this.newColumn("checked", "", "", 50, true);
            this.newColumn("original", "", "QBT_TR(Original)QBT_TR[CONTEXT=TrackerListWidget]", 270, true);
            this.newColumn("renamed", "", "QBT_TR(Renamed)QBT_TR[CONTEXT=TrackerListWidget]", 220, true);

            this.initColumnsFunctions();
        }

        /**
         * Toggles the global checkbox and all checkboxes underneath
         */
        toggleHeaderCheckbox() {
            const checkbox = document.getElementById(this.headerCheckboxId);
            const isChecked = checkbox.checked || checkbox.indeterminate;

            for (const cb of document.querySelectorAll("input.RenamingCB")) {
                cb.indeterminate = false;
                if (isChecked) {
                    cb.checked = true;
                    cb.state = "checked";
                }
                else {
                    cb.checked = false;
                    cb.state = "unchecked";
                }
            }

            const nodes = this.fileTree.toArray();
            for (const node of nodes)
                node.checked = isChecked ? 0 : 1;

            this.updateHeaderCheckbox();
        }

        toggleNodeTreeCheckbox(rowId, checkState) {
            const node = this.getNode(rowId);
            node.checked = checkState;
            const checkbox = document.getElementById(`cbRename${rowId}`);
            checkbox.checked = node.checked === 0;
            checkbox.state = checkbox.checked ? "checked" : "unchecked";

            for (let i = 0; i < node.children.length; ++i)
                this.toggleNodeTreeCheckbox(node.children[i].rowId, checkState);
        }

        isAllCheckboxesChecked() {
            return this.fileTree.toArray().every((node) => node.checked === 0);
        }

        isAllCheckboxesUnchecked() {
            return this.fileTree.toArray().every((node) => node.checked !== 0);
        }

        initColumnsFunctions() {
            const that = this;

            // checked
            this.columns["checked"].updateTd = (td, row) => {
                const id = row.rowId;
                const node = that.getNode(id);

                if (td.firstElementChild === null) {
                    const treeImg = document.createElement("img");
                    treeImg.src = "images/L.svg";
                    treeImg.style.verticalAlign = "top";
                    td.append(treeImg);
                }

                let checkbox = td.children[1];
                if (checkbox === undefined) {
                    checkbox = document.createElement("input");
                    checkbox.type = "checkbox";
                    checkbox.className = "RenamingCB";
                    checkbox.addEventListener("click", (e) => {
                        e.stopPropagation();
                        const targetId = e.target.dataset.id;
                        const ids = [];
                        // when holding shift, set all files between the previously selected one and the clicked one
                        if (e.shiftKey && (that.prevCheckboxNum !== null) && (targetId !== that.prevCheckboxNum)) {
                            const targetState = that.tableBody.querySelector(`.RenamingCB[data-id="${that.prevCheckboxNum}"]`).checked;
                            const checkboxes = that.tableBody.getElementsByClassName("RenamingCB");
                            let started = false;
                            for (const cb of checkboxes) {
                                const currId = cb.dataset.id;
                                if ((currId === targetId) || (currId === that.prevCheckboxNum)) {
                                    if (started) {
                                        ids.push(currId);
                                        cb.checked = targetState;
                                        break;
                                    }
                                    started = true;
                                }
                                if (started) {
                                    ids.push(currId);
                                    cb.checked = targetState;
                                }
                            }
                        }
                        else {
                            ids.push(targetId);
                        }
                        for (const id of ids) {
                            const node = that.getNode(id);
                            node.checked = e.target.checked ? 0 : 1;
                        }
                        that.updateHeaderCheckbox();
                        that.onRowSelectionChange(that.getNode(targetId));
                        that.prevCheckboxNum = targetId;
                    });
                    checkbox.indeterminate = false;
                    td.append(checkbox);
                }
                checkbox.id = `cbRename${id}`;
                checkbox.dataset.id = id;
                checkbox.checked = (node.checked === 0);
                checkbox.state = checkbox.checked ? "checked" : "unchecked";
            };
            this.columns["checked"].staticWidth = 50;

            // original
            this.columns["original"].updateTd = function(td, row) {
                const id = row.rowId;
                const node = that.getNode(id);
                const value = this.getRowValue(row);

                let dirImg = td.children[0];
                if (dirImg === undefined) {
                    dirImg = document.createElement("img");
                    dirImg.src = "images/directory.svg";
                    dirImg.style.height = "18px";
                    dirImg.style.width = "18px";
                    dirImg.style.marginRight = "5px";
                    dirImg.style.verticalAlign = "top";
                    td.append(dirImg);
                }
                if (node.isFolder) {
                    dirImg.style.display = "inline";
                    dirImg.style.marginLeft = `${node.depth * 20}px`;
                }
                else {
                    dirImg.style.display = "none";
                }

                let span = td.children[1];
                if (span === undefined) {
                    span = document.createElement("span");
                    td.append(span);
                }
                span.textContent = value;
                span.style.marginLeft = node.isFolder ? "0" : `${(node.depth + 1) * 20}px`;
            };

            // renamed
            this.columns["renamed"].updateTd = (td, row) => {
                const id = row.rowId;
                const fileNameRenamedId = `filesTablefileRenamed${id}`;
                const node = that.getNode(id);

                let span = td.firstElementChild;
                if (span === null) {
                    span = document.createElement("span");
                    td.append(span);
                }
                span.id = fileNameRenamedId;
                span.textContent = node.renamed;
            };

            for (const column of this.columns) {
                column["getRowValue"] = function(row, pos = 0) {
                    const node = that.getNode(row.rowId);
                    return node[this.dataProperties[pos]];
                };
            }
        }

        onRowSelectionChange(row) {}

        selectRow() {}

        reselectRows(rowIds) {
            this.deselectAll();
            for (const tr of this.getTrs()) {
                if (rowIds.includes(tr.rowId)) {
                    const node = this.getNode(tr.rowId);
                    node.checked = 0;

                    const checkbox = tr.querySelector(".RenamingCB");
                    checkbox.state = "checked";
                    checkbox.indeterminate = false;
                    checkbox.checked = true;
                }
            }
            this.updateHeaderCheckbox();
        }

        generateRowsSignature() {
            const rowsData = [];
            for (const { full_data } of this.getRowValues())
                rowsData.push(full_data);
            return JSON.stringify(rowsData);
        }

        setupCommonEvents() {
            const headerDiv = document.getElementById("bulkRenameFilesTableFixedHeaderDiv");
            this.dynamicTableDiv.addEventListener("scroll", (e) => {
                headerDiv.scrollLeft = this.dynamicTableDiv.scrollLeft;
                // rerender on scroll
                if (this.useVirtualList) {
                    this.renderedOffset = this.dynamicTableDiv.scrollTop;
                    this.rerender();
                }
            });
        }
    }

    class AddTorrentFilesTable extends TorrentFilesTable {
        initColumns() {
            this.newColumn("checked", "", "", 50, true);
            this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TrackerListWidget]", 190, true);
            this.newColumn("size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TrackerListWidget]", 75, true);
            this.newColumn("priority", "", "QBT_TR(Download Priority)QBT_TR[CONTEXT=TrackerListWidget]", 140, true);

            this.initColumnsFunctions();
        }
    }

    class RssFeedTable extends DynamicTable {
        initColumns() {
            this.newColumn("name", "", "QBT_TR(RSS feeds)QBT_TR[CONTEXT=FeedListWidget]", -1, true);

            // map name row to "[name] ([unread])"
            this.columns["name"].dataProperties.push("unread", "status");
            this.columns["name"].updateTd = (td, row) => {
                td.style.display = "block";

                const data = row.full_data;
                const name = data.name;
                const indentation = data.indentation;
                const value = `${name} (${data.unread})`;

                let statusImg = "images/application-rss.svg";
                switch (data.status) {
                    case "hasError":
                        statusImg = "images/task-reject.svg";
                        break;
                    case "isLoading":
                        statusImg = "images/spinner.svg";
                        break;
                    case "unread":
                        statusImg = "images/mail-inbox.svg";
                        break;
                    case "isFolder":
                        statusImg = "images/folder-documents.svg";
                        break;
                }
                let img = td.children[0];
                if (img === undefined) {
                    img = document.createElement("img");
                    img.className = "stateIcon";
                    img.style.width = "20px";
                    img.style.paddingRight = "5px";
                    img.style.marginBottom = "-3px";
                    td.append(img);
                }
                img.title = value;
                img.src = statusImg;
                img.style.marginLeft = `${indentation * 20}px`;

                let span = td.children[1];
                if (span === undefined) {
                    span = document.createElement("span");
                    td.append(span);
                }
                span.textContent = value;
            };
        }
        setupHeaderMenu() {}
        setupHeaderEvents() {}
        getFilteredAndSortedRows() {
            return [...this.getRowValues()];
        }
        selectRow(rowId) {
            super.selectRow(rowId);

            const row = this.getRow(rowId);
            const path = row.full_data.dataPath;
            window.qBittorrent.Rss.showRssFeed(path);
        }
        setupCommonEvents() {
            super.setupCommonEvents();
            this.dynamicTableDiv.addEventListener("dblclick", (e) => {
                const tr = e.target.closest("tr");
                if (!tr || (tr.rowId === "0"))
                    return;

                window.qBittorrent.Rss.moveItem(this.getRow(tr.rowId).full_data.dataPath);
            });
        }
        updateRowElement(tr, rowId, top) {
            super.updateRowElement(tr, rowId, top);
            if (this.useVirtualList)
                tr.style.width = "100%";
        }
    }

    class RssArticleTable extends DynamicTable {
        initColumns() {
            this.newColumn("name", "", "QBT_TR(Torrents: (double-click to download))QBT_TR[CONTEXT=RSSWidget]", -1, true);

            this.columns["name"].updateTd = (td, row) => {
                td.style.display = "block";

                const data = row.full_data;
                td.textContent = data.name;
                td.title = data.name;

                const tr = td.parentElement;
                tr.classList.toggle("unreadArticle", !data.isRead);
            };
        }
        setupHeaderMenu() {}
        setupHeaderEvents() {}
        getFilteredAndSortedRows() {
            return [...this.getRowValues()];
        }
        selectRow(rowId) {
            super.selectRow(rowId);

            const tr = this.getTrByRowId(rowId);
            tr.classList.remove("unreadArticle");

            const row = this.getRow(rowId);
            const data = row.full_data;
            data.isRead = true;
            window.qBittorrent.Rss.showDetails(data.feedUid, data.dataId);
        }

        setupCommonEvents() {
            super.setupCommonEvents();
            this.dynamicTableDiv.addEventListener("dblclick", (e) => {
                const tr = e.target.closest("tr");
                if (!tr)
                    return;

                const { name, torrentURL } = this.getRow(tr.rowId).full_data;
                qBittorrent.Client.createAddTorrentWindow(name, torrentURL);
            });
        }
        updateRowElement(tr, rowId, top) {
            super.updateRowElement(tr, rowId, top);
            if (this.useVirtualList)
                tr.style.width = "100%";
        }
    }

    class RssDownloaderRulesTable extends DynamicTable {
        initColumns() {
            this.newColumn("checked", "", "", 30, true);
            this.newColumn("name", "", "", -1, true);

            this.columns["checked"].updateTd = (td, row) => {
                let checkbox = td.firstElementChild;
                if (checkbox === null) {
                    checkbox = document.createElement("input");
                    checkbox.type = "checkbox";
                    checkbox.dataset.id = row.rowId;
                    checkbox.addEventListener("click", (e) => {
                        const targetId = e.target.dataset.id;
                        const row = window.qBittorrent.RssDownloader.rssDownloaderRulesTable.getRow(targetId);
                        window.qBittorrent.RssDownloader.rssDownloaderRulesTable.updateRowData({
                            rowId: row.rowId,
                            checked: checkbox.checked
                        });
                        window.qBittorrent.RssDownloader.modifyRuleState(row.full_data.name, "enabled", checkbox.checked);
                        e.stopPropagation();
                    });

                    td.append(checkbox);
                }
                checkbox.checked = row.full_data.checked;
            };
            this.columns["checked"].staticWidth = 50;
        }
        setupHeaderMenu() {}
        setupHeaderEvents() {}
        getFilteredAndSortedRows() {
            return [...this.getRowValues()];
        }

        setupCommonEvents() {
            super.setupCommonEvents();
            this.dynamicTableDiv.addEventListener("dblclick", (e) => {
                const tr = e.target.closest("tr");
                if (!tr)
                    return;

                window.qBittorrent.RssDownloader.renameRule(this.getRow(tr.rowId).full_data.name);
            });
        }
        selectRow(rowId) {
            super.selectRow(rowId);

            const row = this.getRow(rowId);
            const name = row.full_data.name;
            window.qBittorrent.RssDownloader.showRule(name);
        }
        updateRowElement(tr, rowId, top) {
            super.updateRowElement(tr, rowId, top);
            if (this.useVirtualList)
                tr.style.width = "100%";
        }
    }

    class RssDownloaderFeedSelectionTable extends DynamicTable {
        initColumns() {
            this.newColumn("checked", "", "", 30, true);
            this.newColumn("name", "", "", -1, true);

            this.columns["checked"].updateTd = (td, row) => {
                let checkbox = td.firstElementChild;
                if (checkbox === null) {
                    checkbox = document.createElement("input");
                    checkbox.type = "checkbox";
                    checkbox.dataset.id = row.rowId;
                    checkbox.addEventListener("click", (e) => {
                        const targetId = e.target.dataset.id;
                        const row = window.qBittorrent.RssDownloader.rssDownloaderRulesTable.getRow(targetId);
                        window.qBittorrent.RssDownloader.rssDownloaderFeedSelectionTable.updateRowData({
                            rowId: row.rowId,
                            checked: checkbox.checked
                        });
                        e.stopPropagation();
                    });

                    td.append(checkbox);
                }
                checkbox.checked = row.full_data.checked;
            };
            this.columns["checked"].staticWidth = 50;
        }
        setupHeaderMenu() {}
        setupHeaderEvents() {}
        getFilteredAndSortedRows() {
            return [...this.getRowValues()];
        }
        selectRow() {}
        updateRowElement(tr, rowId, top) {
            super.updateRowElement(tr, rowId, top);
            if (this.useVirtualList)
                tr.style.width = "100%";
        }
    }

    class RssDownloaderArticlesTable extends DynamicTable {
        initColumns() {
            this.newColumn("name", "", "", -1, true);

            this.columns["name"].updateTd = (td, row) => {
                td.style.display = "block";

                const data = row.full_data;
                td.textContent = data.name;
                td.title = data.name;
                const tr = td.parentElement;
                tr.classList.toggle("articleTableFeed", data.isFeed);
                tr.classList.toggle("articleTableArticle", !data.isFeed);
            };
        }
        setupHeaderMenu() {}
        setupHeaderEvents() {}
        getFilteredAndSortedRows() {
            return [...this.getRowValues()];
        }
        selectRow() {}
        updateRowElement(tr, rowId, top) {
            super.updateRowElement(tr, rowId, top);
            if (this.useVirtualList)
                tr.style.width = "100%";
        }
    }

    class LogMessageTable extends DynamicTable {
        filterText = "";

        initColumns() {
            this.newColumn("rowId", "", "QBT_TR(ID)QBT_TR[CONTEXT=ExecutionLogWidget]", 50, true);
            this.newColumn("message", "", "QBT_TR(Message)QBT_TR[CONTEXT=ExecutionLogWidget]", 350, true);
            this.newColumn("timestamp", "", "QBT_TR(Timestamp)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
            this.newColumn("type", "", "QBT_TR(Log Type)QBT_TR[CONTEXT=ExecutionLogWidget]", 100, true);
            this.initColumnsFunctions();
        }

        initColumnsFunctions() {
            this.columns["timestamp"].updateTd = function(td, row) {
                const date = window.qBittorrent.Misc.formatDate(new Date(this.getRowValue(row) * 1000));
                td.textContent = date;
                td.title = date;
            };

            this.columns["type"].updateTd = function(td, row) {
                // Type of the message: Log::NORMAL: 1, Log::INFO: 2, Log::WARNING: 4, Log::CRITICAL: 8
                let logLevel, addClass;
                switch (Number(this.getRowValue(row))) {
                    case 1:
                        logLevel = "QBT_TR(Normal)QBT_TR[CONTEXT=ExecutionLogWidget]";
                        addClass = "logNormal";
                        break;
                    case 2:
                        logLevel = "QBT_TR(Info)QBT_TR[CONTEXT=ExecutionLogWidget]";
                        addClass = "logInfo";
                        break;
                    case 4:
                        logLevel = "QBT_TR(Warning)QBT_TR[CONTEXT=ExecutionLogWidget]";
                        addClass = "logWarning";
                        break;
                    case 8:
                        logLevel = "QBT_TR(Critical)QBT_TR[CONTEXT=ExecutionLogWidget]";
                        addClass = "logCritical";
                        break;
                    default:
                        logLevel = "QBT_TR(Unknown)QBT_TR[CONTEXT=ExecutionLogWidget]";
                        addClass = "logUnknown";
                        break;
                }
                td.textContent = logLevel;
                td.title = logLevel;
                td.closest("tr").classList.add(`logTableRow${addClass}`);
            };
        }

        getFilteredAndSortedRows() {
            let filteredRows = [];
            this.filterText = window.qBittorrent.Log.getFilterText();
            const filterTerms = (this.filterText.length > 0) ? this.filterText.toLowerCase().split(" ") : [];
            const logLevels = window.qBittorrent.Log.getSelectedLevels();
            if ((filterTerms.length > 0) || (logLevels.length < 4)) {
                for (const row of this.getRowValues()) {
                    if (!logLevels.includes(row.full_data.type.toString()))
                        continue;

                    if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.message, filterTerms))
                        continue;

                    filteredRows.push(row);
                }
            }
            else {
                filteredRows = [...this.getRowValues()];
            }

            const column = this.columns[this.sortedColumn];
            const isReverseSort = (this.reverseSort === "0");
            filteredRows.sort((row1, row2) => {
                const result = column.compareRows(row1, row2);
                return isReverseSort ? result : -result;
            });

            this.filteredLength = filteredRows.length;

            return filteredRows;
        }
    }

    class LogPeerTable extends LogMessageTable {
        initColumns() {
            this.newColumn("rowId", "", "QBT_TR(ID)QBT_TR[CONTEXT=ExecutionLogWidget]", 50, true);
            this.newColumn("ip", "", "QBT_TR(IP)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
            this.newColumn("timestamp", "", "QBT_TR(Timestamp)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
            this.newColumn("blocked", "", "QBT_TR(Status)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);
            this.newColumn("reason", "", "QBT_TR(Reason)QBT_TR[CONTEXT=ExecutionLogWidget]", 150, true);

            this.columns["timestamp"].updateTd = function(td, row) {
                const date = window.qBittorrent.Misc.formatDate(new Date(this.getRowValue(row) * 1000));
                td.textContent = date;
                td.title = date;
            };

            this.columns["blocked"].updateTd = function(td, row) {
                let status, addClass;
                if (this.getRowValue(row)) {
                    status = "QBT_TR(Blocked)QBT_TR[CONTEXT=ExecutionLogWidget]";
                    addClass = "peerBlocked";
                }
                else {
                    status = "QBT_TR(Banned)QBT_TR[CONTEXT=ExecutionLogWidget]";
                    addClass = "peerBanned";
                }
                td.textContent = status;
                td.title = status;
                td.closest("tr").classList.add(`logTableRow${addClass}`);
            };
        }

        getFilteredAndSortedRows() {
            let filteredRows = [];
            this.filterText = window.qBittorrent.Log.getFilterText();
            const filterTerms = (this.filterText.length > 0) ? this.filterText.toLowerCase().split(" ") : [];
            if (filterTerms.length > 0) {
                for (const row of this.getRowValues()) {
                    if ((filterTerms.length > 0) && !window.qBittorrent.Misc.containsAllTerms(row.full_data.ip, filterTerms))
                        continue;

                    filteredRows.push(row);
                }
            }
            else {
                filteredRows = [...this.getRowValues()];
            }

            const column = this.columns[this.sortedColumn];
            const isReverseSort = (this.reverseSort === "0");
            filteredRows.sort((row1, row2) => {
                const result = column.compareRows(row1, row2);
                return isReverseSort ? result : -result;
            });

            return filteredRows;
        }
    }

    class TorrentWebseedsTable extends DynamicTable {
        initColumns() {
            this.newColumn("url", "", "QBT_TR(URL)QBT_TR[CONTEXT=HttpServer]", 500, true);
        }
    }

    class TorrentCreationTasksTable extends DynamicTable {
        initColumns() {
            this.newColumn("state_icon", "", "QBT_TR(Status Icon)QBT_TR[CONTEXT=TorrentCreator]", 30, false);
            this.newColumn("source_path", "", "QBT_TR(Source Path)QBT_TR[CONTEXT=TorrentCreator]", 200, true);
            this.newColumn("progress", "", "QBT_TR(Progress)QBT_TR[CONTEXT=TorrentCreator]", 85, true);
            this.newColumn("status", "", "QBT_TR(Status)QBT_TR[CONTEXT=TorrentCreator]", 100, true);
            this.newColumn("torrent_format", "", "QBT_TR(Format)QBT_TR[CONTEXT=TorrentCreator]", 100, true);
            this.newColumn("piece_size", "", "QBT_TR(Piece Size)QBT_TR[CONTEXT=TorrentCreator]", 100, true);
            this.newColumn("private", "", "QBT_TR(Private)QBT_TR[CONTEXT=TorrentCreator]", 30, true);
            this.newColumn("added_on", "", "QBT_TR(Added On)QBT_TR[CONTEXT=TorrentCreator]", 100, true);
            this.newColumn("start_on", "", "QBT_TR(Started On)QBT_TR[CONTEXT=TorrentCreator]", 100, true);
            this.newColumn("completion_on", "", "QBT_TR(Completed On)QBT_TR[CONTEXT=TorrentCreator]", 100, true);
            this.newColumn("trackers", "", "QBT_TR(Trackers)QBT_TR[CONTEXT=TorrentCreator]", 100, true);
            this.newColumn("web_seeds", "", "QBT_TR(Web Seeds)QBT_TR[CONTEXT=TorrentCreator]", 100, false);
            this.newColumn("comment", "", "QBT_TR(Comment)QBT_TR[CONTEXT=TorrentCreator]", 100, true);
            this.newColumn("source", "", "QBT_TR(Source)QBT_TR[CONTEXT=TorrentCreator]", 100, false);
            this.newColumn("error_message", "", "QBT_TR(Error Message)QBT_TR[CONTEXT=TorrentCreator]", 100, true);

            this.columns["state_icon"].dataProperties[0] = "status";
            this.columns["source_path"].dataProperties.push("status");

            this.initColumnsFunctions();
        }

        initColumnsFunctions() {
            const getStateIconClasses = (state) => {
                let stateClass = "stateUnknown";
                // normalize states
                switch (state) {
                    case "Running":
                        stateClass = "stateRunning";
                        break;
                    case "Queued":
                        stateClass = "stateQueued";
                        break;
                    case "Finished":
                        stateClass = "stateStoppedUP";
                        break;
                    case "Failed":
                        stateClass = "stateError";
                        break;
                }

                return `stateIcon ${stateClass}`;
            };

            // state_icon
            this.columns["state_icon"].updateTd = function(td, row) {
                const state = this.getRowValue(row);
                let div = td.firstElementChild;
                if (div === null) {
                    div = document.createElement("div");
                    td.append(div);
                }

                div.className = `${getStateIconClasses(state)} stateIconColumn`;
            };

            this.columns["state_icon"].onVisibilityChange = (columnName) => {
                // show state icon in name column only when standalone
                // state icon column is hidden
                this.updateColumn("name", true);
            };

            // source_path
            this.columns["source_path"].updateTd = function(td, row) {
                const name = this.getRowValue(row, 0);
                const state = this.getRowValue(row, 1);
                let span = td.firstElementChild;
                if (span === null) {
                    span = document.createElement("span");
                    td.append(span);
                }

                span.className = this.isStateIconShown() ? getStateIconClasses(state) : "";
                span.textContent = name;
                td.title = name;
            };

            this.columns["source_path"].isStateIconShown = () => !this.columns["state_icon"].isVisible();

            // status
            this.columns["status"].updateTd = function(td, row) {
                const state = this.getRowValue(row);
                if (!state)
                    return;

                let status = "QBT_TR(Unknown)QBT_TR[CONTEXT=HttpServer]";
                switch (state) {
                    case "Queued":
                        status = "QBT_TR(Queued)QBT_TR[CONTEXT=TorrentCreator]";
                        break;
                    case "Running":
                        status = "QBT_TR(Running)QBT_TR[CONTEXT=TorrentCreator]";
                        break;
                    case "Finished":
                        status = "QBT_TR(Finished)QBT_TR[CONTEXT=TorrentCreator]";
                        break;
                    case "Failed":
                        status = "QBT_TR(Failed)QBT_TR[CONTEXT=TorrentCreator]";
                        break;
                }

                td.textContent = status;
                td.title = status;
            };

            // torrent_format
            this.columns["torrent_format"].updateTd = function(td, row) {
                const torrentFormat = this.getRowValue(row);
                if (!torrentFormat)
                    return;

                let format = "QBT_TR(Unknown)QBT_TR[CONTEXT=HttpServer]";
                switch (torrentFormat) {
                    case "v1":
                        format = "V1";
                        break;
                    case "v2":
                        format = "V2";
                        break;
                    case "hybrid":
                        format = "QBT_TR(Hybrid)QBT_TR[CONTEXT=TorrentCreator]";
                        break;
                }

                td.textContent = format;
                td.title = format;
            };

            // progress
            this.columns["progress"].updateTd = function(td, row) {
                const progress = this.getRowValue(row);

                const div = td.firstElementChild;
                if (div !== null) {
                    if (div.getValue() !== progress)
                        div.setValue(progress);
                }
                else {
                    td.append(new window.qBittorrent.ProgressBar.ProgressBar(progress));
                }
            };
            this.columns["progress"].staticWidth = 100;

            // piece_size
            this.columns["piece_size"].updateTd = function(td, row) {
                const pieceSize = this.getRowValue(row);
                const size = (pieceSize === 0) ? "QBT_TR(N/A)QBT_TR[CONTEXT=TorrentCreator]" : window.qBittorrent.Misc.friendlyUnit(pieceSize, false);
                td.textContent = size;
                td.title = size;
            };

            // private
            this.columns["private"].updateTd = function(td, row) {
                const isPrivate = this.getRowValue(row);
                const string = isPrivate
                    ? "QBT_TR(Yes)QBT_TR[CONTEXT=PropertiesWidget]"
                    : "QBT_TR(No)QBT_TR[CONTEXT=PropertiesWidget]";
                td.textContent = string;
                td.title = string;
            };

            const displayDate = function(td, row) {
                const val = this.getRowValue(row);
                if (!val) {
                    td.textContent = "";
                    td.title = "";
                }
                else {
                    const date = window.qBittorrent.Misc.formatDate(new Date(val));
                    td.textContent = date;
                    td.title = date;
                }
            };

            // added_on, start_on, completion_on
            this.columns["added_on"].updateTd = displayDate;
            this.columns["start_on"].updateTd = displayDate;
            this.columns["completion_on"].updateTd = displayDate;
        }

        setupCommonEvents() {
            super.setupCommonEvents();
            this.dynamicTableDiv.addEventListener("dblclick", (e) => {
                const tr = e.target.closest("tr");
                if (!tr)
                    return;

                this.deselectAll();
                this.selectRow(tr.rowId);

                window.qBittorrent.TorrentCreator.exportTorrents();
            });
        }
    }

    return exports();
})();
Object.freeze(window.qBittorrent.DynamicTable);

/*************************************************************/
