fix(organize): stop keyword-dump tags from becoming folder names (#1119)

CivitAI tags are normally short single-concept labels, but some uploaders
pack their entire keyword list into one tag. The model in #1119 carries
"lora, character, rosie, irish, ... face" as a single 181-character tag.
Priority resolution matches aliases by exact equality, so that tag matched
nothing and resolve_priority_tag_for_model fell back to tags[0] -- the blob.
With the default "{base_model}/{first_tag}" template the model was filed
under "Krea 2/<181-character blob>/", and the full path plus the
".civitai.info" sidecar and the preview images next to it ran into the
Windows MAX_PATH limit.

Tags also bypassed sanitization on the way into a path: both
calculate_relative_path_for_model and DownloadManager._calculate_relative_path
sanitized model_name and version_name but interpolated {first_tag} verbatim,
so a tag containing "/" or ":" silently produced nested or illegal folders.

Two changes:

- The fallback skips tags that cannot serve as a folder name.
  is_usable_path_tag rejects comma-separated keyword dumps and tags longer
  than MAX_PATH_TAG_LENGTH; the resolver returns "" when nothing usable is
  left, which callers already render as "no tags". Whole-tag priority
  matching is untouched, so existing priority configurations behave the
  same.
- sanitize_folder_name gains an optional max_length, and every tag-derived
  segment now goes through it. Tags are capped at MAX_PATH_TAG_LENGTH, model
  and version names at MAX_FOLDER_NAME_LENGTH, and rendered filename stems at
  MAX_FILENAME_STEM_LENGTH.

For the reported model the folder becomes "Krea 2/base model" instead of the
blob, and the full path drops from 235 to 64 characters.

Existing libraries are not migrated up front: a path is only recomputed on
download, on an auto-organize run or when a filename template is applied, and
values already inside the caps are left byte-identical. Models previously
filed under a keyword-dump folder move on the next auto-organize run.
This commit is contained in:
Will Miao
2026-09-23 13:16:26 +08:00
parent 521531111a
commit 0ada32d0c7
9 changed files with 318 additions and 16 deletions
+5 -1
View File
@@ -30,7 +30,8 @@ Aliases live inside `()` and are separated with `|`. The canonical name is what
When your path template contains `{first_tag}`, the app picks a folder name based on your priority list and the models own tags: When your path template contains `{first_tag}`, the app picks a folder name based on your priority list and the models own tags:
- It checks the priority list from top to bottom. If a canonical tag or any of its aliases appear in the model tags, that canonical name becomes the folder name. - It checks the priority list from top to bottom. If a canonical tag or any of its aliases appear in the model tags, that canonical name becomes the folder name.
- If no priority tags are found but the model has tags, the very first model tag is used. - If no priority tags are found but the model has tags, the first tag that can be used as a folder name is chosen.
- Tags that contain a comma, or that are longer than 50 characters, are treated as unusable and skipped: some uploaders pack their whole keyword list into a single tag. If every tag is unusable, the folder falls back to `no tags`.
- If the model has no tags at all, the folder falls back to `no tags`. - If the model has no tags at all, the folder falls back to `no tags`.
### Example ### Example
@@ -42,6 +43,7 @@ With a template like `/{model_type}/{first_tag}` and the priority entry list `ch
| `["chars", "female"]` | `character` | `chars` matches the `character` alias, so the canonical wins. | | `["chars", "female"]` | `character` | `chars` matches the `character` alias, so the canonical wins. |
| `["anime", "portrait"]` | `style` | `anime` hits the `style` entry, so its canonical label is used. | | `["anime", "portrait"]` | `style` | `anime` hits the `style` entry, so its canonical label is used. |
| `["portrait", "bw"]` | `portrait` | No priority match, so the first model tag is used. | | `["portrait", "bw"]` | `portrait` | No priority match, so the first model tag is used. |
| `["lora, character, rosie, ... face"]` | `no tags` | The only tag is a keyword dump, so it is skipped. |
| `[]` | `no tags` | Nothing to match, so the fallback is applied. | | `[]` | `no tags` | Nothing to match, so the fallback is applied. |
## 3. Save the Settings ## 3. Save the Settings
@@ -61,10 +63,12 @@ After editing the entry list, press **Enter** to save. Use **Shift+Enter** whene
- Keep canonical names short and meaningful—they become folder names. - Keep canonical names short and meaningful—they become folder names.
- Place the most important categories first; the first match wins. - Place the most important categories first; the first match wins.
- Avoid duplicate canonical names within the same list; only the first instance is used. - Avoid duplicate canonical names within the same list; only the first instance is used.
- Folder names built from tags are sanitized for filesystem safety and truncated to 50 characters.
## Troubleshooting ## Troubleshooting
- **Unexpected folder name?** Check that the canonical name you want is placed before other matches. - **Unexpected folder name?** Check that the canonical name you want is placed before other matches.
- **Folder named `no tags`?** Every model tag was either missing or unusable (a comma-separated keyword dump, or longer than 50 characters). Add the tags you care about to your priority list so they match by name instead.
- **Alias not working?** Ensure the alias is inside parentheses and separated with `|`, e.g. `character(char|chars)`. - **Alias not working?** Ensure the alias is inside parentheses and separated with `|`, e.g. `character(char|chars)`.
- **Validation error?** Look for missing parentheses or stray commas. Each entry must follow the `canonical(alias|alias)` pattern or just `canonical`. - **Validation error?** Look for missing parentheses or stray commas. Each entry must follow the `canonical(alias|alias)` pattern or just `canonical`.
+14 -2
View File
@@ -25,6 +25,8 @@ from ..utils.models import (
) )
from ..utils.constants import ( from ..utils.constants import (
CARD_PREVIEW_WIDTH, CARD_PREVIEW_WIDTH,
MAX_FOLDER_NAME_LENGTH,
MAX_PATH_TAG_LENGTH,
MODEL_WEIGHT_FILE_TYPES, MODEL_WEIGHT_FILE_TYPES,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS, SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES, VALID_LORA_TYPES,
@@ -2327,16 +2329,26 @@ class DownloadManager:
if not first_tag: if not first_tag:
first_tag = "no tags" # Default if no tags available first_tag = "no tags" # Default if no tags available
# Tags come straight from CivitAI, so sanitize the value before it
# becomes a path segment and cap its length (#1119).
first_tag = sanitize_folder_name(first_tag, max_length=MAX_PATH_TAG_LENGTH)
# Format the template with available data # Format the template with available data
formatted_path = path_template formatted_path = path_template
formatted_path = formatted_path.replace("{base_model}", mapped_base_model) formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
formatted_path = formatted_path.replace("{first_tag}", first_tag) formatted_path = formatted_path.replace("{first_tag}", first_tag)
formatted_path = formatted_path.replace("{author}", author) formatted_path = formatted_path.replace("{author}", author)
formatted_path = formatted_path.replace( formatted_path = formatted_path.replace(
"{model_name}", sanitize_folder_name(model_info.get("name", "")) "{model_name}",
sanitize_folder_name(
model_info.get("name", ""), max_length=MAX_FOLDER_NAME_LENGTH
),
) )
formatted_path = formatted_path.replace( formatted_path = formatted_path.replace(
"{version_name}", sanitize_folder_name(version_info.get("name", "")) "{version_name}",
sanitize_folder_name(
version_info.get("name", ""), max_length=MAX_FOLDER_NAME_LENGTH
),
) )
if model_type == "embedding": if model_type == "embedding":
+6 -2
View File
@@ -47,6 +47,7 @@ from ..utils.settings_paths import (
from ..utils.tag_priorities import ( from ..utils.tag_priorities import (
PriorityTagEntry, PriorityTagEntry,
collect_canonical_tags, collect_canonical_tags,
is_usable_path_tag,
parse_priority_tag_string, parse_priority_tag_string,
resolve_priority_tag, resolve_priority_tag,
) )
@@ -1569,9 +1570,12 @@ class SettingsManager:
if resolved: if resolved:
return resolved return resolved
# Fall back to the first tag that is usable as a folder name. The raw
# tag list can contain keyword dumps that would become unusable folders
# and break path length limits, so skip those (#1119).
for tag in tags: for tag in tags:
if isinstance(tag, str) and tag: if is_usable_path_tag(tag):
return tag return tag.strip()
return "" return ""
def get_priority_tag_suggestions(self) -> Dict[str, List[str]]: def get_priority_tag_suggestions(self) -> Dict[str, List[str]]:
+13
View File
@@ -293,6 +293,19 @@ DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = {
"other": "", "other": "",
} }
# Length guards for template placeholders that end up in file and folder names.
# Windows enforces MAX_PATH (260 characters) on the full path and 255 on a
# single path component, and a model folder also has to leave room for the
# model file, its ".civitai.info"/".json" sidecars and preview images. Values
# stay well below those limits so the surrounding files still fit.
#
# Tags get a much tighter budget than other names: some CivitAI uploaders dump
# their whole keyword list into a single tag (see issue #1119), and such a tag
# is only useful as a folder name after truncation.
MAX_FOLDER_NAME_LENGTH = 100
MAX_PATH_TAG_LENGTH = 50
MAX_FILENAME_STEM_LENGTH = 150
# baseModel values from CivitAI that should be treated as diffusion models (unet) # baseModel values from CivitAI that should be treated as diffusion models (unet)
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models # These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
DIFFUSION_MODEL_BASE_MODELS = frozenset( DIFFUSION_MODEL_BASE_MODELS = frozenset(
+26
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional, Sequence, Set from typing import Dict, Iterable, List, Optional, Sequence, Set
from .constants import MAX_PATH_TAG_LENGTH
@dataclass(frozen=True) @dataclass(frozen=True)
class PriorityTagEntry: class PriorityTagEntry:
@@ -102,3 +104,27 @@ def collect_canonical_tags(entries: Iterable[PriorityTagEntry]) -> List[str]:
"""Return the ordered list of canonical tags from the parsed entries.""" """Return the ordered list of canonical tags from the parsed entries."""
return [entry.canonical for entry in entries] return [entry.canonical for entry in entries]
def is_usable_path_tag(tag: object) -> bool:
"""Return True when a tag is a sane single-concept folder-name candidate.
CivitAI tags are normally short labels ("character", "anime"), but some
uploaders dump their whole keyword list into a single tag, e.g.
``"lora, character, rosie, irish, ... face"``. Using such a tag as a folder
name produces unwieldy and path-length-breaking directories (#1119), so
tag-derived path segments only accept single-concept tags.
"""
if not isinstance(tag, str):
return False
candidate = tag.strip()
if not candidate:
return False
# Commas mean the tag is a keyword dump rather than one concept.
if "," in candidate:
return False
return len(candidate) <= MAX_PATH_TAG_LENGTH
+45 -11
View File
@@ -6,6 +6,11 @@ from typing import Any, Dict, List, Optional
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..config import config from ..config import config
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from .constants import (
MAX_FILENAME_STEM_LENGTH,
MAX_FOLDER_NAME_LENGTH,
MAX_PATH_TAG_LENGTH,
)
import asyncio import asyncio
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -417,12 +422,17 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
return True return True
def sanitize_folder_name(name: str, replacement: str = "_") -> str: def sanitize_folder_name(
name: str, replacement: str = "_", max_length: Optional[int] = None
) -> str:
"""Sanitize a folder name by removing or replacing invalid characters. """Sanitize a folder name by removing or replacing invalid characters.
Args: Args:
name: The original folder name. name: The original folder name.
replacement: The character to use when replacing invalid characters. replacement: The character to use when replacing invalid characters.
max_length: Optional maximum length for the resulting name. Longer
names are truncated (and re-trimmed) so that a single untrusted
value cannot blow past filesystem path limits.
Returns: Returns:
A sanitized folder name safe to use across common filesystems. A sanitized folder name safe to use across common filesystems.
@@ -449,6 +459,15 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
# If no replacement, just strip spaces and dots from right, spaces from left # If no replacement, just strip spaces and dots from right, spaces from left
sanitized = sanitized.rstrip(" .").lstrip(" ") sanitized = sanitized.rstrip(" .").lstrip(" ")
if max_length is not None and max_length > 0 and len(sanitized) > max_length:
sanitized = sanitized[:max_length]
# Re-trim separators and spaces exposed by the cut so the truncated
# name stays filesystem-safe.
if replacement:
sanitized = sanitized.rstrip(" ." + replacement).lstrip(" " + replacement)
else:
sanitized = sanitized.rstrip(" .").lstrip(" ")
if not sanitized: if not sanitized:
return "unnamed" return "unnamed"
@@ -575,12 +594,20 @@ def calculate_relative_path_for_model(
if not first_tag: if not first_tag:
first_tag = "no tags" # Default if no tags available first_tag = "no tags" # Default if no tags available
# Tags are user-generated on CivitAI, so sanitize the value before it
# becomes a path segment and cap its length (#1119).
first_tag = sanitize_folder_name(first_tag, max_length=MAX_PATH_TAG_LENGTH)
# Format the template with available data # Format the template with available data
model_name = sanitize_folder_name(model_data.get("model_name", "")) model_name = sanitize_folder_name(
model_data.get("model_name", ""), max_length=MAX_FOLDER_NAME_LENGTH
)
version_name = "" version_name = ""
if isinstance(civitai_data, dict): if isinstance(civitai_data, dict):
version_name = sanitize_folder_name(civitai_data.get("name") or "") version_name = sanitize_folder_name(
civitai_data.get("name") or "", max_length=MAX_FOLDER_NAME_LENGTH
)
formatted_path = path_template formatted_path = path_template
formatted_path = formatted_path.replace("{base_model}", mapped_base_model) formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
@@ -667,20 +694,22 @@ def calculate_filename_for_model(
else: else:
original_name = os.path.splitext(str(model_data.get("file_name", "")))[0] original_name = os.path.splitext(str(model_data.get("file_name", "")))[0]
def _sanitize_value(value: Any) -> str: def _sanitize_value(value: Any, max_length: Optional[int] = None) -> str:
# sanitize_folder_name falls back to "unnamed" for empty input; for # sanitize_folder_name falls back to "unnamed" for empty input; for
# templates an empty value must stay empty so segments collapse. # templates an empty value must stay empty so segments collapse.
text = str(value) if value else "" text = str(value) if value else ""
return sanitize_folder_name(text) if text else "" if not text:
return ""
return sanitize_folder_name(text, max_length=max_length)
replacements = { replacements = {
"{model_name}": _sanitize_value(model_name), "{model_name}": _sanitize_value(model_name, MAX_FILENAME_STEM_LENGTH),
"{version_name}": _sanitize_value(version_name), "{version_name}": _sanitize_value(version_name, MAX_FILENAME_STEM_LENGTH),
"{base_model}": _sanitize_value(mapped_base_model), "{base_model}": _sanitize_value(mapped_base_model, MAX_FILENAME_STEM_LENGTH),
"{author}": _sanitize_value(author), "{author}": _sanitize_value(author, MAX_FILENAME_STEM_LENGTH),
"{first_tag}": _sanitize_value(first_tag), "{first_tag}": _sanitize_value(first_tag, MAX_PATH_TAG_LENGTH),
"{hash_short}": hash_short, "{hash_short}": hash_short,
"{original_name}": _sanitize_value(original_name), "{original_name}": _sanitize_value(original_name, MAX_FILENAME_STEM_LENGTH),
} }
result = template result = template
@@ -699,6 +728,11 @@ def calculate_filename_for_model(
# A stem must not start or end with separators, spaces or dots. # A stem must not start or end with separators, spaces or dots.
result = result.strip("-_. ") result = result.strip("-_. ")
# A template can concatenate several values, so cap the rendered stem as
# well and re-trim the cut.
if len(result) > MAX_FILENAME_STEM_LENGTH:
result = result[:MAX_FILENAME_STEM_LENGTH].strip("-_. ")
return result return result
@@ -238,6 +238,47 @@ async def test_successful_download_uses_defaults(
assert captured["download_urls"] == ["https://example.invalid/file.safetensors"] assert captured["download_urls"] == ["https://example.invalid/file.safetensors"]
def test_calculate_relative_path_ignores_keyword_dump_tag():
"""The #1119 download flow: a keyword-dump tag must not become a folder."""
keyword_dump = (
"lora, character, rosie, irish, redhead, auburn, freckles, green eyes, "
"curly hair, woman, female, photorealistic, realistic, krea2, dark beast, "
"kreativity, nsfw, nude, portrait, face"
)
manager = DownloadManager()
relative_path = manager._calculate_relative_path(
{
"baseModel": "BaseModel",
"creator": {"username": "mad_macs"},
"name": "v1.2",
"model": {"name": "Rosie", "tags": [keyword_dump, "base model"]},
},
"lora",
)
assert relative_path == "MappedModel/base model"
assert keyword_dump not in relative_path
assert len(relative_path) < 50
def test_calculate_relative_path_sanitizes_tag_segment():
"""A tag with path separators must not create nested folders."""
manager = DownloadManager()
relative_path = manager._calculate_relative_path(
{
"baseModel": "BaseModel",
"creator": {"username": "author"},
"name": "v1.2",
"model": {"name": "Rosie", "tags": ["a/b:c"]},
},
"lora",
)
assert relative_path == "MappedModel/a_b_c"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_accepts_enhancement_lora_primary_file( async def test_download_accepts_enhancement_lora_primary_file(
monkeypatch, scanners, metadata_provider, tmp_path monkeypatch, scanners, metadata_provider, tmp_path
+47
View File
@@ -365,6 +365,53 @@ def test_download_path_template_unknown_type_is_flat(manager):
assert manager.get_download_path_template("not-a-model-type") == "" assert manager.get_download_path_template("not-a-model-type") == ""
# Real CivitAI data for the model reported in issue #1119: the uploader dumped
# a whole keyword list into a single tag.
KEYWORD_DUMP_TAG = (
"lora, character, rosie, irish, redhead, auburn, freckles, green eyes, "
"curly hair, woman, female, photorealistic, realistic, krea2, dark beast, "
"kreativity, nsfw, nude, portrait, face"
)
def test_resolve_priority_tag_prefers_configured_priority(manager):
# Priority order from CIVITAI_MODEL_TAGS: "character" precedes "anime".
assert manager.resolve_priority_tag_for_model(["anime", "character"], "lora") == (
"character"
)
def test_resolve_priority_tag_falls_back_to_first_usable_tag(manager):
assert (
manager.resolve_priority_tag_for_model(["portrait", "anime-ish"], "lora")
== "portrait"
)
def test_resolve_priority_tag_skips_keyword_dump_tag(manager):
"""A keyword-dump tag must not be used as a folder name (#1119)."""
assert manager.resolve_priority_tag_for_model([KEYWORD_DUMP_TAG], "lora") == ""
def test_resolve_priority_tag_skips_keyword_dump_and_uses_next_tag(manager):
assert (
manager.resolve_priority_tag_for_model([KEYWORD_DUMP_TAG, "portrait"], "lora")
== "portrait"
)
def test_resolve_priority_tag_skips_unusable_tags(manager):
overlong_tag = "x" * 51
assert manager.resolve_priority_tag_for_model([overlong_tag], "lora") == ""
assert manager.resolve_priority_tag_for_model([overlong_tag, " "], "lora") == ""
# Non-string entries never win the fallback.
assert manager.resolve_priority_tag_for_model([None, 42], "lora") == ""
# A tag at the length budget is still accepted and stripped.
assert manager.resolve_priority_tag_for_model(["x" * 50], "lora") == "x" * 50
assert manager.resolve_priority_tag_for_model([" portrait "], "lora") == "portrait"
def test_auto_set_default_roots(manager): def test_auto_set_default_roots(manager):
# Clear any previously auto-set values to test fresh behavior # Clear any previously auto-set values to test fresh behavior
manager.settings["default_lora_root"] = "" manager.settings["default_lora_root"] = ""
+121
View File
@@ -2,6 +2,11 @@ import pytest
from py.services.settings_manager import SettingsManager, get_settings_manager from py.services.settings_manager import SettingsManager, get_settings_manager
from py.services.service_registry import ServiceRegistry from py.services.service_registry import ServiceRegistry
from py.utils.constants import (
MAX_FILENAME_STEM_LENGTH,
MAX_FOLDER_NAME_LENGTH,
MAX_PATH_TAG_LENGTH,
)
from py.utils.utils import ( from py.utils.utils import (
calculate_filename_for_model, calculate_filename_for_model,
calculate_recipe_fingerprint, calculate_recipe_fingerprint,
@@ -12,6 +17,15 @@ from py.utils.utils import (
) )
# Real CivitAI data for the model reported in issue #1119: the uploader dumped
# a whole keyword list into a single tag.
KEYWORD_DUMP_TAG = (
"lora, character, rosie, irish, redhead, auburn, freckles, green eyes, "
"curly hair, woman, female, photorealistic, realistic, krea2, dark beast, "
"kreativity, nsfw, nude, portrait, face"
)
class _FakeCache: class _FakeCache:
def __init__(self, items): def __init__(self, items):
self.raw_data = list(items) self.raw_data = list(items)
@@ -147,6 +161,66 @@ def test_calculate_relative_path_sanitizes_double_slashes(isolated_settings):
assert relative_path == "no tags/Author" assert relative_path == "no tags/Author"
def test_calculate_relative_path_ignores_keyword_dump_tag(isolated_settings):
"""A tag holding a whole keyword list must not become a folder name (#1119)."""
model_data = {"base_model": "Krea 2", "tags": [KEYWORD_DUMP_TAG]}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == "Krea 2/no tags"
def test_calculate_relative_path_uses_next_usable_tag(isolated_settings):
"""Unusable tags are skipped instead of hijacking the folder name (#1119)."""
model_data = {"base_model": "Krea 2", "tags": [KEYWORD_DUMP_TAG, "portrait"]}
assert calculate_relative_path_for_model(model_data, "lora") == "Krea 2/portrait"
def test_calculate_relative_path_sanitizes_tag_segment(isolated_settings):
"""A tag with path separators must not create nested folders."""
model_data = {"base_model": "SDXL", "tags": ["a/b:c"]}
assert calculate_relative_path_for_model(model_data, "lora") == "SDXL/a_b_c"
def test_calculate_relative_path_caps_tag_segment(isolated_settings):
"""A long configured priority tag is truncated to the tag length budget."""
long_tag = "y" * 80
isolated_settings["priority_tags"] = {"lora": long_tag}
model_data = {"base_model": "SDXL", "tags": [long_tag]}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == "SDXL/" + "y" * MAX_PATH_TAG_LENGTH
def test_calculate_relative_path_keeps_tag_within_budget(isolated_settings):
model_data = {"base_model": "SDXL", "tags": ["t" * 40]}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == "SDXL/" + "t" * 40
def test_calculate_relative_path_caps_model_and_version_names(isolated_settings):
isolated_settings["download_path_templates"]["lora"] = "{model_name}/{version_name}"
model_data = {
"model_name": "m" * 300,
"base_model": "SDXL",
"tags": [],
"civitai": {"id": 1, "name": "v" * 300, "creator": {"username": "Creator"}},
}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == (
"m" * MAX_FOLDER_NAME_LENGTH + "/" + "v" * MAX_FOLDER_NAME_LENGTH
)
def test_calculate_recipe_fingerprint_filters_and_sorts(): def test_calculate_recipe_fingerprint_filters_and_sorts():
loras = [ loras = [
{"hash": "ABC", "strength": 0.1234}, {"hash": "ABC", "strength": 0.1234},
@@ -304,6 +378,32 @@ def test_calculate_filename_original_name_falls_back_to_file_name(isolated_setti
assert calculate_filename_for_model(model_data, "lora") == "legacy-name-0123456789" assert calculate_filename_for_model(model_data, "lora") == "legacy-name-0123456789"
def test_calculate_filename_drops_keyword_dump_tag(isolated_settings):
"""The keyword-dump tag collapses instead of filling the filename (#1119)."""
_set_filename_templates(isolated_settings, "{base_model}-{first_tag}")
model_data = {
"base_model": "Krea 2",
"tags": [KEYWORD_DUMP_TAG],
"file_path": "/models/V1.safetensors",
}
assert calculate_filename_for_model(model_data, "lora") == "Krea 2"
def test_calculate_filename_caps_rendered_stem(isolated_settings):
_set_filename_templates(isolated_settings, "{model_name}")
model_data = {
"model_name": "m" * 400,
"file_path": "/models/V1.safetensors",
}
result = calculate_filename_for_model(model_data, "lora")
assert len(result) == MAX_FILENAME_STEM_LENGTH
@pytest.mark.parametrize( @pytest.mark.parametrize(
"original, expected", "original, expected",
[ [
@@ -318,6 +418,27 @@ def test_sanitize_folder_name(original, expected):
assert sanitize_folder_name(original) == expected assert sanitize_folder_name(original) == expected
def test_sanitize_folder_name_without_max_length_is_unbounded():
assert sanitize_folder_name("x" * 300) == "x" * 300
@pytest.mark.parametrize(
"original, max_length, expected",
[
("abcdefghij", 4, "abcd"),
# Re-trim separators and spaces exposed by the cut.
("abc...defg", 4, "abc"),
("abcdefg hij", 8, "abcdefg"),
# Shorter than the cap is returned untouched.
("short", 10, "short"),
# A cut that leaves only separators falls back to "unnamed".
("...abcdef", 3, "unnamed"),
],
)
def test_sanitize_folder_name_truncates_to_max_length(original, max_length, expected):
assert sanitize_folder_name(original, max_length=max_length) == expected
def test_get_lora_info_absolute_bare_name(mock_lora_scanner): def test_get_lora_info_absolute_bare_name(mock_lora_scanner):
mock_lora_scanner([ mock_lora_scanner([
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}}, {"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}},