table_fix_and_folder_management
This commit is contained in:
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user