mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
feat(sidebar): rename folders from the sidebar (#999)
Follows the folder create/delete work: a typo'd directory could be
removed but not corrected, and for a folder holding models the only fix
was to move every model out by hand.
Adds POST /api/lm/{prefix}/rename-folder. Unlike the delete path this one
deliberately works on folders that hold models — a rename keeps every
file, so nothing is cascaded over: the directory is renamed on disk and
the scanner re-keys the records that pointed at the old prefix (recorded
folder list, cache file_path/folder/preview_url, hash and autov3 index
paths, excluded-model paths, and the metadata sidecars that travelled
with the directory). Ancestors are never touched, and only the leaf name
is accepted so a rename can never escape its parent.
Library roots, top-level symlinks and folders holding a staged delete are
refused; the last because a staging manifest records absolute
original/staged paths, so moving it would break undo and purge. A name
collision is a 409 target_exists conflict.
The sidebar reuses the inline-row idiom from folder creation: prefilled
with the current name, inserted in place of the node with that node
hidden while editing, Enter confirms and Escape/blur cancels. The
persisted selection and the expanded set are re-keyed across the rename
so the user keeps their place in the refreshed tree.
This commit is contained in:
@@ -712,6 +712,131 @@ class ModelMoveService:
|
||||
if callable(remove_known_folder):
|
||||
await remove_known_folder(relative_folder)
|
||||
|
||||
async def rename_folder(self, folder_path: str, new_name: str) -> Dict[str, Any]:
|
||||
"""Rename a directory inside the model library roots.
|
||||
|
||||
Unlike :meth:`delete_folder` this works on folders that hold models.
|
||||
A rename keeps every file, so no per-model lifecycle step is bypassed:
|
||||
the directory is renamed on disk and the affected folder, cache, hash
|
||||
index and metadata-sidecar records are re-keyed onto the new prefix by
|
||||
the scanner.
|
||||
|
||||
Args:
|
||||
folder_path: Absolute path of the directory to rename (business
|
||||
path — symlinks are not resolved)
|
||||
new_name: New leaf name; a single path segment, not a path
|
||||
|
||||
Returns:
|
||||
Dictionary with the success flag, the previous/next library-relative
|
||||
folder names and whether the directory actually moved.
|
||||
"""
|
||||
try:
|
||||
if not folder_path or not str(folder_path).strip():
|
||||
return {"success": False, "error": "Folder path is required"}
|
||||
|
||||
new_name = str(new_name or "").strip()
|
||||
if not new_name:
|
||||
return {"success": False, "error": "New folder name is required"}
|
||||
if new_name in (".", "..") or any(
|
||||
char in new_name for char in '/\\:*?"<>|'
|
||||
):
|
||||
return {"success": False, "error": "Invalid characters in folder name"}
|
||||
|
||||
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
|
||||
|
||||
absolute_path = os.path.abspath(folder_path)
|
||||
if os.path.islink(absolute_path):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Symlinked folders cannot be renamed",
|
||||
}
|
||||
if not os.path.isdir(absolute_path):
|
||||
return {"success": False, "error": "Folder no longer exists"}
|
||||
|
||||
if self._is_model_root(absolute_path):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "The library root itself cannot be renamed",
|
||||
}
|
||||
|
||||
previous_relative = self._calculate_relative_folder(absolute_path)
|
||||
target = os.path.join(os.path.dirname(absolute_path), new_name)
|
||||
|
||||
if os.path.normpath(target) == os.path.normpath(absolute_path):
|
||||
return {
|
||||
"success": True,
|
||||
"renamed": False,
|
||||
"folder": previous_relative,
|
||||
"previous_folder": previous_relative,
|
||||
"folder_path": absolute_path.replace(os.sep, "/"),
|
||||
}
|
||||
|
||||
if os.path.exists(target):
|
||||
return {
|
||||
"success": False,
|
||||
"code": "target_exists",
|
||||
"error": f"A folder named \"{new_name}\" already exists here",
|
||||
}
|
||||
|
||||
# A staging manifest records absolute original/staged paths, so
|
||||
# moving a folder that holds one would break its undo and purge.
|
||||
if self._has_pending_delete_job(absolute_path):
|
||||
return {
|
||||
"success": False,
|
||||
"code": "busy",
|
||||
"error": (
|
||||
"A staged delete is still pending inside this folder; "
|
||||
"wait for the undo window to expire"
|
||||
),
|
||||
}
|
||||
|
||||
os.rename(absolute_path, target)
|
||||
|
||||
new_relative = self._calculate_relative_folder(target)
|
||||
await self._rename_folder_records(
|
||||
previous_relative, new_relative, absolute_path, target
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"renamed": True,
|
||||
"folder": new_relative,
|
||||
"previous_folder": previous_relative,
|
||||
"folder_path": target.replace(os.sep, "/"),
|
||||
}
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.error(f"Error renaming folder: {exc}", exc_info=True)
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
@staticmethod
|
||||
def _has_pending_delete_job(absolute_path: str) -> bool:
|
||||
"""Return True when a staged-delete batch lives inside the subtree."""
|
||||
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
|
||||
if PENDING_DELETE_DIR_NAME in dirnames:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _rename_folder_records(
|
||||
self,
|
||||
previous_relative: str,
|
||||
new_relative: str,
|
||||
previous_path: str,
|
||||
new_path: str,
|
||||
) -> None:
|
||||
"""Hand the rename to the scanner so folder/cache records follow it."""
|
||||
if not previous_relative or not new_relative:
|
||||
return
|
||||
rename_known_folder = getattr(self.scanner, "rename_known_folder", None)
|
||||
if callable(rename_known_folder):
|
||||
await rename_known_folder(
|
||||
previous_relative,
|
||||
new_relative,
|
||||
previous_path=previous_path,
|
||||
new_path=new_path,
|
||||
)
|
||||
|
||||
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
|
||||
"""Move a single model file
|
||||
|
||||
|
||||
@@ -1562,6 +1562,159 @@ class ModelScanner:
|
||||
"""Return True when *candidate* is *target* or lives below it."""
|
||||
return candidate == target or candidate.startswith(f"{target}/")
|
||||
|
||||
@staticmethod
|
||||
def _rekey_path(value: str, old_prefix: str, new_prefix: str) -> str:
|
||||
"""Move a stored path (or URL) from *old_prefix* onto *new_prefix*."""
|
||||
if not value:
|
||||
return value
|
||||
normalized = value.replace("\\", "/")
|
||||
if normalized.startswith(old_prefix):
|
||||
return new_prefix + normalized[len(old_prefix):]
|
||||
return value
|
||||
|
||||
async def rename_known_folder(
|
||||
self,
|
||||
previous_folder: str,
|
||||
new_folder: str,
|
||||
*,
|
||||
previous_path: str,
|
||||
new_path: str,
|
||||
) -> bool:
|
||||
"""Re-key folder, cache and metadata records after a directory rename.
|
||||
|
||||
Counterpart of :meth:`add_known_folder` / :meth:`remove_known_folder`.
|
||||
A rename keeps every file, so nothing may be dropped: the recorded
|
||||
folder list, the affected cache entries (``file_path``/``folder``/
|
||||
``preview_url``), the hash index and the on-disk metadata sidecars are
|
||||
all rewritten onto the new prefix. That is what lets a folder full of
|
||||
models be renamed without a rescan and without breaking per-model
|
||||
bookkeeping.
|
||||
|
||||
Args:
|
||||
previous_folder: Library-relative folder name before the rename
|
||||
new_folder: Library-relative folder name after the rename
|
||||
previous_path: Absolute directory path before the rename
|
||||
new_path: Absolute directory path after the rename
|
||||
|
||||
Returns:
|
||||
True when any recorded data was rewritten.
|
||||
"""
|
||||
previous = previous_folder.replace("\\", "/").strip("/")
|
||||
current = new_folder.replace("\\", "/").strip("/")
|
||||
if not previous or not current or previous == current:
|
||||
return False
|
||||
|
||||
old_rel_prefix = f"{previous}/"
|
||||
new_rel_prefix = f"{current}/"
|
||||
old_abs_prefix = f"{str(previous_path).replace(chr(92), '/').rstrip('/')}/"
|
||||
new_abs_prefix = f"{str(new_path).replace(chr(92), '/').rstrip('/')}/"
|
||||
|
||||
cache = self._cache
|
||||
if cache is None:
|
||||
return False
|
||||
|
||||
changed = False
|
||||
|
||||
recorded = getattr(cache, "all_folders", None)
|
||||
if recorded is not None:
|
||||
rekeyed = sorted(
|
||||
(
|
||||
self._rekey_folder_name(entry, previous, old_rel_prefix, new_rel_prefix)
|
||||
for entry in recorded
|
||||
),
|
||||
key=lambda entry: entry.lower(),
|
||||
)
|
||||
if rekeyed != list(recorded):
|
||||
cache.all_folders = rekeyed
|
||||
changed = True
|
||||
|
||||
excluded = getattr(self, "_excluded_models", None)
|
||||
if excluded:
|
||||
rekeyed_excluded = [
|
||||
self._rekey_path(entry, old_abs_prefix, new_abs_prefix)
|
||||
for entry in excluded
|
||||
]
|
||||
if rekeyed_excluded != list(excluded):
|
||||
self._excluded_models = rekeyed_excluded
|
||||
changed = True
|
||||
|
||||
touched: List[Dict[str, Any]] = []
|
||||
for item in cache.raw_data or []:
|
||||
folder_value = item.get("folder", "") or self._calculate_folder(
|
||||
item.get("file_path", "")
|
||||
)
|
||||
if not self._folder_within(folder_value, previous):
|
||||
continue
|
||||
|
||||
old_file_path = item.get("file_path", "")
|
||||
if old_file_path:
|
||||
cache.remove_from_version_index(item)
|
||||
item["file_path"] = self._rekey_path(
|
||||
old_file_path, old_abs_prefix, new_abs_prefix
|
||||
)
|
||||
hash_value = (item.get("sha256") or "").lower()
|
||||
if hash_value:
|
||||
self._hash_index.remove_by_path(old_file_path, hash_value)
|
||||
self._hash_index.add_entry(
|
||||
hash_value, item["file_path"], item.get("autov3") or None
|
||||
)
|
||||
|
||||
item["folder"] = self._rekey_folder_name(
|
||||
folder_value, previous, old_rel_prefix, new_rel_prefix
|
||||
)
|
||||
if item.get("preview_url"):
|
||||
item["preview_url"] = self._rekey_path(
|
||||
item["preview_url"], old_abs_prefix, new_abs_prefix
|
||||
)
|
||||
touched.append(item)
|
||||
|
||||
if touched:
|
||||
changed = True
|
||||
await self._rewrite_sidecar_paths(touched)
|
||||
folders = set(item.get("folder", "") for item in cache.raw_data)
|
||||
cache.folders = sorted(folders, key=lambda x: x.lower())
|
||||
cache.rebuild_version_index()
|
||||
await cache.resort()
|
||||
|
||||
if changed:
|
||||
await self._persist_current_cache()
|
||||
|
||||
self.bump_cache_version()
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _rekey_folder_name(
|
||||
entry: str, previous: str, old_rel_prefix: str, new_rel_prefix: str
|
||||
) -> str:
|
||||
"""Move a library-relative folder name (and its subtree) under a new name."""
|
||||
if entry == previous:
|
||||
return new_rel_prefix.rstrip("/")
|
||||
if entry.startswith(old_rel_prefix):
|
||||
return new_rel_prefix + entry[len(old_rel_prefix):]
|
||||
return entry
|
||||
|
||||
async def _rewrite_sidecar_paths(self, entries: List[Dict[str, Any]]) -> None:
|
||||
"""Point each model's metadata sidecar at its new location.
|
||||
|
||||
Sidecars travel with the renamed directory, so only the recorded
|
||||
``file_path``/``preview_url`` inside them need rewriting. Failures are
|
||||
logged and skipped — a stale sidecar is repaired by the next metadata
|
||||
refresh, and must not abort the rename.
|
||||
"""
|
||||
for item in entries:
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
if not os.path.exists(metadata_path):
|
||||
continue
|
||||
try:
|
||||
await self._update_metadata_paths(metadata_path, file_path)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
"Failed to rewrite metadata sidecar %s: %s", metadata_path, exc
|
||||
)
|
||||
|
||||
def _schedule_all_folders_backfill(self) -> None:
|
||||
"""Kick off a one-shot background folder walk if none is running."""
|
||||
if self._all_folders_backfill_running:
|
||||
|
||||
Reference in New Issue
Block a user