From 9584fa85c9ef67b015e2802dc23a42f7d53ba2cc Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 1 Sep 2026 21:58:47 +0800 Subject: [PATCH] feat(recipes): add location open and recipe ID copy to recipe modal Add a de-emphasized meta footer to the recipe modal, mirroring the model modal's hash footnote: a clickable file location on the left (opens the recipe JSON via the generic open-file-location route, with the Docker clipboard fallback) and a middle-truncated recipe ID with copy button on the right. The recipe detail API now exposes recipe_json_path so the frontend does not have to guess the on-disk storage layout. Translations for the new recipes.modal.* keys are filled in for all 9 locales, reusing the model modal's openFileLocation wording per locale. --- locales/de.json | 15 + locales/en.json | 15 + locales/es.json | 15 + locales/fr.json | 15 + locales/he.json | 15 + locales/ja.json | 15 + locales/ko.json | 15 + locales/ru.json | 15 + locales/zh-CN.json | 15 + locales/zh-TW.json | 15 + py/routes/handlers/recipe_handlers.py | 11 + static/css/components/recipe-modal.css | 90 ++++++ static/js/components/RecipeModal.js | 113 +++++++- templates/components/recipe_modal.html | 5 +- .../components/recipeModal.metaFooter.test.js | 270 ++++++++++++++++++ tests/routes/test_recipe_routes.py | 28 ++ 16 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 tests/frontend/components/recipeModal.metaFooter.test.js diff --git a/locales/de.json b/locales/de.json index fbc6d4e2..35c803a6 100644 --- a/locales/de.json +++ b/locales/de.json @@ -868,6 +868,21 @@ "previousWithShortcut": "Vorheriges Rezept (←)", "nextWithShortcut": "Nächstes Rezept (→)" }, + "modal": { + "metadata": { + "id": "ID" + }, + "actions": { + "openFileLocation": "Dateispeicherort öffnen", + "copyId": "Rezept-ID kopieren" + }, + "openFileLocation": { + "success": "Dateispeicherort erfolgreich geöffnet", + "failed": "Fehler beim Öffnen des Dateispeicherorts", + "copied": "Pfad in die Zwischenablage kopiert: {{path}}", + "clipboardFallback": "Pfad: {{path}}" + } + }, "workflow": { "sendWorkflow": "Workflow an ComfyUI senden", "sent": "Workflow an ComfyUI gesendet", diff --git a/locales/en.json b/locales/en.json index f52c8e10..cc87adc4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -868,6 +868,21 @@ "previousWithShortcut": "Previous recipe (←)", "nextWithShortcut": "Next recipe (→)" }, + "modal": { + "metadata": { + "id": "ID" + }, + "actions": { + "openFileLocation": "Open File Location", + "copyId": "Copy recipe ID" + }, + "openFileLocation": { + "success": "File location opened successfully", + "failed": "Failed to open file location", + "copied": "Path copied to clipboard: {{path}}", + "clipboardFallback": "Path: {{path}}" + } + }, "workflow": { "sendWorkflow": "Send Workflow to ComfyUI", "sent": "Workflow sent to ComfyUI", diff --git a/locales/es.json b/locales/es.json index 56c2162a..721f9e51 100644 --- a/locales/es.json +++ b/locales/es.json @@ -868,6 +868,21 @@ "previousWithShortcut": "Receta anterior (←)", "nextWithShortcut": "Siguiente receta (→)" }, + "modal": { + "metadata": { + "id": "ID" + }, + "actions": { + "openFileLocation": "Abrir ubicación del archivo", + "copyId": "Copiar ID de la receta" + }, + "openFileLocation": { + "success": "Ubicación del archivo abierta exitosamente", + "failed": "Error al abrir la ubicación del archivo", + "copied": "Ruta copiada al portapapeles: {{path}}", + "clipboardFallback": "Ruta: {{path}}" + } + }, "workflow": { "sendWorkflow": "Enviar workflow a ComfyUI", "sent": "Workflow enviado a ComfyUI", diff --git a/locales/fr.json b/locales/fr.json index 09b54565..541e38b6 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -868,6 +868,21 @@ "previousWithShortcut": "Recette précédente (←)", "nextWithShortcut": "Recette suivante (→)" }, + "modal": { + "metadata": { + "id": "ID" + }, + "actions": { + "openFileLocation": "Ouvrir l’emplacement du fichier", + "copyId": "Copier l’ID de la Recipe" + }, + "openFileLocation": { + "success": "Emplacement du fichier ouvert avec succès", + "failed": "Échec de l’ouverture de l’emplacement du fichier", + "copied": "Chemin copié dans le presse-papiers: {{path}}", + "clipboardFallback": "Chemin: {{path}}" + } + }, "workflow": { "sendWorkflow": "Envoyer le workflow vers ComfyUI", "sent": "Workflow envoyé vers ComfyUI", diff --git a/locales/he.json b/locales/he.json index e05ed992..62a6339a 100644 --- a/locales/he.json +++ b/locales/he.json @@ -868,6 +868,21 @@ "previousWithShortcut": "המתכון הקודם (←)", "nextWithShortcut": "המתכון הבא (→)" }, + "modal": { + "metadata": { + "id": "ID" + }, + "actions": { + "openFileLocation": "פתח מיקום קובץ", + "copyId": "העתק מזהה מתכון" + }, + "openFileLocation": { + "success": "מיקום הקובץ נפתח בהצלחה", + "failed": "פתיחת מיקום הקובץ נכשלה", + "copied": "הנתיב הועתק ללוח העריכה: {{path}}", + "clipboardFallback": "נתיב: {{path}}" + } + }, "workflow": { "sendWorkflow": "שלח workflow ל-ComfyUI", "sent": "ה-workflow נשלח ל-ComfyUI", diff --git a/locales/ja.json b/locales/ja.json index f7c59569..3cfc07cc 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -868,6 +868,21 @@ "previousWithShortcut": "前のレシピ(←)", "nextWithShortcut": "次のレシピ(→)" }, + "modal": { + "metadata": { + "id": "ID" + }, + "actions": { + "openFileLocation": "ファイルの場所を開く", + "copyId": "レシピIDをコピー" + }, + "openFileLocation": { + "success": "ファイルの場所を正常に開きました", + "failed": "ファイルの場所を開くのに失敗しました", + "copied": "パスをクリップボードにコピーしました: {{path}}", + "clipboardFallback": "パス: {{path}}" + } + }, "workflow": { "sendWorkflow": "ワークフローをComfyUIへ送信", "sent": "ワークフローをComfyUIへ送信しました", diff --git a/locales/ko.json b/locales/ko.json index b414fa2e..abcdb9f0 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -868,6 +868,21 @@ "previousWithShortcut": "이전 레시피(←)", "nextWithShortcut": "다음 레시피(→)" }, + "modal": { + "metadata": { + "id": "ID" + }, + "actions": { + "openFileLocation": "파일 위치 열기", + "copyId": "레시피 ID 복사" + }, + "openFileLocation": { + "success": "파일 위치가 성공적으로 열렸습니다", + "failed": "파일 위치 열기에 실패했습니다", + "copied": "경로가 클립보드에 복사되었습니다: {{path}}", + "clipboardFallback": "경로: {{path}}" + } + }, "workflow": { "sendWorkflow": "워크플로를 ComfyUI로 보내기", "sent": "워크플로를 ComfyUI로 보냈습니다", diff --git a/locales/ru.json b/locales/ru.json index f91036c4..42545594 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -868,6 +868,21 @@ "previousWithShortcut": "Предыдущий рецепт (←)", "nextWithShortcut": "Следующий рецепт (→)" }, + "modal": { + "metadata": { + "id": "ID" + }, + "actions": { + "openFileLocation": "Открыть расположение файла", + "copyId": "Копировать ID рецепта" + }, + "openFileLocation": { + "success": "Расположение файла успешно открыто", + "failed": "Не удалось открыть расположение файла", + "copied": "Путь скопирован в буфер обмена: {{path}}", + "clipboardFallback": "Путь: {{path}}" + } + }, "workflow": { "sendWorkflow": "Отправить workflow в ComfyUI", "sent": "Workflow отправлен в ComfyUI", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 34b3e18f..792e329b 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -868,6 +868,21 @@ "previousWithShortcut": "上一个配方(←)", "nextWithShortcut": "下一个配方(→)" }, + "modal": { + "metadata": { + "id": "ID" + }, + "actions": { + "openFileLocation": "打开文件位置", + "copyId": "复制配方 ID" + }, + "openFileLocation": { + "success": "文件位置已成功打开", + "failed": "打开文件位置失败", + "copied": "路径已复制到剪贴板:{{path}}", + "clipboardFallback": "路径:{{path}}" + } + }, "workflow": { "sendWorkflow": "发送工作流到 ComfyUI", "sent": "工作流已发送到 ComfyUI", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 21c82329..037305b8 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -868,6 +868,21 @@ "previousWithShortcut": "上一個配方(←)", "nextWithShortcut": "下一個配方(→)" }, + "modal": { + "metadata": { + "id": "ID" + }, + "actions": { + "openFileLocation": "開啟檔案位置", + "copyId": "複製配方 ID" + }, + "openFileLocation": { + "success": "檔案位置已成功開啟", + "failed": "開啟檔案位置失敗", + "copied": "路徑已複製到剪貼簿:{{path}}", + "clipboardFallback": "路徑:{{path}}" + } + }, "workflow": { "sendWorkflow": "傳送工作流到 ComfyUI", "sent": "工作流已傳送到 ComfyUI", diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 2827123b..e990ee3c 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -353,6 +353,17 @@ class RecipeListingHandler: if not recipe: return web.json_response({"error": "Recipe not found"}, status=404) + + # Expose the on-disk recipe JSON path so the modal can offer + # "open file location" without guessing the storage layout. + recipe = dict(recipe) + try: + json_path = await recipe_scanner.get_recipe_json_path(recipe_id) + except Exception: # pragma: no cover - details must still load + json_path = None + if json_path: + recipe["recipe_json_path"] = json_path + return web.json_response(recipe) except Exception as exc: self._logger.error( diff --git a/static/css/components/recipe-modal.css b/static/css/components/recipe-modal.css index d9819830..6f061a69 100644 --- a/static/css/components/recipe-modal.css +++ b/static/css/components/recipe-modal.css @@ -1547,3 +1547,93 @@ width: 20px; height: calc(1em * 1.3); } + +/* Meta footer: de-emphasized location + recipe ID line below the modal body, + mirroring the hash footnote in the shared model modal. Location sits left + (tail of the path survives truncation), ID + copy button sit right. */ +.recipe-meta-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 6px; + margin-top: 8px; + border-top: 1px solid var(--border-color); + font-size: 0.75em; + color: var(--text-muted); +} + +.recipe-meta-footer[hidden] { + display: none; +} + +.recipe-meta-location { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + cursor: pointer; +} + +.recipe-meta-location i { + flex-shrink: 0; + opacity: 0.6; +} + +.recipe-meta-location-path { + font-family: var(--font-mono, monospace); + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recipe-meta-location:hover .recipe-meta-location-path, +.recipe-meta-location:focus-visible .recipe-meta-location-path { + opacity: 1; + text-decoration: underline; +} + +.recipe-meta-location:focus-visible { + outline: 1px solid var(--lora-accent); + outline-offset: 2px; + border-radius: var(--border-radius-xs); +} + +.recipe-meta-id { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.recipe-meta-id-label { + font-size: 0.9em; + opacity: 0.5; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.recipe-meta-id-value { + font-family: var(--font-mono, monospace); + opacity: 0.7; + white-space: nowrap; +} + +.recipe-meta-copy-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 2px; + border: none; + background: none; + color: var(--text-muted); + opacity: 0.35; + font-size: 0.95em; + cursor: pointer; + flex-shrink: 0; +} + +.recipe-meta-copy-btn:hover { + opacity: 0.9; +} diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 38b0c663..e613d0f6 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -10,7 +10,7 @@ import { downloadManager } from '../managers/DownloadManager.js'; import { MODEL_TYPES } from '../api/apiConfig.js'; import { openMediaViewer } from './shared/MediaViewer.js'; import { showRecipeDeleteConfirmation } from './RecipeCard.js'; -import { renderCompactTags, setupTagTooltip } from './shared/utils.js'; +import { renderCompactTags, setupTagTooltip, escapeAttribute } from './shared/utils.js'; import { setupTagEditMode } from './shared/ModelTags.js'; import { Combobox } from './Combobox.js'; @@ -66,6 +66,39 @@ function escapeHtml(value) { .replace(/'/g, '''); } +/** + * Call backend to open the recipe's file location and select the file. + * Mirrors the model modal's openFileLocation, including the Docker + * clipboard fallback. + * @param {string} filePath + */ +async function openRecipeFileLocation(filePath) { + try { + const resp = await fetch('/api/lm/open-file-location', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ 'file_path': filePath }) + }); + if (!resp.ok) throw new Error('Failed to open file location'); + + const data = await resp.json(); + + if (data.mode === 'clipboard' && data.path) { + try { + await navigator.clipboard.writeText(data.path); + showToast('recipes.modal.openFileLocation.copied', { path: data.path }, 'success'); + } catch (clipboardErr) { + console.warn('Clipboard API not available:', clipboardErr); + showToast('recipes.modal.openFileLocation.clipboardFallback', { path: data.path }, 'info'); + } + } else { + showToast('recipes.modal.openFileLocation.success', {}, 'success'); + } + } catch (err) { + showToast('recipes.modal.openFileLocation.failed', {}, 'error'); + } +} + // Fallback English strings for the collapsed "Why no LoRAs?" panel. // Translations live in locales/*.json under recipes.resources. const NO_LORAS_REASON_FALLBACKS = { @@ -421,6 +454,7 @@ class RecipeModal { this.syncGenerationParams(hydratedRecipe.gen_params); this.syncResourcesSection(hydratedRecipe); this.syncHeaderActions(); + this.syncMetaFooter(); // Show the modal modalManager.showModal('recipeModal', null, null, () => this.cleanupNavigationShortcuts()); @@ -446,6 +480,79 @@ class RecipeModal { } } + /** + * Render the meta footer: clickable file location (opens the recipe JSON + * in the OS file manager) plus the truncated recipe ID with copy button. + * De-emphasized by design, mirroring the model modal's hash footnote. + */ + syncMetaFooter() { + const footer = document.getElementById('recipeMetaFooter'); + if (!footer) { + return; + } + + const recipeId = this.currentRecipe?.id || ''; + const filePath = this.currentRecipe?.file_path || ''; + const openTarget = this.currentRecipe?.recipe_json_path || filePath; + const folderPath = filePath.replace(/[^/\\]+$/, ''); + + if (!recipeId && !folderPath) { + footer.hidden = true; + footer.innerHTML = ''; + return; + } + + const truncatedId = recipeId.length > 14 + ? `${recipeId.slice(0, 8)}…${recipeId.slice(-4)}` + : recipeId; + const openLocationLabel = translate('recipes.modal.actions.openFileLocation', {}, 'Open File Location'); + const copyIdLabel = translate('recipes.modal.actions.copyId', {}, 'Copy recipe ID'); + + const locationMarkup = folderPath ? ` + + + ${escapeHtml(folderPath)} + ` : ''; + + const idMarkup = recipeId ? ` + + ${translate('recipes.modal.metadata.id', {}, 'ID')} + ${escapeHtml(truncatedId)} + + ` : ''; + + footer.innerHTML = locationMarkup + idMarkup; + footer.hidden = false; + + const locationEl = footer.querySelector('.recipe-meta-location'); + if (locationEl) { + const openLocation = () => { + if (locationEl.dataset.filepath) { + openRecipeFileLocation(locationEl.dataset.filepath); + } + }; + locationEl.addEventListener('click', openLocation); + locationEl.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openLocation(); + } + }); + } + + const copyBtn = footer.querySelector('.recipe-meta-copy-btn'); + if (copyBtn && recipeId) { + copyBtn.addEventListener('click', () => { + copyToClipboard(recipeId); + }); + } + } + async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) { try { const fullRecipe = await fetchRecipeDetails(recipeId); @@ -471,6 +578,9 @@ class RecipeModal { if (fullRecipe.file_path !== undefined) { nextRecipe.file_path = fullRecipe.file_path; } + if (fullRecipe.recipe_json_path !== undefined) { + nextRecipe.recipe_json_path = fullRecipe.recipe_json_path; + } if (fullRecipe.file_url !== undefined) { nextRecipe.file_url = fullRecipe.file_url; } @@ -568,6 +678,7 @@ class RecipeModal { this.updateSourceUrlDisplay(this.currentRecipe.source_path || ''); } this.syncHeaderActions(); + this.syncMetaFooter(); } getPreviewMediaUrl(recipe = {}) { diff --git a/templates/components/recipe_modal.html b/templates/components/recipe_modal.html index 8ab7ec10..e0985473 100644 --- a/templates/components/recipe_modal.html +++ b/templates/components/recipe_modal.html @@ -144,5 +144,8 @@ - + + + + diff --git a/tests/frontend/components/recipeModal.metaFooter.test.js b/tests/frontend/components/recipeModal.metaFooter.test.js new file mode 100644 index 00000000..b11871a8 --- /dev/null +++ b/tests/frontend/components/recipeModal.metaFooter.test.js @@ -0,0 +1,270 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const copyToClipboardMock = vi.fn(); +const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)); + +const loadingManagerStub = { + showSimpleLoading: vi.fn(), + hide: vi.fn(), + show: vi.fn(), + restoreProgressBar: vi.fn(), +}; + +const recipeItem = { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + file_path: '/recipes/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png', + title: 'Demo Recipe', + tags: [], + loras: [], +}; + +const virtualScrollerStub = { + updateSingleItem: vi.fn(), + getNavigationState: vi.fn(() => ({ + index: 0, + hasPrev: false, + hasNext: false, + loadedItems: 1, + totalItems: 1, + })), + getAdjacentItemByFilePath: vi.fn(async () => null), +}; + +const stateStub = { + global: { settings: {}, loadingManager: loadingManagerStub }, + loadingManager: loadingManagerStub, + virtualScroller: virtualScrollerStub, +}; + +const modalManagerMock = { + showModal: vi.fn(), + closeModal: vi.fn(), +}; + +const fetchRecipeDetailsMock = vi.fn(async () => ({})); +const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true })); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, + copyToClipboard: copyToClipboardMock, + sendLoraToWorkflow: vi.fn(), + sendModelPathToWorkflow: vi.fn(), + openCivitaiByMetadata: vi.fn(), + stripLoraTags: vi.fn((text) => text), + sendPromptToWorkflow: vi.fn(), + sendGenParamsToWorkflow: vi.fn(), +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: translateMock, +})); + +vi.mock('../../../static/js/state/index.js', () => ({ + state: stateStub, +})); + +vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ + setSessionItem: vi.fn(), + removeSessionItem: vi.fn(), + getStorageItem: vi.fn(() => null), + setStorageItem: vi.fn(), +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + fetchRecipeDetails: fetchRecipeDetailsMock, + updateRecipeMetadata: updateRecipeMetadataMock, + sendRecipeWorkflow: vi.fn(), +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + MODEL_TYPES: { + LORA: 'loras', + CHECKPOINT: 'checkpoints', + EMBEDDING: 'embeddings', + }, +})); + +function recipeModalFixture() { + return ` + + `; +} + +async function flushAsyncTasks() { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +const createdModals = []; + +async function createRecipeModal() { + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + createdModals.push(recipeModal); + return recipeModal; +} + +function openLocationFetchCalls() { + return global.fetch.mock.calls.filter(([url]) => url === '/api/lm/open-file-location'); +} + +describe('RecipeModal meta footer', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = recipeModalFixture(); + global.modalManager = modalManagerMock; + global.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({}), + })); + }); + + afterEach(() => { + createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts()); + createdModals.length = 0; + document.body.innerHTML = ''; + delete global.modalManager; + delete global.fetch; + }); + + it('shows the folder path and a middle-truncated recipe ID', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.showRecipeDetails(recipeItem); + + const footer = document.getElementById('recipeMetaFooter'); + expect(footer.hidden).toBe(false); + + const location = footer.querySelector('.recipe-meta-location'); + expect(location.querySelector('.recipe-meta-location-path').textContent).toBe('/recipes/'); + expect(location.dataset.filepath).toBe(recipeItem.file_path); + + const idValue = footer.querySelector('.recipe-meta-id-value'); + expect(idValue.textContent).toBe('a1b2c3d4…7890'); + expect(idValue.getAttribute('title')).toBe(recipeItem.id); + }); + + it('copies the full recipe ID when the copy button is clicked', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.showRecipeDetails(recipeItem); + + document.querySelector('.recipe-meta-copy-btn').click(); + + expect(copyToClipboardMock).toHaveBeenCalledWith(recipeItem.id); + }); + + it('opens the recipe file location when the path is clicked', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.showRecipeDetails(recipeItem); + + document.querySelector('.recipe-meta-location').click(); + await flushAsyncTasks(); + + const calls = openLocationFetchCalls(); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0][1].body)).toEqual({ file_path: recipeItem.file_path }); + expect(showToastMock).toHaveBeenCalledWith('recipes.modal.openFileLocation.success', {}, 'success'); + }); + + it('opens the recipe JSON path once hydration provides it', async () => { + const jsonPath = '/recipes/a1b2c3d4-e5f6-7890-abcd-ef1234567890.recipe.json'; + fetchRecipeDetailsMock.mockResolvedValueOnce({ + id: recipeItem.id, + file_path: recipeItem.file_path, + recipe_json_path: jsonPath, + }); + + const recipeModal = await createRecipeModal(); + recipeModal.showRecipeDetails(recipeItem); + await flushAsyncTasks(); + + const location = document.querySelector('.recipe-meta-location'); + expect(location.dataset.filepath).toBe(jsonPath); + }); + + it('copies the path to clipboard when the backend reports clipboard mode', async () => { + const writeTextMock = vi.fn(async () => {}); + Object.defineProperty(window.navigator, 'clipboard', { + value: { writeText: writeTextMock }, + configurable: true, + }); + + global.fetch = vi.fn(async (url) => ({ + ok: true, + json: async () => (url === '/api/lm/open-file-location' + ? { mode: 'clipboard', path: '/recipes' } + : {}), + })); + + const recipeModal = await createRecipeModal(); + recipeModal.showRecipeDetails(recipeItem); + + document.querySelector('.recipe-meta-location').click(); + await flushAsyncTasks(); + + expect(writeTextMock).toHaveBeenCalledWith('/recipes'); + expect(showToastMock).toHaveBeenCalledWith( + 'recipes.modal.openFileLocation.copied', + { path: '/recipes' }, + 'success', + ); + }); + + it('hides the footer when neither ID nor file path is available', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.showRecipeDetails({ title: 'Orphan', tags: [], loras: [] }); + + const footer = document.getElementById('recipeMetaFooter'); + expect(footer.hidden).toBe(true); + expect(footer.innerHTML).toBe(''); + }); +}); diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index 2965a18b..b2d5846c 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -2246,3 +2246,31 @@ async def test_reimport_without_any_source_returns_400( assert payload["success"] is False assert harness.analysis.local_calls == [] assert harness.persistence.delete_calls == [] + + +async def test_get_recipe_detail_includes_recipe_json_path( + monkeypatch, tmp_path: Path +) -> None: + """The detail response exposes the recipe JSON path for open-location UI.""" + async with recipe_harness(monkeypatch, tmp_path) as harness: + recipes_dir = Path(harness.scanner.recipes_dir) + recipes_dir.mkdir(parents=True, exist_ok=True) + harness.scanner.recipes["recipe-1"] = { + "id": "recipe-1", + "title": "Demo", + "file_path": str(recipes_dir / "recipe-1.png"), + } + json_file = recipes_dir / "recipe-1.recipe.json" + json_file.write_text("{}", encoding="utf-8") + + response = await harness.client.get("/api/lm/recipe/recipe-1") + assert response.status == 200 + payload = await response.json() + assert payload["recipe_json_path"] == str(json_file) + + # Without the JSON file on disk the key is omitted entirely. + json_file.unlink() + response = await harness.client.get("/api/lm/recipe/recipe-1") + assert response.status == 200 + payload = await response.json() + assert "recipe_json_path" not in payload