Merge remote-tracking branch 'origin/feat/workflow-auto-organize'
This commit is contained in:
+3
-1
@@ -759,10 +759,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\")",
|
||||
|
||||
+3
-1
@@ -759,10 +759,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\")",
|
||||
|
||||
+3
-1
@@ -759,10 +759,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\")",
|
||||
|
||||
+3
-1
@@ -759,10 +759,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\")",
|
||||
|
||||
+3
-1
@@ -759,10 +759,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\")",
|
||||
|
||||
+3
-1
@@ -759,10 +759,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\")",
|
||||
|
||||
+3
-1
@@ -759,10 +759,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\")",
|
||||
|
||||
+3
-1
@@ -759,10 +759,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\")",
|
||||
|
||||
+3
-1
@@ -759,10 +759,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”)",
|
||||
|
||||
+3
-1
@@ -759,10 +759,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」)",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "",
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -93,6 +93,29 @@
|
||||
<div class="template-preview" id="embeddingPreview"></div>
|
||||
</div>
|
||||
|
||||
<!-- Workflow routing exists only while Workflow management is enabled;
|
||||
SettingsManager.updateWorkflowsControls() gates this row. -->
|
||||
<div class="setting-item" id="workflowPathTemplateSetting" style="display: none;">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="workflowTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.workflow') }}</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="workflowTemplatePreset" onchange="settingsManager.updateTemplatePreset('workflow', this.value)">
|
||||
{% for value, option_label in template_preset_options %}
|
||||
<option value="{{ value }}">{{ t(option_label) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-custom-row" id="workflowCustomRow" style="display: none;">
|
||||
<input type="text" id="workflowCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
|
||||
<div class="template-validation" id="workflowValidation"></div>
|
||||
</div>
|
||||
<div class="template-preview" id="workflowPreview"></div>
|
||||
<div class="input-help">{{ t('settings.downloadPathTemplates.workflowHint') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "/"))
|
||||
]
|
||||
|
||||
@@ -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"] = {}
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user