luffier / Ladderr

// ==UserScript==
// @name         Ladderr
// @version      0.5.8
// @description  Access your remote files directly from qBittorrent Web UI, just like in the desktop app.
// @author       luffier
// @namespace    ladderr
// @license      MIT
// @copyright    2023, luffier (https://github.com/Luffier)
// @match        https://*/
// @match        http://*/
// @icon         data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAkFBMVEUAAAAxxP4xw/4wxP4uw/8xxP4ww/4xw/4py/8xxP4ww/4zxP4xxP8wxP4wxP4wxP4wxP4wxP4wxP4wxP4wxP4wxP8vw/8yw/8wxP4xxP4wxP4wxP8wxP4wxP4ww/4xxP4wxP4xxP8xwv8ws/8xxv8xxP8xxP8vxP4xxf4xxv4vxP4wxf4yw/8vx/8wwv4xxP/uOx+5AAAAL3RSTlMA65pkPIzXtQbJeicZxvn18d3PubCDUArl2r6ooJRuamBCHQUSwHZbWEhGNTMgFcZCVYAAAAD3SURBVDjLZZPZkoIwEACDCKtgkEM5VARl76v//+/2ZYXMpN+6qqkiyYx5MJCbmZij0Rwp0hnLxQsuCE5ecMImMxWdFxQki4zsvSBjXOQGv0ayg9tiKXyr4AuKeKaDWgU1bB0yriq4ErhaEqkgYuvqgbMKzhxc7bEqsJSRg6VVQYsim4zLBOHOoYa7CH5g7fpduVmrL6aMdxG8wVoQ8CqCBI9eBD1xKLDyXvybiyiNvnv9NsIDBu91G0cbyANBDqkRE/TyJKjEhJlPeDaSPaNjoT/FHYlYq9jfk5VYq3alKKicoAKf2Iht3rg0m2YgF78cGsXH/8H+ALCrJ8qsl2pUAAAAAElFTkSuQmCC
// @grant        GM.getValue
// @grant        GM.setValue
// @run-at       document-end
// @sandbox      raw
// @homepageURL  https://github.com/Luffier/ladderr
// @supportURL   https://github.com/Luffier/ladderr/issues
// ==/UserScript==

/* jshint esversion: 8 */

(() => {
    'use strict';

    /* VARIABLES */

    // Global styles
    const style = `
    <style>
        #ladderrSettingsMenu {
            background-color: var(--color-background-popup);
            color: var(--color-text-default);
            position: absolute;
            z-index: 9999;
            top: 100px;
            width: 500px;
            left: 200px;
            font-size: 12px;
            box-shadow: black 1px 0 6px;
            border-radius: 5px;
        }
        #ladderrSettingsMenu .header {
            background-color: var(--color-background-default);
            border-bottom: 1px solid var(--color-border-default);
            border-radius: 5px 5px 0 0;
        }
        #ladderrSettingsMenu .header h3 {
            padding: 5px 10px 4px 12px;
        }
        #ladderrSettingsMenu .main {
            display: flex;
            flex-direction: column;
            justify-content: space-around;
            width: 100%;
            box-sizing: border-box;
            padding: 10px;
        }
        #ladderrSettingsMenu .main .variable {
            display: flex;
            align-items: center;
            padding-bottom: 10px;
            flex-grow: 1;
        }
        #ladderrSettingsMenu .main .variable label:first-child {
            width: 90px;
            font-weight: bold;
        }
        #ladderrSettingsMenu .main .variable input[type="text"] {
            flex-grow: 1;
        }
        #ladderrSettingsMenu .main .variable input[type="checkbox"] {
            margin-right: 1em;
        }
        #ladderrSettingsMenu .footer {
            display: flex;
            justify-content: center;
            padding-bottom: 10px;
        }
        #ladderrSettingsMenu .footer button {
            margin: 0px 10px;
        }
    </style>
    `;

    // Helper for whenPageReady function
    const Ladderr = {
        url: location.protocol + location.hostname + location.port,
        basePathLocal: null,
        basePathRemote: null,
        dClickOpen: null,
        pageTimeout: true,
        pageTimer: null,
        panelCollapsed: null,
        panelTabSelected: null,
    }

    /* FUNCTIONS */

    // Single element selector shorthand
    const $ = document.querySelector.bind(document);

    // Multiple elements selector shorthand
    const $$ = document.querySelectorAll.bind(document);

    // Create element
    function createElement(html) {
        const template = document.createElement('template');
        template.innerHTML = html.trim();
        return template.content.firstChild;
    }

    // Function to replicate the `on` function in jQuery
    function addEventListener(el, eventName, eventHandler, selector) {
        if (selector) {
            const wrappedHandler = (e) => {
                if (e.target && e.target.matches(selector)) {
                    eventHandler(e);
                }
            };
            el.addEventListener(eventName, wrappedHandler, true);
            return wrappedHandler;
        } else {
            el.addEventListener(eventName, eventHandler, true);
            return eventHandler;
        }
    }

    // Encode string to Base64 UTF-8
    function toBase64String(str) {
        return btoa(unescape(encodeURIComponent(str)));
    }

    // Checks if the current page is a qBittorrent Web UI page
    function isCurrentPageValid() {
        const appNameElement = document.getElementsByName('application-name');
        if (appNameElement.length > 0) {
            return appNameElement[0].getAttribute('content') === 'qBittorrent';
        }
        return false;
    }

    // Executes the callback after the page finishes loading
    function whenPageReady(callback, intervalTime) {
        Ladderr.pageTimer = Date.now();
        console.debug('[Ladderr] Waiting for page to load');
        const observerCallback = (mutations, observer) => {
            if (Ladderr.pageTimeout) {
                clearTimeout(Ladderr.pageTimeout);
                Ladderr.pageTimeout = setTimeout(() => {
                    clearTimeout(Ladderr.pageTimeout);
                    Ladderr.pageTimeout = null;
                    observer.disconnect();
                    if (isCurrentPageValid()) {
                        console.debug(`[Ladderr] Page ready in ${Date.now() - Ladderr.pageTimer}ms!`);
                        callback();
                    } else {
                        console.debug(`[Ladderr] Page is not a qBittorrent Web UI`);
                    }
                }, intervalTime)
            } else {
                observer.disconnect();
            }
        };
        const observer = new MutationObserver(observerCallback);
        observer.observe($('body'), {
            attributes: true,
            childList: true,
            subtree: true
        });
    }

    // Create menu items for the different context menus
    function createContextMenuItems() {
        // Torrents queue list ("Open destination folder" context menu item)
        const queueMenuItem = $('#queueingMenuItems');
        const openDestinationMenutItem = createElement(`
        <li>
            <a><img src="images/directory.svg" alt="Open destination folder">
                <span>Open destination folder</span>
            </a>
        </li>
        `);
        addEventListener(openDestinationMenutItem, 'click', openDestinationFolder);
        queueMenuItem.after(openDestinationMenutItem);

        // Torrent files ("Open" context menu item)
        const torrentFilesMenu = $('#torrentFilesMenu');
        const openMenuItem = createElement(`
        <li>
            <a><img src="images/folder-documents.svg" alt="Open"> Open</a>
        </li>
        `);
        addEventListener(openMenuItem, 'click', openDirectly);
        torrentFilesMenu.append(openMenuItem);

        // Torrent files ("Open containing folder" context menu item)
        const openContainingMenuItem = createElement(`
        <li>
            <a><img src="images/directory.svg" alt="Open containing folder"> Open containing folder</a>
        </li>
        `);
        addEventListener(openContainingMenuItem, 'click', openContainingFolder);
        torrentFilesMenu.append(openContainingMenuItem);
    }

    // Save Ladderr settings to localStorage
    async function saveSettings() {
        const basePathRemote = $('#ladderrSettingsMenu_pathRemote').value;
        const basePathLocal = $('#ladderrSettingsMenu_pathLocal').value;
        const dClickOpen = $('#ladderrSettingsMenu_dClickOpen').checked;
        await GM.setValue(Ladderr.url + 'pathRemote', basePathRemote);
        await GM.setValue(Ladderr.url + 'pathLocal', basePathLocal);
        await GM.setValue(Ladderr.url + 'dClickOpen', `${dClickOpen}`);
        Ladderr.basePathRemote = basePathRemote;
        Ladderr.basePathLocal = basePathLocal;
        Ladderr.dClickOpen = dClickOpen;
        $('#torrentsTableDiv table').removeEventListener('dblclick', handleMainDClick, true);
        if (dClickOpen) {
            addEventListener($('#torrentsTableDiv table'), 'dblclick', handleMainDClick);
        }
    }

    // Load Ladderr settings from localStorage
    async function loadSettings() {
        const basePathRemote = await GM.getValue(Ladderr.url + 'pathRemote', Ladderr.basePathRemote);
        const basePathLocal = await GM.getValue(Ladderr.url + 'pathLocal', Ladderr.basePathLocal);
        const dClickOpen = (await GM.getValue(Ladderr.url + 'dClickOpen', Ladderr.dClickOpen)) === 'true';
        $('#ladderrSettingsMenu_pathRemote').value = basePathRemote;
        $('#ladderrSettingsMenu_pathLocal').value = basePathLocal;
        $('#ladderrSettingsMenu_dClickOpen').checked = dClickOpen;
        Ladderr.basePathRemote = basePathRemote;
        Ladderr.basePathLocal = basePathLocal;
        Ladderr.dClickOpen = dClickOpen;
        if (dClickOpen) {
            addEventListener($('#torrentsTableDiv table'), 'dblclick', handleMainDClick);
        }
    }

    // Create Ladderr settings menu
    function createSettingsMenu() {
        // Settings menu
        const ladderrSettingsMenu = createElement(`
            <div id="ladderrSettingsMenu">
                <div class="header">
                    <h3>Ladderr Settings Menu</h3>
                </div>
                <div class="main">
                    <div class="variable">
                        <label>· Remote path:</label><input type="text" id="ladderrSettingsMenu_pathRemote" size="10" />
                    </div>
                    <div class="variable">
                        <label>· Local path:</label><input type="text" id="ladderrSettingsMenu_pathLocal" size="10" />
                    </div>
                    <div class="variable">
                        <input type="checkbox" id="ladderrSettingsMenu_dClickOpen"/><label for="ladderrSettingsMenu_dClickOpen">Open destination folder with double-click</label>
                    </div>
                </div>
                <div class="footer">
                    <button id="ladderrSettingsMenu_saveBtn" title="Save settings" class="saveclose_buttons">Save</button>
                    <button id="ladderrSettingsMenu_closeBtn" title="Close window" class="saveclose_buttons">Close</button>
                </div>
            </div>
        `);
        $('#desktop').append(ladderrSettingsMenu);
        ladderrSettingsMenu.style.display = 'none';

        addEventListener($('#ladderrSettingsMenu_saveBtn'), 'click', () => {
            saveSettings();
            $('#ladderrSettingsMenu').style.display = 'none';
        });
        addEventListener($('#ladderrSettingsMenu_closeBtn'), 'click', () => {
            $('#ladderrSettingsMenu').style.display = 'none';
        });

        // Open settings context menu item
        const ladderrSettingsIcon = createElement(`
            <li>
                <a id="ladderrOptions">
                    <img class="MyMenuIcon" src="images/configure.svg" alt="Ladderr Options" width="16" height="16" style="filter: hue-rotate(180deg);">Ladderr Options
                </a>
            </li>
        `);
        $('#preferencesLink').parentNode.after(ladderrSettingsIcon);
        addEventListener(ladderrSettingsIcon, 'click', () => {
            loadSettings();
            $('#ladderrSettingsMenu').style.display = 'block';
        });
    }

    function openUriLink(action=null) {
        if (Ladderr.basePathRemote == null || Ladderr.basePathLocal == null) {
            console.log('[Ladderr] Please configure your local and remote paths');
            return;
        }

        // Get 'Save path' column index
        const torrentTable = $('#torrentsTableDiv table');
        const torrentTableHeader = torrentTable.querySelector('thead tr');
        const torrentTableHeaders = Array.from(torrentTableHeader.children);

        // Get torrent remote path
        const torrentRow = torrentTable.querySelector('tbody tr.selected');
        const pathHeader = torrentTableHeader.querySelector('th.column_save_path');
        const pathHeaderIndex = torrentTableHeaders.indexOf(pathHeader);
        const pathRemote = torrentRow.querySelector(`td:nth-child(${pathHeaderIndex + 1})`).textContent;

        // Get torrent filename path
        const fileTableHeader = $('#torrentFilesTableFixedHeaderDiv table thead tr');
        const fileTableHeaders = Array.from(fileTableHeader.children);
        const fileProgressHeader = fileTableHeader.querySelector('th.column_progress');
        const fileProgressHeaderIndex = fileTableHeaders.indexOf(fileProgressHeader);
        const pathParts = [];
        let previousLevel = null;
        let fileIndex = null;
        let fileRow = null;
        let isRowFolder = null;
        let isTreeDone = true;
        let isTargetDone = true;
        while (fileIndex != 0) {
            if (fileRow == null) {
                fileRow = $('#torrentFilesTableDiv table tbody tr.selected');
                if (action === 'openDestination') {
                    fileRow = $('#torrentFilesTableDiv table tbody tr[data-row-id="0"]');
                }
                isTargetDone = (fileRow.querySelector(`td:nth-child(${fileProgressHeaderIndex + 1}) div div`).textContent !== '0.0%');
            } else {
                fileRow = fileRow.previousSibling;
            }
            fileIndex = fileRow.getAttribute('data-row-id');
            const fileName = fileRow.querySelector('span[id^="filesTablefileName"]');
            isRowFolder = (fileRow.querySelector('.filesTableCollapseIcon') != null);
            if (isRowFolder) {
                const rowCollapseIcon = fileRow.querySelector('.filesTableCollapseIcon');
                let folderLevel = getComputedStyle(rowCollapseIcon).marginLeft;
                folderLevel = parseInt(folderLevel.substring(0, folderLevel.length - 2));
                if (folderLevel < previousLevel || previousLevel == null) {
                    if (previousLevel !== null) {
                        isTreeDone = !isTreeDone ? false : (fileRow.querySelector(`td:nth-child(${fileProgressHeaderIndex + 1}) div div`).textContent !== '0.0%');
                    }
                    previousLevel = folderLevel;
                    pathParts.push(fileName.textContent);
                }
            } else if (pathParts.length === 0) {
                let folderLevel = getComputedStyle(fileName).marginLeft;
                folderLevel = parseInt(folderLevel.substring(0, folderLevel.length - 2)) - 39;
                previousLevel = folderLevel;
                pathParts.push(fileName.textContent);
            }
        }

        if ((action === 'openFolder' && !isTreeDone) ||
            (action === 'openDirectly' && !isTargetDone) ||
            (action === 'openDestination' && isRowFolder && !isTargetDone)) {
            console.log('[Ladderr] Can\'t open folder or file for not initialized torrents');
            return;
        }

        let protocol = '';
        if (action === 'openDirectly') {
            protocol = 'ladderr-open:';
        } else if (action === 'openFolder') {
            if (isTargetDone) {
                protocol = 'ladderr-select:';
            } else {
                protocol = 'ladderr-open:';
                pathParts.shift();
            }
        } else if (action === 'openDestination') {
            protocol = 'ladderr-open:';
            if (!isRowFolder) {
                pathParts.shift();
            }
        }

        const pathLocal = pathRemote.replace(Ladderr.basePathRemote, Ladderr.basePathLocal).replaceAll('/', '\\');
        let fileNamePath = pathParts.reverse().join('\\');
        if (fileNamePath.length > 0) {
            fileNamePath = `\\${fileNamePath}`;
        }
        const remotePath = pathLocal + fileNamePath;
        const encodedRemotePath = toBase64String(remotePath)
        const uri = `${protocol}${encodedRemotePath}`;

        console.debug('[Ladderr] Remote path: ', remotePath);
        console.debug('[Ladderr] URI created: ', uri);
        window.open(uri, '_self');
    }

    function openContainingFolder() {
        openUriLink('openFolder');
    }

    function openDirectly() {
        openUriLink('openDirectly');
    }

    function openDestinationFolder() {
        const panel = $('#propertiesPanel_wrapper');
        Ladderr.panelTabSelected = $('#propertiesTabs li.selected').id;
        Ladderr.panelCollapsed = panel.classList.contains('collapsed');
        if (Ladderr.panelCollapsed || Ladderr.panelTabSelected != 'PropFilesLink') {
            const filesTableObserver = new MutationObserver((mutations, observer) => {
                if ($('#filesTablefileName0')) {
                    observer.disconnect();
                    openUriLink('openDestination');
                    $(`#${Ladderr.panelTabSelected}`).click();
                    if ($('#propertiesPanel_wrapper').classList.contains('expanded') && Ladderr.panelCollapsed) {
                        $('#propertiesPanel_collapseToggle').click();
                    }
                }
            });
            filesTableObserver.observe($('#torrentFilesTableDiv table tbody'), { childList: true });

            if (!panel.classList.contains('expanded')) {
                $('#propertiesPanel_collapseToggle').click();
            }
            if ($('#propertiesTabs li.selected').id != 'PropFilesLink'){
                $('#PropFilesLink').click();
            }
        } else {
            openUriLink('openDestination');
        }
    }

    function handleContentDClick(event) {
        let element = event.target;
        while (element) {
            if (element === this) {
                openDirectly();
                break;
            }
            element = element.parentNode;
        }
    }

    function handleMainDClick(event) {
        event.stopPropagation();
        let element = event.target;
        while (element) {
            if (element === this) {
                openDestinationFolder();
                break;
            }
            element = element.parentNode;
        }
    }

    function processPage() {
        createSettingsMenu();
        loadSettings();
        createContextMenuItems();
        addEventListener($('#torrentFilesTableDiv table'), 'dblclick', handleContentDClick);
    }

    whenPageReady(() => {
        $('head').append(createElement(style));
        processPage();
    }, 250);

})();