2 lines
22 KiB
JavaScript
2 lines
22 KiB
JavaScript
export const stateSource = ' const $ = (id) => document.getElementById(id);\n const esc = (s) => String(s ?? "").replace(/[&<>\'"]/g, c => ({"&":"&","<":"<",">":">","\'":"'",\'"\':"""}[c]));\n const ROW_HEIGHT = 32, OVERSCAN = 14;\n const torrents = new Map();\n const browserViewPrefs = (()=>{ try{return JSON.parse(localStorage.getItem(\'pyTorrent.mobileViewPrefs\')||\'{}\')||{};}catch(e){return {};} })();\n const savedFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || "all");\n // Note: Mobile has both "All" and "All trackers" options, so keep the exact selected option separate from the shared filter state.\n let mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || savedFilter || "all");\n let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = savedFilter.startsWith("tracker:") ? "all" : (savedFilter || "all");\n let activeTrackerFilter = savedFilter.startsWith("tracker:") ? savedFilter.slice(8) : "";\n const SORT_KEYS = new Set(["name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"]);\n const savedSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n let sortState = {key: SORT_KEYS.has(savedSort.key) ? savedSort.key : "name", dir: Number(savedSort.dir) < 0 ? -1 : 1}, renderPending = false, renderVersion = 0, lastRenderSignature = "";\n const MOBILE_SORT_STEPS = [\n {key:"down_rate", dir:-1, label:"DL"},\n {key:"up_rate", dir:-1, label:"UL"},\n {key:"progress", dir:-1, label:"Progress"},\n {key:"ratio", dir:-1, label:"Ratio"},\n {key:"size", dir:-1, label:"Size"},\n {key:"seeds", dir:-1, label:"Seeds"},\n {key:"created", dir:-1, label:"Added"},\n {key:"name", dir:1, label:"Name"}\n ];\n let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = "/";\n const traffic = [], systemUsage = [];\n const socket = (typeof io === "function") ? io({transports:["polling"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}) : {connected:false,on(){},emit(){},io:{on(){}}};\n const COLUMN_DEFS = [["status","Status",false],["size","Size",false],["progress","Progressbar",false],["down_rate","DL",false],["up_rate","UL",false],["eta","ETA",false],["seeds","Seeds",false],["peers","Peers",false],["ratio","Ratio",false],["path","Path",false],["label","Label",false],["ratio_group","Ratio group",false],["down_total","Downloaded",true],["to_download","To download",true],["up_total","Uploaded",true],["created","Added",true],["priority","Priority",true],["state","State",true],["active","Active",true],["complete","Complete",true],["hashing","Hashing",true],["message","Message",true],["hash","Hash",true]];\n const DEFAULT_HIDDEN_COLUMNS = new Set(COLUMN_DEFS.filter(([, , hiddenByDefault]) => hiddenByDefault).map(([key]) => key));\n const savedColumns = window.PYTORRENT?.tableColumns || {};\n const DEFAULT_COLUMN_WIDTHS = {\n select: 34, name: 360, status: 110, size: 90, progress: 120,\n down_rate: 86, up_rate: 86, eta: 92, seeds: 70, peers: 70,\n ratio: 72, path: 300, label: 140, ratio_group: 130,\n down_total: 120, to_download: 120, up_total: 120, created: 150,\n priority: 80, state: 70, active: 70, complete: 82, hashing: 82,\n message: 220, hash: 280\n };\n const COLUMN_WIDTH_MIN = 44;\n const COLUMN_WIDTH_MAX = 720;\n const explicitlyShownColumns = new Set(savedColumns.shown || []);\n let hiddenColumns = new Set([...(savedColumns.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShownColumns.has(key))]);\n // Note: Column widths are persisted with the existing column preferences payload, so no database migration is needed.\n function normalizeColumnWidths(value={}){\n const allowed = new Set([\'select\', ...COLUMN_DEFS.map(([key]) => key)]);\n const normalized = {...DEFAULT_COLUMN_WIDTHS};\n Object.entries(value || {}).forEach(([key, width])=>{\n if(allowed.has(key)) normalized[key] = clampNumber(width, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, DEFAULT_COLUMN_WIDTHS[key] || 120);\n });\n return normalized;\n }\n let columnWidths = normalizeColumnWidths(savedColumns.widths || {});\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n const DEFAULT_MOBILE_COLUMNS = new Set(["status","progress","down_rate","up_rate","eta","seeds","peers","ratio","path"]);\n const MOBILE_COLUMN_DEFS = COLUMN_DEFS.map(([key,label]) => [key, label, DEFAULT_MOBILE_COLUMNS.has(key)]);\n function normalizeMobileColumns(value={}){\n const normalized = {...Object.fromEntries(MOBILE_COLUMN_DEFS.map(([key,,shown])=>[key, shown]))};\n Object.entries(value || {}).forEach(([key, shown])=>{\n if(key === "speed"){ normalized.down_rate = !!shown; normalized.up_rate = !!shown; }\n else if(key === "seed_peer"){ normalized.seeds = !!shown; normalized.peers = !!shown; }\n else if(key in normalized) normalized[key] = !!shown;\n });\n return normalized;\n }\n let mobileColumns = normalizeMobileColumns(savedColumns.mobile || {});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n let mobileSmartFiltersEnabled = browserViewPrefs.mobileSmartFiltersEnabled ?? savedColumns.mobileSmartFiltersEnabled ?? true;\n let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || "default";\n let fontFamily = window.PYTORRENT?.fontFamily || "default";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || "default";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || "";\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = \'idle\';\n let trackerSummarySignature = "";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = "";\n let lastTrackerFiltersSignature = "";\n let lastMobileFiltersSignature = "";\n const BASE_TITLE = document.title || "pyTorrent";\n const lastBrowserSpeed = {down: "0 B/s", up: "0 B/s"};\n const FOOTER_STATUS_STORAGE_KEY = "pytorrent.footerStatus.v1";\n const FOOTER_RT_METRIC_KEYS = new Set(["sockets", "rt_downloads", "rt_uploads", "rt_http", "rt_files", "rt_port"]);\n const FOOTER_ITEM_DEFS = [\n ["cpu", "CPU"], ["ram", "RAM"], ["usage_chart", "CPU/RAM chart"], ["disk", "Disk"],\n ["version", "rTorrent version"], ["speed_down", "Download speed"], ["speed_up", "Upload speed"],\n ["speed_peaks", "Peak speeds"], ["limits", "Speed limits"], ["totals", "Total transfer"], ["port_check", "Port check"],\n ["clock", "Clock"], ["sockets", "Open sockets"], ["rt_downloads", "Downloads (D)"], ["rt_uploads", "Uploads (U)"], ["rt_http", "HTTP (H)"], ["rt_files", "Files (F)"], ["rt_port", "Incoming port"], ["shown", "Shown torrents"], ["selected", "Selected torrents"], ["docs", "API docs"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = \'\';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n const hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n function clampNumber(value, min, max, fallback){\n const num = Number(value);\n if(!Number.isFinite(num)) return fallback;\n return Math.max(min, Math.min(max, Math.round(num)));\n }\n function savePreferencePatch(payload, delay=350){\n const key = Object.keys(payload).sort().join(\'|\');\n clearTimeout(preferenceSaveTimers.get(key));\n preferenceSaveTimers.set(key, setTimeout(async()=>{\n try{ await post(\'/api/preferences\', payload); }catch(e){ console.warn(\'Preference save failed\', e); }\n finally{ preferenceSaveTimers.delete(key); }\n }, delay));\n }\n function currentActiveFilterPreference(){\n return activeTrackerFilter ? `tracker:${activeTrackerFilter}` : activeFilter;\n }\n function saveTorrentSortPreference(){\n // Note: Sorting is persisted together with the current filter so mobile tracker scope cannot fall back to All trackers after a quick sort change.\n saveBrowserViewPrefs();\n savePreferencePatch({torrent_sort_json:{key:sortState.key, dir:sortState.dir}, active_filter:currentActiveFilterPreference()}, 200);\n }\n function saveBrowserViewPrefs(extra={}){\n try{\n const prev=JSON.parse(localStorage.getItem(\'pyTorrent.mobileViewPrefs\')||\'{}\')||{};\n localStorage.setItem(\'pyTorrent.mobileViewPrefs\', JSON.stringify({...prev, activeFilter:currentActiveFilterPreference(), mobileFilterKey:mobileActiveFilterKey, sortState, mobileColumns, columnWidths, ...extra}));\n }catch(e){}\n }\n function saveActiveFilterPreference(){\n saveBrowserViewPrefs();\n savePreferencePatch({active_filter:currentActiveFilterPreference()}, 250);\n }\n function cleanColumnPrefsHidden(values){ return [...values].filter(key => key !== "progressbar"); }\n async function resetViewPreferences(){\n activeFilter = "all";\n activeTrackerFilter = "";\n mobileActiveFilterKey = "all";\n sortState = {key:"name", dir:1};\n mobileColumns = normalizeMobileColumns();\n hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS);\n columnWidths = normalizeColumnWidths();\n const height = applyDetailPanelHeight(255);\n renderColumnManager();\n document.querySelectorAll(\'.filter\').forEach(x=>x.classList.toggle(\'active\', x.dataset.filter === \'all\'));\n if($(\'tableWrap\')) $(\'tableWrap\').scrollTop = 0;\n if($(\'mobileList\')) $(\'mobileList\').scrollTop = 0;\n try{\n await post(\'/api/preferences\', {active_filter:"all", torrent_sort_json:{key:"name", dir:1}, detail_panel_height:height, table_columns_json:JSON.stringify({hidden:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS), shown:[], mobile:mobileColumns, mobileSmartFiltersEnabled:true, widths:columnWidths})});\n toast(\'View preferences reset\',\'success\');\n }catch(e){ toast(e.message,\'danger\'); }\n scheduleRender(true);\n }\n function applyDetailPanelHeight(height){\n const safeHeight = clampNumber(height, 160, 720, 255);\n document.documentElement.style.setProperty(\'--detail-panel-height\', `${safeHeight}px`);\n const handle = $(\'detailResizeHandle\');\n if(handle) handle.setAttribute(\'aria-valuenow\', String(safeHeight));\n return safeHeight;\n }\n function saveDetailPanelHeight(height){\n const safeHeight = applyDetailPanelHeight(height);\n savePreferencePatch({detail_panel_height:safeHeight}, 250);\n }\n function setupDetailResizer(){\n const handle = $(\'detailResizeHandle\');\n const content = document.querySelector(\'.content\');\n if(!handle || !content) return;\n applyDetailPanelHeight(window.PYTORRENT?.detailPanelHeight || 255);\n let startY = 0, startHeight = 0;\n const onMove = (event) => {\n const pointerY = event.clientY ?? event.touches?.[0]?.clientY ?? startY;\n applyDetailPanelHeight(startHeight - (pointerY - startY));\n scheduleRender(false);\n };\n const onUp = () => {\n document.body.classList.remove(\'resizing-details\');\n document.removeEventListener(\'pointermove\', onMove);\n document.removeEventListener(\'pointerup\', onUp);\n const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue(\'--detail-panel-height\'), 10);\n saveDetailPanelHeight(value);\n };\n handle.addEventListener(\'pointerdown\', (event) => {\n event.preventDefault();\n startY = event.clientY;\n startHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue(\'--detail-panel-height\'), 10) || 255;\n document.body.classList.add(\'resizing-details\');\n document.addEventListener(\'pointermove\', onMove);\n document.addEventListener(\'pointerup\', onUp);\n });\n }\n function toastKey(msg, type){ return `${type}::${String(msg ?? \'\')}`; }\n function isAutomationEvent(msg){ return msg?.automation === true || msg?.source === \'automation\'; }\n function shouldShowOperationToast(msg){\n // Note: Automation-created operation toasts follow the Automation toasts preference.\n return !isAutomationEvent(msg) || automationToastsEnabled;\n }\n function toast(msg, type="secondary") {\n // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.\n const h=$(\'toastHost\');\n if(!h) return;\n const text=String(msg ?? \'\');\n const key=toastKey(text,type);\n const existing=toastGroups.get(key);\n if(existing){\n existing.count += 1;\n const badge=existing.el.querySelector(\'.toast-count\');\n if(badge){ badge.textContent=`×${existing.count}`; badge.classList.remove(\'d-none\'); }\n clearTimeout(existing.timer);\n existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);\n return;\n }\n const el=document.createElement(\'div\');\n el.className=`toast-item text-bg-${type}`;\n el.innerHTML=`<span class="toast-message">${esc(text)}</span><span class="toast-count d-none">×1</span>`;\n h.appendChild(el);\n const entry={el,count:1,timer:null};\n entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);\n toastGroups.set(key,entry);\n }\n function setBusy(on, label=\'Working...\'){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; const loader=$(\'globalLoader\'); if(loader){ loader.classList.toggle(\'d-none\', pendingBusy===0); const span=loader.querySelector(\'span:last-child\'); if(span) span.textContent=label; } $(\'busyBadge\')?.classList.toggle(\'d-none\', pendingBusy===0); }\n function setInitialLoader(title, text){ if(initialLoaderDone) return; if($(\'initialLoaderTitle\') && title) $(\'initialLoaderTitle\').textContent=title; if($(\'initialLoaderText\') && text) $(\'initialLoaderText\').textContent=text; }\n function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $(\'initialLoader\')?.classList.add(\'is-hidden\'); }\n function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector(\'.btn-label\'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?`<span class="spinner-border spinner-border-sm me-1"></span>Working...`:label.dataset.orig; }}\n function activeTab(){ return document.querySelector(\'#detailTabs .nav-link.active\')?.dataset.tab || \'general\'; }\n function loadingMarkup(label=\'Loading data...\'){ return `<div class="loading-line loading-center"><span class="spinner-border spinner-border-sm" aria-hidden="true"></span><span>${esc(label)}</span></div>`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 25; }\n function loadingTableRow(label=\'Loading torrents...\'){ return `<tr><td colspan="${torrentColumnSpan()}" class="empty loading-cell">${loadingMarkup(label)}</td></tr>`; }\n // Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.\n function renderNoProfileState(){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $(\'torrentBody\');\n if(body){\n body.innerHTML = `<tr><td colspan="${torrentColumnSpan()}" class="empty"><div class="empty-state"><b>No rTorrent profile configured.</b><span>Add the first rTorrent profile to start loading torrents.</span><button id="setupProfileBtn" class="btn btn-sm btn-primary" type="button"><i class="fa-solid fa-server"></i> Add rTorrent profile</button></div></td></tr>`;\n }\n if($(\'detailPane\')) $(\'detailPane\').innerHTML = \'Add rTorrent profile first.\';\n }\n function clearRtorrentStartingState(){\n rtorrentStartingMessage=\'\';\n rtorrentStartingSince=0;\n if(rtorrentStartingTimer){ clearTimeout(rtorrentStartingTimer); rtorrentStartingTimer=null; }\n }\n function rtorrentStartingHtml(error=\'\'){\n const details=error ? `<small>${esc(error)}</small>` : \'<small>Port can already be open while XML-RPC/SCGI is still warming up. The list will load automatically after rTorrent answers.</small>\';\n return `<div class="empty-state"><b>rTorrent is starting or not responding yet.</b><span>Waiting for torrent data from the active profile.</span>${details}</div>`;\n }\n function scheduleRtorrentStartingState(error=\'\'){\n rtorrentStartingMessage = String(error || \'rTorrent is starting or not responding yet.\');\n if(!(hasTorrentSnapshot && torrents.size)){\n renderRtorrentStartingState(rtorrentStartingMessage, true);\n return;\n }\n if(!rtorrentStartingSince) rtorrentStartingSince = Date.now();\n if(rtorrentStartingTimer) return;\n rtorrentStartingTimer = setTimeout(() => {\n rtorrentStartingTimer = null;\n if(rtorrentStartingMessage) renderRtorrentStartingState(rtorrentStartingMessage, true);\n }, RTORRENT_STALE_GRACE_MS);\n }\n function renderRtorrentStartingState(error=\'\', force=false){\n rtorrentStartingMessage = String(error || \'rTorrent is starting or not responding yet.\');\n if(hasTorrentSnapshot && torrents.size && !force) return;\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body=$(\'torrentBody\');\n if(body) body.innerHTML = `<tr><td colspan="${torrentColumnSpan()}" class="empty">${rtorrentStartingHtml(rtorrentStartingMessage)}</td></tr>`;\n const list=$(\'mobileList\');\n if(list) list.innerHTML = `<div class="empty">${rtorrentStartingHtml(rtorrentStartingMessage)}</div>`;\n if($(\'detailPane\')) $(\'detailPane\').innerHTML = \'rTorrent is starting. Details will appear after the first successful response.\';\n }\n function parseDate(value){ const raw=String(value||\'\').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }\n function formatDate(value, mode=\'short\'){\n const parsed=parseDate(value);\n if(!parsed) return String(value||\'\');\n const opts=mode===\'full\'\n ? {year:\'numeric\',month:\'2-digit\',day:\'2-digit\',hour:\'2-digit\',minute:\'2-digit\',second:\'2-digit\'}\n : {month:\'2-digit\',day:\'2-digit\',hour:\'2-digit\',minute:\'2-digit\'};\n return new Intl.DateTimeFormat(\'pl-PL\', opts).format(parsed.d).replace(\',\', \'\');\n }\n function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||\'\'); return `<span class="date-compact" title="${esc(formatDate(value,\'full\'))}">${esc(formatDate(value))}</span>`; }\n // Note: Human-readable date cells keep full timestamps visible without squeezing table columns.\n function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||\'\'); const full=formatDate(value,\'full\'); return `<span class="date-readable" title="${esc(parsed.raw)}">${esc(full)}</span>`; }\n function compactCell(value, max=120){ const text=String(value||""); if(!text) return ""; const short=text.length>max ? `${text.slice(0, Math.floor(max*0.62))}…${text.slice(-Math.floor(max*0.28))}` : text; return `<span class="text-compact" title="${esc(text)}">${esc(short)}</span>`; }\n function progressBar(value, extraClass=\'\'){ const pct=Math.max(0,Math.min(100,Number(value||0))); const hue=Math.round((pct/100)*120); const light=30+Math.round((pct/100)*5); const bg=pct<=0?\'transparent\':pct>=100?\'var(--torrent-progress-complete)\':`hsl(${hue} 52% ${light}%)`; const done=pct>=100?\' is-complete\':\'\'; const cls=extraClass?` ${extraClass}`:\'\'; return `<div class="progress torrent-progress${done}${cls}" title="${esc(pct)}%"><div class="progress-bar" style="width:${pct}%;background:${bg}"></div><span>${esc(pct)}%</span></div>`; }\n function progress(t){ return progressBar(t.progress); }\n';
|