From 4cb319b393ac6f0f3bb0b73c0a006a1e54e0d455 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 14 May 2026 12:04:05 -0400 Subject: [PATCH] feat(fc2a): add slug and paths utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/utils/__init__.py | 1 + backend/app/utils/paths.py | 41 +++++++++++++++++++++++++++++++++ backend/app/utils/slug.py | 22 ++++++++++++++++++ tests/test_paths.py | 43 +++++++++++++++++++++++++++++++++++ tests/test_slug.py | 25 ++++++++++++++++++++ 5 files changed, 132 insertions(+) create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/paths.py create mode 100644 backend/app/utils/slug.py create mode 100644 tests/test_paths.py create mode 100644 tests/test_slug.py diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..9be5cae --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ +"""Pure-function utilities shared across services and tasks.""" diff --git a/backend/app/utils/paths.py b/backend/app/utils/paths.py new file mode 100644 index 0000000..c19b7f8 --- /dev/null +++ b/backend/app/utils/paths.py @@ -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__'. + + 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] diff --git a/backend/app/utils/slug.py b/backend/app/utils/slug.py new file mode 100644 index 0000000..e7eeee1 --- /dev/null +++ b/backend/app/utils/slug.py @@ -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" diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..9856fb5 --- /dev/null +++ b/tests/test_paths.py @@ -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 diff --git a/tests/test_slug.py b/tests/test_slug.py new file mode 100644 index 0000000..41e3092 --- /dev/null +++ b/tests/test_slug.py @@ -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"