From 93c8be5b5daaac282ab2231179e34cb9623bb953 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Sun, 20 Jan 2019 14:02:19 -0800 Subject: [PATCH 1/3] Ignore npm installed packages --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index f4cab7b2f..3d75b2b0a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ config.status src/icons/qbt-theme/build-icons/node_modules/ src/icons/skin/build-icons/node_modules/ src/icons/skin/build-icons/icons/*.png + +# Web UI tools +node_modules +package-lock.json From e0037b819ae0b60d00cedf2762e6514b4913d47e Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Mon, 3 Jun 2019 17:22:35 -0700 Subject: [PATCH 2/3] Always send paths with '/' as file separator --- src/webui/api/torrentscontroller.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webui/api/torrentscontroller.cpp b/src/webui/api/torrentscontroller.cpp index 0a965bf28..d5092c6bb 100644 --- a/src/webui/api/torrentscontroller.cpp +++ b/src/webui/api/torrentscontroller.cpp @@ -460,7 +460,7 @@ void TorrentsController::filesAction() QString fileName = torrent->filePath(i); if (fileName.endsWith(QB_EXT, Qt::CaseInsensitive)) fileName.chop(QB_EXT.size()); - fileDict[KEY_FILE_NAME] = Utils::Fs::toNativePath(fileName); + fileDict[KEY_FILE_NAME] = Utils::Fs::toUniformPath(fileName); const BitTorrent::TorrentInfo::PieceRange idx = info.filePieces(i); fileDict[KEY_FILE_PIECE_RANGE] = QVariantList {idx.first(), idx.last()}; From 60a183581381359bbb6beb6ced6077a98c0fea18 Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Fri, 26 Jul 2019 02:06:45 -0700 Subject: [PATCH 3/3] Display files hierarchically in Web UI content tab --- src/webui/www/private/css/style.css | 42 +- src/webui/www/private/index.html | 4 +- src/webui/www/private/properties.html | 3 + src/webui/www/private/scripts/client.js | 13 + src/webui/www/private/scripts/dynamicTable.js | 293 +++++++++- src/webui/www/private/scripts/file-tree.js | 176 ++++++ src/webui/www/private/scripts/filesystem.js | 61 +++ src/webui/www/private/scripts/prop-files.js | 505 ++++++++++++++---- src/webui/www/webui.qrc | 2 + 9 files changed, 980 insertions(+), 119 deletions(-) create mode 100644 src/webui/www/private/scripts/file-tree.js create mode 100644 src/webui/www/private/scripts/filesystem.js diff --git a/src/webui/www/private/css/style.css b/src/webui/www/private/css/style.css index b625af7ab..ee027d3d1 100644 --- a/src/webui/www/private/css/style.css +++ b/src/webui/www/private/css/style.css @@ -352,7 +352,6 @@ a.propButton img { #torrentsFilterToolbar { float: right; margin-right: 30px; - margin-right: 30px; } #torrentsFilterInput { @@ -364,6 +363,20 @@ a.propButton img { background-position: left; } +#torrentFilesFilterToolbar { + float: right; + margin-right: 30px; +} + +#torrentFilesFilterInput { + width: 160px; + padding-left: 2em; + background-image: url("../images/qbt-theme/edit-find.svg"); + background-repeat: no-repeat; + background-size: 1.5em; + background-position: left; +} + /* Tri-state checkbox */ label.tristate { @@ -470,6 +483,19 @@ td.generalLabel { line-height: 25px; } +.filesTableCollapseIcon { + width: 15px; + height: 15px; + cursor: pointer; + margin-bottom: -3px; + padding-right: 5px; +} + +.filesTableCollapseIcon.rotate { + transform: rotate(270deg); + margin-bottom: -1px; +} + .unselectable { -webkit-touch-callout: none; -webkit-user-select: none; @@ -596,3 +622,17 @@ td.statusBarSeparator { .searchPluginsTableRow { cursor: pointer; } + +#torrentFilesTableDiv .dynamicTable tr.nonAlt { + background-color: #fff; +} + +#torrentFilesTableDiv .dynamicTable tr.nonAlt.selected { + background-color: #354158; + color: #fff; +} + +#torrentFilesTableDiv .dynamicTable tr.nonAlt:hover { + background-color: #ee6600; + color: #fff; +} diff --git a/src/webui/www/private/index.html b/src/webui/www/private/index.html index 89fc2c68e..fe430ec1d 100644 --- a/src/webui/www/private/index.html +++ b/src/webui/www/private/index.html @@ -21,8 +21,10 @@ + + @@ -189,7 +191,7 @@
  • QBT_TR(Copy tracker URL)QBT_TR[CONTEXT=TrackerListWidget] QBT_TR(Copy tracker URL)QBT_TR[CONTEXT=TrackerListWidget]
    • -
    • +
    • QBT_TR(Priority)QBT_TR[CONTEXT=PropertiesWidget]
      • QBT_TR(Do not download)QBT_TR[CONTEXT=PropListDelegate]
      • diff --git a/src/webui/www/private/properties.html b/src/webui/www/private/properties.html index 66486e917..789dc320e 100644 --- a/src/webui/www/private/properties.html +++ b/src/webui/www/private/properties.html @@ -1,4 +1,7 @@
        +
          diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index f1dba3980..16ed90d05 100644 --- a/src/webui/www/private/scripts/client.js +++ b/src/webui/www/private/scripts/client.js @@ -887,6 +887,7 @@ window.addEvent('load', function() { $('PropGeneralLink').addEvent('click', function(e) { $$('.propertiesTabContent').addClass('invisible'); $('prop_general').removeClass("invisible"); + hideFilesFilter(); updatePropertiesPanel(); localStorage.setItem('selected_tab', this.id); }); @@ -894,6 +895,7 @@ window.addEvent('load', function() { $('PropTrackersLink').addEvent('click', function(e) { $$('.propertiesTabContent').addClass('invisible'); $('prop_trackers').removeClass("invisible"); + hideFilesFilter(); updatePropertiesPanel(); localStorage.setItem('selected_tab', this.id); }); @@ -901,6 +903,7 @@ window.addEvent('load', function() { $('PropPeersLink').addEvent('click', function(e) { $$('.propertiesTabContent').addClass('invisible'); $('prop_peers').removeClass("invisible"); + hideFilesFilter(); updatePropertiesPanel(); localStorage.setItem('selected_tab', this.id); }); @@ -908,6 +911,7 @@ window.addEvent('load', function() { $('PropWebSeedsLink').addEvent('click', function(e) { $$('.propertiesTabContent').addClass('invisible'); $('prop_webseeds').removeClass("invisible"); + hideFilesFilter(); updatePropertiesPanel(); localStorage.setItem('selected_tab', this.id); }); @@ -915,6 +919,7 @@ window.addEvent('load', function() { $('PropFilesLink').addEvent('click', function(e) { $$('.propertiesTabContent').addClass('invisible'); $('prop_files').removeClass("invisible"); + showFilesFilter(); updatePropertiesPanel(); localStorage.setItem('selected_tab', this.id); }); @@ -927,6 +932,14 @@ window.addEvent('load', function() { height: prop_h }); + const showFilesFilter = function() { + $('torrentFilesFilterToolbar').removeClass("invisible"); + }; + + const hideFilesFilter = function() { + $('torrentFilesFilterToolbar').addClass("invisible"); + }; + let prevTorrentsFilterValue; let torrentsFilterInputTimer = null; // listen for changes to torrentsFilterInput diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index e4faa17c2..78d0d5baf 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -468,7 +468,7 @@ const DynamicTable = new Class({ } else { // Toggle sort order - this.reverseSort = this.reverseSort == '0' ? '1' : '0'; + this.reverseSort = this.reverseSort === '0' ? '1' : '0'; this.setSortedColumnIcon(column, null, (this.reverseSort === '1')); } localStorage.setItem('sorted_column_' + this.dynamicTableDivId, column); @@ -627,7 +627,7 @@ const DynamicTable = new Class({ filteredRows.sort(function(row1, row2) { const column = this.columns[this.sortedColumn]; const res = column.compareRows(row1, row2); - if (this.reverseSort == '0') + if (this.reverseSort === '0') return res; else return -res; @@ -883,19 +883,20 @@ const TorrentsTable = new Class({ const img_path = 'images/skin/' + state + '.svg'; - if (td.getChildren('img').length) { + if (td.getChildren('img').length > 0) { const img = td.getChildren('img')[0]; if (img.src.indexOf(img_path) < 0) { img.set('src', img_path); img.set('title', state); } } - else + else { td.adopt(new Element('img', { 'src': img_path, 'class': 'stateIcon', 'title': state })); + } }; // status @@ -1008,7 +1009,7 @@ const TorrentsTable = new Class({ if (progressFormated == 100.0 && progress != 1.0) progressFormated = 99.9; - if (td.getChildren('div').length) { + if (td.getChildren('div').length > 0) { const div = td.getChildren('div')[0]; if (td.resized) { td.resized = false; @@ -1314,7 +1315,7 @@ const TorrentsTable = new Class({ filteredRows.sort(function(row1, row2) { const column = this.columns[this.sortedColumn]; const res = column.compareRows(row1, row2); - if (this.reverseSort == '0') + if (this.reverseSort === '0') return res; else return -res; @@ -1379,14 +1380,14 @@ const TorrentPeersTable = new Class({ const country_code = this.getRowValue(row, 1); if (!country_code) { - if (td.getChildren('img').length) + if (td.getChildren('img').length > 0) td.getChildren('img')[0].dispose(); return; } const img_path = 'images/flags/' + country_code + '.svg'; - if (td.getChildren('img').length) { + if (td.getChildren('img').length > 0) { const img = td.getChildren('img')[0]; img.set('src', img_path); img.set('class', 'flags'); @@ -1566,12 +1567,12 @@ const SearchResultsTable = new Class({ const seedsFilters = getSeedsFilters(); const searchInTorrentName = $('searchInTorrentName').get('value') === "names"; - if (searchInTorrentName || filterTerms.length || (searchSizeFilter.min > 0.00) || (searchSizeFilter.max > 0.00)) { + if (searchInTorrentName || (filterTerms.length > 0) || (searchSizeFilter.min > 0.00) || (searchSizeFilter.max > 0.00)) { for (let i = 0; i < rows.length; ++i) { const row = rows[i]; if (searchInTorrentName && !containsAll(row.full_data.fileName, searchTerms)) continue; - if (filterTerms.length && !containsAll(row.full_data.fileName, filterTerms)) continue; + if ((filterTerms.length > 0) && !containsAll(row.full_data.fileName, filterTerms)) continue; if ((sizeFilters.min > 0.00) && (row.full_data.fileSize < sizeFilters.min)) continue; if ((sizeFilters.max > 0.00) && (row.full_data.fileSize > sizeFilters.max)) continue; if ((seedsFilters.min > 0) && (row.full_data.nbSeeders < seedsFilters.min)) continue; @@ -1587,7 +1588,7 @@ const SearchResultsTable = new Class({ filteredRows.sort(function(row1, row2) { const column = this.columns[this.sortedColumn]; const res = column.compareRows(row1, row2); - if (this.reverseSort == '0') + if (this.reverseSort === '0') return res; else return -res; @@ -1663,6 +1664,65 @@ const TorrentTrackersTable = new Class({ const TorrentFilesTable = new Class({ Extends: DynamicTable, + filterTerms: [], + prevFilterTerms: [], + prevRowsString: null, + prevFilteredRows: [], + prevSortedColumn: null, + prevReverseSort: null, + fileTree: new FileTree(), + + populateTable: function(root) { + this.fileTree.setRoot(root); + root.children.each(function(node) { + this._addNodeToTable(node, 0); + }.bind(this)); + }, + + _addNodeToTable: function(node, depth) { + node.depth = depth; + + if (node.isFolder) { + const data = { + rowId: node.rowId, + size: node.size, + checked: node.checked, + remaining: node.remaining, + progress: node.progress, + priority: normalizePriority(node.priority), + availability: node.availability, + fileId: -1, + name: node.name + }; + + node.data = data; + node.full_data = data; + this.updateRowData(data); + } + else { + node.data.rowId = node.rowId; + node.full_data = node.data; + this.updateRowData(node.data); + } + + node.children.each(function(child) { + this._addNodeToTable(child, depth + 1); + }.bind(this)); + }, + + getRoot: function() { + return this.fileTree.getRoot(); + }, + + getNode: function(rowId) { + return this.fileTree.getNode(rowId); + }, + + getRow: function(node) { + const rowId = this.fileTree.getRowId(node); + return this.rows.get(rowId); + }, + initColumns: function() { this.newColumn('checked', '', '', 50, true); this.newColumn('name', '', 'QBT_TR(Name)QBT_TR[CONTEXT=TrackerListWidget]', 300, true); @@ -1676,6 +1736,7 @@ const TorrentFilesTable = new Class({ }, initColumnsFunctions: function() { + const that = this; const displaySize = function(td, row) { const size = friendlyUnit(this.getRowValue(row), false); td.set('html', size); @@ -1687,6 +1748,60 @@ const TorrentFilesTable = new Class({ td.set('title', value); }; + this.columns['name'].updateTd = function(td, row) { + const id = row.rowId; + const fileNameId = 'filesTablefileName' + id; + const node = that.getNode(id); + + if (node.isFolder) { + const value = this.getRowValue(row); + const collapseIconId = 'filesTableCollapseIcon' + id; + const dirImgId = 'filesTableDirImg' + id; + if ($(dirImgId)) { + // just update file name + $(fileNameId).textContent = escapeHtml(value); + } + else { + const collapseIcon = new Element('img', { + src: 'images/qbt-theme/go-down.svg', + styles: { + 'margin-left': (node.depth * 20) + }, + class: "filesTableCollapseIcon", + id: collapseIconId, + "data-id": id, + onclick: "collapseIconClicked(this)" + }); + const span = new Element('span', { + text: escapeHtml(value), + id: fileNameId + }); + const dirImg = new Element('img', { + src: 'images/qbt-theme/inode-directory.svg', + styles: { + 'width': 15, + 'padding-right': 5, + 'margin-bottom': -3 + }, + id: dirImgId + }); + const html = collapseIcon.outerHTML + dirImg.outerHTML + span.outerHTML; + td.set('html', html); + } + } + else { + const value = this.getRowValue(row); + const span = new Element('span', { + text: escapeHtml(value), + id: fileNameId, + styles: { + 'margin-left': ((node.depth + 1) * 20) + } + }); + td.set('html', span.outerHTML); + } + }; + this.columns['checked'].updateTd = function(td, row) { const id = row.rowId; const value = this.getRowValue(row); @@ -1697,9 +1812,11 @@ const TorrentFilesTable = new Class({ else { const treeImg = new Element('img', { src: 'images/L.gif', - style: 'margin-bottom: -2px' + styles: { + 'margin-bottom': -2 + } }); - td.adopt(treeImg, createDownloadCheckbox(row.rowId, value)); + td.adopt(treeImg, createDownloadCheckbox(id, row.full_data.fileId, value)); } }; @@ -1712,8 +1829,8 @@ const TorrentFilesTable = new Class({ const progressBar = $('pbf_' + id); if (progressBar === null) { td.adopt(new ProgressBar(value.toFloat(), { - 'id': 'pbf_' + id, - 'width': 80 + id: 'pbf_' + id, + width: 80 })); } else { @@ -1728,11 +1845,155 @@ const TorrentFilesTable = new Class({ if (isPriorityComboExists(id)) updatePriorityCombo(id, value); else - td.adopt(createPriorityCombo(id, value)); + td.adopt(createPriorityCombo(id, row.full_data.fileId, value)); }; this.columns['remaining'].updateTd = displaySize; this.columns['availability'].updateTd = displayPercentage; + }, + + altRow: function() { + let addClass = false; + const trs = this.tableBody.getElements('tr'); + trs.each(function(tr) { + if (tr.hasClass("invisible")) + return; + + if (addClass){ + tr.addClass("alt"); + tr.removeClass("nonAlt"); + } + else { + tr.removeClass("alt"); + tr.addClass("nonAlt"); + } + addClass = !addClass; + }.bind(this)); + }, + + _sortNodesByColumn: function(nodes, column) { + nodes.sort(function(row1, row2) { + // list folders before files when sorting by name + if (column.name === "name") { + const node1 = this.getNode(row1.data.rowId); + const node2 = this.getNode(row2.data.rowId); + if (node1.isFolder && !node2.isFolder) + return -1; + if (node2.isFolder && !node1.isFolder) + return 1; + } + + const res = column.compareRows(row1, row2); + return (this.reverseSort === '0') ? res : -res; + }.bind(this)); + + nodes.each(function(node) { + if (node.children.length > 0) + this._sortNodesByColumn(node.children, column); + }.bind(this)); + }, + + _filterNodes: function(node, filterTerms, filteredRows) { + if (node.isFolder) { + const childAdded = node.children.reduce(function (acc, child) { + // we must execute the function before ORing w/ acc or we'll stop checking child nodes after the first successful match + return (this._filterNodes(child, filterTerms, filteredRows) || acc); + }.bind(this), false); + + if (childAdded) { + const row = this.getRow(node); + filteredRows.push(row); + return true; + } + } + + const lowercaseName = node.name.toLowerCase(); + const matchesFilter = filterTerms.every(function(term) { + return (lowercaseName.indexOf(term) !== -1); + }); + + if (matchesFilter) { + const row = this.getRow(node); + filteredRows.push(row); + return true; + } + + return false; + }, + + setFilter: function(text) { + const filterTerms = text.trim().toLowerCase().split(' '); + if ((filterTerms.length === 1) && (filterTerms[0] === '')) + this.filterTerms = []; + else + this.filterTerms = filterTerms; + }, + + getFilteredAndSortedRows: function() { + if (this.getRoot() === null) + return []; + + const generateRowsSignature = function(rows) { + const rowsData = rows.map(function(row) { + return row.full_data; + }); + return JSON.stringify(rowsData); + }; + + const getFilteredRows = function() { + if (this.filterTerms.length === 0) { + const nodeArray = this.fileTree.toArray(); + const filteredRows = nodeArray.map(function(node) { + return this.getRow(node); + }.bind(this)); + return filteredRows; + } + + const filteredRows = []; + this.getRoot().children.each(function(child) { + this._filterNodes(child, this.filterTerms, filteredRows); + }.bind(this)); + filteredRows.reverse(); + return filteredRows; + }.bind(this); + + const hasRowsChanged = function(rowsString, prevRowsStringString) { + const rowsChanged = (rowsString !== prevRowsStringString); + const isFilterTermsChanged = this.filterTerms.reduce(function(acc, term, index) { + return (acc || (term !== this.prevFilterTerms[index])); + }.bind(this), 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 = generateRowsSignature(this.rows); + if (!hasRowsChanged(rowsString, this.prevRowsString)) { + return this.prevFilteredRows; + } + + // sort, then filter + const column = this.columns[this.sortedColumn]; + this._sortNodesByColumn(this.getRoot().children, column); + const filteredRows = getFilteredRows(); + + this.prevFilterTerms = this.filterTerms; + this.prevRowsString = rowsString; + this.prevFilteredRows = filteredRows; + this.prevSortedColumn = this.sortedColumn; + this.prevReverseSort = this.reverseSort; + return filteredRows; + }, + + setIgnored: function(rowId, ignore) { + const row = this.rows.get(rowId); + if (ignore) + row.full_data.remaining = 0; + else + row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100))); } }); diff --git a/src/webui/www/private/scripts/file-tree.js b/src/webui/www/private/scripts/file-tree.js new file mode 100644 index 000000000..c84738f29 --- /dev/null +++ b/src/webui/www/private/scripts/file-tree.js @@ -0,0 +1,176 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2019 Thomas Piccirello + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +'use strict'; + +const FilePriority = { + "Ignored": 0, + "Normal": 1, + "High": 6, + "Maximum": 7, + "Mixed": -1 +}; +Object.freeze(FilePriority); + +const TriState = { + "Unchecked": 0, + "Checked": 1, + "Partial": 2 +}; +Object.freeze(TriState); + +const FileTree = new Class({ + root: null, + nodeMap: {}, + + setRoot: function(root) { + this.root = root; + this.generateNodeMap(root); + + if (this.root.isFolder) + this.root.calculateSize(); + }, + + getRoot: function() { + return this.root; + }, + + generateNodeMap: function(node) { + // don't store root node in map + if (node.root !== null) { + this.nodeMap[node.rowId] = node; + } + + node.children.each(function(child) { + this.generateNodeMap(child); + }.bind(this)); + }, + + getNode: function(rowId) { + return (this.nodeMap[rowId] === undefined) + ? null + : this.nodeMap[rowId]; + }, + + getRowId: function(node) { + return node.rowId; + }, + + /** + * Returns the nodes in dfs order + */ + toArray: function() { + const nodes = []; + this.root.children.each(function(node) { + this._getArrayOfNodes(node, nodes); + }.bind(this)); + return nodes; + }, + + _getArrayOfNodes: function(node, array) { + array.push(node); + node.children.each(function(child) { + this._getArrayOfNodes(child, array); + }.bind(this)); + } +}); + +const FileNode = new Class({ + name: "", + rowId: null, + size: 0, + checked: TriState.Unchecked, + remaining: 0, + progress: 0, + priority: FilePriority.Normal, + availability: 0, + depth: 0, + root: null, + data: null, + isFolder: false, + children: [], +}); + +const FolderNode = new Class({ + Extends: FileNode, + + initialize: function() { + this.isFolder = true; + }, + + addChild(node) { + this.children.push(node); + }, + + /** + * Recursively calculate size of node and its children + */ + calculateSize: function() { + let size = 0; + let remaining = 0; + let progress = 0; + let availability = 0; + let checked = TriState.Unchecked; + let priority = FilePriority.Normal; + + let isFirstFile = true; + + this.children.each(function(node) { + if (node.isFolder) + node.calculateSize(); + + size += node.size; + + if (isFirstFile) { + priority = node.priority; + checked = node.checked; + isFirstFile = false; + } + else { + if (priority !== node.priority) + priority = FilePriority.Mixed; + if (checked !== node.checked) + checked = TriState.Partial; + } + + const isIgnored = (node.priority === FilePriority.Ignored); + if (!isIgnored) { + remaining += node.remaining; + progress += (node.progress * node.size); + availability += (node.availability * node.size); + } + }.bind(this)); + + this.size = size; + this.remaining = remaining; + this.checked = checked; + this.progress = (progress / size); + this.priority = priority; + this.availability = (availability / size); + } +}); diff --git a/src/webui/www/private/scripts/filesystem.js b/src/webui/www/private/scripts/filesystem.js new file mode 100644 index 000000000..5a9df7d8e --- /dev/null +++ b/src/webui/www/private/scripts/filesystem.js @@ -0,0 +1,61 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2019 Thomas Piccirello + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +'use strict'; + +// This file is the JavaScript implementation of base/utils/fs.cpp + +const QB_EXT = '.!qB'; +const PathSeparator = '/'; + +/** + * Returns the file extension part of a file name. + */ +function fileExtension(filename) { + const name = filename.endsWith(QB_EXT) + ? filename.substring(0, filename.length - QB_EXT.length) + : filename; + const pointIndex = name.lastIndexOf('.'); + if (pointIndex === -1) + return ''; + return name.substring(pointIndex + 1); +} + +function fileName(filepath) { + const slashIndex = filepath.lastIndexOf(PathSeparator); + if (slashIndex === -1) + return filepath; + return filepath.substring(slashIndex + 1); +} + +function folderName(filepath) { + const slashIndex = filepath.lastIndexOf(PathSeparator); + if (slashIndex === -1) + return filepath; + return filepath.substring(0, slashIndex); +} diff --git a/src/webui/www/private/scripts/prop-files.js b/src/webui/www/private/scripts/prop-files.js index b3b648967..485b54f2a 100644 --- a/src/webui/www/private/scripts/prop-files.js +++ b/src/webui/www/private/scripts/prop-files.js @@ -31,14 +31,6 @@ let is_seed = true; this.current_hash = ""; -const FilePriority = { - "Ignored": 0, - "Normal": 1, - "High": 6, - "Maximum": 7, - "Mixed": -1 -}; - const normalizePriority = function(priority) { switch (priority) { case FilePriority.Ignored: @@ -52,45 +44,102 @@ const normalizePriority = function(priority) { } }; -const fileCheckboxChanged = function(e) { +const getAllChildren = function(id, fileId) { + const node = torrentFilesTable.getNode(id); + if (!node.isFolder) { + return { + rowIds: [id], + fileIds: [fileId] + }; + } + + const rowIds = []; + const fileIds = []; + + const getChildFiles = function(node) { + if (node.isFolder) { + node.children.each(function(child) { + getChildFiles(child); + }); + } + else { + rowIds.push(node.data.rowId); + fileIds.push(node.data.fileId); + } + }; + + node.children.each(function(child) { + getChildFiles(child); + }); + + return { + rowIds: rowIds, + fileIds: fileIds + }; +}; + +const fileCheckboxClicked = function(e) { + e.stopPropagation(); + const checkbox = e.target; const priority = checkbox.checked ? FilePriority.Normal : FilePriority.Ignored; const id = checkbox.get('data-id'); + const fileId = checkbox.get('data-file-id'); - setFilePriority(id, priority); - setGlobalCheckboxState(); - return true; + const rows = getAllChildren(id, fileId); + + setFilePriority(rows.rowIds, rows.fileIds, priority); + updateGlobalCheckbox(); }; const fileComboboxChanged = function(e) { const combobox = e.target; - const newPriority = combobox.value; + const priority = combobox.value; const id = combobox.get('data-id'); + const fileId = combobox.get('data-file-id'); - setFilePriority(id, newPriority); + const rows = getAllChildren(id, fileId); + + setFilePriority(rows.rowIds, rows.fileIds, priority); + updateGlobalCheckbox(); }; const isDownloadCheckboxExists = function(id) { return ($('cbPrio' + id) !== null); }; -const createDownloadCheckbox = function(id, download) { +const createDownloadCheckbox = function(id, fileId, checked) { const checkbox = new Element('input'); checkbox.set('type', 'checkbox'); - if (download) - checkbox.set('checked', 'checked'); checkbox.set('id', 'cbPrio' + id); checkbox.set('data-id', id); + checkbox.set('data-file-id', fileId); checkbox.set('class', 'DownloadedCB'); - checkbox.addEvent('change', fileCheckboxChanged); + checkbox.addEvent('click', fileCheckboxClicked); + + updateCheckbox(checkbox, checked); return checkbox; }; -const updateDownloadCheckbox = function(id, download) { +const updateDownloadCheckbox = function(id, checked) { const checkbox = $('cbPrio' + id); - checkbox.checked = download; + updateCheckbox(checkbox, checked); }; +const updateCheckbox = function(checkbox, checked) { + switch (checked) { + case TriState.Checked: + setCheckboxChecked(checkbox); + break; + case TriState.Unchecked: + setCheckboxUnchecked(checkbox); + break; + case TriState.Partial: + setCheckboxPartial(checkbox); + break; + } +} + const isPriorityComboExists = function(id) { return ($('comboPrio' + id) !== null); }; @@ -104,10 +153,11 @@ const createPriorityOptionElement = function(priority, selected, html) { return elem; }; -const createPriorityCombo = function(id, selectedPriority) { +const createPriorityCombo = function(id, fileId, selectedPriority) { const select = new Element('select'); select.set('id', 'comboPrio' + id); select.set('data-id', id); + select.set('data-file-id', fileId); select.set('disabled', is_seed); select.addClass('combo_priority'); select.addEvent('change', fileComboboxChanged); @@ -117,6 +167,11 @@ const createPriorityCombo = function(id, selectedPriority) { createPriorityOptionElement(FilePriority.High, (FilePriority.High === selectedPriority), 'QBT_TR(High)QBT_TR[CONTEXT=PropListDelegate]').injectInside(select); createPriorityOptionElement(FilePriority.Maximum, (FilePriority.Maximum === selectedPriority), 'QBT_TR(Maximum)QBT_TR[CONTEXT=PropListDelegate]').injectInside(select); + // "Mixed" priority is for display only; it shouldn't be selectable + const mixedPriorityOption = createPriorityOptionElement(FilePriority.Mixed, (FilePriority.Mixed === selectedPriority), 'QBT_TR(Mixed)QBT_TR[CONTEXT=PropListDelegate]'); + mixedPriorityOption.set('disabled', true); + mixedPriorityOption.injectInside(select); + return select; }; @@ -143,56 +198,73 @@ const selectComboboxPriority = function(combobox, priority) { combobox.value = priority; }; -const switchCheckboxState = function() { - const rows = []; - let priority = FilePriority.Ignored; +const switchCheckboxState = function(e) { + e.stopPropagation(); - if ($('tristate_cb').state === "checked") { - setGlobalCheckboxUnchecked(); + const rowIds = []; + const fileIds = []; + let priority = FilePriority.Ignored; + const checkbox = $('tristate_cb'); + + if (checkbox.state === "checked") { + setCheckboxUnchecked(checkbox); // set file priority for all checked to Ignored torrentFilesTable.getFilteredAndSortedRows().forEach(function(row) { - if (row.full_data.checked) - rows.push(row.full_data.rowId); + const rowId = row.rowId; + const fileId = row.full_data.fileId; + const isChecked = (row.full_data.checked === TriState.Checked); + const isFolder = (fileId === -1); + if (!isFolder && isChecked) { + rowIds.push(rowId); + fileIds.push(fileId); + } }); } else { - setGlobalCheckboxChecked(); + setCheckboxChecked(checkbox); priority = FilePriority.Normal; // set file priority for all unchecked to Normal torrentFilesTable.getFilteredAndSortedRows().forEach(function(row) { - if (!row.full_data.checked) - rows.push(row.full_data.rowId); + const rowId = row.rowId; + const fileId = row.full_data.fileId; + const isUnchecked = (row.full_data.checked === TriState.Unchecked); + const isFolder = (fileId === -1); + if (!isFolder && isUnchecked) { + rowIds.push(rowId); + fileIds.push(fileId); + } }); } - if (rows.length > 0) - setFilePriority(rows, priority); + if (rowIds.length > 0) + setFilePriority(rowIds, fileIds, priority); }; -const setGlobalCheckboxState = function() { +const updateGlobalCheckbox = function() { + const checkbox = $('tristate_cb'); if (isAllCheckboxesChecked()) - setGlobalCheckboxChecked(); + setCheckboxChecked(checkbox); else if (isAllCheckboxesUnchecked()) - setGlobalCheckboxUnchecked(); + setCheckboxUnchecked(checkbox); else - setGlobalCheckboxPartial(); + setCheckboxPartial(checkbox); }; -const setGlobalCheckboxChecked = function() { - $('tristate_cb').state = "checked"; - $('tristate_cb').indeterminate = false; - $('tristate_cb').checked = true; +const setCheckboxChecked = function(checkbox) { + checkbox.state = "checked"; + checkbox.indeterminate = false; + checkbox.checked = true; }; -const setGlobalCheckboxUnchecked = function() { - $('tristate_cb').state = "unchecked"; - $('tristate_cb').indeterminate = false; - $('tristate_cb').checked = false; +const setCheckboxUnchecked = function(checkbox) { + checkbox.state = "unchecked"; + checkbox.indeterminate = false; + checkbox.checked = false; }; -const setGlobalCheckboxPartial = function() { - $('tristate_cb').state = "partial"; - $('tristate_cb').indeterminate = true; +const setCheckboxPartial = function(checkbox) { + checkbox.state = "partial"; + checkbox.indeterminate = true; }; const isAllCheckboxesChecked = function() { @@ -213,9 +285,8 @@ const isAllCheckboxesUnchecked = function() { return true; }; -const setFilePriority = function(id, priority) { +const setFilePriority = function(ids, fileIds, priority) { if (current_hash === "") return; - const ids = Array.isArray(id) ? id : [id]; clearTimeout(loadTorrentFilesDataTimer); new Request({ @@ -223,7 +294,7 @@ const setFilePriority = function(id, priority) { method: 'post', data: { 'hash': current_hash, - 'id': ids.join('|'), + 'id': fileIds.join('|'), 'priority': priority }, onComplete: function() { @@ -231,11 +302,16 @@ const setFilePriority = function(id, priority) { } }).send(); + const ignore = (priority === FilePriority.Ignored); ids.forEach(function(_id) { + torrentFilesTable.setIgnored(_id, ignore); + const combobox = $('comboPrio' + _id); if (combobox !== null) selectComboboxPriority(combobox, priority); }); + + torrentFilesTable.updateTable(false); }; let loadTorrentFilesDataTimer; @@ -252,9 +328,11 @@ const loadTorrentFilesData = function() { loadTorrentFilesDataTimer = loadTorrentFilesData.delay(5000); return; } + let loadedNewTorrent = false; if (new_hash != current_hash) { torrentFilesTable.clear(); current_hash = new_hash; + loadedNewTorrent = true; } const url = new URI('api/v2/torrents/files?hash=' + current_hash); new Request.JSON({ @@ -266,43 +344,16 @@ const loadTorrentFilesData = function() { loadTorrentFilesDataTimer = loadTorrentFilesData.delay(5000); }, onSuccess: function(files) { - const selectedFiles = torrentFilesTable.selectedRowsIds(); + clearTimeout(torrentFilesFilterInputTimer); - if (!files) { + if (files.length === 0) { torrentFilesTable.clear(); - return; } - - let i = 0; - files.each(function(file) { - if (i === 0) - is_seed = file.is_seed; - - const row = { - rowId: i, - checked: (file.priority !== FilePriority.Ignored), - name: escapeHtml(file.name), - size: file.size, - progress: (file.progress * 100).round(1), - priority: normalizePriority(file.priority), - remaining: (file.size * (1.0 - file.progress)), - availability: file.availability - }; - - if ((row.progress === 100) && (file.progress < 1)) - row.progress = 99.9; - - ++i; - torrentFilesTable.updateRowData(row); - }.bind(this)); - - torrentFilesTable.updateTable(false); - torrentFilesTable.altRow(); - - if (selectedFiles.length > 0) - torrentFilesTable.reselectRows(selectedFiles); - - setGlobalCheckboxState(); + else { + handleNewTorrentFiles(files); + if (loadedNewTorrent) + collapseAllNodes(); + } } }).send(); }; @@ -312,33 +363,154 @@ updateTorrentFilesData = function() { loadTorrentFilesData(); }; +const handleNewTorrentFiles = function(files) { + is_seed = (files.length > 0) ? files[0].is_seed : true; + + const rows = files.map(function(file, index) { + let progress = (file.progress * 100).round(1); + if ((progress === 100) && (file.progress < 1)) + progress = 99.9; + + const name = escapeHtml(file.name); + const ignore = (file.priority === FilePriority.Ignored); + const checked = (ignore ? TriState.Unchecked : TriState.Checked); + const remaining = (ignore ? 0 : (file.size * (1.0 - file.progress))); + const row = { + fileId: index, + checked: checked, + fileName: name, + name: fileName(name), + size: file.size, + progress: progress, + priority: normalizePriority(file.priority), + remaining: remaining, + availability: file.availability + }; + + return row; + }); + + addRowsToTable(rows); + updateGlobalCheckbox(); +}; + +const addRowsToTable = function(rows) { + const selectedFiles = torrentFilesTable.selectedRowsIds(); + let rowId = 0; + + const rootNode = new FolderNode(); + + rows.forEach(function(row) { + let parent = rootNode; + const pathFolders = row.fileName.split(PathSeparator); + pathFolders.pop(); + pathFolders.forEach(function(folderName) { + if (folderName === '.unwanted') + return; + + let parentNode = null; + if (parent.children !== null) { + for (let i = 0; i < parent.children.length; ++i) { + const childFolder = parent.children[i]; + if (childFolder.name === folderName) { + parentNode = childFolder; + break; + } + } + } + if (parentNode === null) { + parentNode = new FolderNode(); + parentNode.name = folderName; + parentNode.rowId = rowId; + parentNode.root = parent; + parent.addChild(parentNode); + + ++rowId; + } + + parent = parentNode; + }); + + const isChecked = row.checked ? TriState.Checked : TriState.Unchecked; + const remaining = (row.priority === FilePriority.Ignored) ? 0 : row.remaining; + const childNode = new FileNode(); + childNode.name = row.name; + childNode.rowId = rowId; + childNode.size = row.size; + childNode.checked = isChecked; + childNode.remaining = remaining; + childNode.progress = row.progress; + childNode.priority = row.priority; + childNode.availability = row.availability; + childNode.root = parent; + childNode.data = row; + parent.addChild(childNode); + + ++rowId; + }.bind(this)); + + torrentFilesTable.populateTable(rootNode); + torrentFilesTable.updateTable(false); + torrentFilesTable.altRow(); + + if (selectedFiles.length > 0) + torrentFilesTable.reselectRows(selectedFiles); +}; + +const collapseIconClicked = function(event) { + const id = event.get("data-id"); + const node = torrentFilesTable.getNode(id); + const isCollapsed = (event.parentElement.get("data-collapsed") === "true"); + + if (isCollapsed) + expandNode(node); + else + collapseNode(node); +}; + +const filesPriorityMenuClicked = function(priority) { + const selectedRows = torrentFilesTable.selectedRowsIds(); + if (selectedRows.length === 0) return; + + const rowIds = []; + const fileIds = []; + selectedRows.forEach(function(rowId) { + const elem = $('comboPrio' + rowId); + rowIds.push(rowId); + fileIds.push(elem.get("data-file-id")); + }); + + const uniqueRowIds = {}; + const uniqueFileIds = {}; + for (let i = 0; i < rowIds.length; ++i) { + const rows = getAllChildren(rowIds[i], fileIds[i]); + rows.rowIds.forEach(function(rowId) { + uniqueRowIds[rowId] = true; + }); + rows.fileIds.forEach(function(fileId) { + uniqueFileIds[fileId] = true; + }); + } + + setFilePriority(Object.keys(uniqueRowIds), Object.keys(uniqueFileIds), priority); +}; + const torrentFilesContextMenu = new ContextMenu({ targets: '#torrentFilesTableDiv tr', menu: 'torrentFilesMenu', actions: { - FilePrioIgnore: function(element, ref) { - const selectedRows = torrentFilesTable.selectedRowsIds(); - if (selectedRows.length === 0) return; - setFilePriority(selectedRows, FilePriority.Ignored); + FilePrioIgnore: function(element, ref) { + filesPriorityMenuClicked(FilePriority.Ignored); }, FilePrioNormal: function(element, ref) { - const selectedRows = torrentFilesTable.selectedRowsIds(); - if (selectedRows.length === 0) return; - - setFilePriority(selectedRows, FilePriority.Normal); + filesPriorityMenuClicked(FilePriority.Normal); }, FilePrioHigh: function(element, ref) { - const selectedRows = torrentFilesTable.selectedRowsIds(); - if (selectedRows.length === 0) return; - - setFilePriority(selectedRows, FilePriority.High); + filesPriorityMenuClicked(FilePriority.High); }, FilePrioMaximum: function(element, ref) { - const selectedRows = torrentFilesTable.selectedRowsIds(); - if (selectedRows.length === 0) return; - - setFilePriority(selectedRows, FilePriority.Maximum); + filesPriorityMenuClicked(FilePriority.Maximum); } }, offsets: { @@ -369,3 +541,134 @@ if (tableHeaders.length > 0) { // default sort by name column if (torrentFilesTable.getSortedColumn() === null) torrentFilesTable.setSortedColumn('name'); + +let prevTorrentFilesFilterValue; +let torrentFilesFilterInputTimer = null; +// listen for changes to torrentFilesFilterInput +$('torrentFilesFilterInput').addEvent('input', function() { + const value = $('torrentFilesFilterInput').get("value"); + if (value !== prevTorrentFilesFilterValue) { + prevTorrentFilesFilterValue = value; + torrentFilesTable.setFilter(value); + clearTimeout(torrentFilesFilterInputTimer); + torrentFilesFilterInputTimer = setTimeout(function() { + if (current_hash === "") return; + torrentFilesTable.updateTable(false); + + if (value.trim() === "") + collapseAllNodes(); + else + expandAllNodes(); + }, 400); + } +}); + +/** + * Show/hide a node's row + */ +const _hideNode = function(node, shouldHide) { + const span = $('filesTablefileName' + node.rowId); + // span won't exist if row has been filtered out + if (span === null) + return; + const rowElem = span.parentElement.parentElement; + if (shouldHide) + rowElem.addClass("invisible"); + else + rowElem.removeClass("invisible"); +} + +/** + * Update a node's collapsed state and icon + */ +const _updateNodeState = function(node, isCollapsed) { + const span = $('filesTablefileName' + node.rowId); + // span won't exist if row has been filtered out + if (span === null) + return; + const td = span.parentElement; + const rowElem = td.parentElement; + + // store collapsed state + td.set("data-collapsed", isCollapsed); + + // rotate the collapse icon + const collapseIcon = td.getElementsByClassName("filesTableCollapseIcon")[0]; + if (isCollapsed) + collapseIcon.addClass("rotate"); + else + collapseIcon.removeClass("rotate"); +} + +const _isCollapsed = function(node) { + const span = $('filesTablefileName' + node.rowId); + if (span === null) + return true; + + const td = span.parentElement; + return (td.get("data-collapsed") === "true"); +}; + +const expandNode = function(node) { + _collapseNode(node, false, false, false); + torrentFilesTable.altRow(); +}; + +const collapseNode = function(node) { + _collapseNode(node, true, false, false); + torrentFilesTable.altRow(); +}; + +const expandAllNodes = function() { + const root = torrentFilesTable.getRoot(); + root.children.each(function(node) { + node.children.each(function(child) { + _collapseNode(child, false, true, false); + }); + }); + torrentFilesTable.altRow(); +}; + +const collapseAllNodes = function() { + const root = torrentFilesTable.getRoot(); + root.children.each(function(node) { + node.children.each(function(child) { + _collapseNode(child, true, true, false); + }); + }); + torrentFilesTable.altRow(); +} + +/** + * Collapses a folder node with the option to recursively collapse all children + * @param {FolderNode} node the node to collapse/expand + * @param {boolean} shouldCollapse true if the node should be collapsed, false if it should be expanded + * @param {boolean} applyToChildren true if the node's children should also be collapsed, recursively + * @param {boolean} isChildNode true if the current node is a child of the original node we collapsed/expanded + */ +const _collapseNode = function(node, shouldCollapse, applyToChildren, isChildNode) { + if (!node.isFolder) + return; + + const shouldExpand = !shouldCollapse; + const isNodeCollapsed = _isCollapsed(node); + const nodeInCorrectState = ((shouldCollapse && isNodeCollapsed) || (shouldExpand && !isNodeCollapsed)); + const canSkipNode = (isChildNode && (!applyToChildren || nodeInCorrectState)); + if (!isChildNode || applyToChildren || !canSkipNode) + _updateNodeState(node, shouldCollapse); + + node.children.each(function(child) { + _hideNode(child, shouldCollapse); + + if (!child.isFolder) + return; + + // don't expand children that have been independently collapsed, unless applyToChildren is true + const shouldExpandChildren = (shouldExpand && applyToChildren); + const isChildCollapsed = _isCollapsed(child); + if (!shouldExpandChildren && isChildCollapsed) + return; + + _collapseNode(child, shouldCollapse, applyToChildren, true); + }); +}; diff --git a/src/webui/www/webui.qrc b/src/webui/www/webui.qrc index 0e8270e93..5b8c2e211 100644 --- a/src/webui/www/webui.qrc +++ b/src/webui/www/webui.qrc @@ -30,6 +30,8 @@ private/scripts/contextmenu.js private/scripts/download.js private/scripts/dynamicTable.js + private/scripts/file-tree.js + private/scripts/filesystem.js private/scripts/lib/clipboard-2.0.0.min.js private/scripts/lib/mocha-0.9.6-yc.js private/scripts/lib/mootools-1.2-core-yc.js