table_fix_and_folder_management

This commit is contained in:
Mateusz Gruszczyński
2026-06-12 23:20:42 +02:00
parent 90989e81ad
commit a2cdc203c2
8 changed files with 344 additions and 22 deletions
+153 -2
View File
@@ -955,8 +955,7 @@
"properties": { "properties": {
"dirs": { "dirs": {
"items": { "items": {
"additionalProperties": true, "$ref": "#/components/schemas/PathDirectoryEntry"
"type": "object"
}, },
"type": "array" "type": "array"
}, },
@@ -2155,6 +2154,72 @@
"url", "url",
"expires_in" "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": { "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": []
}
]
}
} }
} }
} }
+58 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from ._shared import * from ._shared import *
import posixpath
from ..services import operation_logs from ..services import operation_logs
from ..services.frontend_assets import static_hash 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") @bp.get("/path/default")
def path_default(): def path_default():
profile = preferences.active_profile() profile = preferences.active_profile()
@@ -356,7 +380,40 @@ def path_browse():
return jsonify({"ok": False, "error": "No profile"}), 400 return jsonify({"ok": False, "error": "No profile"}), 400
base = request.args.get("path") or "" base = request.args.get("path") or ""
try: 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: except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
+54
View File
@@ -78,6 +78,60 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
"used_percent": disk_percent, "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: def remote_public_ip(profile: dict, force: bool = False) -> str:
profile_id = int(profile.get("id") or 0) profile_id = int(profile.get("id") or 0)
now = time.monotonic() now = time.monotonic()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+72 -16
View File
@@ -449,10 +449,12 @@ body {
.table-wrap { .table-wrap {
contain: content; contain: content;
overflow: auto; overflow: auto;
overflow-anchor: none;
position: relative; position: relative;
} }
.torrent-table { .torrent-table {
--torrent-row-height: 32px; --torrent-row-height: 32px;
overflow-anchor: none;
font-size: var(--torrent-list-font-size, 13px); font-size: var(--torrent-list-font-size, 13px);
margin: 0; margin: 0;
white-space: nowrap; white-space: nowrap;
@@ -478,6 +480,24 @@ body {
cursor: default; cursor: default;
height: var(--torrent-row-height); 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) > * > * { .torrent-table > :not(caption) > * > * {
padding-bottom: 0.22rem; padding-bottom: 0.22rem;
padding-top: 0.22rem; padding-top: 0.22rem;
@@ -570,10 +590,6 @@ body.resizing-columns {
user-select: none; user-select: none;
} }
.virtual-spacer td {
padding: 0 !important;
border: 0 !important;
}
.empty { .empty {
height: 120px; height: 120px;
text-align: center; text-align: center;
@@ -1042,25 +1058,71 @@ body.resizing-details {
.torrent-action + .torrent-action { .torrent-action + .torrent-action {
margin-left: 0.08rem !important; 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 { .path-list {
height: 360px; background: rgba(var(--bs-secondary-bg-rgb), 0.35);
overflow: auto;
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
border-radius: 0.6rem; border-radius: 0.6rem;
background: rgba(var(--bs-secondary-bg-rgb), 0.35); height: 360px;
overflow: auto;
} }
.path-row { .path-row {
display: flex;
align-items: center; align-items: center;
border-bottom: 1px solid var(--bs-border-color);
display: flex;
gap: 0.5rem; gap: 0.5rem;
padding: 0.42rem 0.6rem; padding: 0.42rem 0.6rem;
border-bottom: 1px solid var(--bs-border-color);
cursor: pointer;
} }
.path-row:hover { .path-row:hover {
background: var(--bs-primary-bg-subtle); background: var(--bs-primary-bg-subtle);
color: var(--bs-primary-text-emphasis); 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 { .chips {
display: flex; display: flex;
gap: 0.35rem; gap: 0.35rem;
@@ -1290,12 +1352,6 @@ body.mobile-mode .main-grid {
.column-card i { .column-card i {
opacity: 0.72; 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 { body.mobile-mode .mobile-card {
display: block; display: block;
} }
+4
View File
@@ -228,6 +228,10 @@
<label class="form-check-label" for="moveRecheck">Recheck after move</label> <label class="form-check-label" for="moveRecheck">Recheck after move</label>
</div> </div>
</div> </div>
<div class="path-inline-create mb-3">
<input id="pathCreateName" class="form-control form-control-sm" autocomplete="off" placeholder="New folder name">
<button id="pathCreateBtn" class="btn btn-sm btn-outline-primary" type="button"><i class="fa-solid fa-folder-plus"></i> Create</button>
</div>
<div id="pathList" class="path-list"><div class="p-3 text-muted">No path loaded.</div></div> <div id="pathList" class="path-list"><div class="p-3 text-muted">No path loaded.</div></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">