mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-06 22:10:14 -03:00
Compare commits
12 Commits
0d8805cdee
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2228627ff4 | ||
|
|
4c647ad9c8 | ||
|
|
8ca3e6c33f | ||
|
|
dd6bdbf297 | ||
|
|
b47dde87e4 | ||
|
|
99e65cccd8 | ||
|
|
3bdacb8f46 | ||
|
|
b4f9c224d3 | ||
|
|
5ec0399c81 | ||
|
|
b464fdc333 | ||
|
|
53825500db | ||
|
|
f2ac790752 |
File diff suppressed because it is too large
Load Diff
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "Zu Nightly-Kanal wechseln",
|
||||
"nightlyMessage": "Der Wechsel zu Nightly initialisiert ein Git-Repository und verfolgt die neuesten Commits des main-Branches. Updates sind häufiger, können aber instabil sein. Sie können jederzeit zu Release zurückwechseln.",
|
||||
"releaseTitle": "Zu Release-Kanal wechseln",
|
||||
"releaseMessage": "Der Wechsel zu Release entfernt das Git-Repository und installiert die neueste stabile Version. Zukünftige Updates verwenden nur stabile Versionen.",
|
||||
"releaseMessage": "Der Wechsel zu Release checkt den neuesten stabilen Versions-Tag aus. Sie können jederzeit zu Nightly zurückwechseln.",
|
||||
"switching": "Wechsle zu {channel}-Kanal...",
|
||||
"completed": "Erfolgreich zu {channel}-Kanal gewechselt",
|
||||
"failed": "Kanalwechsel fehlgeschlagen"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "Switch to Nightly Channel",
|
||||
"nightlyMessage": "Switching to Nightly will initialize a Git repository and track the latest main branch commits. Updates will be more frequent but may be unstable. You can switch back to Release at any time.",
|
||||
"releaseTitle": "Switch to Release Channel",
|
||||
"releaseMessage": "Switching to Release will remove the Git repository and install the latest stable release. Future updates will use stable releases only.",
|
||||
"releaseMessage": "Switching to Release will checkout the latest stable release tag. You can switch back to Nightly at any time.",
|
||||
"switching": "Switching to {channel} channel...",
|
||||
"completed": "Successfully switched to {channel} channel",
|
||||
"failed": "Failed to switch channel"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "Cambiar a canal Nightly",
|
||||
"nightlyMessage": "Cambiar a Nightly inicializara un repositorio Git y seguira los ultimos commits de la rama main. Las actualizaciones son mas frecuentes pero pueden ser inestables. Puede volver a Release en cualquier momento.",
|
||||
"releaseTitle": "Cambiar a canal Release",
|
||||
"releaseMessage": "Cambiar a Release eliminara el repositorio Git e instalara la ultima version estable. Las futuras actualizaciones usaran solo versiones estables.",
|
||||
"releaseMessage": "Cambiar a Release hara checkout de la ultima etiqueta de version estable. Puede volver a Nightly en cualquier momento.",
|
||||
"switching": "Cambiando a canal {channel}...",
|
||||
"completed": "Cambio a canal {channel} exitoso",
|
||||
"failed": "Error al cambiar de canal"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "Passer au canal Nightly",
|
||||
"nightlyMessage": "Passer a Nightly initialisera un depot Git et suivra les derniers commits de la branche main. Les mises a jour sont plus frequentes mais peuvent etre instables. Vous pouvez revenir a Release a tout moment.",
|
||||
"releaseTitle": "Passer au canal Release",
|
||||
"releaseMessage": "Passer a Release supprimera le depot Git et installera la derniere version stable. Les futures mises a jour utiliseront uniquement des versions stables.",
|
||||
"releaseMessage": "Passer a Release passera au dernier tag de version stable. Vous pouvez revenir a Nightly a tout moment.",
|
||||
"switching": "Passage au canal {channel}...",
|
||||
"completed": "Basculement vers le canal {channel} reussi",
|
||||
"failed": "Echec du changement de canal"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "מעבר לערוץ Nightly",
|
||||
"nightlyMessage": "מעבר ל-Nightly יאתחל מאגר Git ויעקוב אחר הקומיטים האחרונים בענף main. העדכונים תכופים יותר אך עשויים להיות לא יציבים. ניתן לחזור ל-Release בכל עת.",
|
||||
"releaseTitle": "מעבר לערוץ Release",
|
||||
"releaseMessage": "מעבר ל-Release יסיר את מאגר ה-Git ויתקין את הגרסה היציבה האחרונה. עדכונים עתידיים ישתמשו בגרסאות יציבות בלבד.",
|
||||
"releaseMessage": "מעבר ל-Release יעבור לתגית הגרסה היציבה האחרונה. ניתן לחזור ל-Nightly בכל עת.",
|
||||
"switching": "מעבר לערוץ {channel}...",
|
||||
"completed": "המעבר לערוץ {channel} הושלם",
|
||||
"failed": "החלפת ערוץ נכשלה"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "ナイトリーチャンネルに切り替え",
|
||||
"nightlyMessage": "ナイトリーに切り替えると、Gitリポジトリが初期化され、mainブランチの最新コミットを追跡します。更新頻度は高くなりますが、不安定な場合があります。いつでもリリース版に戻せます。",
|
||||
"releaseTitle": "リリースチャンネルに切り替え",
|
||||
"releaseMessage": "リリースに切り替えると、Gitリポジトリが削除され、最新の安定版がインストールされます。以降の更新は安定版のみが使用されます。",
|
||||
"releaseMessage": "リリースに切り替えると、最新の安定版タグにチェックアウトされます。いつでもNightlyに戻せます。",
|
||||
"switching": "{channel} チャンネルに切り替え中...",
|
||||
"completed": "{channel} チャンネルに切り替えました",
|
||||
"failed": "チャンネルの切り替えに失敗しました"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "나이틀리 채널로 전환",
|
||||
"nightlyMessage": "나이틀리로 전환하면 Git 저장소가 초기화되고 main 브랜치의 최신 커밋을 추적합니다. 업데이트 빈도는 높지만 불안정할 수 있습니다. 언제든지 릴리스로 돌아갈 수 있습니다.",
|
||||
"releaseTitle": "릴리스 채널로 전환",
|
||||
"releaseMessage": "릴리스로 전환하면 Git 저장소가 제거되고 최신 안정 버전이 설치됩니다. 이후 업데이트는 안정 버전만 사용됩니다.",
|
||||
"releaseMessage": "릴리스로 전환하면 최신 안정 버전 태그로 체크아웃됩니다. 언제든지 나이틀리로 돌아갈 수 있습니다.",
|
||||
"switching": "{channel} 채널로 전환 중...",
|
||||
"completed": "{channel} 채널로 전환 완료",
|
||||
"failed": "채널 전환 실패"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "Переключиться на Nightly",
|
||||
"nightlyMessage": "Переключение на Nightly инициализирует Git-репозиторий и отслеживает последние коммиты ветки main. Обновления чаще, но могут быть нестабильными. Вы можете вернуться к Release в любое время.",
|
||||
"releaseTitle": "Переключиться на Release",
|
||||
"releaseMessage": "Переключение на Release удалит Git-репозиторий и установит последнюю стабильную версию. Будущие обновления будут использовать только стабильные версии.",
|
||||
"releaseMessage": "Переключение на Release выполнит checkout последнего стабильного тега. Вы можете вернуться к Nightly в любое время.",
|
||||
"switching": "Переключение на канал {channel}...",
|
||||
"completed": "Успешно переключено на канал {channel}",
|
||||
"failed": "Не удалось переключить канал"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "切换到 Nightly",
|
||||
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
|
||||
"releaseTitle": "切换到稳定版",
|
||||
"releaseMessage": "切换到稳定版将移除 Git 仓库并安装最新的稳定发布版本,后续仅使用稳定版更新。",
|
||||
"releaseMessage": "切换到稳定版将检出最新的发布标签。可随时切换回每日构建版。",
|
||||
"switching": "正在切换到 {channel} 频道...",
|
||||
"completed": "已切换到 {channel} 频道",
|
||||
"failed": "切换频道失败"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "切换到 Nightly",
|
||||
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
|
||||
"releaseTitle": "切换到稳定版",
|
||||
"releaseMessage": "切换到稳定版将移除 Git 仓库并安装最新的稳定发布版本,后续仅使用稳定版更新。",
|
||||
"releaseMessage": "切換到穩定版將檢出最新的發布標籤。可隨時切換回每日構建版。",
|
||||
"switching": "正在切換到 {channel} 頻道...",
|
||||
"completed": "已切換到 {channel} 頻道",
|
||||
"failed": "切換頻道失敗"
|
||||
|
||||
@@ -1562,6 +1562,11 @@ class SettingsHandler:
|
||||
{"success": False, "error": validation_error}
|
||||
)
|
||||
|
||||
if key == "update_channel" and value not in ("release", "nightly"):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "update_channel must be 'release' or 'nightly'"}
|
||||
)
|
||||
|
||||
if value == "__DELETE__" and key in (
|
||||
"proxy_username",
|
||||
"proxy_password",
|
||||
|
||||
@@ -38,6 +38,84 @@ def _clean_excludes() -> List[str]:
|
||||
return excludes
|
||||
|
||||
|
||||
def _stage_preserved_items(plugin_root: str) -> tuple[str, list[str]]:
|
||||
"""Move preserved user-data items to a temp directory outside *plugin_root*.
|
||||
|
||||
This ensures that ``git reset --hard``, ``git clean -fd``, and ZIP-based
|
||||
replacement cannot touch these files even when ``-e`` exclusion patterns
|
||||
are mishandled (e.g. on Windows where forward-slash patterns may not
|
||||
match backslash-prefixed paths in some Git builds, or where file locks
|
||||
prevent deletion/recreation).
|
||||
|
||||
Returns:
|
||||
``(backup_root, staged_names)``: the temp directory path and the
|
||||
list of item names that were successfully moved.
|
||||
"""
|
||||
backup_root = tempfile.mkdtemp(prefix='lora_manager_update_')
|
||||
staged: list[str] = []
|
||||
for name in _PRESERVE_DIRS:
|
||||
src = os.path.join(plugin_root, name)
|
||||
if not os.path.lexists(src):
|
||||
continue
|
||||
dst = os.path.join(backup_root, name)
|
||||
try:
|
||||
shutil.move(src, dst)
|
||||
staged.append(name)
|
||||
logger.debug("Staged '%s' for update safety", name)
|
||||
except OSError:
|
||||
# ``shutil.move`` may fail on Windows if a file handle inside
|
||||
# the directory is still open (e.g. a SQLite WAL file). Fall
|
||||
# back to copy-then-remove.
|
||||
logger.debug("Move failed for '%s', falling back to copy", name)
|
||||
try:
|
||||
if os.path.isdir(src) and not os.path.islink(src):
|
||||
shutil.copytree(src, dst, symlinks=True)
|
||||
shutil.rmtree(src, ignore_errors=True)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
os.remove(src)
|
||||
staged.append(name)
|
||||
logger.info("Copied (then removed) '%s' for update safety", name)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not stage '%s': %s (will rely on git -e / skip lists)", name, exc
|
||||
)
|
||||
return backup_root, staged
|
||||
|
||||
|
||||
def _restore_preserved_items(plugin_root: str, backup_root: str, staged: list[str]) -> None:
|
||||
"""Move staged items back from *backup_root* into *plugin_root*.
|
||||
|
||||
Any leftover placeholder at the destination (created by git checkout or
|
||||
ZIP extraction) is removed before the move.
|
||||
"""
|
||||
for name in staged:
|
||||
src = os.path.join(backup_root, name)
|
||||
dst = os.path.join(plugin_root, name)
|
||||
try:
|
||||
if os.path.lexists(dst):
|
||||
if os.path.isdir(dst) and not os.path.islink(dst):
|
||||
shutil.rmtree(dst, ignore_errors=True)
|
||||
else:
|
||||
os.remove(dst)
|
||||
shutil.move(src, dst)
|
||||
logger.debug("Restored '%s' after update", name)
|
||||
except OSError:
|
||||
logger.debug("Move failed restoring '%s', falling back to copy", name)
|
||||
try:
|
||||
if os.path.isdir(src) and not os.path.islink(src):
|
||||
shutil.copytree(src, dst, symlinks=True, dirs_exist_ok=True)
|
||||
shutil.rmtree(src, ignore_errors=True)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
os.remove(src)
|
||||
logger.info("Copied '%s' back after update", name)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to restore '%s': %s", name, exc)
|
||||
shutil.rmtree(backup_root, ignore_errors=True)
|
||||
|
||||
|
||||
|
||||
class UpdateRoutes:
|
||||
"""Routes for handling plugin update checks"""
|
||||
|
||||
@@ -173,20 +251,22 @@ class UpdateRoutes:
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings_backup = f.read()
|
||||
logger.info("Backed up settings.json")
|
||||
logger.debug("Backed up settings.json (%d bytes)", len(settings_backup))
|
||||
|
||||
git_folder = os.path.join(plugin_root, '.git')
|
||||
if os.path.exists(git_folder):
|
||||
# Git update
|
||||
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
|
||||
else:
|
||||
# Fallback: Download ZIP and replace files
|
||||
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||
staged_backup_dir, staged_items = _stage_preserved_items(plugin_root)
|
||||
try:
|
||||
git_folder = os.path.join(plugin_root, '.git')
|
||||
if os.path.exists(git_folder):
|
||||
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
|
||||
else:
|
||||
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||
finally:
|
||||
_restore_preserved_items(plugin_root, staged_backup_dir, staged_items)
|
||||
|
||||
if settings_backup and success:
|
||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||
f.write(settings_backup)
|
||||
logger.info("Restored settings.json")
|
||||
logger.debug("Restored settings.json content (%d bytes)", len(settings_backup))
|
||||
|
||||
if success:
|
||||
return web.json_response({
|
||||
@@ -211,9 +291,11 @@ class UpdateRoutes:
|
||||
async def switch_channel(request):
|
||||
"""
|
||||
Switch between release and nightly update channels.
|
||||
|
||||
Release → Nightly: Initialize a Git repository (from ZIP/CM stable mode)
|
||||
Nightly → Release: Remove .git, download latest release ZIP, write .tracking
|
||||
|
||||
ZIP/CNR install → Nightly: git init + checkout main (one-way upgrade)
|
||||
Git install → Release: git checkout latest tag (.git preserved)
|
||||
ZIP/CNR install → Release: ZIP download (no .git, stays in ZIP mode)
|
||||
Git install → Nightly: git checkout main + pull
|
||||
"""
|
||||
try:
|
||||
body = await request.json() if request.has_body else {}
|
||||
@@ -233,47 +315,47 @@ class UpdateRoutes:
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings_backup = f.read()
|
||||
logger.info("Backed up settings.json before channel switch")
|
||||
logger.debug("Backed up settings.json before channel switch (%d bytes)", len(settings_backup))
|
||||
|
||||
git_folder = os.path.join(plugin_root, '.git')
|
||||
staged_backup_dir, staged_items = _stage_preserved_items(plugin_root)
|
||||
try:
|
||||
git_folder = os.path.join(plugin_root, '.git')
|
||||
|
||||
if channel == 'nightly':
|
||||
git_backup = None
|
||||
if os.path.exists(git_folder):
|
||||
git_backup = UpdateRoutes._backup_git(git_folder, 'nightly')
|
||||
if channel == 'nightly':
|
||||
git_backup = None
|
||||
if os.path.exists(git_folder):
|
||||
git_backup = UpdateRoutes._backup_git(git_folder, 'nightly')
|
||||
|
||||
success = False
|
||||
new_version = ''
|
||||
try:
|
||||
success = False
|
||||
new_version = ''
|
||||
try:
|
||||
if os.path.exists(git_folder):
|
||||
success, new_version = await UpdateRoutes._perform_git_update(
|
||||
plugin_root, nightly=True
|
||||
)
|
||||
else:
|
||||
success, new_version = UpdateRoutes._init_git_repo(plugin_root)
|
||||
finally:
|
||||
UpdateRoutes._restore_git(git_backup, git_folder, success, 'nightly')
|
||||
else:
|
||||
success = False
|
||||
new_version = ''
|
||||
if os.path.exists(git_folder):
|
||||
success, new_version = await UpdateRoutes._perform_git_update(
|
||||
plugin_root, nightly=True
|
||||
plugin_root, nightly=False
|
||||
)
|
||||
else:
|
||||
success, new_version = UpdateRoutes._init_git_repo(plugin_root)
|
||||
finally:
|
||||
UpdateRoutes._restore_git(git_backup, git_folder, success, 'nightly')
|
||||
else:
|
||||
git_backup = None
|
||||
if os.path.exists(git_folder):
|
||||
git_backup = UpdateRoutes._backup_git(git_folder, 'release')
|
||||
|
||||
success = False
|
||||
new_version = ''
|
||||
try:
|
||||
if os.path.exists(git_folder):
|
||||
shutil.rmtree(git_folder)
|
||||
tracking_file = os.path.join(plugin_root, '.tracking')
|
||||
if os.path.exists(tracking_file):
|
||||
os.remove(tracking_file)
|
||||
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||
finally:
|
||||
UpdateRoutes._restore_git(git_backup, git_folder, success, 'release')
|
||||
tracking_file = os.path.join(plugin_root, '.tracking')
|
||||
if os.path.exists(tracking_file):
|
||||
os.remove(tracking_file)
|
||||
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||
finally:
|
||||
_restore_preserved_items(plugin_root, staged_backup_dir, staged_items)
|
||||
|
||||
if settings_backup and success:
|
||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||
f.write(settings_backup)
|
||||
logger.info("Restored settings.json after channel switch")
|
||||
logger.debug("Restored settings.json content after channel switch (%d bytes)", len(settings_backup))
|
||||
|
||||
if success:
|
||||
return web.json_response({
|
||||
@@ -417,8 +499,7 @@ class UpdateRoutes:
|
||||
except Exception:
|
||||
logger.debug("Could not close downloaded-version history database", exc_info=True)
|
||||
|
||||
# Skip settings.json, civitai, model cache and runtime cache folders
|
||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups', 'stats'])
|
||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=list(_PRESERVE_DIRS))
|
||||
|
||||
# Extract ZIP to temp dir
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
@@ -428,7 +509,7 @@ class UpdateRoutes:
|
||||
extracted_root = next(os.scandir(tmp_dir)).path
|
||||
|
||||
# Copy files, skipping user data that should be preserved
|
||||
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups', 'stats'}
|
||||
skip_items = set(_PRESERVE_DIRS)
|
||||
for item in os.listdir(extracted_root):
|
||||
if item in skip_items:
|
||||
continue
|
||||
@@ -445,7 +526,7 @@ class UpdateRoutes:
|
||||
# for ComfyUI Manager to work properly
|
||||
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
||||
tracking_files = []
|
||||
skip_tracked = {'civitai', 'wildcards', 'backups', 'stats'}
|
||||
skip_tracked = set(_PRESERVE_DIRS) - {'settings.json'}
|
||||
for root, dirs, files in os.walk(extracted_root):
|
||||
# Skip user data directories and their contents
|
||||
rel_root = os.path.relpath(root, extracted_root)
|
||||
|
||||
@@ -475,13 +475,19 @@ class MetadataUpdater:
|
||||
return False
|
||||
|
||||
model_folder = get_model_folder(model_hash)
|
||||
if not model_folder:
|
||||
if not model_folder or not os.path.isdir(model_folder):
|
||||
return False
|
||||
|
||||
civitai = getattr(metadata, "civitai", None)
|
||||
if not isinstance(civitai, dict):
|
||||
return False
|
||||
|
||||
# Read the directory listing once so every image entry reuses it.
|
||||
try:
|
||||
dir_entries = os.listdir(model_folder)
|
||||
except OSError:
|
||||
dir_entries = []
|
||||
|
||||
has_changes = False
|
||||
|
||||
custom_images = civitai.get("customImages")
|
||||
@@ -493,24 +499,15 @@ class MetadataUpdater:
|
||||
if not img_id:
|
||||
continue
|
||||
|
||||
if not os.path.isdir(model_folder):
|
||||
prefix = f"custom_{img_id}"
|
||||
found = any(
|
||||
f.startswith(prefix) and os.path.isfile(
|
||||
os.path.join(model_folder, f)
|
||||
)
|
||||
for f in dir_entries
|
||||
)
|
||||
if not found:
|
||||
stale.append(idx)
|
||||
else:
|
||||
found = False
|
||||
try:
|
||||
prefix = f"custom_{img_id}"
|
||||
for fname in os.listdir(model_folder):
|
||||
if fname.startswith(prefix) and os.path.isfile(
|
||||
os.path.join(model_folder, fname)
|
||||
):
|
||||
found = True
|
||||
break
|
||||
except OSError:
|
||||
stale.append(idx)
|
||||
continue
|
||||
|
||||
if not found:
|
||||
stale.append(idx)
|
||||
|
||||
if stale:
|
||||
for idx in reversed(stale):
|
||||
@@ -532,22 +529,9 @@ class MetadataUpdater:
|
||||
# is gone.
|
||||
continue
|
||||
|
||||
if not os.path.isdir(model_folder):
|
||||
prefix = f"image_{idx}."
|
||||
if not any(f.startswith(prefix) for f in dir_entries):
|
||||
stale.append(idx)
|
||||
else:
|
||||
found = False
|
||||
try:
|
||||
prefix = f"image_{idx}."
|
||||
for fname in os.listdir(model_folder):
|
||||
if fname.startswith(prefix):
|
||||
found = True
|
||||
break
|
||||
except OSError:
|
||||
stale.append(idx)
|
||||
continue
|
||||
|
||||
if not found:
|
||||
stale.append(idx)
|
||||
|
||||
if stale:
|
||||
for idx in reversed(stale):
|
||||
|
||||
@@ -3,9 +3,16 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import shutil
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.example_images_paths import iter_library_roots
|
||||
from ..utils.example_images_paths import (
|
||||
get_example_images_root,
|
||||
is_hash_folder,
|
||||
iter_library_roots,
|
||||
uses_library_scoped_folders,
|
||||
_library_folder_has_only_hash_dirs,
|
||||
)
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.example_images_processor import ExampleImagesProcessor
|
||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||
@@ -36,6 +43,90 @@ settings = _SettingsProxy()
|
||||
class ExampleImagesMigration:
|
||||
"""Handles migrations for example images naming conventions"""
|
||||
|
||||
@staticmethod
|
||||
def _consolidate_library_folders():
|
||||
"""Move hash folders from library-named subdirectories back to root.
|
||||
|
||||
When a user switches from multi-library mode back to single-library
|
||||
mode, example images previously stored under e.g.
|
||||
``<root>/default/<hash>/`` need to be moved back to
|
||||
``<root>/<hash>/``. Running this once at startup removes the need
|
||||
for ``get_model_folder()`` to perform directory scans on every
|
||||
request.
|
||||
"""
|
||||
if uses_library_scoped_folders():
|
||||
return
|
||||
|
||||
root = get_example_images_root()
|
||||
if not root or not os.path.isdir(root):
|
||||
return
|
||||
|
||||
moved: list[str] = []
|
||||
cleaned: list[str] = []
|
||||
|
||||
try:
|
||||
for entry in os.listdir(root):
|
||||
# Fast regex checks first — no filesystem I/O.
|
||||
if is_hash_folder(entry) or entry == "_deleted":
|
||||
continue
|
||||
|
||||
entry_path = os.path.join(root, entry)
|
||||
if not os.path.isdir(entry_path):
|
||||
continue
|
||||
if not _library_folder_has_only_hash_dirs(entry_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
for hash_entry in os.listdir(entry_path):
|
||||
hash_path = os.path.join(entry_path, hash_entry)
|
||||
if not os.path.isdir(hash_path) or not is_hash_folder(hash_entry):
|
||||
continue
|
||||
target = os.path.join(root, hash_entry)
|
||||
if not os.path.exists(target):
|
||||
try:
|
||||
shutil.move(hash_path, target)
|
||||
moved.append(hash_entry)
|
||||
except (OSError, shutil.Error) as exc:
|
||||
logger.error(
|
||||
"Failed to move '%s' → '%s': %s",
|
||||
hash_path, target, exc,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"Failed to list library subdirectory '%s': %s",
|
||||
entry_path, exc,
|
||||
)
|
||||
|
||||
try:
|
||||
remaining = os.listdir(entry_path)
|
||||
except OSError:
|
||||
remaining = []
|
||||
if not remaining:
|
||||
try:
|
||||
os.rmdir(entry_path)
|
||||
cleaned.append(entry)
|
||||
except OSError as exc:
|
||||
logger.debug(
|
||||
"Could not remove empty library dir '%s': %s",
|
||||
entry_path, exc,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"Failed to list example images root during consolidation: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
if moved:
|
||||
logger.info(
|
||||
"Consolidated %d example image folder(s) to root",
|
||||
len(moved),
|
||||
)
|
||||
if cleaned:
|
||||
logger.info(
|
||||
"Removed %d empty library directories",
|
||||
len(cleaned),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def check_and_run_migrations():
|
||||
"""Check if migrations are needed and run them in background"""
|
||||
@@ -44,6 +135,10 @@ class ExampleImagesMigration:
|
||||
logger.debug("No example images path configured or path doesn't exist, skipping migrations")
|
||||
return
|
||||
|
||||
# Run library-to-root consolidation once at startup so the hot
|
||||
# path (get_model_folder) stays a pure-path computation.
|
||||
ExampleImagesMigration._consolidate_library_folders()
|
||||
|
||||
for library_name, library_path in iter_library_roots():
|
||||
if not library_path or not os.path.exists(library_path):
|
||||
continue
|
||||
|
||||
@@ -83,7 +83,12 @@ def ensure_library_root_exists(library_name: Optional[str] = None) -> str:
|
||||
|
||||
|
||||
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
|
||||
"""Return the folder path for a model's example images."""
|
||||
"""Return the folder path for a model's example images.
|
||||
|
||||
Multi-library ↔ single-library consolidation is handled once at startup by
|
||||
``ExampleImagesMigration._consolidate_library_folders`` — this function is a
|
||||
pure path computation on the hot path (no directory scans).
|
||||
"""
|
||||
|
||||
if not model_hash:
|
||||
return ""
|
||||
@@ -113,35 +118,6 @@ def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str
|
||||
exc,
|
||||
)
|
||||
return legacy_folder
|
||||
elif not os.path.exists(resolved_folder):
|
||||
# Reverse migration: when consolidating from multi-library to
|
||||
# single-library mode (e.g. after "default" was cleaned up), look
|
||||
# for existing example images inside library-named subdirectories
|
||||
# and bring them back to the root level.
|
||||
root = get_example_images_root()
|
||||
if root:
|
||||
try:
|
||||
for entry in os.listdir(root):
|
||||
entry_path = os.path.join(root, entry)
|
||||
if not os.path.isdir(entry_path):
|
||||
continue
|
||||
if is_hash_folder(entry) or entry == "_deleted":
|
||||
continue
|
||||
if not _library_folder_has_only_hash_dirs(entry_path):
|
||||
continue
|
||||
legacy = os.path.join(entry_path, normalized_hash)
|
||||
if os.path.exists(legacy):
|
||||
shutil.move(legacy, resolved_folder)
|
||||
logger.info(
|
||||
"Consolidated example images from '%s' to '%s'",
|
||||
legacy, resolved_folder,
|
||||
)
|
||||
break
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"Failed to consolidate example images during "
|
||||
"library merge: %s", exc,
|
||||
)
|
||||
|
||||
return resolved_folder
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.1.9"
|
||||
version = "1.2.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -27,6 +27,8 @@ export class BulkManager {
|
||||
|
||||
// Drag detection properties
|
||||
this.dragThreshold = 5; // Pixels to move before considering it a drag
|
||||
this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
|
||||
this.minMarqueeSize = 10; // Minimum drag box (px) before a marquee counts as a selection
|
||||
this.mouseDownTime = 0;
|
||||
this.mouseDownPosition = { x: 0, y: 0 };
|
||||
|
||||
@@ -173,6 +175,19 @@ export class BulkManager {
|
||||
});
|
||||
|
||||
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
|
||||
// Only track marquee/drag while the left button is physically held.
|
||||
// mouseup can be missed (release outside the window, focus loss, driver quirks),
|
||||
// so mousemove must verify the button state itself instead of relying on it.
|
||||
if (!(e.buttons & 1)) {
|
||||
if (this.isMarqueeActive) {
|
||||
this.endMarqueeSelection(e);
|
||||
} else {
|
||||
this.mouseDownTime = 0;
|
||||
this.isDragging = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isMarqueeActive) {
|
||||
this.lastClientX = e.clientX;
|
||||
this.lastClientY = e.clientY;
|
||||
@@ -184,7 +199,10 @@ export class BulkManager {
|
||||
const dy = e.clientY - this.mouseDownPosition.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance >= this.dragThreshold) {
|
||||
// Require both enough movement AND enough hold time so quick
|
||||
// click jitter from micro-movement input devices is not a marquee.
|
||||
const heldTime = Date.now() - this.mouseDownTime;
|
||||
if (heldTime >= this.dragDelayMs && distance >= this.dragThreshold) {
|
||||
this.isDragging = true;
|
||||
this.startMarqueeSelection(e, true);
|
||||
}
|
||||
@@ -1958,9 +1976,31 @@ export class BulkManager {
|
||||
// Remove visual feedback class
|
||||
document.body.classList.remove('marquee-selecting');
|
||||
|
||||
// Compute the actual drag box size in document coordinates, matching how
|
||||
// updateMarqueeSelectionFromPosition tracks the rectangle. Client-space
|
||||
// size would wrongly flag auto-scroll marquees (tiny pointer movement,
|
||||
// large document-space box) as accidental clicks.
|
||||
const container = document.querySelector('.page-content');
|
||||
const scrollX = container?.scrollLeft || 0;
|
||||
const scrollY = container?.scrollTop || 0;
|
||||
const dragWidth = Math.abs((e.clientX + scrollX) - this.marqueeStartDoc.x);
|
||||
const dragHeight = Math.abs((e.clientY + scrollY) - this.marqueeStartDoc.y);
|
||||
const isTinyMarquee = dragWidth < this.minMarqueeSize && dragHeight < this.minMarqueeSize;
|
||||
|
||||
// Get selection count
|
||||
const selectionCount = state.selectedModels.size;
|
||||
|
||||
// A tiny box (e.g. click jitter that happened to graze a card) is treated
|
||||
// as an accidental click: undo any selection and leave bulk mode.
|
||||
if (isTinyMarquee) {
|
||||
this.clearSelection();
|
||||
if (state.bulkMode) {
|
||||
this.toggleBulkMode();
|
||||
}
|
||||
this.initialSelectedModels.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// If no models were selected, exit bulk mode
|
||||
if (selectionCount === 0) {
|
||||
if (state.bulkMode) {
|
||||
|
||||
@@ -1517,11 +1517,20 @@ export class SettingsManager {
|
||||
return data;
|
||||
}
|
||||
|
||||
async loadLoraRoots() {
|
||||
try {
|
||||
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
|
||||
if (!defaultLoraRootSelect) return;
|
||||
showNoRootsPlaceholder(select) {
|
||||
select.innerHTML = '';
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = translate('settings.folderSettings.noDefault', {}, 'No Default');
|
||||
select.appendChild(option);
|
||||
select.disabled = true;
|
||||
}
|
||||
|
||||
async loadLoraRoots() {
|
||||
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
|
||||
if (!defaultLoraRootSelect) return;
|
||||
|
||||
try {
|
||||
// Fetch lora roots
|
||||
const response = await fetch('/api/lm/loras/roots');
|
||||
if (!response.ok) {
|
||||
@@ -1530,10 +1539,12 @@ export class SettingsManager {
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.roots || data.roots.length === 0) {
|
||||
throw new Error('No LoRA roots found');
|
||||
this.showNoRootsPlaceholder(defaultLoraRootSelect);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultLoraRootSelect.innerHTML = '';
|
||||
defaultLoraRootSelect.disabled = false;
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
@@ -1548,15 +1559,16 @@ export class SettingsManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading LoRA roots:', error);
|
||||
this.showNoRootsPlaceholder(defaultLoraRootSelect);
|
||||
showToast('toast.settings.loraRootsFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadCheckpointRoots() {
|
||||
try {
|
||||
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
|
||||
if (!defaultCheckpointRootSelect) return;
|
||||
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
|
||||
if (!defaultCheckpointRootSelect) return;
|
||||
|
||||
try {
|
||||
// Fetch checkpoint roots (checkpoint paths only, not unet)
|
||||
const response = await fetch('/api/lm/checkpoints/checkpoints_roots');
|
||||
if (!response.ok) {
|
||||
@@ -1565,10 +1577,12 @@ export class SettingsManager {
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.roots || data.roots.length === 0) {
|
||||
throw new Error('No checkpoint roots found');
|
||||
this.showNoRootsPlaceholder(defaultCheckpointRootSelect);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultCheckpointRootSelect.innerHTML = '';
|
||||
defaultCheckpointRootSelect.disabled = false;
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
@@ -1583,15 +1597,16 @@ export class SettingsManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading checkpoint roots:', error);
|
||||
this.showNoRootsPlaceholder(defaultCheckpointRootSelect);
|
||||
showToast('toast.settings.checkpointRootsFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadUnetRoots() {
|
||||
try {
|
||||
const defaultUnetRootSelect = document.getElementById('defaultUnetRoot');
|
||||
if (!defaultUnetRootSelect) return;
|
||||
const defaultUnetRootSelect = document.getElementById('defaultUnetRoot');
|
||||
if (!defaultUnetRootSelect) return;
|
||||
|
||||
try {
|
||||
// Fetch unet roots (diffusion model paths only)
|
||||
const response = await fetch('/api/lm/checkpoints/unet_roots');
|
||||
if (!response.ok) {
|
||||
@@ -1600,10 +1615,12 @@ export class SettingsManager {
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.roots || data.roots.length === 0) {
|
||||
throw new Error('No diffusion model roots found');
|
||||
this.showNoRootsPlaceholder(defaultUnetRootSelect);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultUnetRootSelect.innerHTML = '';
|
||||
defaultUnetRootSelect.disabled = false;
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
@@ -1618,15 +1635,16 @@ export class SettingsManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading diffusion model roots:', error);
|
||||
this.showNoRootsPlaceholder(defaultUnetRootSelect);
|
||||
showToast('toast.settings.unetRootsFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadEmbeddingRoots() {
|
||||
try {
|
||||
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
||||
if (!defaultEmbeddingRootSelect) return;
|
||||
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
||||
if (!defaultEmbeddingRootSelect) return;
|
||||
|
||||
try {
|
||||
// Fetch embedding roots
|
||||
const response = await fetch('/api/lm/embeddings/roots');
|
||||
if (!response.ok) {
|
||||
@@ -1635,10 +1653,12 @@ export class SettingsManager {
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.roots || data.roots.length === 0) {
|
||||
throw new Error('No embedding roots found');
|
||||
this.showNoRootsPlaceholder(defaultEmbeddingRootSelect);
|
||||
return;
|
||||
}
|
||||
|
||||
defaultEmbeddingRootSelect.innerHTML = '';
|
||||
defaultEmbeddingRootSelect.disabled = false;
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
@@ -1653,6 +1673,7 @@ export class SettingsManager {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading embedding roots:', error);
|
||||
this.showNoRootsPlaceholder(defaultEmbeddingRootSelect);
|
||||
showToast('toast.settings.embeddingRootsFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import {
|
||||
getStorageItem,
|
||||
setStorageItem,
|
||||
getStoredVersionInfo,
|
||||
import {
|
||||
getStorageItem,
|
||||
setStorageItem,
|
||||
getStoredVersionInfo,
|
||||
setStoredVersionInfo,
|
||||
isVersionMatch
|
||||
} from '../utils/storageHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { bannerService } from './BannerService.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
|
||||
@@ -26,6 +27,8 @@ export class UpdateService {
|
||||
this.isUpdating = false;
|
||||
this.channelMode = null;
|
||||
this.hasGit = false;
|
||||
this.nightlyNotifyDate = getStorageItem('nightly_notify_date', '');
|
||||
this.nightlyBadgeShown = false;
|
||||
this.progressKeepVisible = false;
|
||||
this.currentVersionInfo = null;
|
||||
this.versionMismatch = false;
|
||||
@@ -59,9 +62,6 @@ export class UpdateService {
|
||||
|
||||
// Perform update check if needed
|
||||
this.checkVersionInfo().then(() => {
|
||||
if (this.channelMode === null) {
|
||||
this.channelMode = this.hasGit ? 'nightly' : 'release';
|
||||
}
|
||||
this.checkForUpdates().then(() => {
|
||||
this.updateBadgeVisibility();
|
||||
});
|
||||
@@ -118,6 +118,14 @@ export class UpdateService {
|
||||
|
||||
if (data.success) {
|
||||
this.channelMode = channel;
|
||||
// Persist channel preference to settings.json
|
||||
fetch('/api/lm/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ update_channel: channel })
|
||||
}).then(r => {
|
||||
if (!r.ok) console.warn('Failed to persist update channel:', r.status);
|
||||
}).catch(e => console.warn('Failed to persist update channel:', e));
|
||||
await this.checkForUpdates({ force: true });
|
||||
this.updateModalContent();
|
||||
this.updateChannelUI();
|
||||
@@ -154,6 +162,20 @@ export class UpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
_resolveChannelFromSettings() {
|
||||
const stored = state?.global?.settings?.update_channel;
|
||||
if (stored === 'nightly' || stored === 'release') {
|
||||
return stored;
|
||||
}
|
||||
if (!this.hasGit) {
|
||||
return 'release';
|
||||
}
|
||||
if (this.gitInfo?.branch === 'detached') {
|
||||
return 'release';
|
||||
}
|
||||
return 'nightly';
|
||||
}
|
||||
|
||||
async _confirmChannelSwitch(titleKey, messageKey) {
|
||||
return new Promise((resolve) => {
|
||||
const title = translate(titleKey);
|
||||
@@ -475,6 +497,18 @@ export class UpdateService {
|
||||
}
|
||||
|
||||
async checkForUpdates({ force = false } = {}) {
|
||||
let needsMigration = false;
|
||||
if (this.channelMode === null) {
|
||||
const stored = state?.global?.settings?.update_channel;
|
||||
if (stored === 'nightly' || stored === 'release') {
|
||||
this.channelMode = stored;
|
||||
} else if (!this.hasGit) {
|
||||
this.channelMode = 'release';
|
||||
needsMigration = true;
|
||||
}
|
||||
// hasGit=true with no stored value: wait for gitInfo.branch
|
||||
}
|
||||
|
||||
if (!force && !this.updateNotificationsEnabled) {
|
||||
return;
|
||||
}
|
||||
@@ -493,7 +527,7 @@ export class UpdateService {
|
||||
|
||||
try {
|
||||
// Call backend API to check for updates with nightly flag
|
||||
const nightly = this.channelMode === 'nightly';
|
||||
const nightly = (this.channelMode ?? (this.hasGit ? 'nightly' : 'release')) === 'nightly';
|
||||
const response = await fetch(`/api/lm/check-updates?nightly=${nightly}`);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -503,12 +537,28 @@ export class UpdateService {
|
||||
this.updateInfo = data;
|
||||
this.gitInfo = data.git_info || this.gitInfo;
|
||||
this.hasGit = data.has_git || false;
|
||||
if (this.channelMode === null) {
|
||||
this.channelMode = this.hasGit ? 'nightly' : 'release';
|
||||
|
||||
if (needsMigration || this.channelMode === null) {
|
||||
this.channelMode = this._resolveChannelFromSettings();
|
||||
if (state?.global?.settings) {
|
||||
state.global.settings.update_channel = this.channelMode;
|
||||
}
|
||||
fetch('/api/lm/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ update_channel: this.channelMode })
|
||||
}).then(r => {
|
||||
if (!r.ok) console.warn('Failed to persist update channel:', r.status);
|
||||
}).catch(e => console.warn('Failed to persist update channel:', e));
|
||||
}
|
||||
|
||||
this.updateAvailable = data.update_available;
|
||||
|
||||
// Nightly channel: surface the update badge at most once per calendar day.
|
||||
if (this.updateAvailable && this.channelMode === 'nightly' && this.nightlyNotifyDate !== this._getTodayKey()) {
|
||||
this._markNightlyNotified();
|
||||
}
|
||||
|
||||
this.lastCheckTime = now;
|
||||
setStorageItem('last_update_check', now.toString());
|
||||
|
||||
@@ -558,6 +608,28 @@ export class UpdateService {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_getTodayKey() {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
_isNightlyBadgeAllowed() {
|
||||
if (this.channelMode !== 'nightly') {
|
||||
return true;
|
||||
}
|
||||
// Keep the badge visible for the rest of the session once shown, but do
|
||||
// not show it again on later sessions within the same calendar day.
|
||||
return this.nightlyNotifyDate !== this._getTodayKey() || this.nightlyBadgeShown;
|
||||
}
|
||||
|
||||
_markNightlyNotified() {
|
||||
this.nightlyNotifyDate = this._getTodayKey();
|
||||
this.nightlyBadgeShown = true;
|
||||
setStorageItem('nightly_notify_date', this.nightlyNotifyDate);
|
||||
}
|
||||
|
||||
updateBadgeVisibility() {
|
||||
const updateToggle = document.querySelector('.update-toggle');
|
||||
@@ -566,9 +638,12 @@ export class UpdateService {
|
||||
? bannerService.getUnreadBannerCount()
|
||||
: 0;
|
||||
|
||||
// Force updating badges visibility based on current state
|
||||
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable && this._isNightlyBadgeAllowed();
|
||||
|
||||
if (updateToggle) {
|
||||
let tooltipKey = 'header.actions.notifications';
|
||||
if (this.updateNotificationsEnabled && this.updateAvailable) {
|
||||
if (shouldShowUpdate) {
|
||||
tooltipKey = 'update.updateAvailable';
|
||||
} else if (unreadBanners > 0) {
|
||||
tooltipKey = 'update.tabs.messages';
|
||||
@@ -576,8 +651,6 @@ export class UpdateService {
|
||||
updateToggle.title = translate(tooltipKey);
|
||||
}
|
||||
|
||||
// Force updating badges visibility based on current state
|
||||
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable;
|
||||
const shouldShow = shouldShowUpdate || unreadBanners > 0;
|
||||
|
||||
if (updateBadge) {
|
||||
|
||||
186
tests/frontend/managers/BulkManager.marquee.test.js
Normal file
186
tests/frontend/managers/BulkManager.marquee.test.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
import { MODEL_TYPES } from '../../../static/js/api/apiConfig.js';
|
||||
import { eventManager } from '../../../static/js/utils/EventManager.js';
|
||||
import { BulkManager } from '../../../static/js/managers/BulkManager.js';
|
||||
|
||||
function fire(type, init = {}) {
|
||||
return new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
|
||||
}
|
||||
|
||||
describe('BulkManager marquee guards', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// jsdom may not provide requestAnimationFrame; stub it so the auto-scroll loop is a no-op.
|
||||
window.requestAnimationFrame = vi.fn();
|
||||
window.cancelAnimationFrame = vi.fn();
|
||||
|
||||
eventManager.cleanup();
|
||||
state.currentPageType = MODEL_TYPES.LORA;
|
||||
state.bulkMode = false;
|
||||
state.selectedModels.clear();
|
||||
|
||||
document.body.innerHTML = '<div class="page-content"></div>';
|
||||
const pageContent = document.querySelector('.page-content');
|
||||
pageContent.getBoundingClientRect = () => ({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 1000,
|
||||
bottom: 1000,
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
pageContent.scrollBy = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
eventManager.cleanup();
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
function createBulkManager() {
|
||||
const bulk = new BulkManager();
|
||||
bulk.initialize();
|
||||
return bulk;
|
||||
}
|
||||
|
||||
it('never starts a marquee when the left button is not held', () => {
|
||||
const bulk = createBulkManager();
|
||||
const pageContent = document.querySelector('.page-content');
|
||||
|
||||
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 10, clientY: 10 }));
|
||||
document.dispatchEvent(fire('mousemove', { buttons: 0, clientX: 50, clientY: 50 }));
|
||||
|
||||
expect(bulk.mouseDownTime).toBe(0);
|
||||
expect(bulk.isMarqueeActive).toBe(false);
|
||||
expect(state.bulkMode).toBe(false);
|
||||
expect(document.querySelector('.marquee-selection')).toBeNull();
|
||||
});
|
||||
|
||||
it('requires holding the left button for the drag delay before starting a marquee', () => {
|
||||
const bulk = createBulkManager();
|
||||
const pageContent = document.querySelector('.page-content');
|
||||
|
||||
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 10, clientY: 10 }));
|
||||
|
||||
// Fast movement: far enough, but too soon after mousedown.
|
||||
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 30, clientY: 10 }));
|
||||
expect(state.bulkMode).toBe(false);
|
||||
expect(bulk.isMarqueeActive).toBe(false);
|
||||
|
||||
// Once the hold time has elapsed, the same drag qualifies.
|
||||
vi.advanceTimersByTime(100);
|
||||
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 35, clientY: 12 }));
|
||||
expect(state.bulkMode).toBe(true);
|
||||
expect(bulk.isMarqueeActive).toBe(true);
|
||||
expect(document.querySelector('.marquee-selection')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('ends an active marquee if the left button is released without a mouseup event', () => {
|
||||
const bulk = createBulkManager();
|
||||
bulk.mouseDownPosition = { x: 10, y: 10 };
|
||||
bulk.startMarqueeSelection({}, true);
|
||||
expect(state.bulkMode).toBe(true);
|
||||
expect(document.querySelector('.marquee-selection')).not.toBeNull();
|
||||
|
||||
// No mouseup was dispatched; a plain move with the button released finalizes it.
|
||||
document.dispatchEvent(fire('mousemove', { buttons: 0, clientX: 50, clientY: 50 }));
|
||||
|
||||
expect(bulk.isMarqueeActive).toBe(false);
|
||||
expect(document.querySelector('.marquee-selection')).toBeNull();
|
||||
expect(state.bulkMode).toBe(false); // zero selected -> auto-exit
|
||||
});
|
||||
|
||||
it('treats a tiny marquee as an accidental click: clears selection and exits bulk mode', () => {
|
||||
const bulk = createBulkManager();
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card selected';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
state.selectedModels.add('/models/test.safetensors');
|
||||
|
||||
bulk.mouseDownPosition = { x: 100, y: 100 };
|
||||
bulk.startMarqueeSelection({}, true);
|
||||
expect(state.bulkMode).toBe(true);
|
||||
|
||||
bulk.endMarqueeSelection({ clientX: 103, clientY: 104 });
|
||||
|
||||
expect(state.bulkMode).toBe(false);
|
||||
expect(state.selectedModels.size).toBe(0);
|
||||
expect(card.classList.contains('selected')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps selection and bulk mode when the marquee is large enough', () => {
|
||||
const bulk = createBulkManager();
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card selected';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
state.selectedModels.add('/models/test.safetensors');
|
||||
|
||||
bulk.mouseDownPosition = { x: 100, y: 100 };
|
||||
bulk.startMarqueeSelection({}, true);
|
||||
|
||||
bulk.endMarqueeSelection({ clientX: 130, clientY: 140 });
|
||||
|
||||
expect(state.bulkMode).toBe(true);
|
||||
expect(state.selectedModels.has('/models/test.safetensors')).toBe(true);
|
||||
expect(card.classList.contains('selected')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps auto-scroll marquee selections when the pointer only moved a few pixels', () => {
|
||||
const bulk = createBulkManager();
|
||||
const pageContent = document.querySelector('.page-content');
|
||||
|
||||
// Card just below the press point in document coordinates.
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = '/models/off-screen.safetensors';
|
||||
card.getBoundingClientRect = () => ({
|
||||
top: 950,
|
||||
left: 400,
|
||||
right: 600,
|
||||
bottom: 1050,
|
||||
width: 200,
|
||||
height: 100,
|
||||
x: 400,
|
||||
y: 950,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
document.body.appendChild(card);
|
||||
|
||||
pageContent.dispatchEvent(fire('mousedown', { button: 0, clientX: 500, clientY: 900 }));
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// Small pointer move: enough to start the marquee, but under minMarqueeSize.
|
||||
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 506, clientY: 906 }));
|
||||
expect(bulk.isMarqueeActive).toBe(true);
|
||||
|
||||
// Auto-scroll grows the document-space box while the pointer stays nearly still.
|
||||
pageContent.scrollTop = 200;
|
||||
card.getBoundingClientRect = () => ({
|
||||
top: 750,
|
||||
left: 400,
|
||||
right: 600,
|
||||
bottom: 850,
|
||||
width: 200,
|
||||
height: 100,
|
||||
x: 400,
|
||||
y: 750,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
document.dispatchEvent(fire('mousemove', { buttons: 1, clientX: 506, clientY: 906 }));
|
||||
|
||||
expect(state.selectedModels.has('/models/off-screen.safetensors')).toBe(true);
|
||||
|
||||
// Release: the client-space box is tiny, but the document-space box is not.
|
||||
document.dispatchEvent(fire('mouseup', { button: 0, clientX: 506, clientY: 906 }));
|
||||
|
||||
expect(state.selectedModels.has('/models/off-screen.safetensors')).toBe(true);
|
||||
expect(state.bulkMode).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -106,6 +106,118 @@ afterEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsManager root selects', () => {
|
||||
const rootCases = [
|
||||
{
|
||||
method: 'loadLoraRoots',
|
||||
selectId: 'defaultLoraRoot',
|
||||
endpoint: '/api/lm/loras/roots',
|
||||
errorKey: 'toast.settings.loraRootsFailed',
|
||||
},
|
||||
{
|
||||
method: 'loadCheckpointRoots',
|
||||
selectId: 'defaultCheckpointRoot',
|
||||
endpoint: '/api/lm/checkpoints/checkpoints_roots',
|
||||
errorKey: 'toast.settings.checkpointRootsFailed',
|
||||
},
|
||||
{
|
||||
method: 'loadUnetRoots',
|
||||
selectId: 'defaultUnetRoot',
|
||||
endpoint: '/api/lm/checkpoints/unet_roots',
|
||||
errorKey: 'toast.settings.unetRootsFailed',
|
||||
},
|
||||
{
|
||||
method: 'loadEmbeddingRoots',
|
||||
selectId: 'defaultEmbeddingRoot',
|
||||
endpoint: '/api/lm/embeddings/roots',
|
||||
errorKey: 'toast.settings.embeddingRootsFailed',
|
||||
},
|
||||
];
|
||||
|
||||
const appendRootSelect = (id) => {
|
||||
const select = document.createElement('select');
|
||||
select.id = id;
|
||||
document.body.appendChild(select);
|
||||
return select;
|
||||
};
|
||||
|
||||
it.each(rootCases)(
|
||||
'populates the $method select with roots and keeps it enabled',
|
||||
async ({ method, selectId, endpoint }) => {
|
||||
const manager = createManager();
|
||||
const select = appendRootSelect(selectId);
|
||||
select.disabled = true;
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
roots: ['/models/root-a', '/models/root-b'],
|
||||
}),
|
||||
});
|
||||
|
||||
await manager[method]();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(endpoint);
|
||||
expect(Array.from(select.options).map(option => option.value)).toEqual([
|
||||
'/models/root-a',
|
||||
'/models/root-b',
|
||||
]);
|
||||
expect(select.disabled).toBe(false);
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(rootCases)(
|
||||
'shows a placeholder and no error toast when $method has empty roots',
|
||||
async ({ method, selectId, endpoint }) => {
|
||||
const manager = createManager();
|
||||
const select = appendRootSelect(selectId);
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
roots: [],
|
||||
}),
|
||||
});
|
||||
|
||||
await manager[method]();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(endpoint);
|
||||
expect(select.options).toHaveLength(1);
|
||||
expect(select.options[0].value).toBe('');
|
||||
expect(select.options[0].textContent).toBe('No Default');
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(rootCases)(
|
||||
'shows an error toast when the $method roots request fails',
|
||||
async ({ method, selectId, errorKey }) => {
|
||||
const manager = createManager();
|
||||
const select = appendRootSelect(selectId);
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await manager[method]();
|
||||
|
||||
expect(select.options).toHaveLength(1);
|
||||
expect(select.options[0].value).toBe('');
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
errorKey,
|
||||
expect.objectContaining({ message: expect.any(String) }),
|
||||
'error',
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('SettingsManager library controls', () => {
|
||||
it('loads libraries and populates the select', async () => {
|
||||
const manager = createManager();
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest';
|
||||
import { UpdateService } from '../../../static/js/managers/UpdateService.js';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
|
||||
function createFetchResponse(payload) {
|
||||
return {
|
||||
json: vi.fn().mockResolvedValue(payload)
|
||||
json: vi.fn().mockResolvedValue(payload),
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
function stubSettingsUpdateChannel(channel) {
|
||||
state.global = state.global || {};
|
||||
state.global.settings = state.global.settings || {};
|
||||
state.global.settings.update_channel = channel;
|
||||
}
|
||||
|
||||
function clearSettingsUpdateChannel() {
|
||||
if (state.global?.settings) {
|
||||
delete state.global.settings.update_channel;
|
||||
}
|
||||
}
|
||||
|
||||
describe('UpdateService passive checks', () => {
|
||||
let service;
|
||||
let fetchMock;
|
||||
@@ -16,10 +30,13 @@ describe('UpdateService passive checks', () => {
|
||||
success: true,
|
||||
current_version: 'v1.0.0',
|
||||
latest_version: 'v1.0.0',
|
||||
git_info: { short_hash: 'abc123' }
|
||||
git_info: { short_hash: 'abc123' },
|
||||
has_git: true,
|
||||
}));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
stubSettingsUpdateChannel('release');
|
||||
|
||||
service = new UpdateService();
|
||||
service.updateNotificationsEnabled = false;
|
||||
service.lastCheckTime = 0;
|
||||
@@ -28,6 +45,7 @@ describe('UpdateService passive checks', () => {
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
clearSettingsUpdateChannel();
|
||||
});
|
||||
|
||||
it('skips passive update checks when notifications are disabled', async () => {
|
||||
@@ -43,3 +61,106 @@ describe('UpdateService passive checks', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/lm/check-updates?nightly=false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateService nightly notification throttling', () => {
|
||||
let fetchMock;
|
||||
let updateToggle;
|
||||
let updateBadge;
|
||||
|
||||
function stubUpdateBadgeDom() {
|
||||
updateToggle = document.createElement('div');
|
||||
updateToggle.className = 'update-toggle';
|
||||
updateBadge = document.createElement('span');
|
||||
updateBadge.className = 'update-badge';
|
||||
updateToggle.appendChild(updateBadge);
|
||||
document.body.appendChild(updateToggle);
|
||||
|
||||
vi.spyOn(document, 'querySelector').mockImplementation((selector) => {
|
||||
if (selector === '.update-toggle') return updateToggle;
|
||||
if (selector === '.update-toggle .update-badge') return updateBadge;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function makeUpdateResponse(channel) {
|
||||
return {
|
||||
success: true,
|
||||
current_version: 'v1.0.0',
|
||||
latest_version: channel === 'nightly' ? 'main-abc1234' : 'v1.1.0',
|
||||
update_available: true,
|
||||
git_info: { short_hash: 'abc123' },
|
||||
has_git: true,
|
||||
nightly: channel === 'nightly',
|
||||
changelog: ['test: change'],
|
||||
releases: [],
|
||||
behind_by: 3,
|
||||
commit_date: '2026-07-31',
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn().mockResolvedValue(createFetchResponse(makeUpdateResponse('release')));
|
||||
global.fetch = fetchMock;
|
||||
stubUpdateBadgeDom();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it('shows the nightly badge once and keeps it visible for the session', async () => {
|
||||
stubSettingsUpdateChannel('nightly');
|
||||
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('nightly')));
|
||||
|
||||
const service = new UpdateService();
|
||||
service.updateNotificationsEnabled = true;
|
||||
|
||||
await service.checkForUpdates({ force: true });
|
||||
|
||||
expect(service.updateAvailable).toBe(true);
|
||||
expect(service.nightlyBadgeShown).toBe(true);
|
||||
expect(service.nightlyNotifyDate).toBe(service._getTodayKey());
|
||||
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||
|
||||
// A repeated check within the same session keeps the badge visible.
|
||||
await service.checkForUpdates({ force: true });
|
||||
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the nightly badge on a later session in the same day', async () => {
|
||||
stubSettingsUpdateChannel('nightly');
|
||||
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('nightly')));
|
||||
|
||||
const firstService = new UpdateService();
|
||||
firstService.updateNotificationsEnabled = true;
|
||||
await firstService.checkForUpdates({ force: true });
|
||||
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||
|
||||
// Simulate a fresh page session on the same calendar day.
|
||||
const secondService = new UpdateService();
|
||||
secondService.updateNotificationsEnabled = true;
|
||||
await secondService.checkForUpdates({ force: true });
|
||||
|
||||
expect(secondService.updateAvailable).toBe(true);
|
||||
expect(secondService.nightlyBadgeShown).toBe(false);
|
||||
expect(updateBadge.classList.contains('visible')).toBe(false);
|
||||
});
|
||||
|
||||
it('is not affected by the daily limit on the release channel', async () => {
|
||||
stubSettingsUpdateChannel('release');
|
||||
fetchMock.mockResolvedValue(createFetchResponse(makeUpdateResponse('release')));
|
||||
|
||||
const firstService = new UpdateService();
|
||||
firstService.updateNotificationsEnabled = true;
|
||||
await firstService.checkForUpdates({ force: true });
|
||||
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||
|
||||
const secondService = new UpdateService();
|
||||
secondService.updateNotificationsEnabled = true;
|
||||
await secondService.checkForUpdates({ force: true });
|
||||
|
||||
expect(secondService.updateAvailable).toBe(true);
|
||||
expect(updateBadge.classList.contains('visible')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -344,7 +344,7 @@ async def test_switch_channel_to_nightly_with_git_calls_git_update(monkeypatch,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_channel_to_release_with_git_downloads_zip(monkeypatch, tmp_path):
|
||||
async def test_switch_channel_to_release_with_git_calls_git_update(monkeypatch, tmp_path):
|
||||
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
|
||||
routes_file.parent.mkdir(parents=True)
|
||||
routes_file.write_text("")
|
||||
@@ -353,11 +353,11 @@ async def test_switch_channel_to_release_with_git_downloads_zip(monkeypatch, tmp
|
||||
|
||||
(tmp_path / ".git").mkdir()
|
||||
|
||||
async def _fake_zip(*args, **kwargs):
|
||||
async def _fake_git_update(*args, **kwargs):
|
||||
return True, "v9.9.9"
|
||||
|
||||
monkeypatch.setattr(
|
||||
update_routes.UpdateRoutes, "_download_and_replace_zip", _fake_zip
|
||||
update_routes.UpdateRoutes, "_perform_git_update", _fake_git_update
|
||||
)
|
||||
|
||||
req = _fake_request({"channel": "release"})
|
||||
|
||||
Reference in New Issue
Block a user