WebUI: Add ability to toggle alternating row colors in tables
[qBittorrent.git] / src / webui / www / private / scripts / prop-files.js
blob9acc35163231987fd6361074faaac18a6bb8a0a0
1 /*
2 * Bittorrent Client using Qt and libtorrent.
3 * Copyright (C) 2009 Christophe Dumez <chris@qbittorrent.org>
5 * This program is free software; you can redistribute it and/or
6 * modify it under the terms of the GNU General Public License
7 * as published by the Free Software Foundation; either version 2
8 * of the License, or (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License
16 * along with this program; if not, write to the Free Software
17 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * In addition, as a special exception, the copyright holders give permission to
20 * link this program with the OpenSSL project's "OpenSSL" library (or with
21 * modified versions of it that use the same license as the "OpenSSL" library),
22 * and distribute the linked executables. You must obey the GNU General Public
23 * License in all respects for all of the code used other than "OpenSSL". If you
24 * modify file(s), you may extend this exception to your version of the file(s),
25 * but you are not obligated to do so. If you do not wish to do so, delete this
26 * exception statement from your version.
29 "use strict";
31 window.qBittorrent ??= {};
32 window.qBittorrent.PropFiles ??= (() => {
33 const exports = () => {
34 return {
35 normalizePriority: normalizePriority,
36 isDownloadCheckboxExists: isDownloadCheckboxExists,
37 createDownloadCheckbox: createDownloadCheckbox,
38 updateDownloadCheckbox: updateDownloadCheckbox,
39 isPriorityComboExists: isPriorityComboExists,
40 createPriorityCombo: createPriorityCombo,
41 updatePriorityCombo: updatePriorityCombo,
42 updateData: updateData,
43 collapseIconClicked: collapseIconClicked,
44 expandFolder: expandFolder,
45 collapseFolder: collapseFolder
49 const torrentFilesTable = new window.qBittorrent.DynamicTable.TorrentFilesTable();
50 const FilePriority = window.qBittorrent.FileTree.FilePriority;
51 const TriState = window.qBittorrent.FileTree.TriState;
52 let is_seed = true;
53 let current_hash = "";
55 const normalizePriority = function(priority) {
56 switch (priority) {
57 case FilePriority.Ignored:
58 case FilePriority.Normal:
59 case FilePriority.High:
60 case FilePriority.Maximum:
61 case FilePriority.Mixed:
62 return priority;
63 default:
64 return FilePriority.Normal;
68 const getAllChildren = function(id, fileId) {
69 const node = torrentFilesTable.getNode(id);
70 if (!node.isFolder) {
71 return {
72 rowIds: [id],
73 fileIds: [fileId]
77 const rowIds = [];
78 const fileIds = [];
80 const getChildFiles = function(node) {
81 if (node.isFolder) {
82 node.children.each((child) => {
83 getChildFiles(child);
84 });
86 else {
87 rowIds.push(node.data.rowId);
88 fileIds.push(node.data.fileId);
92 node.children.each((child) => {
93 getChildFiles(child);
94 });
96 return {
97 rowIds: rowIds,
98 fileIds: fileIds
102 const fileCheckboxClicked = function(e) {
103 e.stopPropagation();
105 const checkbox = e.target;
106 const priority = checkbox.checked ? FilePriority.Normal : FilePriority.Ignored;
107 const id = checkbox.getAttribute("data-id");
108 const fileId = checkbox.getAttribute("data-file-id");
110 const rows = getAllChildren(id, fileId);
112 setFilePriority(rows.rowIds, rows.fileIds, priority);
113 updateGlobalCheckbox();
116 const fileComboboxChanged = function(e) {
117 const combobox = e.target;
118 const priority = combobox.value;
119 const id = combobox.getAttribute("data-id");
120 const fileId = combobox.getAttribute("data-file-id");
122 const rows = getAllChildren(id, fileId);
124 setFilePriority(rows.rowIds, rows.fileIds, priority);
125 updateGlobalCheckbox();
128 const isDownloadCheckboxExists = function(id) {
129 return ($("cbPrio" + id) !== null);
132 const createDownloadCheckbox = function(id, fileId, checked) {
133 const checkbox = new Element("input");
134 checkbox.type = "checkbox";
135 checkbox.id = "cbPrio" + id;
136 checkbox.setAttribute("data-id", id);
137 checkbox.setAttribute("data-file-id", fileId);
138 checkbox.className = "DownloadedCB";
139 checkbox.addEvent("click", fileCheckboxClicked);
141 updateCheckbox(checkbox, checked);
142 return checkbox;
145 const updateDownloadCheckbox = function(id, checked) {
146 const checkbox = $("cbPrio" + id);
147 updateCheckbox(checkbox, checked);
150 const updateCheckbox = function(checkbox, checked) {
151 switch (checked) {
152 case TriState.Checked:
153 setCheckboxChecked(checkbox);
154 break;
155 case TriState.Unchecked:
156 setCheckboxUnchecked(checkbox);
157 break;
158 case TriState.Partial:
159 setCheckboxPartial(checkbox);
160 break;
164 const isPriorityComboExists = function(id) {
165 return ($("comboPrio" + id) !== null);
168 const createPriorityOptionElement = function(priority, selected, html) {
169 const elem = new Element("option");
170 elem.value = priority.toString();
171 elem.innerHTML = html;
172 if (selected)
173 elem.selected = true;
174 return elem;
177 const createPriorityCombo = function(id, fileId, selectedPriority) {
178 const select = new Element("select");
179 select.id = "comboPrio" + id;
180 select.setAttribute("data-id", id);
181 select.setAttribute("data-file-id", fileId);
182 select.addClass("combo_priority");
183 select.addEvent("change", fileComboboxChanged);
185 createPriorityOptionElement(FilePriority.Ignored, (FilePriority.Ignored === selectedPriority), "QBT_TR(Do not download)QBT_TR[CONTEXT=PropListDelegate]").injectInside(select);
186 createPriorityOptionElement(FilePriority.Normal, (FilePriority.Normal === selectedPriority), "QBT_TR(Normal)QBT_TR[CONTEXT=PropListDelegate]").injectInside(select);
187 createPriorityOptionElement(FilePriority.High, (FilePriority.High === selectedPriority), "QBT_TR(High)QBT_TR[CONTEXT=PropListDelegate]").injectInside(select);
188 createPriorityOptionElement(FilePriority.Maximum, (FilePriority.Maximum === selectedPriority), "QBT_TR(Maximum)QBT_TR[CONTEXT=PropListDelegate]").injectInside(select);
190 // "Mixed" priority is for display only; it shouldn't be selectable
191 const mixedPriorityOption = createPriorityOptionElement(FilePriority.Mixed, (FilePriority.Mixed === selectedPriority), "QBT_TR(Mixed)QBT_TR[CONTEXT=PropListDelegate]");
192 mixedPriorityOption.disabled = true;
193 mixedPriorityOption.injectInside(select);
195 return select;
198 const updatePriorityCombo = function(id, selectedPriority) {
199 const combobox = $("comboPrio" + id);
200 if (parseInt(combobox.value, 10) !== selectedPriority)
201 selectComboboxPriority(combobox, selectedPriority);
204 const selectComboboxPriority = function(combobox, priority) {
205 const options = combobox.options;
206 for (let i = 0; i < options.length; ++i) {
207 const option = options[i];
208 if (parseInt(option.value, 10) === priority)
209 option.selected = true;
210 else
211 option.selected = false;
214 combobox.value = priority;
217 const switchCheckboxState = function(e) {
218 e.stopPropagation();
220 const rowIds = [];
221 const fileIds = [];
222 let priority = FilePriority.Ignored;
223 const checkbox = $("tristate_cb");
225 if (checkbox.state === "checked") {
226 setCheckboxUnchecked(checkbox);
227 // set file priority for all checked to Ignored
228 torrentFilesTable.getFilteredAndSortedRows().forEach((row) => {
229 const rowId = row.rowId;
230 const fileId = row.full_data.fileId;
231 const isChecked = (row.full_data.checked === TriState.Checked);
232 const isFolder = (fileId === -1);
233 if (!isFolder && isChecked) {
234 rowIds.push(rowId);
235 fileIds.push(fileId);
239 else {
240 setCheckboxChecked(checkbox);
241 priority = FilePriority.Normal;
242 // set file priority for all unchecked to Normal
243 torrentFilesTable.getFilteredAndSortedRows().forEach((row) => {
244 const rowId = row.rowId;
245 const fileId = row.full_data.fileId;
246 const isUnchecked = (row.full_data.checked === TriState.Unchecked);
247 const isFolder = (fileId === -1);
248 if (!isFolder && isUnchecked) {
249 rowIds.push(rowId);
250 fileIds.push(fileId);
255 if (rowIds.length > 0)
256 setFilePriority(rowIds, fileIds, priority);
259 const updateGlobalCheckbox = function() {
260 const checkbox = $("tristate_cb");
261 if (isAllCheckboxesChecked())
262 setCheckboxChecked(checkbox);
263 else if (isAllCheckboxesUnchecked())
264 setCheckboxUnchecked(checkbox);
265 else
266 setCheckboxPartial(checkbox);
269 const setCheckboxChecked = function(checkbox) {
270 checkbox.state = "checked";
271 checkbox.indeterminate = false;
272 checkbox.checked = true;
275 const setCheckboxUnchecked = function(checkbox) {
276 checkbox.state = "unchecked";
277 checkbox.indeterminate = false;
278 checkbox.checked = false;
281 const setCheckboxPartial = function(checkbox) {
282 checkbox.state = "partial";
283 checkbox.indeterminate = true;
286 const isAllCheckboxesChecked = function() {
287 const checkboxes = $$("input.DownloadedCB");
288 for (let i = 0; i < checkboxes.length; ++i) {
289 if (!checkboxes[i].checked)
290 return false;
292 return true;
295 const isAllCheckboxesUnchecked = function() {
296 const checkboxes = $$("input.DownloadedCB");
297 for (let i = 0; i < checkboxes.length; ++i) {
298 if (checkboxes[i].checked)
299 return false;
301 return true;
304 const setFilePriority = function(ids, fileIds, priority) {
305 if (current_hash === "")
306 return;
308 clearTimeout(loadTorrentFilesDataTimer);
309 new Request({
310 url: "api/v2/torrents/filePrio",
311 method: "post",
312 data: {
313 "hash": current_hash,
314 "id": fileIds.join("|"),
315 "priority": priority
317 onComplete: function() {
318 loadTorrentFilesDataTimer = loadTorrentFilesData.delay(1000);
320 }).send();
322 const ignore = (priority === FilePriority.Ignored);
323 ids.forEach((_id) => {
324 torrentFilesTable.setIgnored(_id, ignore);
326 const combobox = $("comboPrio" + _id);
327 if (combobox !== null)
328 selectComboboxPriority(combobox, priority);
331 torrentFilesTable.updateTable(false);
334 let loadTorrentFilesDataTimer;
335 const loadTorrentFilesData = function() {
336 if ($("prop_files").hasClass("invisible")
337 || $("propertiesPanel_collapseToggle").hasClass("panel-expand")) {
338 // Tab changed, don't do anything
339 return;
341 const new_hash = torrentsTable.getCurrentTorrentID();
342 if (new_hash === "") {
343 torrentFilesTable.clear();
344 clearTimeout(loadTorrentFilesDataTimer);
345 loadTorrentFilesDataTimer = loadTorrentFilesData.delay(5000);
346 return;
348 let loadedNewTorrent = false;
349 if (new_hash !== current_hash) {
350 torrentFilesTable.clear();
351 current_hash = new_hash;
352 loadedNewTorrent = true;
354 const url = new URI("api/v2/torrents/files?hash=" + current_hash);
355 new Request.JSON({
356 url: url,
357 method: "get",
358 noCache: true,
359 onComplete: function() {
360 clearTimeout(loadTorrentFilesDataTimer);
361 loadTorrentFilesDataTimer = loadTorrentFilesData.delay(5000);
363 onSuccess: function(files) {
364 clearTimeout(torrentFilesFilterInputTimer);
365 torrentFilesFilterInputTimer = -1;
367 if (files.length === 0) {
368 torrentFilesTable.clear();
370 else {
371 handleNewTorrentFiles(files);
372 if (loadedNewTorrent)
373 collapseAllNodes();
376 }).send();
379 const updateData = function() {
380 clearTimeout(loadTorrentFilesDataTimer);
381 loadTorrentFilesData();
384 const handleNewTorrentFiles = function(files) {
385 is_seed = (files.length > 0) ? files[0].is_seed : true;
387 const rows = files.map((file, index) => {
388 let progress = (file.progress * 100).round(1);
389 if ((progress === 100) && (file.progress < 1))
390 progress = 99.9;
392 const ignore = (file.priority === FilePriority.Ignored);
393 const checked = (ignore ? TriState.Unchecked : TriState.Checked);
394 const remaining = (ignore ? 0 : (file.size * (1.0 - file.progress)));
395 const row = {
396 fileId: index,
397 checked: checked,
398 fileName: file.name,
399 name: window.qBittorrent.Filesystem.fileName(file.name),
400 size: file.size,
401 progress: progress,
402 priority: normalizePriority(file.priority),
403 remaining: remaining,
404 availability: file.availability
407 return row;
410 addRowsToTable(rows);
411 updateGlobalCheckbox();
414 const addRowsToTable = function(rows) {
415 const selectedFiles = torrentFilesTable.selectedRowsIds();
416 let rowId = 0;
418 const rootNode = new window.qBittorrent.FileTree.FolderNode();
420 rows.forEach((row) => {
421 const pathItems = row.fileName.split(window.qBittorrent.Filesystem.PathSeparator);
423 pathItems.pop(); // remove last item (i.e. file name)
424 let parent = rootNode;
425 pathItems.forEach((folderName) => {
426 if (folderName === ".unwanted")
427 return;
429 let folderNode = null;
430 if (parent.children !== null) {
431 for (let i = 0; i < parent.children.length; ++i) {
432 const childFolder = parent.children[i];
433 if (childFolder.name === folderName) {
434 folderNode = childFolder;
435 break;
440 if (folderNode === null) {
441 folderNode = new window.qBittorrent.FileTree.FolderNode();
442 folderNode.path = (parent.path === "")
443 ? folderName
444 : [parent.path, folderName].join(window.qBittorrent.Filesystem.PathSeparator);
445 folderNode.name = folderName;
446 folderNode.rowId = rowId;
447 folderNode.root = parent;
448 parent.addChild(folderNode);
450 ++rowId;
453 parent = folderNode;
456 const isChecked = row.checked ? TriState.Checked : TriState.Unchecked;
457 const remaining = (row.priority === FilePriority.Ignored) ? 0 : row.remaining;
458 const childNode = new window.qBittorrent.FileTree.FileNode();
459 childNode.name = row.name;
460 childNode.path = row.fileName;
461 childNode.rowId = rowId;
462 childNode.size = row.size;
463 childNode.checked = isChecked;
464 childNode.remaining = remaining;
465 childNode.progress = row.progress;
466 childNode.priority = row.priority;
467 childNode.availability = row.availability;
468 childNode.root = parent;
469 childNode.data = row;
470 parent.addChild(childNode);
472 ++rowId;
475 torrentFilesTable.populateTable(rootNode);
476 torrentFilesTable.updateTable(false);
478 if (selectedFiles.length > 0)
479 torrentFilesTable.reselectRows(selectedFiles);
482 const collapseIconClicked = function(event) {
483 const id = event.getAttribute("data-id");
484 const node = torrentFilesTable.getNode(id);
485 const isCollapsed = (event.parentElement.getAttribute("data-collapsed") === "true");
487 if (isCollapsed)
488 expandNode(node);
489 else
490 collapseNode(node);
493 const expandFolder = function(id) {
494 const node = torrentFilesTable.getNode(id);
495 if (node.isFolder)
496 expandNode(node);
499 const collapseFolder = function(id) {
500 const node = torrentFilesTable.getNode(id);
501 if (node.isFolder)
502 collapseNode(node);
505 const filesPriorityMenuClicked = function(priority) {
506 const selectedRows = torrentFilesTable.selectedRowsIds();
507 if (selectedRows.length === 0)
508 return;
510 const rowIds = [];
511 const fileIds = [];
512 selectedRows.forEach((rowId) => {
513 const elem = $("comboPrio" + rowId);
514 rowIds.push(rowId);
515 fileIds.push(elem.getAttribute("data-file-id"));
518 const uniqueRowIds = {};
519 const uniqueFileIds = {};
520 for (let i = 0; i < rowIds.length; ++i) {
521 const rows = getAllChildren(rowIds[i], fileIds[i]);
522 rows.rowIds.forEach((rowId) => {
523 uniqueRowIds[rowId] = true;
525 rows.fileIds.forEach((fileId) => {
526 uniqueFileIds[fileId] = true;
530 setFilePriority(Object.keys(uniqueRowIds), Object.keys(uniqueFileIds), priority);
533 const singleFileRename = function(hash) {
534 const rowId = torrentFilesTable.selectedRowsIds()[0];
535 if (rowId === undefined)
536 return;
537 const row = torrentFilesTable.rows[rowId];
538 if (!row)
539 return;
541 const node = torrentFilesTable.getNode(rowId);
542 const path = node.path;
544 new MochaUI.Window({
545 id: "renamePage",
546 title: "QBT_TR(Renaming)QBT_TR[CONTEXT=TorrentContentTreeView]",
547 loadMethod: "iframe",
548 contentURL: "rename_file.html?hash=" + hash + "&isFolder=" + node.isFolder
549 + "&path=" + encodeURIComponent(path),
550 scrollbars: false,
551 resizable: true,
552 maximizable: false,
553 paddingVertical: 0,
554 paddingHorizontal: 0,
555 width: 400,
556 height: 100
560 const multiFileRename = function(hash) {
561 new MochaUI.Window({
562 id: "multiRenamePage",
563 title: "QBT_TR(Renaming)QBT_TR[CONTEXT=TorrentContentTreeView]",
564 data: { hash: hash, selectedRows: torrentFilesTable.selectedRows },
565 loadMethod: "xhr",
566 contentURL: "rename_files.html",
567 scrollbars: false,
568 resizable: true,
569 maximizable: false,
570 paddingVertical: 0,
571 paddingHorizontal: 0,
572 width: 800,
573 height: 420,
574 resizeLimit: { "x": [800], "y": [420] }
578 const torrentFilesContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({
579 targets: "#torrentFilesTableDiv tr",
580 menu: "torrentFilesMenu",
581 actions: {
582 Rename: function(element, ref) {
583 const hash = torrentsTable.getCurrentTorrentID();
584 if (!hash)
585 return;
587 if (torrentFilesTable.selectedRowsIds().length > 1)
588 multiFileRename(hash);
589 else
590 singleFileRename(hash);
593 FilePrioIgnore: function(element, ref) {
594 filesPriorityMenuClicked(FilePriority.Ignored);
596 FilePrioNormal: function(element, ref) {
597 filesPriorityMenuClicked(FilePriority.Normal);
599 FilePrioHigh: function(element, ref) {
600 filesPriorityMenuClicked(FilePriority.High);
602 FilePrioMaximum: function(element, ref) {
603 filesPriorityMenuClicked(FilePriority.Maximum);
606 offsets: {
607 x: -15,
608 y: 2
610 onShow: function() {
611 if (is_seed)
612 this.hideItem("FilePrio");
613 else
614 this.showItem("FilePrio");
618 torrentFilesTable.setup("torrentFilesTableDiv", "torrentFilesTableFixedHeaderDiv", torrentFilesContextMenu);
619 // inject checkbox into table header
620 const tableHeaders = $$("#torrentFilesTableFixedHeaderDiv .dynamicTableHeader th");
621 if (tableHeaders.length > 0) {
622 const checkbox = new Element("input");
623 checkbox.type = "checkbox";
624 checkbox.id = "tristate_cb";
625 checkbox.addEvent("click", switchCheckboxState);
627 const checkboxTH = tableHeaders[0];
628 checkbox.injectInside(checkboxTH);
631 // default sort by name column
632 if (torrentFilesTable.getSortedColumn() === null)
633 torrentFilesTable.setSortedColumn("name");
635 // listen for changes to torrentFilesFilterInput
636 let torrentFilesFilterInputTimer = -1;
637 $("torrentFilesFilterInput").addEvent("input", () => {
638 clearTimeout(torrentFilesFilterInputTimer);
640 const value = $("torrentFilesFilterInput").value;
641 torrentFilesTable.setFilter(value);
643 torrentFilesFilterInputTimer = setTimeout(() => {
644 torrentFilesFilterInputTimer = -1;
646 if (current_hash === "")
647 return;
649 torrentFilesTable.updateTable();
651 if (value.trim() === "")
652 collapseAllNodes();
653 else
654 expandAllNodes();
655 }, window.qBittorrent.Misc.FILTER_INPUT_DELAY);
659 * Show/hide a node's row
661 const _hideNode = function(node, shouldHide) {
662 const span = $("filesTablefileName" + node.rowId);
663 // span won't exist if row has been filtered out
664 if (span === null)
665 return;
666 const rowElem = span.parentElement.parentElement;
667 if (shouldHide)
668 rowElem.addClass("invisible");
669 else
670 rowElem.removeClass("invisible");
674 * Update a node's collapsed state and icon
676 const _updateNodeState = function(node, isCollapsed) {
677 const span = $("filesTablefileName" + node.rowId);
678 // span won't exist if row has been filtered out
679 if (span === null)
680 return;
681 const td = span.parentElement;
683 // store collapsed state
684 td.setAttribute("data-collapsed", isCollapsed);
686 // rotate the collapse icon
687 const collapseIcon = td.getElementsByClassName("filesTableCollapseIcon")[0];
688 if (isCollapsed)
689 collapseIcon.addClass("rotate");
690 else
691 collapseIcon.removeClass("rotate");
694 const _isCollapsed = function(node) {
695 const span = $("filesTablefileName" + node.rowId);
696 if (span === null)
697 return true;
699 const td = span.parentElement;
700 return td.getAttribute("data-collapsed") === "true";
703 const expandNode = function(node) {
704 _collapseNode(node, false, false, false);
707 const collapseNode = function(node) {
708 _collapseNode(node, true, false, false);
711 const expandAllNodes = function() {
712 const root = torrentFilesTable.getRoot();
713 root.children.each((node) => {
714 node.children.each((child) => {
715 _collapseNode(child, false, true, false);
720 const collapseAllNodes = function() {
721 const root = torrentFilesTable.getRoot();
722 root.children.each((node) => {
723 node.children.each((child) => {
724 _collapseNode(child, true, true, false);
730 * Collapses a folder node with the option to recursively collapse all children
731 * @param {FolderNode} node the node to collapse/expand
732 * @param {boolean} shouldCollapse true if the node should be collapsed, false if it should be expanded
733 * @param {boolean} applyToChildren true if the node's children should also be collapsed, recursively
734 * @param {boolean} isChildNode true if the current node is a child of the original node we collapsed/expanded
736 const _collapseNode = function(node, shouldCollapse, applyToChildren, isChildNode) {
737 if (!node.isFolder)
738 return;
740 const shouldExpand = !shouldCollapse;
741 const isNodeCollapsed = _isCollapsed(node);
742 const nodeInCorrectState = ((shouldCollapse && isNodeCollapsed) || (shouldExpand && !isNodeCollapsed));
743 const canSkipNode = (isChildNode && (!applyToChildren || nodeInCorrectState));
744 if (!isChildNode || applyToChildren || !canSkipNode)
745 _updateNodeState(node, shouldCollapse);
747 node.children.each((child) => {
748 _hideNode(child, shouldCollapse);
750 if (!child.isFolder)
751 return;
753 // don't expand children that have been independently collapsed, unless applyToChildren is true
754 const shouldExpandChildren = (shouldExpand && applyToChildren);
755 const isChildCollapsed = _isCollapsed(child);
756 if (!shouldExpandChildren && isChildCollapsed)
757 return;
759 _collapseNode(child, shouldCollapse, applyToChildren, true);
763 return exports();
764 })();
765 Object.freeze(window.qBittorrent.PropFiles);