Files
transmission-mirror/web/src/transmission.js
Yat Ho 05aef3e787 refactor: unify quarks and strings to snake_case (#7108)
* refactor: change `leftUntilDone` to `left_until_done`

* refactor: change `magnetLink` to `magnet_link`

* refactor: change `manualAnnounceTime` to `manual_announce_time`

* refactor: change `maxConnectedPeers` to `max_connected_peers`

* refactor: change `metadataPercentComplete` to `metadata_percent_complete`

* refactor: change `peersConnected` to `peers_connected`

* refactor: change `peersFrom` to `peers_from`

* refactor: change `peersGettingFromUs` to `peers_getting_from_us`

* refactor: change `peersSendingToUs` to `peers_sending_to_us`

* refactor: change `percentComplete` to `percent_complete`

* refactor: change `percentDone` to `percent_done`

* refactor: change `pieceCount` to `piece_count`

* refactor: use quark when possible

* refactor: change `pieceSize` to `piece_size`

* refactor: change `primary-mime-type` to `primary_mime_type`

* refactor: change `rateDownload` to `rate_download`

* refactor: change `rateUpload` to `rate_upload`

* refactor: change `recheckProgress` to `recheck_progress`

* refactor: change `secondsDownloading` to `seconds_downloading`

* refactor: change `secondsSeeding` to `seconds_seeding`

* refactor: change `sizeWhenDone` to `size_when_done`

* refactor: change `startDate` to `start_date`

* refactor: change `trackerStats` to `tracker_stats`

* refactor: change `totalSize` to `total_size`

* refactor: change `torrentFile` to `torrent_file`

* refactor: change `uploadedEver` to `uploaded_ever`

* refactor: change `uploadRatio` to `upload_ratio`

* refactor: change `webseedsSendingToUs` to `webseeds_sending_to_us`

* refactor: change `bytesCompleted` to `bytes_completed`

* refactor: change `clientName` to `client_name`

* refactor: change `clientIsChoked` to `client_is_choked`

* refactor: change `clientIsInterested` to `client_is_interested`

* refactor: change `flagStr` to `flag_str`

* refactor: change `isDownloadingFrom` to `is_downloading_from`

* refactor: change `isEncrypted` to `is_encrypted`

* refactor: change `isIncoming` to `is_incoming`

* refactor: change `isUploadingTo` to `is_uploading_to`

* refactor: change `isUTP` to `is_utp`

* refactor: change `peerIsChoked` to `peer_is_choked`

* refactor: change `peerIsInterested` to `peer_is_interested`

* refactor: change `rateToClient` to `rate_to_client`

* refactor: change `rateToPeer` to `rate_to_peer`

* refactor: change `fromCache` to `from_cache`

* refactor: change `fromDht` to `from_dht`

* refactor: change `fromIncoming` to `from_incoming`

* refactor: change `fromLpd` to `from_lpd`

* refactor: change `fromLtep` to `from_ltep`

* refactor: change `fromPex` to `from_pex`

* refactor: change `fromTracker` to `from_tracker`

* refactor: change `announceState` to `announce_state`

* refactor: change `downloadCount` to `download_count`

* refactor: change `hasAnnounced` to `has_announced`

* refactor: change `hasScraped` to `has_scraped`

* refactor: change `isBackup` to `is_backup`

* refactor: change `lastAnnouncePeerCount` to `last_announce_peer_count`

* refactor: change `lastAnnounceResult` to `last_announce_result`

* refactor: change `lastAnnounceStartTime` to `last_announce_start_time`

* refactor: change `lastAnnounceSucceeded` to `last_announce_succeeded`

* refactor: change `lastAnnounceTime` to `last_announce_time`

* refactor: change `lastAnnounceTimedOut` to `last_announce_timed_out`

* refactor: change `lastScrapeResult` to `last_scrape_result`

* refactor: change `lastScrapeStartTime` to `last_scrape_start_time`

* refactor: change `lastScrapeSucceeded` to `last_scrape_succeeded`

* refactor: change `lastScrapeTime` to `last_scrape_time`

* refactor: change `lastScrapeTimedOut` to `last_scrape_timed_out`

* refactor: change `leecherCount` to `leecher_count`

* refactor: change `nextAnnounceTime` to `next_announce_time`

* refactor: change `nextScrapeTime` to `next_scrape_time`

* refactor: change `scrapeState` to `scrape_state`

* refactor: change `seederCount` to `seeder_count`

* refactor: change `torrent-added` to `torrent_added`

* refactor: change `torrent-duplicate` to `torrent_duplicate`

* refactor: change `torrent-remove` to `torrent_remove`

* refactor: change `delete-local-data` to `delete_local_data`

* refactor: change `torrent-rename-path` to `torrent_rename_path`

* refactor: change `alt-speed-down` to `alt_speed_down`

* refactor: convert `pref_toggle_entries` to quark array

* refactor: change `alt-speed-enabled` to `alt_speed_enabled`

* refactor: change `compact-view` to `compact_view`

* refactor: change `sort-reversed` to `sort_reversed`

* refactor: change `show-filterbar` to `show_filterbar`

* refactor: change `show-statusbar` to `show_statusbar`

* refactor: change `show-toolbar` to `show_toolbar`

* refactor: change `alt-speed-time-begin` to `alt_speed_time_begin`

* refactor: change `alt-speed-time-day` to `alt_speed_time_day`

* refactor: change `alt-speed-time-end` to `alt_speed_time_end`

* refactor: change `alt-speed-up` to `alt_speed_up`

* refactor: change `alt-speed-time-enabled` to `alt_speed_time_enabled`

* refactor: change `blocklist-enabled` to `blocklist_enabled`

* refactor: change `blocklist-size` to `blocklist_size`

* refactor: change `blocklist-url` to `blocklist_url`

* refactor: change `cache-size-mb` to `cache_size_mb`

* refactor: change `config-dir` to `config_dir`

* refactor: change `default-trackers` to `default_trackers`

* refactor: change `dht-enabled` to `dht_enabled`

* refactor: change `download-dir-free-space` to `download_dir_free_space`

* refactor: change `download-queue-enabled` to `download_queue_enabled`

* refactor: change `download-queue-size` to `download_queue_size`

* refactor: change `idle-seeding-limit-enabled` to `idle_seeding_limit_enabled`

* refactor: change `idle-seeding-limit` to `idle_seeding_limit`

* refactor: change `incomplete-dir-enabled` to `incomplete_dir_enabled`

* refactor: change `incomplete-dir` to `incomplete_dir`

* refactor: change `lpd-enabled` to `lpd_enabled`

* refactor: change `peer-limit-global` to `peer_limit_global`

* refactor: change `peer-limit-per-torrent` to `peer_limit_per_torrent`

* refactor: change `peer-port-random-on-start` to `peer_port_random_on_start`

* refactor: change `peer-port` to `peer_port`

* refactor: change `pex-enabled` to `pex_enabled`

* refactor: change `port-forwarding-enabled` to `port_forwarding_enabled`

* refactor: change `queue-stalled-enabled` to `queue_stalled_enabled`

* refactor: change `queue-stalled-minutes` to `queue_stalled_minutes`

* refactor: change `rename-partial-files` to `rename_partial_files`

* refactor: change `rpc-version-minimum` to `rpc_version_minimum`

* refactor: change `rpc-version-semver` to `rpc_version_semver`

* refactor: change `rpc-version` to `rpc_version`

* refactor: change `script-torrent-added-enabled` to `script_torrent_added_enabled`

* refactor: change `script-torrent-added-filename` to `script_torrent_added_filename`

* refactor: change `script-torrent-done-enabled` to `script_torrent_done_enabled`

* refactor: change `script-torrent-done-filename` to `script_torrent_done_filename`

* refactor: change `script-torrent-done-seeding-enabled` to `script_torrent_done_seeding_enabled`

* refactor: change `script-torrent-done-seeding-filename` to `script_torrent_done_seeding_filename`

* refactor: change `seed-queue-enabled` to `seed_queue_enabled`

* refactor: change `seed-queue-size` to `seed_queue_size`

* refactor: change `seedRatioLimited` to `seed_ratio_limited`

* refactor: change `session-id` to `session_id`

* refactor: change `speed-limit-down-enabled` to `speed_limit_down_enabled`

* refactor: change `speed-limit-down` to `speed_limit_down`

* refactor: change `speed-limit-up-enabled` to `speed_limit_up_enabled`

* refactor: change `speed-limit-up` to `speed_limit_up`

* refactor: change `start-added-torrents` to `start_added_torrents`

* refactor: change `trash-original-torrent-files` to `trash_original_torrent_files`

* refactor: change `utp-enabled` to `utp_enabled`

* refactor: change `tcp-enabled` to `tcp_enabled`

* docs: add missing docs for RPC `tcp_enabled`

* refactor: change `speed-units` to `speed_units`

* refactor: change `speed-bytes` to `speed_bytes`

* refactor: change `size-units` to `size_units`

* refactor: change `size-bytes` to `size_bytes`

* refactor: change `memory-units` to `memory_units`

* refactor: change `memory-bytes` to `memory_bytes`

* refactor: change `session-set` to `session_set`

* refactor: change `session-get` to `session_get`

* refactor: change `session-stats` to `session_stats`

* refactor: change `activeTorrentCount` to `active_torrent_count`

* refactor: change `downloadSpeed` to `download_speed`

* refactor: change `pausedTorrentCount` to `paused_torrent_count`

* refactor: change `torrentCount` to `torrent_count`

* refactor: change `uploadSpeed` to `upload_speed`

* refactor: change `cumulative-stats` to `cumulative_stats`

* refactor: change `current-stats` to `current_stats`

* refactor: change `uploadedBytes` and `uploaded-bytes` to `uploaded_bytes`

* refactor: change `downloadedBytes` and `downloaded-bytes` to `downloaded_bytes`

* refactor: change `filesAdded` and `files-added` to `files_added`

* refactor: change `sessionCount` and `session-count` to `session_count`

* refactor: change `secondsActive` and `seconds-active` to `seconds_active`

* refactor: change `blocklist-update` to `blocklist_update`

* refactor: change `port-test` to `port_test`

* refactor: change `session-close` to `session_close`

* refactor: change `queue-move-top` to `queue_move_top`

* refactor: change `queue-move-up` to `queue_move_up`

* refactor: change `queue-move-down` to `queue_move_down`

* refactor: change `queue-move-bottom` to `queue_move_bottom`

* refactor: change `free-space` to `free_space`

* refactor: change `group-set` to `group_set`

* refactor: change `group-get` to `group_get`

* refactor: change `announce-ip` to `announce_ip`

* refactor: change `announce-ip-enabled` to `announce_ip_enabled`

* refactor: change `upload-slots-per-torrent` to `upload_slots_per_torrent`

* refactor: change `trash-can-enabled` to `trash_can_enabled`

* refactor: change `watch-dir-enabled` to `watch_dir_enabled`

* refactor: change `watch-dir-force-generic` to `watch_dir_force_generic`

* refactor: change `watch-dir` to `watch_dir`

* refactor: change `message-level` to `message_level`

* refactor: change `scrape-paused-torrents-enabled` to `scrape_paused_torrents_enabled`

* refactor: change `torrent-added-verify-mode` to `torrent_added_verify_mode`

* refactor: change `sleep-per-seconds-during-verify` to `sleep_per_seconds_during_verify`

* refactor: change `bind-address-ipv4` to `bind_address_ipv4`

* refactor: change `bind-address-ipv6` to `bind_address_ipv6`

* refactor: change `peer-congestion-algorithm` to `peer_congestion_algorithm`

* refactor: change `peer-socket-tos` to `peer_socket_tos`

* refactor: change `peer-port-random-high` to `peer_port_random_high`

* refactor: change `peer-port-random-low` to `peer_port_random_low`

* refactor: change `anti-brute-force-enabled` to `anti_brute_force_enabled`

* refactor: change `rpc-authentication-required` to `rpc_authentication_required`

* refactor: change `rpc-bind-address` to `rpc_bind_address`

* refactor: change `rpc-enabled` to `rpc_enabled`

* refactor: change `rpc-host-whitelist` to `rpc_host_whitelist`

* refactor: change `rpc-host-whitelist-enabled` to `rpc_host_whitelist_enabled`

* refactor: change `rpc-password` to `rpc_password`

* refactor: change `rpc-port` to `rpc_port`

* refactor: change `rpc-socket-mode` to `rpc_socket_mode`

* refactor: change `rpc-url` to `rpc_url`

* refactor: change `rpc-username` to `rpc_username`

* refactor: change `rpc-whitelist` to `rpc_whitelist`

* refactor: change `rpc-whitelist-enabled` to `rpc_whitelist_enabled`

* refactor: change `ratio-limit-enabled` to `ratio_limit_enabled`

* refactor: change `ratio-limit` to `ratio_limit`

* refactor: change `show-options-window` to `show_options_window`

* refactor: change `open-dialog-dir` to `open_dialog_dir`

* refactor: change `inhibit-desktop-hibernation` to `inhibit_desktop_hibernation`

* refactor: change `show-notification-area-icon` to `show_notification_area_icon`

* refactor: change `start-minimized` to `start_minimized`

* refactor: change `torrent-added-notification-enabled` to `torrent_added_notification_enabled`

* refactor: change `anti-brute-force-threshold` to `anti_brute_force_threshold`

* refactor: change `torrent-complete-notification-enabled` to `torrent_complete_notification_enabled`

* refactor: change `prompt-before-exit` to `prompt_before_exit`

* refactor: change `sort-mode` to `sort_mode`

* refactor: change `statusbar-stats` to `statusbar_stats`

* refactor: change `show-extra-peer-details` to `show_extra_peer_details`

* refactor: change `show-backup-trackers` to `show_backup_trackers`

* refactor: change `blocklist-date` to `blocklist_date`

* refactor: change `blocklist-updates-enabled` to `blocklist_updates_enabled`

* refactor: change `main-window-layout-order` to `main_window_layout_order`

* refactor: change `main-window-height` to `main_window_height`

* refactor: change `main-window-width` to `main_window_width`

* refactor: change `main-window-x` to `main_window_x`

* refactor: change `main-window-y` to `main_window_y`

* refactor: change `filter-mode` to `filter_mode`

* refactor: change `filter-trackers` to `filter_trackers`

* refactor: change `filter-text` to `filter_text`

* refactor: change `remote-session-enabled` to `remote_session_enabled`

* refactor: change `remote-session-host` to `remote_session_host`

* refactor: change `remote-session-https` to `remote_session_https`

* refactor: change `remote-session-password` to `remote_session_password`

* refactor: change `remote-session-port` to `remote_session_port`

* refactor: change `remote-session-requres-authentication` to `remote_session_requires_authentication`

* refactor: change `remote-session-username` to `remote_session_username`

* refactor: change `torrent-complete-sound-command` to `torrent_complete_sound_command`

* refactor: change `torrent-complete-sound-enabled` to `torrent_complete_sound_enabled`

* refactor: change `user-has-given-informed-consent` to `user_has_given_informed_consent`

* refactor: change `read-clipboard` to `read_clipboard`

* refactor: change `details-window-height` to `details_window_height`

* refactor: change `details-window-width` to `details_window_width`

* refactor: change `main-window-is-maximized` to `main_window_is_maximized`

* refactor: change `port-is-open` to `port_is_open`

* refactor: change `show-tracker-scrapes` to `show_tracker_scrapes`

* refactor: change `max-peers` to `max_peers`

* refactor: change `peers2-6` to `peers2_6`

* refactor: change `seeding-time-seconds` to `seeding_time_seconds`

* refactor: change `downloading-time-seconds` to `downloading_time_seconds`

* refactor: change `ratio-mode` to `ratio_mode`

* refactor: change `idle-limit` to `idle_limit`

* refactor: change `idle-mode` to `idle_mode`

* refactor: change `speed-Bps` to `speed_Bps`

* refactor: change `use-global-speed-limit` to `use_global_speed_limit`

* refactor: change `use-speed-limit` to `use_speed_limit`

* chore: remove TODO comment

* docs: add upgrade instructions to `5.0.0`

* chore: bump rpc semver major version

* chore: housekeeping
2025-12-01 16:08:18 -06:00

1303 lines
36 KiB
JavaScript

/* @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 {
newOpts,
icon,
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.handler = null;
this.notifications = notifications;
this.prefs = prefs;
this.remote = new Remote(this);
this.speed = {
down: document.querySelector('#speed-down'),
up: document.querySelector('#speed-up'),
};
for (const [selector, name] of [
['#toolbar-open', 'open'],
['#toolbar-delete', 'delete'],
['#toolbar-start', 'start'],
['#toolbar-pause', 'pause'],
['#toolbar-inspector', 'inspector'],
['#toolbar-overflow', 'overflow'],
]) {
document
.querySelector(selector)
.prepend(icon[name](), document.createElement('BR'));
}
document.querySelector('.speed-container').append(icon.speedDown());
document
.querySelector('.speed-container + .speed-container')
.append(icon.speedUp());
this.addEventListener('torrent-selection-changed', (event_) =>
this.action_manager.update(event_),
);
// Initialize the implementation fields
this.filterText = '';
this._torrents = {};
this._rows = [];
this.oldTrackers = [];
this.dirtyTorrents = new Set();
this.changeStatus = false;
this.refilterSoon = debounce(() => this._refilter(false));
this.refilterAllSoon = debounce(() => this._refilter(true));
this.pointer_device = Object.seal({
is_touch_device: 'ontouchstart' in globalThis,
long_press_callback: null,
x: 0,
y: 0,
});
this.popup = Array.from({ length: Transmission.max_popups }).fill(null);
this.busytyping = 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);
});
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 'copy-name':
if (navigator.clipboard) {
navigator.clipboard.writeText(this.handler.subtree.name);
} else {
// navigator.clipboard requires HTTPS or localhost
// Emergency approach
prompt('Select all then copy', this.handler.subtree.name);
}
this.handler.classList.remove('selected');
break;
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();
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[0] instanceof Inspector) {
this.popup[0].close();
} else {
this.setCurrentPopup(new Inspector(this), 0);
}
break;
case 'show-move-dialog':
this.setCurrentPopup(new MoveDialog(this, this.remote));
break;
case 'show-overflow-menu':
if (
this.popup[Transmission.default_popup_level] instanceof OverflowMenu
) {
this.popup[Transmission.default_popup_level].close();
} 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), 0);
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 'verify-selected-torrents':
this._verifyTorrents(this.getSelectedTorrents());
break;
default:
console.warn(`unhandled action: ${event_.action}`);
}
});
let e = document.querySelector('#filter-mode');
// Initialize filter options
newOpts(e, null, [['All', Prefs.FilterAll]]);
newOpts(e, 'status', [
['Active', Prefs.FilterActive],
['Downloading', Prefs.FilterDownloading],
['Seeding', Prefs.FilterSeeding],
['Paused', Prefs.FilterPaused],
['Finished', Prefs.FilterFinished],
['Error', Prefs.FilterError],
]);
newOpts(e, 'list', [
['Private torrents', Prefs.FilterPrivate],
['Public torrents', Prefs.FilterPublic],
]);
// listen to filter changes
e.value = this.prefs.filter_mode;
e.addEventListener('change', (event_) => {
this.prefs.filter_mode = event_.target.value;
this.refilterAllSoon();
});
e = document.querySelector('#filter-tracker');
newOpts(e, null, [['All', Prefs.FilterAll]]);
const s = document.querySelector('#torrent-search');
e = document.querySelector('#reset');
e.addEventListener('click', () => {
s.value = '';
this._setFilterText(s.value);
this.refilterAllSoon();
});
if (s.value.trim()) {
this.filterText = s.value;
e.style.display = 'block';
this.refilterAllSoon();
}
e = document.querySelector('#turtle');
e.addEventListener('click', (event_) => {
this.remote.savePrefs({
[RPC._TurtleState]:
!event_.target.classList.contains('alt-speed-enabled'),
});
});
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[Transmission.default_popup_level]) {
this.setCurrentPopup(null);
}
if (e_.target === e_.currentTarget) {
this._deselectAll();
}
});
e.addEventListener('dblclick', () => {
if (!this.popup[0] || this.popup[0].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 right_click = (event_) => {
// 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);
}
if (this.handler) {
this.handler.classList.remove('selected');
this.handler = null;
}
this.context_menu('#torrent-container');
event_.preventDefault();
};
this.pointer_event(this.elements.torrent_list, right_click);
// 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(globalThis.location.search).get(
'addtorrent',
);
if (addTorrent) {
this.setCurrentPopup(new OpenDialog(this, this.remote, addTorrent));
const newUrl = new URL(globalThis.location);
newUrl.search = '';
globalThis.history.pushState('', '', newUrl.toString());
}
}, 0);
}
loadDaemonPrefs() {
this.remote.loadDaemonPrefs((data) => {
this.session_properties = data.result;
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('input', () => {
if (e.value.trim() !== this.filterText) {
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', '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 pref = this.prefs.refresh_rate_sec;
const msec = pref > 0 ? pref * 1000 : 1000;
this.refreshTorrentsInterval = setInterval(callback, msec);
break;
}
default:
/*noop*/
break;
}
}
context_menu(container_id, menu_items) {
// open context menu
const popup = new ContextMenu(this, menu_items);
this.setCurrentPopup(popup);
const bounds = document.querySelector(container_id).getBoundingClientRect();
const x = Math.min(
this.pointer_device.x,
bounds.right + globalThis.scrollX - popup.root.clientWidth,
);
const y = Math.min(
this.pointer_device.y,
bounds.bottom + globalThis.scrollY - popup.root.clientHeight,
);
popup.root.style.left = `${Math.max(x, 0)}px`;
popup.root.style.top = `${Math.max(y, 0)}px`;
}
pointer_event(e_, right_click) {
if (this.pointer_device.is_touch_device) {
const touch = this.pointer_device;
e_.addEventListener('touchstart', (event_) => {
touch.x = event_.touches[0].pageX;
touch.y = event_.touches[0].pageY;
if (touch.long_press_callback) {
clearTimeout(touch.long_press_callback);
touch.long_press_callback = null;
} else {
touch.long_press_callback = setTimeout(() => {
if (event_.touches.length === 1) {
right_click(event_);
}
}, 500);
}
});
e_.addEventListener('touchend', () => {
clearTimeout(touch.long_press_callback);
touch.long_press_callback = null;
setTimeout(() => {
const popup = this.popup[Transmission.default_popup_level];
if (popup) {
popup.root.style.pointerEvents = 'auto';
}
}, 1);
});
e_.addEventListener('touchmove', (event_) => {
touch.x = event_.touches[0].pageX;
touch.y = event_.touches[0].pageY;
clearTimeout(touch.long_press_callback);
touch.long_press_callback = null;
});
e_.addEventListener('contextmenu', (event_) => {
event_.preventDefault();
});
} else {
e_.addEventListener('mousemove', (event_) => {
this.pointer_device.x = event_.pageX;
this.pointer_device.y = event_.pageY;
});
e_.addEventListener('contextmenu', (event_) => {
right_click(event_);
const popup = this.popup[Transmission.default_popup_level];
if (popup) {
popup.root.style.pointerEvents = 'auto';
}
});
}
}
/// UTILITIES
static get max_popups() {
return 2;
}
static get default_popup_level() {
return Transmission.max_popups - 1;
}
_getAllTorrents() {
return Object.values(this._torrents);
}
static _getTorrentIds(torrents) {
return torrents.map((t) => t.getId());
}
seedRatioLimit() {
const p = this.session_properties;
if (p && p.seed_ratio_limited) {
return p.seed_ratio_limit;
}
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_.shiftKey) {
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.some(Boolean)) {
this.setCurrentPopup(null, 0);
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 Boolean(url);
} 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.findLast((t) =>
['text/uri-list', 'text/plain'].includes(t),
);
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.setCurrentPopup(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) {
clearTimeout(this.busytyping);
this.busytyping = setTimeout(
() => {
this.busytyping = false;
this.filterText = search.trim();
this.refilterAllSoon();
},
search ? 250 : 0,
);
}
_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[Transmission.default_popup_level]) {
this.setCurrentPopup(null);
}
// 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
globalThis.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() {
const torrents = this.getSelectedTorrents();
if (torrents.length > 0) {
this.setCurrentPopup(new RemoveDialog({ remote: this.remote, torrents }));
}
}
_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('#turtle');
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(this.speed.down, fmt.speedBps(d));
setTextContent(this.speed.up, fmt.speedBps(u));
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).toSorted();
// Update select box only when list of trackers has changed
if (
sitenames.length !== this.oldTrackers.length ||
sitenames.some((ele, idx) => ele !== this.oldTrackers[idx])
) {
this.oldTrackers = sitenames;
const a = [
['All', Prefs.FilterAll, !this.filterTracker],
...sitenames.map((sitename) => [
Transmission._displayName(sitename),
sitename,
sitename === this.filterTracker,
]),
];
const e = document.querySelector('#filter-tracker');
while (e.firstChild) {
e.lastChild.remove();
}
newOpts(e, null, a);
}
}
/// 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;
// TODO: This regex is wrong and is about to be removed in https://github.com/transmission/transmission/pull/7008,
// so it is left alone for now.
// eslint-disable-next-line sonarjs/slow-regex
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();
if (rebuildEverything) {
while (list.firstChild) {
list.firstChild.remove();
}
this._rows = [];
this.dirtyTorrents = new Set(Object.keys(this._torrents));
document.querySelector('#reset').style.display =
this.filterText.length > 0 ? 'block' : 'none';
}
// 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();
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 === Prefs.FilterAll ? '' : 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;
}
///
setCurrentPopup(popup, level = Transmission.default_popup_level) {
for (let index = level; index < Transmission.max_popups; index++) {
if (this.popup[index]) {
this.popup[index].close();
}
}
this.popup[level] = popup;
if (this.popup[level]) {
const listener = () => {
if (this.popup[level]) {
this.popup[level].removeEventListener('close', listener);
this.popup[level] = null;
}
};
this.popup[level].addEventListener('close', listener);
} else if (this.handler) {
this.handler.classList.remove('selected');
}
}
}