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 @@ +