diff --git a/locales/de.json b/locales/de.json index a35c06e8..ad01fa97 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index 95be0d09..97aad0fc 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/es.json b/locales/es.json index 9596613e..56de455e 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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)", diff --git a/locales/fr.json b/locales/fr.json index 5d0d35a2..aa8af106 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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)", diff --git a/locales/he.json b/locales/he.json index 03ad7a2d..0cb19801 100644 --- a/locales/he.json +++ b/locales/he.json @@ -231,9 +231,14 @@ "exampleImages": "תמונות דוגמה", "misc": "שונות", "metadataArchive": "מסד נתונים של ארכיון מטא-דאטה", + "storageLocation": "מיקום ההגדרות", "proxySettings": "הגדרות פרוקסי", "priorityTags": "תגיות עדיפות" }, + "storage": { + "locationLabel": "מצב נייד", + "locationHelp": "הפעל כדי לשמור את settings.json בתוך המאגר; בטל כדי לשמור אותו בתיקיית ההגדרות של המשתמש." + }, "contentFiltering": { "blurNsfwContent": "טשטש תוכן NSFW", "blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)", diff --git a/locales/ja.json b/locales/ja.json index c8fbcc68..66b1b5ff 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -231,9 +231,14 @@ "exampleImages": "例画像", "misc": "その他", "metadataArchive": "メタデータアーカイブデータベース", + "storageLocation": "設定の場所", "proxySettings": "プロキシ設定", "priorityTags": "優先タグ" }, + "storage": { + "locationLabel": "ポータブルモード", + "locationHelp": "有効にすると settings.json をリポジトリ内に保持し、無効にするとユーザー設定ディレクトリに格納します。" + }, "contentFiltering": { "blurNsfwContent": "NSFWコンテンツをぼかす", "blurNsfwContentHelp": "成人向け(NSFW)コンテンツのプレビュー画像をぼかします", diff --git a/locales/ko.json b/locales/ko.json index 06195f3f..e735b42d 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -231,9 +231,14 @@ "exampleImages": "예시 이미지", "misc": "기타", "metadataArchive": "메타데이터 아카이브 데이터베이스", + "storageLocation": "설정 위치", "proxySettings": "프록시 설정", "priorityTags": "우선순위 태그" }, + "storage": { + "locationLabel": "휴대용 모드", + "locationHelp": "활성화하면 settings.json을 리포지토리에 유지하고, 비활성화하면 사용자 구성 디렉터리에 저장합니다." + }, "contentFiltering": { "blurNsfwContent": "NSFW 콘텐츠 블러 처리", "blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다", diff --git a/locales/ru.json b/locales/ru.json index c4186b78..158a9fb8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -231,9 +231,14 @@ "exampleImages": "Примеры изображений", "misc": "Разное", "metadataArchive": "Архив метаданных", + "storageLocation": "Расположение настроек", "proxySettings": "Настройки прокси", "priorityTags": "Приоритетные теги" }, + "storage": { + "locationLabel": "Портативный режим", + "locationHelp": "Включите, чтобы хранить settings.json в репозитории; выключите, чтобы сохранить его в папке конфигурации пользователя." + }, "contentFiltering": { "blurNsfwContent": "Размывать NSFW контент", "blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 8799c757..f1fd9cd9 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -231,9 +231,14 @@ "exampleImages": "示例图片", "misc": "其他", "metadataArchive": "元数据归档数据库", + "storageLocation": "设置位置", "proxySettings": "代理设置", "priorityTags": "优先标签" }, + "storage": { + "locationLabel": "便携模式", + "locationHelp": "开启可将 settings.json 保存在仓库中;关闭则保存在用户配置目录。" + }, "contentFiltering": { "blurNsfwContent": "模糊 NSFW 内容", "blurNsfwContentHelp": "模糊成熟(NSFW)内容预览图片", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 14240a30..c5301fb0 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -231,9 +231,14 @@ "exampleImages": "範例圖片", "misc": "其他", "metadataArchive": "中繼資料封存資料庫", + "storageLocation": "設定位置", "proxySettings": "代理設定", "priorityTags": "優先標籤" }, + "storage": { + "locationLabel": "可攜式模式", + "locationHelp": "啟用可將 settings.json 保存在儲存庫中;停用則保存在使用者設定目錄。" + }, "contentFiltering": { "blurNsfwContent": "模糊 NSFW 內容", "blurNsfwContentHelp": "模糊成熟(NSFW)內容預覽圖片", diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index cb170ece..b7750ce1 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -180,6 +180,7 @@ class SettingsHandler: "download_path_templates", "enable_metadata_archive_db", "language", + "use_portable_settings", "proxy_enabled", "proxy_type", "proxy_host", diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 3ba77183..37476f68 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -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.""" diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 6a925970..705afc43 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -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) { diff --git a/static/js/state/index.js b/static/js/state/index.js index 7df333c9..ee54c066 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -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, diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html index 4cbeab99..4b40e976 100644 --- a/templates/components/modals/settings_modal.html +++ b/templates/components/modals/settings_modal.html @@ -38,6 +38,27 @@ +
+

{{ t('settings.sections.storageLocation') }}

+
+
+
+ +
+
+ +
+
+
+ {{ t('settings.storage.locationHelp') }} +
+
+
+

{{ t('settings.sections.contentFiltering') }}

diff --git a/tests/services/test_settings_manager.py b/tests/services/test_settings_manager.py index c7dde02f..e8298aa6 100644 --- a/tests/services/test_settings_manager.py +++ b/tests/services/test_settings_manager.py @@ -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()