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:
2026-05-14 12:04:05 -04:00
parent 9eb1614fbf
commit 4cb319b393
5 changed files with 132 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Pure-function utilities shared across services and tasks."""
+41
View File
@@ -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]
+22
View File
@@ -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"
+43
View File
@@ -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
+25
View File
@@ -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"