From 37cb01a1ac9caf575ebacd0e37eb77089b177fbb Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 5 Nov 2026 19:53:09 +0800 Subject: [PATCH] feat(workflows): auto-organize the workflow library (#1177) Expose Auto-Organize on the Workflows page and give workflows their own download path template, which both downloads and Auto-Organize use. - Add a `workflow` default ({base_model}) to the backend and frontend path template constants. Local workflows rarely carry CivitAI tags, so the default stops at the base model instead of adding a "no tags" segment. - Enable the bulk Auto-Organize action for workflows; the shared endpoint and scanner move already handled them, only the UI gate was missing. - Add the workflow row to Settings > Organization, rendered hidden and toggled by the existing Workflow management master switch so the config only appears while the feature is enabled. - Preview workflow templates with a .json artifact name instead of a weight-file name. - Cover the default template, download routing, both auto-organize modes, sidecar moves, and the settings gate with tests. --- locales/de.json | 4 +- locales/en.json | 4 +- locales/es.json | 4 +- locales/fr.json | 4 +- locales/he.json | 4 +- locales/ja.json | 4 +- locales/ko.json | 4 +- locales/ru.json | 4 +- locales/zh-CN.json | 4 +- locales/zh-TW.json | 4 +- py/services/download_manager.py | 2 + py/services/settings_manager.py | 2 +- py/utils/constants.py | 7 + static/js/managers/BulkManager.js | 5 +- static/js/managers/SettingsManager.js | 18 +- static/js/utils/constants.js | 4 + .../modals/settings/organization.html | 23 +++ .../managers/BulkManager.bulkDelete.test.js | 4 +- .../managers/settingsManager.library.test.js | 57 +++++++ tests/routes/test_workflow_routes.py | 3 + .../test_download_manager_workflows.py | 50 ++++++ tests/services/test_model_file_service.py | 77 +++++++++ tests/services/test_settings_manager.py | 13 ++ tests/services/test_workflow_auto_organize.py | 157 ++++++++++++++++++ 24 files changed, 445 insertions(+), 17 deletions(-) create mode 100644 tests/services/test_workflow_auto_organize.py diff --git a/locales/de.json b/locales/de.json index 676292e4..f988e709 100644 --- a/locales/de.json +++ b/locales/de.json @@ -749,10 +749,12 @@ "customTemplate": "Benutzerdefinierte Vorlage" }, "customTemplatePlaceholder": "Benutzerdefinierte Vorlage eingeben (z.B. {base_model}/{author}/{first_tag})", + "workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.", "modelTypes": { "lora": "LoRA", "checkpoint": "Checkpoint", - "embedding": "Embedding" + "embedding": "Embedding", + "workflow": "[TODO: Translate] Workflow" }, "baseModelPathMappings": "Basismodell-Pfad-Zuordnungen", "baseModelPathMappingsHelp": "Passen Sie Ordnernamen für spezifische Basismodelle an (z.B. \"Flux.1 D\" → \"flux\")", diff --git a/locales/en.json b/locales/en.json index 7fb5aac2..338cf847 100644 --- a/locales/en.json +++ b/locales/en.json @@ -749,10 +749,12 @@ "customTemplate": "Custom Template" }, "customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})", + "workflowHint": "Applies to workflow downloads and to Auto-Organize on the Workflows page.", "modelTypes": { "lora": "LoRA", "checkpoint": "Checkpoint", - "embedding": "Embedding" + "embedding": "Embedding", + "workflow": "Workflow" }, "baseModelPathMappings": "Base Model Path Mappings", "baseModelPathMappingsHelp": "Customize folder names for specific base models (e.g., \"Flux.1 D\" → \"flux\")", diff --git a/locales/es.json b/locales/es.json index b0b1cda6..419baf08 100644 --- a/locales/es.json +++ b/locales/es.json @@ -749,10 +749,12 @@ "customTemplate": "Plantilla personalizada" }, "customTemplatePlaceholder": "Introduce plantilla personalizada (ej., {base_model}/{author}/{first_tag})", + "workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.", "modelTypes": { "lora": "LoRA", "checkpoint": "Checkpoint", - "embedding": "Embedding" + "embedding": "Embedding", + "workflow": "[TODO: Translate] Workflow" }, "baseModelPathMappings": "Mapeos de rutas de modelo base", "baseModelPathMappingsHelp": "Personalizar nombres de carpetas para modelos base específicos (ej., \"Flux.1 D\" → \"flux\")", diff --git a/locales/fr.json b/locales/fr.json index 056562c4..69987e23 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -749,10 +749,12 @@ "customTemplate": "Modèle personnalisé" }, "customTemplatePlaceholder": "Entrez un modèle personnalisé (ex: {base_model}/{author}/{first_tag})", + "workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.", "modelTypes": { "lora": "LoRA", "checkpoint": "Checkpoint", - "embedding": "Embedding" + "embedding": "Embedding", + "workflow": "[TODO: Translate] Workflow" }, "baseModelPathMappings": "Mappages de chemin de modèle de base", "baseModelPathMappingsHelp": "Personnaliser les noms de dossiers pour des modèles de base spécifiques (ex: \"Flux.1 D\" → \"flux\")", diff --git a/locales/he.json b/locales/he.json index 25eb3ff4..d676db25 100644 --- a/locales/he.json +++ b/locales/he.json @@ -749,10 +749,12 @@ "customTemplate": "תבנית מותאמת אישית" }, "customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})", + "workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.", "modelTypes": { "lora": "LoRA", "checkpoint": "Checkpoint", - "embedding": "Embedding" + "embedding": "Embedding", + "workflow": "[TODO: Translate] Workflow" }, "baseModelPathMappings": "מיפוי נתיבי מודל בסיס", "baseModelPathMappingsHelp": "התאם אישית שמות תיקיות עבור מודלי בסיס ספציפיים (למשל, \"Flux.1 D\" → \"flux\")", diff --git a/locales/ja.json b/locales/ja.json index b467f8cc..0543aac1 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -749,10 +749,12 @@ "customTemplate": "カスタムテンプレート" }, "customTemplatePlaceholder": "カスタムテンプレートを入力(例:{base_model}/{author}/{first_tag})", + "workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.", "modelTypes": { "lora": "LoRA", "checkpoint": "Checkpoint", - "embedding": "Embedding" + "embedding": "Embedding", + "workflow": "[TODO: Translate] Workflow" }, "baseModelPathMappings": "ベースモデルパスマッピング", "baseModelPathMappingsHelp": "特定のベースモデルのフォルダ名をカスタマイズ(例:\"Flux.1 D\" → \"flux\")", diff --git a/locales/ko.json b/locales/ko.json index fcf64cc2..044409c4 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -749,10 +749,12 @@ "customTemplate": "사용자 정의 템플릿" }, "customTemplatePlaceholder": "사용자 정의 템플릿 입력 (예: {base_model}/{author}/{first_tag})", + "workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.", "modelTypes": { "lora": "LoRA", "checkpoint": "Checkpoint", - "embedding": "Embedding" + "embedding": "Embedding", + "workflow": "[TODO: Translate] Workflow" }, "baseModelPathMappings": "베이스 모델 경로 매핑", "baseModelPathMappingsHelp": "특정 베이스 모델의 폴더 이름을 사용자 정의합니다 (예: \"Flux.1 D\" → \"flux\")", diff --git a/locales/ru.json b/locales/ru.json index 7bb2298d..3760e28f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -749,10 +749,12 @@ "customTemplate": "Пользовательский шаблон" }, "customTemplatePlaceholder": "Введите пользовательский шаблон (например, {base_model}/{author}/{first_tag})", + "workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.", "modelTypes": { "lora": "LoRA", "checkpoint": "Checkpoint", - "embedding": "Embedding" + "embedding": "Embedding", + "workflow": "[TODO: Translate] Workflow" }, "baseModelPathMappings": "Сопоставления путей базовых моделей", "baseModelPathMappingsHelp": "Настройте имена папок для конкретных базовых моделей (например, \"Flux.1 D\" → \"flux\")", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 603e3988..8fa43256 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -749,10 +749,12 @@ "customTemplate": "自定义模板" }, "customTemplatePlaceholder": "输入自定义模板(如:{base_model}/{author}/{first_tag})", + "workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.", "modelTypes": { "lora": "LoRA", "checkpoint": "Checkpoint", - "embedding": "Embedding" + "embedding": "Embedding", + "workflow": "[TODO: Translate] Workflow" }, "baseModelPathMappings": "基础模型路径映射", "baseModelPathMappingsHelp": "为特定基础模型自定义文件夹名称(如“Flux.1 D”→“flux”)", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index dac3bddb..8f0febac 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -749,10 +749,12 @@ "customTemplate": "自訂範本" }, "customTemplatePlaceholder": "輸入自訂範本(例如:{base_model}/{author}/{first_tag})", + "workflowHint": "[TODO: Translate] Applies to workflow downloads and to Auto-Organize on the Workflows page.", "modelTypes": { "lora": "LoRA", "checkpoint": "Checkpoint", - "embedding": "Embedding" + "embedding": "Embedding", + "workflow": "[TODO: Translate] Workflow" }, "baseModelPathMappings": "基礎模型路徑對應", "baseModelPathMappingsHelp": "自訂特定基礎模型的資料夾名稱(例如:「Flux.1 D」→「flux」)", diff --git a/py/services/download_manager.py b/py/services/download_manager.py index 6a7e06f7..c2d595bc 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -2177,6 +2177,8 @@ class DownloadManager: elif model_type == "workflow": # Workflows have no default-root setting: the first # configured workflow root is the download target. + # The workflow path template (Settings > Organization) + # is applied below, like every other model type. workflow_scanner = await ServiceRegistry.get_workflow_scanner() workflow_roots = workflow_scanner.get_model_roots() if not workflow_roots: diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 132116cf..0e53ffc2 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -2795,7 +2795,7 @@ class SettingsManager: Args: model_type: The type of model ('lora', 'checkpoint', 'embedding', - 'other') + 'workflow', 'other') Returns: Template string for the model type. Falls back to the per-type diff --git a/py/utils/constants.py b/py/utils/constants.py index 9fecbad9..87d41f0f 100644 --- a/py/utils/constants.py +++ b/py/utils/constants.py @@ -309,10 +309,17 @@ DEFAULT_PRIORITY_TAG_CONFIG = { # "other" entry, so {first_tag} would resolve to an arbitrary CivitAI tag and # scatter files into unstable folders. Users can still opt in to a template by # writing "other" into download_path_templates in settings.json. +# +# Workflows get a single level on purpose: workflow management is opt-in and +# existing libraries are flat, so the first enabled download organizes by the +# resolved base model without scattering files over CivitAI tags, which local +# (non-CivitAI) workflows usually do not have. The same template drives +# Auto-Organize on the Workflows page. DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = { "lora": "{base_model}/{first_tag}", "checkpoint": "{base_model}/{first_tag}", "embedding": "{base_model}/{first_tag}", + "workflow": "{base_model}", "other": "", } diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 077939a0..a628f4f0 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -130,8 +130,9 @@ export class BulkManager { refreshAll: true, checkUpdates: true, moveAll: true, - // Workflows have no base-model/tag folder routing. - autoOrganize: false, + // Workflow routing is the workflow download path template + // (Settings > Organization), the same one downloads use. + autoOrganize: true, deleteAll: true, setContentRating: true, skipMetadataRefresh: true, diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index e4101651..10266136 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -3010,15 +3010,20 @@ export class SettingsManager { const previewElement = document.getElementById(`${modelType}Preview`); if (!previewElement) return; + // Workflow artifacts are JSON/zip documents, not weight files. + const exampleFile = modelType === 'workflow' + ? 'workflow-name.json' + : 'model-name.safetensors'; + if (!template) { - previewElement.textContent = 'model-name.safetensors'; + previewElement.textContent = exampleFile; } else { // Generate example preview const exampleTemplate = template .replace('{base_model}', 'Flux.1 D') .replace('{author}', 'authorname') .replace('{first_tag}', 'style'); - previewElement.textContent = `${exampleTemplate}/model-name.safetensors`; + previewElement.textContent = `${exampleTemplate}/${exampleFile}`; } previewElement.style.display = 'block'; } @@ -3411,7 +3416,9 @@ export class SettingsManager { * Reflect the opt-in Workflow management state in the settings UI: the * master toggle gates the single workflow library folder input * (workflow_path, LoRA-Manager-owned in both plugin and standalone mode, - * applied live without a restart). + * applied live without a restart) and the workflow download path template + * in Settings > Organization. The template row stays hidden while the + * feature is off because no workflow download or organization can use it. */ updateWorkflowsControls() { const enabled = !!state.global.settings.enable_workflows; @@ -3426,6 +3433,11 @@ export class SettingsManager { group.style.display = enabled ? '' : 'none'; } + const templateSetting = document.getElementById('workflowPathTemplateSetting'); + if (templateSetting) { + templateSetting.style.display = enabled ? '' : 'none'; + } + const input = document.getElementById('workflowPath'); if (input) { input.value = state.global.settings.workflow_path || ''; diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js index b5e02713..d1b1c6c9 100644 --- a/static/js/utils/constants.js +++ b/static/js/utils/constants.js @@ -357,6 +357,10 @@ export const DEFAULT_PATH_TEMPLATES = { checkpoint: '{base_model}', unet: '{base_model}', embedding: '{first_tag}', + // Workflows group by the base model resolved from their referenced + // models. Local workflows rarely carry CivitAI tags, so {first_tag} + // would mostly resolve to the "no tags" fallback folder. + workflow: '{base_model}', // Other models (VAE/upscaler/...) default to a flat layout: their root is // already split per sub_type, and priority_tags has no "other" entry, so // {first_tag} would resolve to an arbitrary CivitAI tag. diff --git a/templates/components/modals/settings/organization.html b/templates/components/modals/settings/organization.html index 7edeb8d1..8b4b9993 100644 --- a/templates/components/modals/settings/organization.html +++ b/templates/components/modals/settings/organization.html @@ -93,6 +93,29 @@
+ + +
diff --git a/tests/frontend/managers/BulkManager.bulkDelete.test.js b/tests/frontend/managers/BulkManager.bulkDelete.test.js index 62b3522e..34e242a6 100644 --- a/tests/frontend/managers/BulkManager.bulkDelete.test.js +++ b/tests/frontend/managers/BulkManager.bulkDelete.test.js @@ -329,6 +329,8 @@ describe('BulkManager.confirmBulkDelete undo flows', () => { const config = bulk.actionConfig.workflows; // Model-only actions stay hidden; workflow-capable actions are enabled. + // Auto-Organize is enabled: workflows use the workflow download path + // template from Settings > Organization for folder routing. expect(config).toEqual(expect.objectContaining({ addTags: true, sendToWorkflow: false, @@ -336,7 +338,7 @@ describe('BulkManager.confirmBulkDelete undo flows', () => { refreshAll: true, checkUpdates: true, moveAll: true, - autoOrganize: false, + autoOrganize: true, deleteAll: true, setContentRating: true, skipMetadataRefresh: true, diff --git a/tests/frontend/managers/settingsManager.library.test.js b/tests/frontend/managers/settingsManager.library.test.js index 7d9dea12..93a83b6a 100644 --- a/tests/frontend/managers/settingsManager.library.test.js +++ b/tests/frontend/managers/settingsManager.library.test.js @@ -833,6 +833,63 @@ describe('SettingsManager Other Models nav and banner sync', () => { }); }); +describe('SettingsManager workflow template visibility', () => { + const appendWorkflowControls = () => { + const toggle = document.createElement('input'); + toggle.id = 'enableWorkflows'; + toggle.type = 'checkbox'; + + const pathGroup = document.createElement('div'); + pathGroup.id = 'workflowPathSetting'; + + // Settings > Organization row for the workflow download path template. + const templateGroup = document.createElement('div'); + templateGroup.id = 'workflowPathTemplateSetting'; + + const input = document.createElement('input'); + input.id = 'workflowPath'; + + document.body.append(toggle, pathGroup, templateGroup, input); + }; + + it('keeps the workflow download template hidden while workflow management is off', () => { + const manager = createManager(); + appendWorkflowControls(); + state.global.settings.enable_workflows = false; + + manager.updateWorkflowsControls(); + + expect(document.getElementById('workflowPathTemplateSetting').style.display).toBe('none'); + expect(document.getElementById('workflowPathSetting').style.display).toBe('none'); + expect(document.getElementById('enableWorkflows').checked).toBe(false); + }); + + it('shows the workflow download template once workflow management is enabled', () => { + const manager = createManager(); + appendWorkflowControls(); + state.global.settings.enable_workflows = true; + state.global.settings.workflow_path = '/library/workflows'; + + manager.updateWorkflowsControls(); + + expect(document.getElementById('workflowPathTemplateSetting').style.display).toBe(''); + expect(document.getElementById('workflowPathSetting').style.display).toBe(''); + expect(document.getElementById('enableWorkflows').checked).toBe(true); + expect(document.getElementById('workflowPath').value).toBe('/library/workflows'); + }); + + it('previews workflow templates with a workflow artifact name', () => { + const manager = createManager(); + const preview = document.createElement('div'); + preview.id = 'workflowPreview'; + document.body.appendChild(preview); + + manager.updateTemplatePreview('workflow', '{base_model}'); + + expect(preview.textContent).toBe('Flux.1 D/workflow-name.json'); + }); +}); + describe('SettingsManager recipes layout switch', () => { it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => { const manager = createManager(); diff --git a/tests/routes/test_workflow_routes.py b/tests/routes/test_workflow_routes.py index f9fafc83..cbf81ea1 100644 --- a/tests/routes/test_workflow_routes.py +++ b/tests/routes/test_workflow_routes.py @@ -106,6 +106,9 @@ def test_common_and_specific_routes_registered(): assert ("POST", "/api/lm/workflows/fetch-civitai") in registered assert ("POST", "/api/lm/workflows/delete") in registered assert ("POST", "/api/lm/workflows/move_model") in registered + # Auto-Organize selected (shared model route; workflow path template). + assert ("POST", "/api/lm/workflows/auto-organize") in registered + assert ("GET", "/api/lm/workflows/auto-organize-progress") in registered assert ("GET", "/api/lm/workflows/info/{name}") in registered # The companion browser extension is GET-only (see AGENTS.md). assert ("POST", "/api/lm/workflows/send-to-comfy") in registered diff --git a/tests/services/test_download_manager_workflows.py b/tests/services/test_download_manager_workflows.py index 94047baa..93e16b92 100644 --- a/tests/services/test_download_manager_workflows.py +++ b/tests/services/test_download_manager_workflows.py @@ -268,10 +268,60 @@ async def test_default_paths_use_first_workflow_root( model_version_id=99, use_default_paths=True ) + assert result["success"] is True + # The default workflow template ({base_model}) is applied below the root, + # exactly like the other model types. + assert captured["save_dir"] == str(workflow_root / "SDXL 1.0") + + +@pytest.mark.asyncio +async def test_default_paths_keep_flat_workflow_root_when_template_is_empty( + monkeypatch, scanners, metadata_provider, tmp_path +): + """An explicitly flat workflow template preserves the old layout.""" + get_settings_manager().settings["download_path_templates"] = {"workflow": ""} + + workflow_root = tmp_path / "workflows" + workflow_root.mkdir() + scanners.workflow._roots = [str(workflow_root)] + + captured = {} + _capture_execute(monkeypatch, captured) + + manager = DownloadManager() + result = await manager.download_from_civitai( + model_version_id=99, use_default_paths=True + ) + assert result["success"] is True assert captured["save_dir"] == str(workflow_root) +@pytest.mark.asyncio +async def test_default_paths_apply_workflow_first_tag_template( + monkeypatch, scanners, metadata_provider, tmp_path +): + """CivitAI workflow tags feed {first_tag} like they do for models.""" + get_settings_manager().settings["download_path_templates"] = { + "workflow": "{base_model}/{first_tag}" + } + + workflow_root = tmp_path / "workflows" + workflow_root.mkdir() + scanners.workflow._roots = [str(workflow_root)] + + captured = {} + _capture_execute(monkeypatch, captured) + + manager = DownloadManager() + result = await manager.download_from_civitai( + model_version_id=99, use_default_paths=True + ) + + assert result["success"] is True + assert captured["save_dir"] == str(workflow_root / "SDXL 1.0" / "utility") + + @pytest.mark.asyncio async def test_default_paths_error_without_workflow_root( monkeypatch, scanners, metadata_provider diff --git a/tests/services/test_model_file_service.py b/tests/services/test_model_file_service.py index e659f759..b0eb1b54 100644 --- a/tests/services/test_model_file_service.py +++ b/tests/services/test_model_file_service.py @@ -905,3 +905,80 @@ async def test_auto_organize_skips_model_when_target_path_exceeds_budget( assert result.skipped_count == 1 assert scanner.moves == [] assert "path length limit" in result.results[0]["message"] + + +def _workflow_organize_setup( + tmp_path: Path, *, template: str | None = None +) -> tuple: + """Build a workflow ModelFileService over a fake scanner. + + ``template=None`` leaves the workflow entry out of the settings so the + default in DEFAULT_DOWNLOAD_PATH_TEMPLATES applies; pass a string to pin + the template. + """ + from py.services.model_file_service import ModelFileService + from py.services.settings_manager import get_settings_manager + + manager = get_settings_manager() + templates = dict(manager.settings.get("download_path_templates") or {}) + if template is None: + templates.pop("workflow", None) + else: + templates["workflow"] = template + manager.settings["download_path_templates"] = templates + + nested_dir = tmp_path / "old" + nested_dir.mkdir() + workflow_path = nested_dir / "portrait.workflow.json" + workflow_path.write_text("{}", encoding="utf-8") + models = [ + { + "file_path": workflow_path.as_posix(), + "model_name": "portrait", + "file_name": "portrait", + "base_model": "SDXL 1.0", + "tags": [], + "civitai": {}, + } + ] + scanner = AutoOrganizeScanner(tmp_path, models) + return ModelFileService(scanner, "workflow"), scanner, workflow_path + + +@pytest.mark.asyncio +async def test_auto_organize_workflows_uses_the_workflow_template(tmp_path: Path): + """Auto-Organize on the Workflows page routes by the workflow template.""" + service, scanner, workflow_path = _workflow_organize_setup(tmp_path) + + result = await service.auto_organize_models( + file_paths=[workflow_path.as_posix()] + ) + + assert result.operation_type == "bulk" + assert result.success_count == 1 + assert scanner.moves == [ + ( + workflow_path.as_posix(), + str(tmp_path / "SDXL 1.0").replace(os.sep, "/"), + ) + ] + + +@pytest.mark.asyncio +async def test_auto_organize_workflows_flattens_when_template_is_empty( + tmp_path: Path, +): + """An empty workflow template keeps the historical flat layout: selected + files are moved back to the workflow root, not into a tag folder.""" + service, scanner, workflow_path = _workflow_organize_setup( + tmp_path, template="" + ) + + result = await service.auto_organize_models( + file_paths=[workflow_path.as_posix()] + ) + + assert result.success_count == 1 + assert scanner.moves == [ + (workflow_path.as_posix(), str(tmp_path).replace(os.sep, "/")) + ] diff --git a/tests/services/test_settings_manager.py b/tests/services/test_settings_manager.py index 81b62568..73e7dee2 100644 --- a/tests/services/test_settings_manager.py +++ b/tests/services/test_settings_manager.py @@ -412,6 +412,19 @@ def test_download_path_template_defaults_flat_for_other(manager): assert manager.get_download_path_template("other") == "{base_model}" +def test_download_path_template_defaults_to_base_model_for_workflow(manager): + """Workflow downloads and Auto-Organize share this template. Local + workflows rarely carry CivitAI tags, so the default stops at the base + model instead of adding a "no tags" folder.""" + manager.settings["download_path_templates"] = {} + + assert manager.get_download_path_template("workflow") == "{base_model}" + + # An explicit user configuration still wins. + manager.settings["download_path_templates"] = {"workflow": "{first_tag}"} + assert manager.get_download_path_template("workflow") == "{first_tag}" + + def test_download_path_template_unknown_type_is_flat(manager): manager.settings["download_path_templates"] = {} diff --git a/tests/services/test_workflow_auto_organize.py b/tests/services/test_workflow_auto_organize.py new file mode 100644 index 00000000..3a9e5c46 --- /dev/null +++ b/tests/services/test_workflow_auto_organize.py @@ -0,0 +1,157 @@ +"""Auto-Organize for the workflow library (issue #1177). + +The workflow page reuses the shared auto-organize endpoint; these tests pin +the workflow-specific pieces: the default path template and the fact that a +move carries the workflow sidecars (derived document, metadata, preview) +along with the artifact itself. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from py import config as config_module +from py.services.model_cache import ModelCache +from py.services.model_file_service import ModelFileService +from py.services.model_hash_index import ModelHashIndex +from py.services.settings_manager import get_settings_manager +from py.services.workflow_scanner import WorkflowScanner +from py.utils.models import WorkflowMetadata + + +def _normalize(path) -> str: + return str(path).replace(os.sep, "/") + + +def _make_workflow_scanner(root: Path) -> WorkflowScanner: + """Wire a WorkflowScanner for a move without async initialization.""" + scanner = object.__new__(WorkflowScanner) + scanner.model_type = "workflow" + scanner.model_class = WorkflowMetadata + scanner.file_extensions = {".json", ".zip", ".png", ".webp"} + scanner._cache = None + scanner._cache_version = 0 + scanner._hash_index = ModelHashIndex() + scanner._tags_count = {} + scanner._excluded_models = [] + scanner._is_initializing = False + scanner._persistent_cache = MagicMock() + scanner._name_display_mode = "model_name" + scanner._cancel_requested = False + scanner._move_locks = {} + scanner._all_folders_backfill_running = False + scanner.get_model_roots = lambda: [_normalize(root)] + return scanner + + +def _cache_entry(file_path: str, folder: str) -> dict: + return { + "file_path": _normalize(file_path), + "file_name": "portrait", + "model_name": "portrait", + "folder": folder, + "sha256": "abc123", + "base_model": "SDXL 1.0", + "tags": [], + "civitai": {}, + } + + +@pytest.fixture +def workflow_library(tmp_path, monkeypatch): + root = tmp_path / "workflows" + (root / "old").mkdir(parents=True) + monkeypatch.setattr(config_module.config, "workflows_roots", [_normalize(root)]) + monkeypatch.setattr( + get_settings_manager(), "is_workflows_enabled", lambda: True + ) + # Pin the workflow template so the test does not depend on the default. + monkeypatch.setitem( + get_settings_manager().settings, + "download_path_templates", + {"workflow": "{base_model}"}, + ) + return root + + +@pytest.mark.asyncio +async def test_auto_organize_moves_workflow_with_its_sidecars(workflow_library: Path): + old_dir = workflow_library / "old" + workflow_path = old_dir / "portrait.json" + workflow_path.write_text( + json.dumps({"1": {"class_type": "CheckpointLoaderSimple", "inputs": {}}}), + encoding="utf-8", + ) + (old_dir / "portrait.workflow.json").write_text( + json.dumps({"1": {"class_type": "CheckpointLoaderSimple"}}), + encoding="utf-8", + ) + (old_dir / "portrait.preview.webp").write_bytes(b"preview") + (old_dir / "portrait.metadata.json").write_text( + json.dumps( + { + "file_path": _normalize(workflow_path), + "file_name": "portrait", + "model_name": "portrait", + "sha256": "abc123", + "sub_type": "workflow", + "base_model": "SDXL 1.0", + "tags": [], + } + ), + encoding="utf-8", + ) + + scanner = _make_workflow_scanner(workflow_library) + scanner._cache = ModelCache( + raw_data=[_cache_entry(workflow_path, "old")], folders=["old"] + ) + service = ModelFileService(scanner, "workflow") + + result = await service.auto_organize_models( + file_paths=[_normalize(workflow_path)] + ) + + target_dir = workflow_library / "SDXL 1.0" + assert result.operation_type == "bulk" + assert result.success_count == 1 + assert (target_dir / "portrait.json").is_file() + assert (target_dir / "portrait.workflow.json").is_file() + assert (target_dir / "portrait.preview.webp").read_bytes() == b"preview" + # The metadata sidecar follows the artifact and records the new path. + moved_metadata = json.loads( + (target_dir / "portrait.metadata.json").read_text(encoding="utf-8") + ) + assert moved_metadata["file_path"] == _normalize(target_dir / "portrait.json") + + # The empty source folder is cleaned up after a bulk run. + assert not (old_dir / "portrait.json").exists() + assert not old_dir.exists() + + +@pytest.mark.asyncio +async def test_auto_organize_skips_workflow_already_in_place(workflow_library: Path): + target_dir = workflow_library / "SDXL 1.0" + target_dir.mkdir() + workflow_path = target_dir / "portrait.json" + workflow_path.write_text("{}", encoding="utf-8") + + scanner = _make_workflow_scanner(workflow_library) + scanner._cache = ModelCache( + raw_data=[_cache_entry(workflow_path, "SDXL 1.0")], + folders=["SDXL 1.0"], + ) + service = ModelFileService(scanner, "workflow") + + result = await service.auto_organize_models( + file_paths=[_normalize(workflow_path)] + ) + + assert result.success_count == 0 + assert result.skipped_count == 1 + assert (target_dir / "portrait.json").is_file()