feat(fc2a): add slug and paths utilities
slugify() produces ASCII-only lowercase slugs from arbitrary text (used for artist slugs and tag slugs). paths.derive_subdir/derive_top_level_artist extract the destination layout and folder→artist convention from an import source path. hash_suffixed_name builds IR-style 'stem__hash10.ext' filenames. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
"""Pure-function utilities shared across services and tasks."""
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Filesystem path helpers — destination derivation, hash-suffixed names."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def derive_subdir(source_path: Path, import_root: Path) -> str:
|
||||||
|
"""Returns the relative subdirectory of source_path under import_root.
|
||||||
|
|
||||||
|
The top-level folder name is treated as the 'artist' bucket. Nested
|
||||||
|
paths preserve hierarchy.
|
||||||
|
|
||||||
|
import_root=/import
|
||||||
|
source_path=/import/Alice/sub/x.png -> "Alice/sub"
|
||||||
|
source_path=/import/Alice/x.png -> "Alice"
|
||||||
|
source_path=/import/x.png -> ""
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rel = source_path.parent.relative_to(import_root)
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
return str(rel) if str(rel) != "." else ""
|
||||||
|
|
||||||
|
|
||||||
|
def hash_suffixed_name(stem: str, sha256_hex: str, ext: str) -> str:
|
||||||
|
"""Builds 'stem__<first10ofhash><ext>'.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
hash_suffixed_name("photo", "abcdef1234567890...", ".png")
|
||||||
|
-> "photo__abcdef1234.png"
|
||||||
|
"""
|
||||||
|
return f"{stem}__{sha256_hex[:10]}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def derive_top_level_artist(source_path: Path, import_root: Path) -> str | None:
|
||||||
|
"""Returns the top-level folder name under import_root, or None if the
|
||||||
|
file is directly in import_root.
|
||||||
|
"""
|
||||||
|
subdir = derive_subdir(source_path, import_root)
|
||||||
|
if not subdir:
|
||||||
|
return None
|
||||||
|
return subdir.split("/", 1)[0]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Slug generation for artists, tags, and paths."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(value: str) -> str:
|
||||||
|
"""Lowercase ASCII slug. Empty input becomes 'untitled'.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
slugify("Bob Ross") -> "bob-ross"
|
||||||
|
slugify("hello, world!") -> "hello-world"
|
||||||
|
slugify("naïve café") -> "naive-cafe"
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return "untitled"
|
||||||
|
normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode()
|
||||||
|
normalized = normalized.lower()
|
||||||
|
slug = _SLUG_RE.sub("-", normalized).strip("-")
|
||||||
|
return slug or "untitled"
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.app.utils.paths import (
|
||||||
|
derive_subdir,
|
||||||
|
derive_top_level_artist,
|
||||||
|
hash_suffixed_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
IMPORT = Path("/import")
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_subdir_nested():
|
||||||
|
assert derive_subdir(Path("/import/Alice/sub/x.png"), IMPORT) == "Alice/sub"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_subdir_one_level():
|
||||||
|
assert derive_subdir(Path("/import/Alice/x.png"), IMPORT) == "Alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_subdir_root():
|
||||||
|
assert derive_subdir(Path("/import/x.png"), IMPORT) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_subdir_outside_root():
|
||||||
|
assert derive_subdir(Path("/elsewhere/x.png"), IMPORT) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_suffixed_name():
|
||||||
|
h = "abcdef1234567890" + "0" * 48
|
||||||
|
assert hash_suffixed_name("photo", h, ".png") == "photo__abcdef1234.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_top_level_artist_present():
|
||||||
|
assert derive_top_level_artist(Path("/import/Alice/x.png"), IMPORT) == "Alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_top_level_artist_nested():
|
||||||
|
assert derive_top_level_artist(Path("/import/Alice/sub/x.png"), IMPORT) == "Alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_top_level_artist_root():
|
||||||
|
assert derive_top_level_artist(Path("/import/x.png"), IMPORT) is None
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from backend.app.utils.slug import slugify
|
||||||
|
|
||||||
|
|
||||||
|
def test_slugify_basic():
|
||||||
|
assert slugify("Bob Ross") == "bob-ross"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slugify_punctuation():
|
||||||
|
assert slugify("hello, world!") == "hello-world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slugify_unicode():
|
||||||
|
assert slugify("naïve café") == "naive-cafe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slugify_empty():
|
||||||
|
assert slugify("") == "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slugify_only_punctuation():
|
||||||
|
assert slugify("!!!") == "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slugify_leading_trailing_spaces():
|
||||||
|
assert slugify(" Alice ") == "alice"
|
||||||
Reference in New Issue
Block a user