From a2cdc203c2edbb3011792058c1d99085d18da7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 12 Jun 2026 23:20:42 +0200 Subject: [PATCH] table_fix_and_folder_management --- pytorrent/openapi/openapi.json | 155 +++++++++++++++++++- pytorrent/routes/system.py | 59 +++++++- pytorrent/services/rtorrent/system.py | 54 +++++++ pytorrent/static/js/pathPickerTools.js | 2 +- pytorrent/static/js/torrentRowRenderer.js | 2 +- pytorrent/static/js/torrentTableRenderer.js | 2 +- pytorrent/static/styles.css | 88 +++++++++-- pytorrent/templates/index.html | 4 + 8 files changed, 344 insertions(+), 22 deletions(-) diff --git a/pytorrent/openapi/openapi.json b/pytorrent/openapi/openapi.json index b6c1b9c..5ef9771 100644 --- a/pytorrent/openapi/openapi.json +++ b/pytorrent/openapi/openapi.json @@ -955,8 +955,7 @@ "properties": { "dirs": { "items": { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/PathDirectoryEntry" }, "type": "array" }, @@ -2155,6 +2154,72 @@ "url", "expires_in" ] + }, + "PathDirectoryEntry": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "has_torrents": { + "type": "boolean" + }, + "can_rename": { + "type": "boolean" + } + }, + "additionalProperties": true + }, + "PathDirectoryCreateRequest": { + "type": "object", + "required": [ + "parent", + "name" + ], + "properties": { + "parent": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "PathDirectoryRenameRequest": { + "type": "object", + "required": [ + "path", + "new_name" + ], + "properties": { + "path": { + "type": "string" + }, + "new_name": { + "type": "string" + } + } + }, + "PathDirectoryMutationResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "type": "object", + "properties": { + "directory": { + "$ref": "#/components/schemas/PathDirectoryEntry" + } + } + } + ] } }, "securitySchemes": { @@ -7948,6 +8013,92 @@ } } } + }, + "/api/path/directories": { + "post": { + "summary": "Create an empty directory", + "description": "Creates a directory on the active rTorrent host for inline path-picker use. Existing torrent state is not changed.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PathDirectoryCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PathDirectoryMutationResponse" + } + } + } + }, + "400": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "sessionCookie": [] + } + ] + } + }, + "/api/path/directories/rename": { + "post": { + "summary": "Rename an empty directory", + "description": "Renames a directory only when it is empty and does not contain a cached torrent path.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PathDirectoryRenameRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Renamed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PathDirectoryMutationResponse" + } + } + } + }, + "400": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "sessionCookie": [] + } + ] + } } } } diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py index ffc99c8..fbdd63e 100644 --- a/pytorrent/routes/system.py +++ b/pytorrent/routes/system.py @@ -1,6 +1,7 @@ from __future__ import annotations from ._shared import * +import posixpath from ..services import operation_logs from ..services.frontend_assets import static_hash @@ -337,6 +338,29 @@ def jobs_retry(job_id: str): +def _remote_path_contains(base: str, candidate: str) -> bool: + base = posixpath.normpath(str(base or "").rstrip("/") or "/") + candidate = posixpath.normpath(str(candidate or "").rstrip("/") or "/") + return candidate == base or candidate.startswith(base.rstrip("/") + "/") + + +def _path_has_cached_torrents(profile_id: int, path: str) -> bool: + # Note: The cache check prevents renaming folders that are currently known as torrent locations. + if not str(path or "").strip(): + return False + return any(_remote_path_contains(path, item.get("path") or "") for item in torrent_cache.snapshot(profile_id)) + + +def _annotate_path_directories(profile: dict, payload: dict) -> dict: + dirs = payload.get("dirs") or [] + for item in dirs: + item_path = item.get("path") or "" + has_torrents = _path_has_cached_torrents(int(profile.get("id") or 0), item_path) + item["has_torrents"] = has_torrents + item["can_rename"] = bool(item.get("empty")) and not has_torrents + return payload + + @bp.get("/path/default") def path_default(): profile = preferences.active_profile() @@ -356,7 +380,40 @@ def path_browse(): return jsonify({"ok": False, "error": "No profile"}), 400 base = request.args.get("path") or "" try: - return ok(rtorrent.browse_path(profile, base)) + return ok(_annotate_path_directories(profile, rtorrent.browse_path(profile, base))) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + + +@bp.post("/path/directories") +def path_directory_create(): + profile = preferences.active_profile() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + require_profile_write(profile.get("id")) + data = request.get_json(silent=True) or {} + try: + # Note: This endpoint only creates an empty directory and does not alter any torrent state. + result = rtorrent.create_directory(profile, data.get("parent") or "", data.get("name") or "") + return ok({"directory": result}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + + +@bp.post("/path/directories/rename") +def path_directory_rename(): + profile = preferences.active_profile() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + require_profile_write(profile.get("id")) + data = request.get_json(silent=True) or {} + path = str(data.get("path") or "").strip() + if _path_has_cached_torrents(int(profile.get("id") or 0), path): + return jsonify({"ok": False, "error": "Directory contains a known torrent path"}), 400 + try: + # Note: The service also verifies that the remote directory is empty before renaming. + result = rtorrent.rename_empty_directory(profile, path, data.get("new_name") or "") + return ok({"directory": result}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}), 400 diff --git a/pytorrent/services/rtorrent/system.py b/pytorrent/services/rtorrent/system.py index 9a04e0b..4da5c48 100644 --- a/pytorrent/services/rtorrent/system.py +++ b/pytorrent/services/rtorrent/system.py @@ -78,6 +78,60 @@ def browse_path(profile: dict, path: str | None = None) -> dict: "used_percent": disk_percent, } + + +def _safe_directory_name(name: str) -> str: + value = str(name or "").strip() + if not value or value in {".", ".."} or "/" in value or "\x00" in value: + raise ValueError("Invalid directory name") + return value + + +def create_directory(profile: dict, parent: str, name: str) -> dict: + """Create a remote directory without changing existing path-picker behavior.""" + # Note: Directory creation is remote-side, so Add/Move sees the same filesystem as rTorrent. + c = client_for(profile) + clean_parent = _remote_clean_path(parent or default_download_path(profile)) + clean_name = _safe_directory_name(name) + target = _remote_join(clean_parent, clean_name) + script = ( + 'parent=$1; target=$2; ' + 'if [ ! -d "$parent" ]; then printf "ERR\tParent directory does not exist"; exit 0; fi; ' + 'if [ -e "$target" ] || [ -L "$target" ]; then printf "ERR\tDirectory already exists"; exit 0; fi; ' + 'mkdir -- "$target" 2>/dev/null || { printf "ERR\tCannot create directory"; exit 0; }; ' + 'printf "OK\t%s" "$target"' + ) + output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-mkdir", clean_parent, target) or "").strip() + if not output.startswith("OK\t"): + raise RuntimeError(output.split("\t", 1)[1] if "\t" in output else "Cannot create directory") + return {"path": output.split("\t", 1)[1], "name": clean_name} + + +def rename_empty_directory(profile: dict, path: str, new_name: str) -> dict: + """Rename an empty remote directory in place.""" + # Note: Rename is intentionally limited to empty folders to avoid invalidating active torrent paths. + c = client_for(profile) + source = _remote_clean_path(path or "") + clean_name = _safe_directory_name(new_name) + if not source or source == "/": + raise ValueError("Cannot rename this directory") + parent = posixpath.dirname(source.rstrip("/")) or "/" + target = _remote_join(parent, clean_name) + if source == target: + return {"path": target, "name": clean_name, "parent": parent} + script = ( + 'src=$1; dst=$2; ' + 'if [ ! -d "$src" ]; then printf "ERR\tDirectory does not exist"; exit 0; fi; ' + 'if [ -e "$dst" ] || [ -L "$dst" ]; then printf "ERR\tTarget directory already exists"; exit 0; fi; ' + 'if [ -n "$(find "$src" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then printf "ERR\tOnly empty directories can be renamed"; exit 0; fi; ' + 'mv -- "$src" "$dst" 2>/dev/null || { printf "ERR\tCannot rename directory"; exit 0; }; ' + 'printf "OK\t%s" "$dst"' + ) + output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-rename-dir", source, target) or "").strip() + if not output.startswith("OK\t"): + raise RuntimeError(output.split("\t", 1)[1] if "\t" in output else "Cannot rename directory") + return {"path": output.split("\t", 1)[1], "name": clean_name, "parent": parent} + def remote_public_ip(profile: dict, force: bool = False) -> str: profile_id = int(profile.get("id") or 0) now = time.monotonic() diff --git a/pytorrent/static/js/pathPickerTools.js b/pytorrent/static/js/pathPickerTools.js index 9136e47..9cb2fe4 100644 --- a/pytorrent/static/js/pathPickerTools.js +++ b/pytorrent/static/js/pathPickerTools.js @@ -1 +1 @@ -export const pathPickerToolsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toastMessage('toast.pathPickerUnavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)}`);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used`);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs`);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files`);\n return meta.length ? `
${meta.join('')}
` : '';\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(d=>`
${esc(d.name)}
`).join('')||'
No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`
${esc(e.message)}
`;\n }\n }\n $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);});\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toastMessage('toast.pathEmpty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toastMessage('toast.moveQueued','success',{parts,physical:$('moveDataPhysical')?.checked});\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n\n"; +export const pathPickerToolsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toastMessage('toast.pathPickerUnavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n resetInlineDirectoryCreate();\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)}`);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used`);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs`);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files`);\n return meta.length ? `
${meta.join('')}
` : '';\n }\n function resetInlineDirectoryCreate(){\n const input=$('pathCreateName');\n if(input) input.value='';\n }\n function pathDirectoryRow(d){\n const disabled=!d.can_rename;\n const reason=d.has_torrents?'Folder contains a known torrent path':(!d.empty?'Only empty folders can be renamed':'Rename folder');\n return `
`;\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(pathDirectoryRow).join('')||'
No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`
${esc(e.message)}
`;\n }\n }\n function inlineDirectoryName(value){\n value=String(value||'').trim();\n if(!value || value==='.' || value==='..' || value.includes('/')) throw new Error('Enter a valid folder name');\n return value;\n }\n async function createInlineDirectory(){\n try{\n const parent=($('pathCurrent')?.value||'').trim();\n const name=inlineDirectoryName($('pathCreateName')?.value||'');\n // Note: Inline create avoids an extra modal and leaves the current Add/Move context untouched.\n await post('/api/path/directories',{parent,name});\n resetInlineDirectoryCreate();\n toast('Directory created','success');\n await browsePath(parent);\n }catch(e){ toast(e.message,'danger'); }\n }\n function startInlineRename(row){\n if(!row || row.dataset.canRename!=='1') return;\n const oldName=row.dataset.name||'';\n row.classList.add('is-renaming');\n row.innerHTML=`
`;\n const input=row.querySelector('.path-rename-input');\n input?.focus();\n input?.select();\n }\n async function submitInlineRename(row){\n try{\n const input=row?.querySelector('.path-rename-input');\n const newName=inlineDirectoryName(input?.value||'');\n const path=row?.dataset.path||'';\n if(newName===(row?.dataset.name||'')) return browsePath($('pathCurrent')?.value);\n // Note: Rename is limited by the backend to empty folders with no cached torrent path inside.\n await post('/api/path/directories/rename',{path,new_name:newName});\n toast('Directory renamed','success');\n await browsePath($('pathCurrent')?.value);\n }catch(e){ toast(e.message,'danger'); }\n }\n $('pathList')?.addEventListener('click',e=>{\n const rename=e.target.closest('.path-rename-btn');\n if(rename){ e.preventDefault(); e.stopPropagation(); startInlineRename(rename.closest('.path-row')); return; }\n const cancel=e.target.closest('.path-rename-cancel');\n if(cancel){ e.preventDefault(); browsePath($('pathCurrent')?.value); return; }\n const open=e.target.closest('.path-row-open');\n if(open){ const r=open.closest('.path-row'); if(r) browsePath(r.dataset.path); }\n });\n $('pathList')?.addEventListener('submit',e=>{ const form=e.target.closest('.path-rename-form'); if(form){ e.preventDefault(); submitInlineRename(form.closest('.path-row')); } });\n $('pathCreateBtn')?.addEventListener('click',createInlineDirectory);\n $('pathCreateName')?.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); createInlineDirectory(); } });\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toastMessage('toast.pathEmpty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toastMessage('toast.moveQueued','success',{parts,physical:$('moveDataPhysical')?.checked});\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n"; diff --git a/pytorrent/static/js/torrentRowRenderer.js b/pytorrent/static/js/torrentRowRenderer.js index a2ccf31..2a0bd72 100644 --- a/pytorrent/static/js/torrentRowRenderer.js +++ b/pytorrent/static/js/torrentRowRenderer.js @@ -1 +1 @@ -export const torrentRowRendererSource = " function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `${esc(m.label || t.status)}`; }\n function torrentErrorLog(t){\n // Note: The name-column status icon is useful only when the torrent has an error log to show in the tooltip.\n const status=String(t.status||'').trim().toLowerCase();\n const msg=String(t.message||'').trim();\n if(status==='error') return msg || 'Torrent reported an error.';\n if(!msg) return null;\n const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied'];\n return patterns.some(p=>msg.toLowerCase().includes(p)) ? msg : null;\n }\n function torrentNameStatusIcon(t){\n // Note: Non-error torrents keep the name cell clean; the Status column still shows their normal state.\n const errorLog=torrentErrorLog(t);\n return errorLog ? ` ` : '';\n }\n function boolCell(value){ return Number(value||0) ? 'yes' : 'no'; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ');\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', errorLog?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,errorLog,op?op.label:''].filter(Boolean).join('\\n');\n return ``+\n ``+\n `${torrentNameStatusIcon(t)}${esc(t.name)}`+\n `${statusBadge(t)}`+\n `${esc(t.size_h)}`+\n `${progress(t)}`+\n `${esc(t.down_rate_h)}`+\n `${esc(t.up_rate_h)}`+\n `${esc(t.eta_h||\"-\")}`+\n `${esc(t.seeds)}`+\n `${esc(t.peers)}`+\n `${esc(t.ratio)}`+\n `${esc(t.path)}`+\n `${labels||'-'}`+\n `${esc(t.ratio_group||'')}`+\n `${esc(t.down_total_h||'-')}`+\n `${esc(t.to_download_h||'-')}`+\n `${esc(t.up_total_h||'-')}`+\n `${esc(formatDateTime(t.created))}`+\n `${esc(formatDateTime(t.last_activity))}`+\n `${esc(t.priority ?? '-')}`+\n `${boolCell(t.state)}`+\n `${boolCell(t.active)}`+\n `${boolCell(t.complete)}`+\n `${esc(t.hashing ?? 0)}`+\n `${compactCell(t.message||'', 80)}`+\n `${esc(t.hash||'')}`+\n ``;\n }\n\n\n\n\n"; +export const torrentRowRendererSource = " function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `${esc(m.label || t.status)}`; }\n function torrentErrorLog(t){\n // Note: The name-column status icon is useful only when the torrent has an error log to show in the tooltip.\n const status=String(t.status||'').trim().toLowerCase();\n const msg=String(t.message||'').trim();\n if(status==='error') return msg || 'Torrent reported an error.';\n if(!msg) return null;\n const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied'];\n return patterns.some(p=>msg.toLowerCase().includes(p)) ? msg : null;\n }\n function torrentNameStatusIcon(t){\n // Note: Non-error torrents keep the name cell clean; the Status column still shows their normal state.\n const errorLog=torrentErrorLog(t);\n return errorLog ? ` ` : '';\n }\n function boolCell(value){ return Number(value||0) ? 'yes' : 'no'; }\n function renderRow(t, rowOptions={}){\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ');\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const rowStyle=rowOptions.style ? ` style=\"${esc(rowOptions.style)}\"` : '';\n const extraClass=rowOptions.className ? String(rowOptions.className) : '';\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', errorLog?'torrent-warning':'', extraClass].filter(Boolean).join(' ');\n const title=[t.name,errorLog,op?op.label:''].filter(Boolean).join('\\n');\n return ``+\n ``+\n `${torrentNameStatusIcon(t)}${esc(t.name)}`+\n `${statusBadge(t)}`+\n `${esc(t.size_h)}`+\n `${progress(t)}`+\n `${esc(t.down_rate_h)}`+\n `${esc(t.up_rate_h)}`+\n `${esc(t.eta_h||\"-\")}`+\n `${esc(t.seeds)}`+\n `${esc(t.peers)}`+\n `${esc(t.ratio)}`+\n `${esc(t.path)}`+\n `${labels||'-'}`+\n `${esc(t.ratio_group||'')}`+\n `${esc(t.down_total_h||'-')}`+\n `${esc(t.to_download_h||'-')}`+\n `${esc(t.up_total_h||'-')}`+\n `${esc(formatDateTime(t.created))}`+\n `${esc(formatDateTime(t.last_activity))}`+\n `${esc(t.priority ?? '-')}`+\n `${boolCell(t.state)}`+\n `${boolCell(t.active)}`+\n `${boolCell(t.complete)}`+\n `${esc(t.hashing ?? 0)}`+\n `${compactCell(t.message||'', 80)}`+\n `${esc(t.hash||'')}`+\n ``;\n }\n\n\n\n\n"; diff --git a/pytorrent/static/js/torrentTableRenderer.js b/pytorrent/static/js/torrentTableRenderer.js index 6516730..6646fcb 100644 --- a/pytorrent/static/js/torrentTableRenderer.js +++ b/pytorrent/static/js/torrentTableRenderer.js @@ -1 +1 @@ -export const torrentTableRendererSource = " function renderMobile(){\n const list=$('mobileList');\n if(!list) return;\n const src=mobileVisibleRows();\n const rows=src.slice(0,250);\n renderMobileFilters(src);\n list.innerHTML=rows.map(t=>{\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', errorLog?'torrent-warning':''].filter(Boolean).join(' ');\n const lines=mobileInfoLines(t);\n // Note: Mobile details use a separate corner button so user-configurable action buttons keep their current order.\n return `
${torrentNameStatusIcon(t)}${esc(t.name)}
${lines.primary?`
${lines.primary}
`:''}${lines.secondary?`
${lines.secondary}
`:''}${mobileColumns.path?`
${esc(t.path)}
`:''}
${mobileColumns.progress?`
${progress(t)}
`:''}
`;\n }).join('') || (hasTorrentSnapshot ? `
No torrents.
` : loadingMarkup('Loading torrents...'));\n }\n function renderTable(){ updateBulkBar(); syncActiveFilterSelection(); renderCounts(); renderLabelFilters(); if(typeof renderHealthDashboard==='function') renderHealthDashboard(); if(typeof renderSmartViewsManager==='function') renderSmartViewsManager(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?`No torrents for this filter.`:loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const rowHeight=torrentRowHeight(); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/rowHeight)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/rowHeight)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${compactTorrentListEnabled?1:0}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*rowHeight,bottom=Math.max(0,(visibleRows.length-end)*rowHeight); body.innerHTML=(top?``:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?``:''); applyColumnVisibility(); }\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function applyLiveTorrentStats(msg){ (msg.updated||[]).forEach(p=>{ if(torrents.has(p.hash)) torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p}); }); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n // Note: The desktop bulk toolbar is hidden in mobile mode; mobile has its own compact actions in the filter bar.\n const isMobileMode = document.body.classList.contains('mobile-mode');\n const show = selected.size > 1 && !isMobileMode;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n"; +export const torrentTableRendererSource = " function renderMobile(){\n const list=$('mobileList');\n if(!list) return;\n const src=mobileVisibleRows();\n const rows=src.slice(0,250);\n renderMobileFilters(src);\n list.innerHTML=rows.map(t=>{\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', errorLog?'torrent-warning':''].filter(Boolean).join(' ');\n const lines=mobileInfoLines(t);\n // Note: Mobile details use a separate corner button so user-configurable action buttons keep their current order.\n return `
${torrentNameStatusIcon(t)}${esc(t.name)}
${lines.primary?`
${lines.primary}
`:''}${lines.secondary?`
${lines.secondary}
`:''}${mobileColumns.path?`
${esc(t.path)}
`:''}
${mobileColumns.progress?`
${progress(t)}
`:''}
`;\n }).join('') || (hasTorrentSnapshot ? `
No torrents.
` : loadingMarkup('Loading torrents...'));\n }\n\n\n function torrentHeaderHeight(){\n const header=document.querySelector('.torrent-table thead');\n return Math.ceil(header?.getBoundingClientRect?.().height || 0);\n }\n\n function clearVirtualBody(body){\n body.classList.remove('torrent-virtual-body');\n body.style.height='';\n }\n\n function renderTable(){\n updateBulkBar();\n syncActiveFilterSelection();\n renderCounts();\n renderLabelFilters();\n if(typeof renderHealthDashboard==='function') renderHealthDashboard();\n if(typeof renderSmartViewsManager==='function') renderSmartViewsManager();\n updateSortHeaders();\n buildVisibleRows();\n renderMobile();\n\n const body=$('torrentBody');\n if(!body) return;\n\n if(!visibleRows.length){\n clearVirtualBody(body);\n body.innerHTML=hasTorrentSnapshot?`No torrents for this filter.`:loadingTableRow('Loading torrents...');\n applyColumnVisibility();\n return;\n }\n\n const wrap=$('tableWrap');\n const rowHeight=torrentRowHeight();\n const headerHeight=torrentHeaderHeight();\n const scrollTop=Math.max(0, (wrap?.scrollTop || 0) - headerHeight);\n const viewportHeight=Math.max(rowHeight, (wrap?.clientHeight || 500) - headerHeight);\n const maxStart=Math.max(0, visibleRows.length - 1);\n const start=Math.min(maxStart, Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN));\n const visibleCount=Math.ceil(viewportHeight / rowHeight) + OVERSCAN * 2 + 1;\n const end=Math.min(visibleRows.length, start + visibleCount);\n const totalHeight=visibleRows.length * rowHeight;\n\n const sig=`${renderVersion}:${start}:${end}:${totalHeight}:${rowHeight}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${compactTorrentListEnabled?1:0}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`;\n if(sig===lastRenderSignature) return;\n\n lastRenderSignature=sig;\n body.classList.add('torrent-virtual-body');\n body.style.height=`${totalHeight}px`;\n // Note: Rows are absolutely positioned inside one fixed-height body, so the browser keeps a stable native scroll range even at 50k torrents.\n body.innerHTML=visibleRows.slice(start,end).map((torrent,index)=>renderRow(torrent,{className:'torrent-virtual-row',style:`transform:translateY(${(start+index)*rowHeight}px)`})).join('');\n applyColumnVisibility();\n }\n\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function applyLiveTorrentStats(msg){ (msg.updated||[]).forEach(p=>{ if(torrents.has(p.hash)) torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p}); }); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n // Note: The desktop bulk toolbar is hidden in mobile mode; mobile has its own compact actions in the filter bar.\n const isMobileMode = document.body.classList.contains('mobile-mode');\n const show = selected.size > 1 && !isMobileMode;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 54ce52a..f241d86 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -449,10 +449,12 @@ body { .table-wrap { contain: content; overflow: auto; + overflow-anchor: none; position: relative; } .torrent-table { --torrent-row-height: 32px; + overflow-anchor: none; font-size: var(--torrent-list-font-size, 13px); margin: 0; white-space: nowrap; @@ -478,6 +480,24 @@ body { cursor: default; height: var(--torrent-row-height); } + +/* Virtualized torrent table: a single fixed-height body keeps the native scroll range stable for very large lists. */ +.torrent-table tbody.torrent-virtual-body { + display: block; + position: relative; +} +.torrent-table tbody.torrent-virtual-body tr.torrent-virtual-row { + display: table; + left: 0; + position: absolute; + right: 0; + table-layout: fixed; + top: 0; + width: 100%; +} +.torrent-table tbody.torrent-virtual-body tr.torrent-virtual-row > td { + height: var(--torrent-row-height); +} .torrent-table > :not(caption) > * > * { padding-bottom: 0.22rem; padding-top: 0.22rem; @@ -570,10 +590,6 @@ body.resizing-columns { user-select: none; } -.virtual-spacer td { - padding: 0 !important; - border: 0 !important; -} .empty { height: 120px; text-align: center; @@ -1042,25 +1058,71 @@ body.resizing-details { .torrent-action + .torrent-action { margin-left: 0.08rem !important; } +.path-inline-create { + align-items: center; + display: grid; + gap: 0.5rem; + grid-template-columns: minmax(0, 1fr) auto; +} + .path-list { - height: 360px; - overflow: auto; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); border: 1px solid var(--bs-border-color); border-radius: 0.6rem; - background: rgba(var(--bs-secondary-bg-rgb), 0.35); + height: 360px; + overflow: auto; } + .path-row { - display: flex; align-items: center; + border-bottom: 1px solid var(--bs-border-color); + display: flex; gap: 0.5rem; padding: 0.42rem 0.6rem; - border-bottom: 1px solid var(--bs-border-color); - cursor: pointer; } + .path-row:hover { background: var(--bs-primary-bg-subtle); color: var(--bs-primary-text-emphasis); } + +.path-row-open { + align-items: center; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: flex; + flex: 1 1 auto; + gap: 0.5rem; + min-width: 0; + padding: 0; + text-align: left; +} + +.path-row-open i, +.path-rename-form i { + color: var(--bs-warning); + flex: 0 0 auto; +} + +.path-row-open span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.path-rename-btn { + flex: 0 0 auto; +} + +.path-rename-form { + align-items: center; + display: grid; + flex: 1 1 auto; + gap: 0.45rem; + grid-template-columns: auto minmax(0, 1fr) auto auto; +} .chips { display: flex; gap: 0.35rem; @@ -1290,12 +1352,6 @@ body.mobile-mode .main-grid { .column-card i { opacity: 0.72; } -.path-row::before { - content: "\f07b"; - font-family: "Font Awesome 6 Free"; - font-weight: 900; - color: var(--bs-warning); -} body.mobile-mode .mobile-card { display: block; } diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 00820e0..d533b9d 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -228,6 +228,10 @@ +
+ + +
No path loaded.