fix(settings): sync portable mode toggle

This commit is contained in:
Will Miao
2025-11-16 17:36:52 +08:00
parent 645b7c247d
commit 354cf03bbc
16 changed files with 269 additions and 9 deletions

View File

@@ -232,8 +232,13 @@
"exampleImages": "Beispielbilder",
"misc": "Verschiedenes",
"metadataArchive": "Metadaten-Archiv-Datenbank",
"storageLocation": "Einstellungsort",
"proxySettings": "Proxy-Einstellungen"
},
"storage": {
"locationLabel": "Portabler Modus",
"locationHelp": "Aktiviere, um settings.json im Repository zu belassen; deaktiviere, um es im Benutzerkonfigurationsordner zu speichern."
},
"contentFiltering": {
"blurNsfwContent": "NSFW-Inhalte unscharf stellen",
"blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen",

View File

@@ -232,8 +232,13 @@
"exampleImages": "Example Images",
"misc": "Misc.",
"metadataArchive": "Metadata Archive Database",
"storageLocation": "Settings Location",
"proxySettings": "Proxy Settings"
},
"storage": {
"locationLabel": "Portable mode",
"locationHelp": "Enable to keep settings.json inside the repository; disable to store it in your user config directory."
},
"contentFiltering": {
"blurNsfwContent": "Blur NSFW Content",
"blurNsfwContentHelp": "Blur mature (NSFW) content preview images",

View File

@@ -232,8 +232,13 @@
"exampleImages": "Imágenes de ejemplo",
"misc": "Varios",
"metadataArchive": "Base de datos de archivo de metadatos",
"storageLocation": "Ubicación de ajustes",
"proxySettings": "Configuración de proxy"
},
"storage": {
"locationLabel": "Modo portátil",
"locationHelp": "Activa para mantener settings.json dentro del repositorio; desactívalo para guardarlo en tu directorio de configuración de usuario."
},
"contentFiltering": {
"blurNsfwContent": "Difuminar contenido NSFW",
"blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)",

View File

@@ -231,9 +231,14 @@
"exampleImages": "Images d'exemple",
"misc": "Divers",
"metadataArchive": "Base de données d'archive des métadonnées",
"storageLocation": "Emplacement des paramètres",
"proxySettings": "Paramètres du proxy",
"priorityTags": "Étiquettes prioritaires"
},
"storage": {
"locationLabel": "Mode portable",
"locationHelp": "Activez pour garder settings.json dans le dépôt ; désactivez pour le placer dans votre dossier de configuration utilisateur."
},
"contentFiltering": {
"blurNsfwContent": "Flouter le contenu NSFW",
"blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)",

View File

@@ -231,9 +231,14 @@
"exampleImages": "תמונות דוגמה",
"misc": "שונות",
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
"storageLocation": "מיקום ההגדרות",
"proxySettings": "הגדרות פרוקסי",
"priorityTags": "תגיות עדיפות"
},
"storage": {
"locationLabel": "מצב נייד",
"locationHelp": "הפעל כדי לשמור את settings.json בתוך המאגר; בטל כדי לשמור אותו בתיקיית ההגדרות של המשתמש."
},
"contentFiltering": {
"blurNsfwContent": "טשטש תוכן NSFW",
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",

View File

@@ -231,9 +231,14 @@
"exampleImages": "例画像",
"misc": "その他",
"metadataArchive": "メタデータアーカイブデータベース",
"storageLocation": "設定の場所",
"proxySettings": "プロキシ設定",
"priorityTags": "優先タグ"
},
"storage": {
"locationLabel": "ポータブルモード",
"locationHelp": "有効にすると settings.json をリポジトリ内に保持し、無効にするとユーザー設定ディレクトリに格納します。"
},
"contentFiltering": {
"blurNsfwContent": "NSFWコンテンツをぼかす",
"blurNsfwContentHelp": "成人向けNSFWコンテンツのプレビュー画像をぼかします",

View File

@@ -231,9 +231,14 @@
"exampleImages": "예시 이미지",
"misc": "기타",
"metadataArchive": "메타데이터 아카이브 데이터베이스",
"storageLocation": "설정 위치",
"proxySettings": "프록시 설정",
"priorityTags": "우선순위 태그"
},
"storage": {
"locationLabel": "휴대용 모드",
"locationHelp": "활성화하면 settings.json을 리포지토리에 유지하고, 비활성화하면 사용자 구성 디렉터리에 저장합니다."
},
"contentFiltering": {
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",

View File

@@ -231,9 +231,14 @@
"exampleImages": "Примеры изображений",
"misc": "Разное",
"metadataArchive": "Архив метаданных",
"storageLocation": "Расположение настроек",
"proxySettings": "Настройки прокси",
"priorityTags": "Приоритетные теги"
},
"storage": {
"locationLabel": "Портативный режим",
"locationHelp": "Включите, чтобы хранить settings.json в репозитории; выключите, чтобы сохранить его в папке конфигурации пользователя."
},
"contentFiltering": {
"blurNsfwContent": "Размывать NSFW контент",
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",

View File

@@ -231,9 +231,14 @@
"exampleImages": "示例图片",
"misc": "其他",
"metadataArchive": "元数据归档数据库",
"storageLocation": "设置位置",
"proxySettings": "代理设置",
"priorityTags": "优先标签"
},
"storage": {
"locationLabel": "便携模式",
"locationHelp": "开启可将 settings.json 保存在仓库中;关闭则保存在用户配置目录。"
},
"contentFiltering": {
"blurNsfwContent": "模糊 NSFW 内容",
"blurNsfwContentHelp": "模糊成熟NSFW内容预览图片",

View File

@@ -231,9 +231,14 @@
"exampleImages": "範例圖片",
"misc": "其他",
"metadataArchive": "中繼資料封存資料庫",
"storageLocation": "設定位置",
"proxySettings": "代理設定",
"priorityTags": "優先標籤"
},
"storage": {
"locationLabel": "可攜式模式",
"locationHelp": "啟用可將 settings.json 保存在儲存庫中;停用則保存在使用者設定目錄。"
},
"contentFiltering": {
"blurNsfwContent": "模糊 NSFW 內容",
"blurNsfwContentHelp": "模糊成熟NSFW內容預覽圖片",

View File

@@ -180,6 +180,7 @@ class SettingsHandler:
"download_path_templates",
"enable_metadata_archive_db",
"language",
"use_portable_settings",
"proxy_enabled",
"proxy_type",
"proxy_host",

View File

@@ -2,14 +2,17 @@ import asyncio
import copy
import json
import os
import shutil
import logging
from pathlib import Path
from datetime import datetime, timezone
from threading import Lock
from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
from platformdirs import user_config_dir
from ..utils.constants import DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG
from ..utils.settings_paths import ensure_settings_file
from ..utils.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path
from ..utils.tag_priorities import (
PriorityTagEntry,
collect_canonical_tags,
@@ -64,6 +67,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
class SettingsManager:
def __init__(self):
self.settings_file = ensure_settings_file(logger)
self._pending_portable_switch: Optional[Dict[str, str]] = None
self._standalone_mode = self._detect_standalone_mode()
self._startup_messages: List[Dict[str, Any]] = []
self._needs_initial_save = False
@@ -783,6 +787,10 @@ class SettingsManager:
def set(self, key: str, value: Any) -> None:
"""Set setting value and save"""
self.settings[key] = value
portable_switch_pending = False
if key == "use_portable_settings" and isinstance(value, bool):
portable_switch_pending = True
self._prepare_portable_switch(value)
if key == 'folder_paths' and isinstance(value, Mapping):
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
elif key == 'default_lora_root':
@@ -794,6 +802,8 @@ class SettingsManager:
elif key == 'model_name_display':
self._notify_model_name_display_change(value)
self._save_settings()
if portable_switch_pending:
self._finalize_portable_switch()
def delete(self, key: str) -> None:
"""Delete setting key and save"""
@@ -802,6 +812,113 @@ class SettingsManager:
self._save_settings()
logger.info(f"Deleted setting: {key}")
def _prepare_portable_switch(self, use_portable: bool) -> None:
"""Prepare switching the settings storage location."""
legacy_path = get_legacy_settings_path()
user_dir = self._get_user_config_directory()
user_settings_path = os.path.join(user_dir, "settings.json")
target_path = legacy_path if use_portable else user_settings_path
other_path = user_settings_path if use_portable else legacy_path
target_dir = os.path.dirname(target_path)
os.makedirs(target_dir, exist_ok=True)
previous_path = self.settings_file or target_path
previous_dir = os.path.dirname(previous_path) or target_dir
if os.path.abspath(previous_path) != os.path.abspath(target_path):
self._copy_model_cache_directory(previous_dir, target_dir)
self._pending_portable_switch = {"other_path": other_path}
self.settings_file = target_path
def _finalize_portable_switch(self) -> None:
"""Mirror the latest settings file to the secondary location."""
info = self._pending_portable_switch
if not info:
return
other_path = info.get("other_path")
current_path = self.settings_file
if not other_path or not current_path:
self._pending_portable_switch = None
return
if os.path.abspath(other_path) == os.path.abspath(current_path):
self._pending_portable_switch = None
return
other_dir = os.path.dirname(other_path) or os.path.dirname(current_path)
if other_dir:
os.makedirs(other_dir, exist_ok=True)
try:
shutil.copy2(current_path, other_path)
except Exception as exc:
logger.warning("Failed to mirror settings.json to %s: %s", other_path, exc)
finally:
self._pending_portable_switch = None
def _copy_model_cache_directory(self, source_dir: str, target_dir: str) -> None:
"""Copy model_cache artifacts when switching storage locations."""
if not source_dir or not target_dir:
return
source_cache_dir = os.path.join(source_dir, "model_cache")
target_cache_dir = os.path.join(target_dir, "model_cache")
if (
os.path.isdir(source_cache_dir)
and os.path.abspath(source_cache_dir) != os.path.abspath(target_cache_dir)
):
try:
shutil.copytree(source_cache_dir, target_cache_dir, dirs_exist_ok=True)
except Exception as exc:
logger.warning(
"Failed to copy model_cache directory from %s to %s: %s",
source_cache_dir,
target_cache_dir,
exc,
)
source_cache_file = os.path.join(source_dir, "model_cache.sqlite")
target_cache_file = os.path.join(target_dir, "model_cache.sqlite")
if (
os.path.isfile(source_cache_file)
and os.path.abspath(source_cache_file) != os.path.abspath(target_cache_file)
):
try:
shutil.copy2(source_cache_file, target_cache_file)
except Exception as exc:
logger.warning(
"Failed to copy model_cache.sqlite from %s to %s: %s",
source_cache_file,
target_cache_file,
exc,
)
def _get_user_config_directory(self) -> str:
"""Return the user configuration directory, falling back to ~/.config."""
try:
config_dir = user_config_dir(APP_NAME, appauthor=False) or ""
except Exception as exc: # pragma: no cover - defensive fallback
logger.warning("Failed to determine user config directory: %s", exc)
config_dir = ""
if not config_dir:
config_dir = os.path.join(os.path.expanduser("~"), f".config/{APP_NAME}")
try:
os.makedirs(config_dir, exist_ok=True)
except Exception as exc:
logger.warning("Failed to create user config directory %s: %s", config_dir, exc)
return config_dir
def _notify_model_name_display_change(self, value: Any) -> None:
"""Trigger cache resorting when the model name display preference updates."""

View File

@@ -371,6 +371,11 @@ export class SettingsManager {
showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false;
}
const usePortableCheckbox = document.getElementById('usePortableSettings');
if (usePortableCheckbox) {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
}
// Set video autoplay on hover setting
const autoplayOnHoverCheckbox = document.getElementById('autoplayOnHover');
if (autoplayOnHoverCheckbox) {

View File

@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
const DEFAULT_SETTINGS_BASE = Object.freeze({
civitai_api_key: '',
use_portable_settings: false,
language: 'en',
show_only_sfw: false,
enable_metadata_archive_db: false,

View File

@@ -38,6 +38,27 @@
</div>
</div>
<div class="settings-section">
<h3>{{ t('settings.sections.storageLocation') }}</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="usePortableSettings">{{ t('settings.storage.locationLabel') }}</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="usePortableSettings" {% if settings.get('use_portable_settings', False) %}checked{% endif %}
onchange="settingsManager.saveToggleSetting('usePortableSettings', 'use_portable_settings')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="input-help">
{{ t('settings.storage.locationHelp') }}
</div>
</div>
</div>
<div class="settings-section">
<h3>{{ t('settings.sections.contentFiltering') }}</h3>

View File

@@ -1,12 +1,13 @@
import asyncio
import copy
import threading
import json
import os
from concurrent.futures import Future
import pytest
from py.services import service_registry
from py.services import settings_manager as settings_manager_module
from py.services.settings_manager import SettingsManager
from py.utils import settings_paths
@@ -163,6 +164,68 @@ def _create_manager_with_settings(tmp_path, monkeypatch, initial_settings, *, sa
return mgr
def _setup_storage_paths(tmp_path, monkeypatch):
project_root = tmp_path / "repo"
project_root.mkdir(parents=True, exist_ok=True)
user_dir = tmp_path / "user_config"
user_dir.mkdir(parents=True, exist_ok=True)
user_settings_path = user_dir / "settings.json"
monkeypatch.setattr(
"py.services.settings_manager.ensure_settings_file",
lambda logger=None: str(user_settings_path),
)
monkeypatch.setattr(
settings_manager_module,
"user_config_dir",
lambda *args, **kwargs: str(user_dir),
)
monkeypatch.setattr(settings_paths, "get_project_root", lambda: str(project_root))
return project_root, user_dir, user_settings_path
def _populate_cache(root_dir, marker_name, db_text):
cache_dir = root_dir / "model_cache"
cache_dir.mkdir(exist_ok=True)
marker_file = cache_dir / marker_name
marker_file.write_text(marker_name, encoding="utf-8")
(root_dir / "model_cache.sqlite").write_text(db_text, encoding="utf-8")
def test_switch_to_portable_mode_copies_cache(tmp_path, monkeypatch):
project_root, user_dir, user_settings = _setup_storage_paths(tmp_path, monkeypatch)
_populate_cache(user_dir, "user_marker.txt", "user_db")
manager = SettingsManager()
manager.set("use_portable_settings", True)
assert manager.settings_file == str(project_root / "settings.json")
marker_copy = project_root / "model_cache" / "user_marker.txt"
assert marker_copy.read_text(encoding="utf-8") == "user_marker.txt"
assert (project_root / "model_cache.sqlite").read_text(encoding="utf-8") == "user_db"
assert user_settings.exists()
def test_switching_back_to_user_config_moves_cache(tmp_path, monkeypatch):
project_root, user_dir, user_settings = _setup_storage_paths(tmp_path, monkeypatch)
_populate_cache(user_dir, "user_marker.txt", "user_db")
manager = SettingsManager()
manager.set("use_portable_settings", True)
project_cache_dir = project_root / "model_cache"
project_cache_dir.mkdir(exist_ok=True)
(project_cache_dir / "project_marker.txt").write_text("project_marker", encoding="utf-8")
(project_root / "model_cache.sqlite").write_text("project_db", encoding="utf-8")
manager.set("use_portable_settings", False)
assert manager.settings_file == str(user_settings)
assert (user_dir / "model_cache" / "project_marker.txt").read_text(encoding="utf-8") == "project_marker"
assert (user_dir / "model_cache.sqlite").read_text(encoding="utf-8") == "project_db"
def test_download_path_template_parses_json_string(manager):
templates = {"lora": "{author}", "checkpoint": "{author}", "embedding": "{author}"}
manager.settings["download_path_templates"] = json.dumps(templates)
@@ -212,8 +275,7 @@ def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
loop = asyncio.new_event_loop()
thread = threading.Thread(target=loop.run_forever, daemon=True)
thread.start()
loop._thread_id = 1
class DummyScanner:
def __init__(self):
@@ -227,12 +289,16 @@ def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
dispatched_loops = []
futures = []
original_run_coroutine_threadsafe = asyncio.run_coroutine_threadsafe
def tracking_run_coroutine_threadsafe(coro, target_loop):
dispatched_loops.append(target_loop)
future = original_run_coroutine_threadsafe(coro, target_loop)
future = Future()
futures.append(future)
try:
result = asyncio.run(coro)
except Exception as exc:
future.set_exception(exc)
else:
future.set_result(result)
return future
def fake_get_service_sync(cls, name):
@@ -254,8 +320,7 @@ def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
assert dummy_scanner.calls == ["file_name"]
assert dispatched_loops == [dummy_scanner.loop]
finally:
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=1)
loop._thread_id = None
loop.close()