Skip to content

Commit d13b071

Browse files
refactor: centralize slugify in utils (#30)
Add utils.slugify.slugify (ASCII-safe, matches former export_api). Remove duplicate implementations from api/export_api.py and scripts/export.py; add regression tests. Closes #30
1 parent 09de881 commit d13b071

4 files changed

Lines changed: 56 additions & 25 deletions

File tree

api/export_api.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from utils.md_exporter import session_to_markdown
1515
from utils.json_exporter import session_to_json
1616
from utils.exclusion_rules import is_session_excluded
17+
from utils.slugify import slugify
1718

1819
export_bp = Blueprint("export", __name__)
1920

@@ -49,13 +50,6 @@ def get_export_state():
4950
})
5051

5152

52-
def _slugify(text: str) -> str:
53-
import re
54-
text = text.lower()
55-
text = re.sub(r"[^a-z0-9]+", "-", text)
56-
return text.strip("-")
57-
58-
5953
@export_bp.route("/api/export", methods=["POST"])
6054
def bulk_export():
6155
body = request.get_json(silent=True) or {}
@@ -97,9 +91,9 @@ def bulk_export():
9791

9892
stats = compute_stats(session)
9993
md = session_to_markdown(session, stats)
100-
title_slug = _slugify(session["title"]) or "session"
94+
title_slug = slugify(session["title"]) or "session"
10195
short_id = sid[:8]
102-
proj_slug = _slugify(project["name"])
96+
proj_slug = slugify(project["name"])
10397
ts = session["metadata"].get("first_timestamp", "")
10498
ts_file = ts[:19].replace(":", "-") if ts else "0000-00-00T00-00-00"
10599
rel_path = f"{proj_slug}/{ts_file}__{title_slug}__{short_id}.md"
@@ -155,7 +149,7 @@ def export_session(project_name, session_id):
155149
if is_session_excluded(rules, session, project_name):
156150
return jsonify({"error": "Session not found"}), 404
157151
stats = compute_stats(session)
158-
title_slug = _slugify(session["title"]) or "session"
152+
title_slug = slugify(session["title"]) or "session"
159153

160154
if fmt == "json":
161155
content = session_to_json(session, stats)

scripts/export.py

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
load_rules,
3434
is_session_excluded,
3535
)
36+
from utils.slugify import slugify
3637

3738

3839
STATE_DIR = os.path.join(os.path.expanduser("~"), ".claude-code-chat-browser")
@@ -366,9 +367,9 @@ def cmd_export(args):
366367
meta["first_timestamp"] = ts
367368
date_str = ts[:10]
368369
ts_file = ts[:19].replace(":", "-") # 2026-02-10T01-46-15
369-
title_slug = _slugify(session["title"])
370+
title_slug = slugify(session["title"])
370371
short_id = sid[:8]
371-
project_slug = _slugify(project["name"])
372+
project_slug = slugify(project["name"])
372373

373374
if fmt in ("md", "both"):
374375
md = session_to_markdown(session, stats)
@@ -444,7 +445,7 @@ def cmd_export(args):
444445

445446
def _export_single(session: dict, stats: dict, fmt: str, out_dir: str):
446447
"""Write one session to disk as md, json, or both."""
447-
title_slug = _slugify(session["title"])
448+
title_slug = slugify(session["title"])
448449
short_id = session["session_id"][:8]
449450
ts = session["metadata"].get("first_timestamp", "")
450451
ts_file = ts[:19].replace(":", "-") if ts else "0000-00-00T00-00-00"
@@ -609,18 +610,6 @@ def _save_state(sessions: dict, count: int, out_dir: str):
609610
json.dump(state, f, indent=2)
610611

611612

612-
def _slugify(text: str) -> str:
613-
slug = ""
614-
for c in text.lower():
615-
if c.isalnum():
616-
slug += c
617-
elif c in " -_/.":
618-
slug += "-"
619-
while "--" in slug:
620-
slug = slug.replace("--", "-")
621-
return slug.strip("-")
622-
623-
624613
def _die(msg: str):
625614
print(f"Error: {msg}", file=sys.stderr)
626615
sys.exit(1)

tests/test_slugify.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Regression tests for utils.slugify (Issue #30 / CCC8).
2+
3+
Historically ``scripts/export.py`` used ``isalnum()`` (Unicode letters preserved)
4+
while ``api/export_api.py`` used ASCII-only ``[^a-z0-9]+``. The canonical
5+
implementation matches the API for portable zip / download filenames.
6+
"""
7+
8+
from utils.slugify import slugify
9+
10+
11+
def test_ascii_words_hyphenated():
12+
assert slugify("Hello World") == "hello-world"
13+
14+
15+
def test_punctuation_collapses_to_single_hyphen():
16+
assert slugify("foo__bar") == "foo-bar"
17+
assert slugify("a.b.c") == "a-b-c"
18+
19+
20+
def test_unicode_letters_become_ascii_safe():
21+
"""Old CLI kept Latin-1 letters (e.g. é); canonical slug strips to ASCII."""
22+
assert slugify("Café noir") == "caf-noir"
23+
24+
25+
def test_empty_after_strip():
26+
assert slugify("!!!") == ""
27+
28+
29+
def test_digits_preserved():
30+
assert slugify("Issue 42 Fix") == "issue-42-fix"

utils/slugify.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Filesystem- and URL-safe slugs for export paths and download names.
2+
3+
Uses ASCII letters and digits only; other characters (including Unicode
4+
letters and punctuation) become hyphen runs, then trimmed. Matches the
5+
historical behavior of ``api/export_api.py`` and avoids platform-specific
6+
issues with non-ASCII paths inside zip archives.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import re
12+
13+
14+
def slugify(text: str) -> str:
15+
"""Lowercase *text* and replace each run of non-[a-z0-9] with a single hyphen."""
16+
text = text.lower()
17+
text = re.sub(r"[^a-z0-9]+", "-", text)
18+
return text.strip("-")

0 commit comments

Comments
 (0)