Skip to content

Commit e0e0a76

Browse files
refactor: centralize slugify in utils (#30) (#32)
* 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 * fix(export): default empty slugify slugs to session/project (#30) * refactor(slugify): default= kwarg, caller parity, review feedback (#30) * removed unused import
1 parent 15f2886 commit e0e0a76

4 files changed

Lines changed: 95 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"], default="session")
10195
short_id = sid[:8]
102-
proj_slug = _slugify(project["name"])
96+
proj_slug = slugify(project["name"], default="project")
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"], default="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"], default="session")
370371
short_id = sid[:8]
371-
project_slug = _slugify(project["name"])
372+
project_slug = slugify(project["name"], default="project")
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"], default="session")
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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
import os
9+
10+
from utils.slugify import slugify
11+
12+
13+
def test_ascii_words_hyphenated():
14+
assert slugify("Hello World") == "hello-world"
15+
16+
17+
def test_punctuation_collapses_to_single_hyphen():
18+
assert slugify("foo__bar") == "foo-bar"
19+
assert slugify("a.b.c") == "a-b-c"
20+
21+
22+
def test_unicode_letters_become_ascii_safe():
23+
"""Old CLI kept Latin-1 letters (e.g. é); canonical slug strips to ASCII."""
24+
assert slugify("Café noir") == "caf-noir"
25+
26+
27+
def test_empty_after_strip():
28+
assert slugify("!!!") == ""
29+
30+
31+
def test_digits_preserved():
32+
assert slugify("Issue 42 Fix") == "issue-42-fix"
33+
34+
35+
def test_punctuation_examples_match_regex_behavior():
36+
assert slugify("AT&T") == "at-t"
37+
assert slugify("issue#42") == "issue-42"
38+
39+
40+
def test_default_used_when_slug_empty():
41+
assert slugify("!!!", default="session") == "session"
42+
assert slugify("!!!") == ""
43+
44+
45+
def test_export_leaf_path_parity_api_zip_vs_cli():
46+
"""Same session inputs → same ``proj_slug``, ``title_slug``, and file leaf as API vs CLI."""
47+
title = "Issue #42: AT&T"
48+
project = "Foo/Bar!"
49+
sid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
50+
ts_file = "2026-05-07T12-00-00"
51+
short_id = sid[:8]
52+
title_slug = slugify(title, default="session")
53+
proj_slug = slugify(project, default="project")
54+
leaf_md = f"{ts_file}__{title_slug}__{short_id}.md"
55+
api_zip_inner = f"{proj_slug}/{leaf_md}"
56+
date_str = ts_file[:10]
57+
cli_rel = os.path.join(date_str, proj_slug, leaf_md)
58+
assert api_zip_inner.endswith(leaf_md)
59+
assert os.path.basename(cli_rel) == leaf_md
60+
assert cli_rel.replace("\\", "/").endswith(f"{proj_slug}/{leaf_md}")

utils/slugify.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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+
import re
10+
11+
12+
def slugify(text: str, *, default: str = "") -> str:
13+
"""Lowercase *text* and replace each run of non-[a-z0-9] with one hyphen.
14+
15+
After stripping leading/trailing hyphens, returns that string; if it is
16+
empty, returns *default*. Export code passes ``default="session"`` or
17+
``default="project"``.
18+
Examples (handled by the ``[^a-z0-9]+`` substitution below):
19+
20+
- ``AT&T`` → ``at-t``
21+
- ``issue#42`` → ``issue-42``
22+
"""
23+
text = text.lower()
24+
# Non-ASCII-alphanumeric runs → '-'; e.g. AT&T → at-t, issue#42 → issue-42.
25+
text = re.sub(r"[^a-z0-9]+", "-", text)
26+
stripped = text.strip("-")
27+
return stripped if stripped else default

0 commit comments

Comments
 (0)