/* @license This file Copyright © Charles Kerr, Dave Perrett, Malcolm Jarvis and Bruno Bierbaumer It may be used under GPLv2 (SPDX: GPL-2.0-only). License text can be found in the licenses/ folder. */ import { AboutDialog } from './about-dialog.js'; import { ContextMenu } from './context-menu.js'; import { Formatter } from './formatter.js'; import { Inspector } from './inspector.js'; import { MoveDialog } from './move-dialog.js'; import { OpenDialog } from './open-dialog.js'; import { OverflowMenu } from './overflow-menu.js'; import { Prefs } from './prefs.js'; import { PrefsDialog } from './prefs-dialog.js'; import { Remote, RPC } from './remote.js'; import { RemoveDialog } from './remove-dialog.js'; import { RenameDialog } from './rename-dialog.js'; import { LabelsDialog } from './labels-dialog.js'; import { ShortcutsDialog } from './shortcuts-dialog.js'; import { StatisticsDialog } from './statistics-dialog.js'; import { Torrent } from './torrent.js'; import { TorrentRow, TorrentRendererCompact, TorrentRendererFull, } from './torrent-row.js'; import { debounce, deepEqual, setEnabled, setTextContent } from './utils.js'; export class Transmission extends EventTarget { constructor(action_manager, notifications, prefs) { super(); // Initialize the helper classes this.action_manager = action_manager; this.notifications = notifications; this.prefs = prefs; this.remote = new Remote(this); this.addEventListener('torrent-selection-changed', (event_) => this.action_manager.update(event_), ); // Initialize the implementation fields this.filterText = ''; this._torrents = {}; this._rows = []; this.dirtyTorrents = new Set(); this.changeStatus = false; this.refilterSoon = debounce(() => this._refilter(false)); this.refilterAllSoon = debounce(() => this._refilter(true)); this.boundPopupCloseListener = this.popupCloseListener.bind(this); this.isTouch = 'ontouchstart' in window ? true : false; this.busyclick = false; // listen to actions // TODO: consider adding a mutator listener here to see dynamic additions for (const element of document.querySelectorAll(`button[data-action]`)) { const { action } = element.dataset; setEnabled(element, this.action_manager.isEnabled(action)); element.addEventListener('click', () => { this.action_manager.click(action); }); } document .querySelector('#filter-tracker') .addEventListener('change', (event_) => { this.setFilterTracker( event_.target.value === 'all' ? null : event_.target.value, ); }); this.action_manager.addEventListener('change', (event_) => { for (const element of document.querySelectorAll( `[data-action="${event_.action}"]`, )) { setEnabled(element, event_.enabled); } }); this.action_manager.addEventListener('click', (event_) => { switch (event_.action) { case 'deselect-all': this._deselectAll(); break; case 'move-bottom': this._moveBottom(); break; case 'move-down': this._moveDown(); break; case 'move-top': this._moveTop(); break; case 'move-up': this._moveUp(); break; case 'open-torrent': this.setCurrentPopup(new OpenDialog(this, this.remote)); break; case 'pause-all-torrents': this._stopTorrents(this._getAllTorrents()); break; case 'pause-selected-torrents': this._stopTorrents(this.getSelectedTorrents()); break; case 'reannounce-selected-torrents': this._reannounceTorrents(this.getSelectedTorrents()); break; case 'remove-selected-torrents': this._removeSelectedTorrents(false); break; case 'resume-selected-torrents': this._startSelectedTorrents(false); break; case 'resume-selected-torrents-now': this._startSelectedTorrents(true); break; case 'select-all': this._selectAll(); break; case 'show-about-dialog': this.setCurrentPopup(new AboutDialog(this.version_info)); break; case 'show-inspector': if (!this.popup || this.popup.name !== 'inspector') { this.setCurrentPopup(new Inspector(this)); } break; case 'show-move-dialog': this.setCurrentPopup(new MoveDialog(this, this.remote)); break; case 'show-overflow-menu': if (this.popup instanceof OverflowMenu) { this.setCurrentPopup(null); } else { this.setCurrentPopup( new OverflowMenu( this, this.prefs, this.remote, this.action_manager, ), ); } break; case 'show-preferences-dialog': this.setCurrentPopup(new PrefsDialog(this, this.remote)); break; case 'show-shortcuts-dialog': this.setCurrentPopup(new ShortcutsDialog(this.action_manager)); break; case 'show-statistics-dialog': this.setCurrentPopup(new StatisticsDialog(this.remote)); break; case 'show-rename-dialog': this.setCurrentPopup(new RenameDialog(this, this.remote)); break; case 'show-labels-dialog': this.setCurrentPopup(new LabelsDialog(this, this.remote)); break; case 'start-all-torrents': this._startTorrents(this._getAllTorrents()); break; case 'toggle-compact-rows': this.prefs.display_mode = this.prefs.display_mode === Prefs.DisplayCompact ? Prefs.DisplayFull : Prefs.DisplayCompact; break; case 'trash-selected-torrents': this._removeSelectedTorrents(true); break; case 'verify-selected-torrents': this._verifyTorrents(this.getSelectedTorrents()); break; default: console.warn(`unhandled action: ${event_.action}`); } }); // listen to filter changes let e = document.querySelector('#filter-mode'); e.value = this.prefs.filter_mode; e.addEventListener('change', (event_) => { this.prefs.filter_mode = event_.target.value; this.refilterAllSoon(); }); document.addEventListener('keydown', this._keyDown.bind(this)); document.addEventListener('keyup', this._keyUp.bind(this)); e = document.querySelector('#torrent-container'); e.addEventListener('click', (e_) => { if (this.popup && this.popup.name !== 'inspector') { this.setCurrentPopup(null); } if (e_.target === e_.currentTarget) { this._deselectAll(); } }); e.addEventListener('dblclick', () => { if (!this.popup || this.popup.name !== 'inspector') { this.action_manager.click('show-inspector'); } }); e.addEventListener('dragenter', Transmission._dragenter); e.addEventListener('dragover', Transmission._dragenter); e.addEventListener('drop', this._drop.bind(this)); this._setupSearchBox(); this.elements = { torrent_list: document.querySelector('#torrent-list'), }; const rightc = (event_) => { if (this.isTouch && event_.touches.length > 1) { return; } // if not already, highlight the torrent let row_element = event_.target; while (row_element && !row_element.classList.contains('torrent')) { row_element = row_element.parentNode; } const row = this._rows.find((r) => r.getElement() === row_element); if (row && !row.isSelected()) { this._setSelectedRow(row); } // open context menu const popup = new ContextMenu(this.action_manager); this.setCurrentPopup(popup); const boundingElement = document.querySelector('#torrent-container'); const bounds = boundingElement.getBoundingClientRect(); const x = Math.min( this.isTouch ? event_.touches[0].clientX : event_.x, bounds.x + bounds.width - popup.root.clientWidth, ); const y = Math.min( this.isTouch ? event_.touches[0].clientY : event_.y, bounds.y + bounds.height - popup.root.clientHeight, ); popup.root.style.left = `${x > 0 ? x : 0}px`; popup.root.style.top = `${y > 0 ? y : 0}px`; event_.preventDefault(); }; if (this.isTouch) { this.elements.torrent_list.addEventListener('touchstart', (event_) => { if (this.busyclick) { clearTimeout(this.busyclick); this.busyclick = false; } else { this.busyclick = setTimeout(rightc.bind(this), 500, event_); } }); this.elements.torrent_list.addEventListener('touchend', () => { clearTimeout(this.busyclick); this.busyclick = false; setTimeout(() => { if (this.popup) { this.popup.root.style.pointerEvents = 'auto'; } }, 1); }); this.elements.torrent_list.addEventListener('touchmove', () => { clearTimeout(this.busyclick); this.busyclick = false; }); this.elements.torrent_list.addEventListener('contextmenu', (event_) => { event_.preventDefault(); }); } else { this.elements.torrent_list.addEventListener('contextmenu', (event_) => { rightc(event_); if (this.popup) { this.popup.root.style.pointerEvents = 'auto'; } }); } // Get preferences & torrents from the daemon this.loadDaemonPrefs(); this._initializeTorrents(); this.refreshTorrents(); this.togglePeriodicSessionRefresh(true); // this.updateButtonsSoon(); this.prefs.addEventListener('change', ({ key, value }) => this._onPrefChanged(key, value), ); for (const [key, value] of this.prefs.entries()) { this._onPrefChanged(key, value); } } _openTorrentFromUrl() { setTimeout(() => { const addTorrent = new URLSearchParams(window.location.search).get( 'addtorrent', ); if (addTorrent) { this.setCurrentPopup(new OpenDialog(this, this.remote, addTorrent)); const newUrl = new URL(window.location); newUrl.search = ''; window.history.pushState('', '', newUrl.toString()); } }, 0); } loadDaemonPrefs() { this.remote.loadDaemonPrefs((data) => { this.session_properties = data.arguments; this._openTorrentFromUrl(); }); } get session_properties() { return this._session_properties; } set session_properties(o) { if (deepEqual(this._session_properties, o)) { return; } this._session_properties = Object.seal(o); const event = new Event('session-change'); event.session_properties = o; this.dispatchEvent(event); // TODO: maybe have this in a listener handler? this._updateGuiFromSession(o); } _setupSearchBox() { const e = document.querySelector('#torrent-search'); const blur_token = 'blur'; e.classList.add(blur_token); e.addEventListener('blur', () => e.classList.add(blur_token)); e.addEventListener('focus', () => e.classList.remove(blur_token)); e.addEventListener('keyup', () => this._setFilterText(e.value)); } _onPrefChanged(key, value) { switch (key) { case Prefs.DisplayMode: { this.torrentRenderer = value === 'compact' ? new TorrentRendererCompact() : new TorrentRendererFull(); this.refilterAllSoon(); break; } case Prefs.ContrastMode: { // Add custom class to the body/html element to get the appropriate contrast color scheme document.body.classList.remove('contrast-more'); document.body.classList.remove('contrast-less'); document.body.classList.add(`contrast-${value}`); // this.refilterAllSoon(); break; } case Prefs.FilterMode: case Prefs.SortDirection: case Prefs.SortMode: this.refilterAllSoon(); break; case Prefs.RefreshRate: { clearInterval(this.refreshTorrentsInterval); const callback = this.refreshTorrents.bind(this); const msec = Math.max(2, this.prefs.refresh_rate_sec) * 1000; this.refreshTorrentsInterval = setInterval(callback, msec); break; } default: /*noop*/ break; } } /// UTILITIES _getAllTorrents() { return Object.values(this._torrents); } static _getTorrentIds(torrents) { return torrents.map((t) => t.getId()); } seedRatioLimit() { const p = this.session_properties; if (p && p.seedRatioLimited) { return p.seedRatioLimit; } return -1; } /// SELECTION _getSelectedRows() { return this._rows.filter((r) => r.isSelected()); } getSelectedTorrents() { return this._getSelectedRows().map((r) => r.getTorrent()); } _getSelectedTorrentIds() { return Transmission._getTorrentIds(this.getSelectedTorrents()); } _setSelectedRow(row) { const e_sel = row ? row.getElement() : null; for (const e of this.elements.torrent_list.children) { e.classList.toggle('selected', e === e_sel); } this._dispatchSelectionChanged(); } _selectRow(row) { row.getElement().classList.add('selected'); this._dispatchSelectionChanged(); } _deselectRow(row) { row.getElement().classList.remove('selected'); this._dispatchSelectionChanged(); } _selectAll() { for (const e of this.elements.torrent_list.children) { e.classList.add('selected'); } this._dispatchSelectionChanged(); } _deselectAll() { for (const e of this.elements.torrent_list.children) { e.classList.remove('selected'); } this._dispatchSelectionChanged(); delete this._last_torrent_clicked; } _indexOfLastTorrent() { return this._rows.findIndex( (row) => row.getTorrentId() === this._last_torrent_clicked, ); } // Select a range from this row to the last clicked torrent _selectRange(row) { const last = this._indexOfLastTorrent(); if (last === -1) { this._selectRow(row); } else { // select the range between the previous & current const next = this._rows.indexOf(row); const min = Math.min(last, next); const max = Math.max(last, next); for (let index = min; index <= max; ++index) { this._selectRow(this._rows[index]); } } this._dispatchSelectionChanged(); } _dispatchSelectionChanged() { const nonselected = []; const selected = []; for (const r of this._rows) { (r.isSelected() ? selected : nonselected).push(r.getTorrent()); } const event = new Event('torrent-selection-changed'); event.nonselected = nonselected; event.selected = selected; this.dispatchEvent(event); } /*-------------------------------------------- * * E V E N T F U N C T I O N S * *--------------------------------------------*/ static _createKeyShortcutFromKeyboardEvent(event_) { const a = []; if (event_.ctrlKey) { a.push('Control'); } if (event_.altKey) { a.push('Alt'); } if (event_.metaKey) { a.push('Meta'); } if (event_.shitKey) { a.push('Shift'); } a.push(event_.key.length === 1 ? event_.key.toUpperCase() : event_.key); return a.join('+'); } // Process key events _keyDown(event_) { const { ctrlKey, keyCode, metaKey, shiftKey, target } = event_; // look for a shortcut const is_input_focused = ['INPUT', 'TEXTAREA'].includes(target.tagName); if (!is_input_focused) { const shortcut = Transmission._createKeyShortcutFromKeyboardEvent(event_); const action = this.action_manager.getActionForShortcut(shortcut); if (action) { event_.preventDefault(); this.action_manager.click(action); return; } } const esc_key = keyCode === 27; // esc key pressed if (esc_key && this.popup) { this.setCurrentPopup(null); event_.preventDefault(); return; } const any_popup_active = document.querySelector('.popup:not(.hidden)'); const rows = this._rows; // Some shortcuts can only be used if the following conditions are met: // 1. when no input fields are focused // 2. when no other dialogs are visible // 3. when the meta or ctrl key isn't pressed (i.e. opening dev tools shouldn't trigger the info panel) if (!is_input_focused && !any_popup_active && !metaKey && !ctrlKey) { const shift_key = keyCode === 16; // shift key pressed const up_key = keyCode === 38; // up key pressed const dn_key = keyCode === 40; // down key pressed if ((up_key || dn_key) && rows.length > 0) { const last = this._indexOfLastTorrent(); const anchor = this._shift_index; const min = 0; const max = rows.length - 1; let index = last; if (dn_key && index + 1 <= max) { ++index; } else if (up_key && index - 1 >= min) { --index; } const r = rows[index]; if (anchor >= 0) { // user is extending the selection // with the shift + arrow keys... if ( (anchor <= last && last < index) || (anchor >= last && last > index) ) { this._selectRow(r); } else if ( (anchor >= last && index > last) || (anchor <= last && last > index) ) { this._deselectRow(rows[last]); } } else { if (shiftKey) { this._selectRange(r); } else { this._setSelectedRow(r); } } if (r) { this._last_torrent_clicked = r.getTorrentId(); r.getElement().scrollIntoView(); event_.preventDefault(); } } else if (shift_key) { this._shift_index = this._indexOfLastTorrent(); } } } _keyUp(event_) { if (event_.keyCode === 16) { // shift key pressed delete this._shift_index; } } static _dragenter(event_) { if (event_.dataTransfer && event_.dataTransfer.types) { const copy_types = new Set(['text/uri-list', 'text/plain']); if ( event_.dataTransfer.types.some((type) => copy_types.has(type)) || event_.dataTransfer.types.includes('Files') ) { event_.stopPropagation(); event_.preventDefault(); event_.dataTransfer.dropEffect = 'copy'; return false; } } else if (event_.dataTransfer) { event_.dataTransfer.dropEffect = 'none'; } return true; } static _isValidURL(string) { try { const url = new URL(string); return url ? true : false; } catch { return false; } } shouldAddedTorrentsStart() { return this.session_properties['start-added-torrents']; } _drop(event_) { const paused = !this.shouldAddedTorrentsStart(); if (!event_.dataTransfer || !event_.dataTransfer.types) { return true; } const type = event_.dataTransfer.types .filter((t) => ['text/uri-list', 'text/plain'].includes(t)) .pop(); for (const uri of event_.dataTransfer .getData(type) .split('\n') .map((string) => string.trim()) .filter((string) => Transmission._isValidURL(string))) { this.remote.addTorrentByUrl(uri, paused); } const { files } = event_.dataTransfer; if (files.length > 0) { this.openDialog = new OpenDialog(this, this.remote, '', files); } event_.preventDefault(); return false; } // turn the periodic ajax session refresh on & off togglePeriodicSessionRefresh(enabled) { if (!enabled && this.sessionInterval) { clearInterval(this.sessionInterval); delete this.sessionInterval; } if (enabled) { this.loadDaemonPrefs(); if (!this.sessionInterval) { const msec = 8000; this.sessionInterval = setInterval( this.loadDaemonPrefs.bind(this), msec, ); } } } _setFilterText(search) { this.filterText = search ? search.trim() : null; this.refilterAllSoon(); } _onTorrentChanged(event_) { if (this.changeStatus) { this._dispatchSelectionChanged(); this.changeStatus = false; } // update our dirty fields const tor = event_.currentTarget; this.dirtyTorrents.add(tor.getId()); // enqueue ui refreshes this.refilterSoon(); } updateTorrents(ids, fields) { this.remote.updateTorrents(ids, fields, (table, removed_ids) => { const needinfo = []; const keys = table.shift(); const o = {}; for (const row of table) { for (const [index, key] of keys.entries()) { o[key] = row[index]; } const { id } = o; let t = this._torrents[id]; if (t) { const needed = t.needsMetaData(); t.refresh(o); if (needed && !t.needsMetaData()) { needinfo.push(id); } } else { t = this._torrents[id] = new Torrent(o); t.addEventListener('dataChanged', this._onTorrentChanged.bind(this)); this.dirtyTorrents.add(id); // do we need more info for this torrent? if (!('name' in t.fields) || !('status' in t.fields)) { needinfo.push(id); } } } if (needinfo.length > 0) { // whee, new torrents! get their initial information. const more_fields = [ 'id', ...Torrent.Fields.Metadata, ...Torrent.Fields.Stats, ]; this.updateTorrents(needinfo, more_fields); this.refilterSoon(); } if (removed_ids) { this._deleteTorrents(removed_ids); this.refilterSoon(); } }); } /* TODO: fix this when notifications get fixed t.notifyOnFieldChange('status', (newValue, oldValue) => { if ( oldValue === Torrent._StatusDownload && (newValue === Torrent._StatusSeed || newValue === Torrent._StatusSeedWait) ) { $(this).trigger('downloadComplete', [t]); } else if ( oldValue === Torrent._StatusSeed && newValue === Torrent._StatusStopped && t.isFinished() ) { $(this).trigger('seedingComplete', [t]); } else { $(this).trigger('statusChange', [t]); } }); */ refreshTorrents() { const fields = ['id', ...Torrent.Fields.Stats]; this.updateTorrents('recently-active', fields); } _initializeTorrents() { const fields = ['id', ...Torrent.Fields.Metadata, ...Torrent.Fields.Stats]; this.updateTorrents(null, fields); } _onRowClicked(event_) { const meta_key = event_.metaKey || event_.ctrlKey, { row } = event_.currentTarget; if (this.popup && this.popup.name !== 'inspector') { this.setCurrentPopup(null); } // handle the per-row pause/resume button if (event_.target.classList.contains('torrent-pauseresume-button')) { switch (event_.target.dataset.action) { case 'pause': this._stopTorrents([row.getTorrent()]); break; case 'resume': this._startTorrents([row.getTorrent()]); break; default: break; } } // Prevents click carrying to parent element // which deselects all on click event_.stopPropagation(); if (event_.shiftKey) { this._selectRange(row); // Need to deselect any selected text window.focus(); // Apple-Click, not selected } else if (!row.isSelected() && meta_key) { this._selectRow(row); // Regular Click, not selected } else if (!row.isSelected()) { this._setSelectedRow(row); // Apple-Click, selected } else if (row.isSelected() && meta_key) { this._deselectRow(row); // Regular Click, selected } else if (row.isSelected()) { this._setSelectedRow(row); } this._last_torrent_clicked = row.getTorrentId(); } _deleteTorrents(ids) { if (ids && ids.length > 0) { for (const id of ids) { this.dirtyTorrents.add(id); delete this._torrents[id]; } this.refilterSoon(); } } _removeSelectedTorrents(trash) { const torrents = this.getSelectedTorrents(); if (torrents.length > 0) { this.setCurrentPopup( new RemoveDialog({ remote: this.remote, torrents, trash }), ); } } _startSelectedTorrents(force) { this._startTorrents(this.getSelectedTorrents(), force); } _startTorrents(torrents, force) { this.changeStatus = true; this.remote.startTorrents( Transmission._getTorrentIds(torrents), force, this.refreshTorrents, this, ); } _verifyTorrents(torrents) { this.remote.verifyTorrents( Transmission._getTorrentIds(torrents), this.refreshTorrents, this, ); } _reannounceTorrents(torrents) { this.remote.reannounceTorrents( Transmission._getTorrentIds(torrents), this.refreshTorrents, this, ); } _stopTorrents(torrents) { this.changeStatus = true; this.remote.stopTorrents( Transmission._getTorrentIds(torrents), () => { setTimeout(() => { this.refreshTorrents(); }, 500); }, this, ); } changeFileCommand(torrentId, rowIndices, command) { this.remote.changeFileCommand(torrentId, rowIndices, command); } // Queue _moveTop() { this.remote.moveTorrentsToTop( this._getSelectedTorrentIds(), this.refreshTorrents, this, ); } _moveUp() { this.remote.moveTorrentsUp( this._getSelectedTorrentIds(), this.refreshTorrents, this, ); } _moveDown() { this.remote.moveTorrentsDown( this._getSelectedTorrentIds(), this.refreshTorrents, this, ); } _moveBottom() { this.remote.moveTorrentsToBottom( this._getSelectedTorrentIds(), this.refreshTorrents, this, ); } /// _updateGuiFromSession(o) { const [, version, checksum] = o.version.match(/(.*)\s\(([\da-f]+)\)/); this.version_info = { checksum, version, }; const element = document.querySelector('#toolbar-overflow'); element.classList.toggle('alt-speed-enabled', o[RPC._TurtleState]); } _updateStatusbar() { const fmt = Formatter; const torrents = this._getAllTorrents(); const u = torrents.reduce( (accumulator, tor) => accumulator + tor.getUploadSpeed(), 0, ); const d = torrents.reduce( (accumulator, tor) => accumulator + tor.getDownloadSpeed(), 0, ); const string = fmt.countString('Transfer', 'Transfers', this._rows.length); setTextContent(document.querySelector('#speed-up-label'), fmt.speedBps(u)); setTextContent(document.querySelector('#speed-dn-label'), fmt.speedBps(d)); setTextContent(document.querySelector('#filter-count'), string); } static _displayName(hostname) { let name = hostname; if (name.length > 0) { name = name.charAt(0).toUpperCase() + name.slice(1); } return name; } _updateFilterSelect() { const trackers = this._getTrackerCounts(); const sitenames = Object.keys(trackers).sort(); // build the new html let string = ''; string += this.filterTracker ? '' : ''; for (const sitename of sitenames) { string += ``; } if (!this.filterTrackersStr || this.filterTrackersStr !== string) { this.filterTrackersStr = string; document.querySelector('#filter-tracker').innerHTML = string; } } /// FILTER sortRows(rows) { const torrents = rows.map((row) => row.getTorrent()); const id2row = rows.reduce((accumulator, row) => { accumulator[row.getTorrent().getId()] = row; return accumulator; }, {}); Torrent.sortTorrents( torrents, this.prefs.sort_mode, this.prefs.sort_direction, ); for (const [index, tor] of torrents.entries()) { rows[index] = id2row[tor.getId()]; } } _refilter(rebuildEverything) { const { sort_mode, sort_direction, filter_mode } = this.prefs; const filter_tracker = this.filterTracker; const renderer = this.torrentRenderer; const list = this.elements.torrent_list; let filter_text = null; let labels = null; const m = /^labels:([\w,-\s]*)(.*)$/.exec(this.filterText); if (m) { filter_text = m[2].trim(); labels = m[1].split(','); } else { filter_text = this.filterText; labels = []; } const countRows = () => [...list.children].length; const countSelectedRows = () => [...list.children].reduce( (n, e) => (n + e.classList.contains('selected') ? 1 : 0), 0, ); const old_row_count = countRows(); const old_sel_count = countSelectedRows(); this._updateFilterSelect(); clearTimeout(this.refilterTimer); delete this.refilterTimer; if (rebuildEverything) { while (list.firstChild) { list.firstChild.remove(); } this._rows = []; this.dirtyTorrents = new Set(Object.keys(this._torrents)); } // rows that overlap with dirtyTorrents need to be refiltered. // those that don't are 'clean' and don't need refiltering. const clean_rows = []; let dirty_rows = []; for (const row of this._rows) { if (this.dirtyTorrents.has(row.getTorrentId())) { dirty_rows.push(row); } else { clean_rows.push(row); } } // remove the dirty rows from the dom for (const row of dirty_rows) { row.getElement().remove(); } // drop any dirty rows that don't pass the filter test const temporary = []; for (const row of dirty_rows) { const id = row.getTorrentId(); const t = this._torrents[id]; if (t && t.test(filter_mode, filter_tracker, filter_text, labels)) { temporary.push(row); } this.dirtyTorrents.delete(id); } dirty_rows = temporary; // make new rows for dirty torrents that pass the filter test // but don't already have a row for (const id of this.dirtyTorrents.values()) { const t = this._torrents[id]; if (t && t.test(filter_mode, filter_tracker, filter_text, labels)) { const row = new TorrentRow(renderer, this, t); const e = row.getElement(); e.row = row; dirty_rows.push(row); e.addEventListener('click', this._onRowClicked.bind(this)); } } // sort the dirty rows this.sortRows(dirty_rows); // now we have two sorted arrays of rows // and can do a simple two-way sorted merge. const rows = []; const cmax = clean_rows.length; const dmax = dirty_rows.length; const frag = document.createDocumentFragment(); let ci = 0; let di = 0; while (ci !== cmax || di !== dmax) { let push_clean = null; if (ci === cmax) { push_clean = false; } else if (di === dmax) { push_clean = true; } else { const c = Torrent.compareTorrents( clean_rows[ci].getTorrent(), dirty_rows[di].getTorrent(), sort_mode, sort_direction, ); push_clean = c < 0; } if (push_clean) { rows.push(clean_rows[ci++]); } else { const row = dirty_rows[di++]; const e = row.getElement(); if (ci === cmax) { frag.append(e); } else { list.insertBefore(e, clean_rows[ci].getElement()); } rows.push(row); } } list.append(frag); // update our implementation fields this._rows = rows; this.dirtyTorrents.clear(); // set the odd/even property for (const [index, e] of rows.map((row) => row.getElement()).entries()) { const even = index % 2 === 0; e.classList.toggle('even', even); e.classList.toggle('odd', !even); } this._updateStatusbar(); if ( old_sel_count !== countSelectedRows() || old_row_count !== countRows() ) { this._dispatchSelectionChanged(); } } setFilterTracker(sitename) { const e = document.querySelector('#filter-tracker'); e.value = sitename; this.filterTracker = sitename; this.refilterAllSoon(); } _getTrackerCounts() { const counts = {}; for (const torrent of this._getAllTorrents()) { for (const tracker of torrent.getTrackers()) { const { sitename } = tracker; counts[sitename] = (counts[sitename] || 0) + 1; } } return counts; } /// popupCloseListener(event_) { if (event_.target !== this.popup) { throw new Error(event_); } this.popup.removeEventListener('close', this.boundPopupCloseListener); delete this.popup; } setCurrentPopup(popup) { if (this.popup) { this.popup.close(); } this.popup = popup; if (this.popup) { this.popup.addEventListener('close', this.boundPopupCloseListener); } } }