diff --git a/docs/i18n-translation-guidelines.md b/docs/i18n-translation-guidelines.md index 76efce47..663560df 100644 --- a/docs/i18n-translation-guidelines.md +++ b/docs/i18n-translation-guidelines.md @@ -195,6 +195,14 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL). > root** noun while translating (`library root` → `model root`, R5); the count-bearing > `successMulti` uses the singular `success` key when only one copy was removed. +> **Status (2026-10, model-modal sidecar location):** the model modal's Location row gained +> an inline "open metadata location" icon button (rendered only in centralized sidecar +> storage mode), adding 5 keys (`modals.model.actions.openSidecarLocation` and the 4 +> `modals.model.openSidecarLocation.*` toasts). The renderings mirror each locale's sibling +> `openFileLocation` block verbatim with the §5 "metadata" noun swapped in, so no new +> terminology was introduced. All 9 locales are translated, so the "no remaining +> placeholders" claim holds again. + --- ## 1. Hard rules (do not violate) diff --git a/locales/de.json b/locales/de.json index fd34ccec..9aaafe7e 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "Auf Hugging Face ansehen", "viewCreatorProfile": "Ersteller-Profil anzeigen", "openFileLocation": "Dateispeicherort öffnen", + "openSidecarLocation": "Metadaten-Speicherort öffnen", "sendToWorkflow": "An ComfyUI senden", "sendToWorkflowText": "An ComfyUI senden", "copyHash": "Hash kopieren", @@ -1905,6 +1906,12 @@ "copied": "Pfad in die Zwischenablage kopiert: {{path}}", "clipboardFallback": "Pfad: {{path}}" }, + "openSidecarLocation": { + "success": "Metadaten-Speicherort erfolgreich geöffnet", + "failed": "Fehler beim Öffnen des Metadaten-Speicherorts", + "copied": "Pfad in die Zwischenablage kopiert: {{path}}", + "clipboardFallback": "Pfad: {{path}}" + }, "sendToWorkflow": { "noFilePath": "Kann nicht an ComfyUI senden: Kein Dateipfad verfügbar" }, diff --git a/locales/en.json b/locales/en.json index 0db5e294..ec26e99e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "View on Hugging Face", "viewCreatorProfile": "View Creator Profile", "openFileLocation": "Open File Location", + "openSidecarLocation": "Open metadata location", "sendToWorkflow": "Send to ComfyUI", "sendToWorkflowText": "Send to ComfyUI", "copyHash": "Copy hash", @@ -1905,6 +1906,12 @@ "copied": "Path copied to clipboard: {{path}}", "clipboardFallback": "Path: {{path}}" }, + "openSidecarLocation": { + "success": "Metadata location opened successfully", + "failed": "Failed to open metadata location", + "copied": "Path copied to clipboard: {{path}}", + "clipboardFallback": "Path: {{path}}" + }, "sendToWorkflow": { "noFilePath": "Unable to send to ComfyUI: No file path available" }, diff --git a/locales/es.json b/locales/es.json index b76ff5d9..35f1cbd6 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "Ver en Hugging Face", "viewCreatorProfile": "Ver perfil del creador", "openFileLocation": "Abrir ubicación del archivo", + "openSidecarLocation": "Abrir ubicación de los metadatos", "sendToWorkflow": "Enviar a ComfyUI", "sendToWorkflowText": "Enviar a ComfyUI", "copyHash": "Copiar hash", @@ -1905,6 +1906,12 @@ "copied": "Ruta copiada al portapapeles: {{path}}", "clipboardFallback": "Ruta: {{path}}" }, + "openSidecarLocation": { + "success": "Ubicación de los metadatos abierta exitosamente", + "failed": "Error al abrir la ubicación de los metadatos", + "copied": "Ruta copiada al portapapeles: {{path}}", + "clipboardFallback": "Ruta: {{path}}" + }, "sendToWorkflow": { "noFilePath": "No se puede enviar a ComfyUI: no hay ruta de archivo disponible" }, diff --git a/locales/fr.json b/locales/fr.json index 3842415d..66392892 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "Voir sur Hugging Face", "viewCreatorProfile": "Voir le profil du créateur", "openFileLocation": "Ouvrir l'emplacement du fichier", + "openSidecarLocation": "Ouvrir l'emplacement des métadonnées", "sendToWorkflow": "Envoyer vers ComfyUI", "sendToWorkflowText": "Envoyer vers ComfyUI", "copyHash": "Copier le hash", @@ -1905,6 +1906,12 @@ "copied": "Chemin copié dans le presse-papiers: {{path}}", "clipboardFallback": "Chemin: {{path}}" }, + "openSidecarLocation": { + "success": "Emplacement des métadonnées ouvert avec succès", + "failed": "Échec de l'ouverture de l'emplacement des métadonnées", + "copied": "Chemin copié dans le presse-papiers: {{path}}", + "clipboardFallback": "Chemin: {{path}}" + }, "sendToWorkflow": { "noFilePath": "Impossible d'envoyer vers ComfyUI : aucun chemin de fichier disponible" }, diff --git a/locales/he.json b/locales/he.json index aa752516..2713af84 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "צפייה ב-Hugging Face", "viewCreatorProfile": "הצג פרופיל יוצר", "openFileLocation": "פתח מיקום קובץ", + "openSidecarLocation": "פתח מיקום מטא-נתונים", "sendToWorkflow": "שלח ל-ComfyUI", "sendToWorkflowText": "שלח ל-ComfyUI", "copyHash": "העתק hash", @@ -1905,6 +1906,12 @@ "copied": "הנתיב הועתק ללוח העריכה: {{path}}", "clipboardFallback": "נתיב: {{path}}" }, + "openSidecarLocation": { + "success": "מיקום המטא-נתונים נפתח בהצלחה", + "failed": "פתיחת מיקום המטא-נתונים נכשלה", + "copied": "הנתיב הועתק ללוח העריכה: {{path}}", + "clipboardFallback": "נתיב: {{path}}" + }, "sendToWorkflow": { "noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין" }, diff --git a/locales/ja.json b/locales/ja.json index 806345e9..ae3f37c3 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "Hugging Face で見る", "viewCreatorProfile": "作成者プロフィールを表示", "openFileLocation": "ファイルの場所を開く", + "openSidecarLocation": "メタデータの場所を開く", "sendToWorkflow": "ComfyUI に送信", "sendToWorkflowText": "ComfyUI に送信", "copyHash": "ハッシュをコピー", @@ -1905,6 +1906,12 @@ "copied": "パスをクリップボードにコピーしました: {{path}}", "clipboardFallback": "パス: {{path}}" }, + "openSidecarLocation": { + "success": "メタデータの場所を正常に開きました", + "failed": "メタデータの場所を開くのに失敗しました", + "copied": "パスをクリップボードにコピーしました: {{path}}", + "clipboardFallback": "パス: {{path}}" + }, "sendToWorkflow": { "noFilePath": "ComfyUI に送信できません:ファイルパスがありません" }, diff --git a/locales/ko.json b/locales/ko.json index 944faf2a..e132b7dc 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "Hugging Face에서 보기", "viewCreatorProfile": "제작자 프로필 보기", "openFileLocation": "파일 위치 열기", + "openSidecarLocation": "메타데이터 위치 열기", "sendToWorkflow": "ComfyUI로 보내기", "sendToWorkflowText": "ComfyUI로 보내기", "copyHash": "해시 복사", @@ -1905,6 +1906,12 @@ "copied": "경로가 클립보드에 복사되었습니다: {{path}}", "clipboardFallback": "경로: {{path}}" }, + "openSidecarLocation": { + "success": "메타데이터 위치가 성공적으로 열렸습니다", + "failed": "메타데이터 위치 열기에 실패했습니다", + "copied": "경로가 클립보드에 복사되었습니다: {{path}}", + "clipboardFallback": "경로: {{path}}" + }, "sendToWorkflow": { "noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다" }, diff --git a/locales/ru.json b/locales/ru.json index 8eaf2477..59856844 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "Открыть Hugging Face", "viewCreatorProfile": "Посмотреть профиль создателя", "openFileLocation": "Открыть расположение файла", + "openSidecarLocation": "Открыть расположение метаданных", "sendToWorkflow": "Отправить в ComfyUI", "sendToWorkflowText": "Отправить в ComfyUI", "copyHash": "Копировать хеш", @@ -1905,6 +1906,12 @@ "copied": "Путь скопирован в буфер обмена: {{path}}", "clipboardFallback": "Путь: {{path}}" }, + "openSidecarLocation": { + "success": "Расположение метаданных успешно открыто", + "failed": "Не удалось открыть расположение метаданных", + "copied": "Путь скопирован в буфер обмена: {{path}}", + "clipboardFallback": "Путь: {{path}}" + }, "sendToWorkflow": { "noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен" }, diff --git a/locales/zh-CN.json b/locales/zh-CN.json index dbea1cf1..1960b297 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "在 Hugging Face 查看", "viewCreatorProfile": "查看创作者主页", "openFileLocation": "打开文件位置", + "openSidecarLocation": "打开元数据位置", "sendToWorkflow": "发送到 ComfyUI", "sendToWorkflowText": "发送到 ComfyUI", "copyHash": "复制哈希值", @@ -1905,6 +1906,12 @@ "copied": "路径已复制到剪贴板:{{path}}", "clipboardFallback": "路径:{{path}}" }, + "openSidecarLocation": { + "success": "元数据位置已成功打开", + "failed": "打开元数据位置失败", + "copied": "路径已复制到剪贴板:{{path}}", + "clipboardFallback": "路径:{{path}}" + }, "sendToWorkflow": { "noFilePath": "无法发送到 ComfyUI:没有可用的文件路径" }, diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 043c2cf7..6f1a8f6e 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1892,6 +1892,7 @@ "viewOnHuggingFaceText": "在 Hugging Face 查看", "viewCreatorProfile": "查看創作者個人檔案", "openFileLocation": "開啟檔案位置", + "openSidecarLocation": "開啟中繼資料位置", "sendToWorkflow": "傳送到 ComfyUI", "sendToWorkflowText": "傳送到 ComfyUI", "copyHash": "複製雜湊值", @@ -1905,6 +1906,12 @@ "copied": "路徑已複製到剪貼簿:{{path}}", "clipboardFallback": "路徑:{{path}}" }, + "openSidecarLocation": { + "success": "中繼資料位置已成功開啟", + "failed": "開啟中繼資料位置失敗", + "copied": "路徑已複製到剪貼簿:{{path}}", + "clipboardFallback": "路徑:{{path}}" + }, "sendToWorkflow": { "noFilePath": "無法傳送到 ComfyUI:沒有可用的檔案路徑" }, diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index e226c1ee..72465e84 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -77,6 +77,7 @@ from ...utils.sidecar_paths import ( get_configured_sidecar_root, get_metadata_path, get_preview_dir, + get_sidecar_dir, get_storage_mode, get_unmatched_sidecar_components, ) @@ -3497,6 +3498,63 @@ class FileSystemHandler: {"success": True, "message": f"Opened folder: {path}", "path": path} ) + async def _reveal_file(self, file_path: str) -> web.Response: + """Open the platform file manager with ``file_path`` selected.""" + if os.name == "nt": + subprocess.Popen(["explorer", "/select,", file_path]) + elif os.name == "posix": + if _is_docker(): + return web.json_response( + { + "success": True, + "message": "Running in Docker: Path available for copying", + "path": file_path, + "mode": "clipboard", + } + ) + elif _is_wsl(): + windows_path = _wsl_to_windows_path(file_path) + if windows_path: + subprocess.Popen(["explorer.exe", "/select,", windows_path]) + else: + logger.error( + "Failed to convert WSL path to Windows path: %s", file_path + ) + return web.json_response( + { + "success": False, + "error": "Failed to open file location: path conversion error", + }, + status=500, + ) + elif sys.platform == "darwin": + subprocess.Popen(["open", "-R", file_path]) + else: + if not _has_gui_display(): + # Headless/SSH session: no file manager to open, so + # hand the path to the browser for copying instead + # of reporting a success that never happened. + return web.json_response( + { + "success": True, + "message": "Headless session: path available for copying", + "path": file_path, + "mode": "clipboard", + } + ) + selected = _select_in_linux_file_manager(file_path) + if not selected: + folder = os.path.dirname(file_path) + subprocess.Popen(["xdg-open", folder]) + + return web.json_response( + { + "success": True, + "message": f"Opened folder and selected file: {file_path}", + "path": file_path, + } + ) + async def open_file_location(self, request: web.Request) -> web.Response: try: data = await request.json() @@ -3512,63 +3570,49 @@ class FileSystemHandler: {"success": False, "error": "File does not exist"}, status=404 ) - if os.name == "nt": - subprocess.Popen(["explorer", "/select,", file_path]) - elif os.name == "posix": - if _is_docker(): - return web.json_response( - { - "success": True, - "message": "Running in Docker: Path available for copying", - "path": file_path, - "mode": "clipboard", - } - ) - elif _is_wsl(): - windows_path = _wsl_to_windows_path(file_path) - if windows_path: - subprocess.Popen(["explorer.exe", "/select,", windows_path]) - else: - logger.error( - "Failed to convert WSL path to Windows path: %s", file_path - ) - return web.json_response( - { - "success": False, - "error": "Failed to open file location: path conversion error", - }, - status=500, - ) - elif sys.platform == "darwin": - subprocess.Popen(["open", "-R", file_path]) - else: - if not _has_gui_display(): - # Headless/SSH session: no file manager to open, so - # hand the path to the browser for copying instead - # of reporting a success that never happened. - return web.json_response( - { - "success": True, - "message": "Headless session: path available for copying", - "path": file_path, - "mode": "clipboard", - } - ) - selected = _select_in_linux_file_manager(file_path) - if not selected: - folder = os.path.dirname(file_path) - subprocess.Popen(["xdg-open", folder]) - - return web.json_response( - { - "success": True, - "message": f"Opened folder and selected file: {file_path}", - } - ) + return await self._reveal_file(file_path) except Exception as exc: # pragma: no cover - defensive logging logger.error("Failed to open file location: %s", exc, exc_info=True) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def open_model_sidecar_location(self, request: web.Request) -> web.Response: + """Reveal the model's ``.metadata.json`` sidecar in the file manager. + + Sidecar/preview path derivation must go through + :mod:`py.utils.sidecar_paths` so centralized storage mode resolves to + the mirror tree. When the sidecar has not been written yet, the + containing sidecar directory is opened instead. + """ + try: + data = await request.json() + file_path = data.get("file_path") + if not file_path: + return web.json_response( + {"success": False, "error": "Missing file_path parameter"}, + status=400, + ) + file_path = os.path.abspath(file_path) + if not os.path.isfile(file_path): + return web.json_response( + {"success": False, "error": "File does not exist"}, status=404 + ) + + metadata_path = get_metadata_path(file_path) + if os.path.isfile(metadata_path): + return await self._reveal_file(metadata_path) + + sidecar_dir = get_sidecar_dir(file_path) + if os.path.isdir(sidecar_dir): + return await self._open_path(sidecar_dir) + + return web.json_response( + {"success": False, "error": "Sidecar does not exist yet"}, + status=404, + ) + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Failed to open sidecar location: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + async def open_settings_location(self, request: web.Request) -> web.Response: try: settings_file = getattr(self._settings, "settings_file", None) @@ -4454,6 +4498,7 @@ class MiscHandlerSet: "open_backup_location": self.filesystem.open_backup_location, "open_wildcards_location": self.filesystem.open_wildcards_location, "open_sidecar_location": self.filesystem.open_sidecar_location, + "open_model_sidecar_location": self.filesystem.open_model_sidecar_location, "browse_directory": self.filesystem.browse_directory, "validate_path": self.filesystem.validate_path, "search_custom_words": self.custom_words.search_custom_words, diff --git a/py/routes/misc_route_registrar.py b/py/routes/misc_route_registrar.py index 73e5311d..d43eaf5e 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -125,6 +125,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition( "POST", "/api/lm/sidecars/open-location", "open_sidecar_location" ), + RouteDefinition( + "POST", "/api/lm/models/open-sidecar-location", "open_model_sidecar_location" + ), RouteDefinition( "POST", "/api/lm/download-model-source", "download_model_source" ), diff --git a/static/css/components/lora-modal/lora-modal.css b/static/css/components/lora-modal/lora-modal.css index 4604fea2..cc2b77d9 100644 --- a/static/css/components/lora-modal/lora-modal.css +++ b/static/css/components/lora-modal/lora-modal.css @@ -349,6 +349,34 @@ font-size: 0.9em; } +.file-path-wrapper { + display: flex; + align-items: baseline; + gap: 6px; +} + +.file-path-wrapper .file-path { + flex: 1; + min-width: 0; + word-break: break-all; +} + +.location-action-btn { + background: transparent; + border: none; + color: var(--lora-accent); + cursor: pointer; + padding: 2px 5px; + border-radius: var(--border-radius-xs); + flex-shrink: 0; + opacity: 0.7; +} + +.location-action-btn:hover { + opacity: 1; + background: rgba(66, 153, 225, 0.1); +} + .file-path[data-action="open-file-location"] { cursor: pointer; text-decoration: underline; diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index 9e48ebea..2a306daa 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -365,6 +365,16 @@ export async function showModelModal(model, modelType) { }; const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || ''); const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A'); + // Centralized sidecar storage moves .metadata.json into a mirror tree, so + // the model path link can no longer reveal it — offer a dedicated entry + // only in that mode (alongside mode keeps sidecars next to the model). + const sidecarLocationButton = state.global.settings.sidecar_storage_mode === 'centralized' + ? `` + : ''; // De-emphasized hash display: a borderless full-width footnote line below // the info grid — sha256 middle-truncated (first 10 + last 6), autov3 in // full (12 chars); the full value is copied via data-hash. Civitai model / @@ -697,11 +707,14 @@ export async function showModelModal(model, modelType) {