Milestone 1.
This commit is contained in:
40
app/utils/paths.py
Normal file
40
app/utils/paths.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def atomic_write(path: str, data: bytes, mode: int = 0o644) -> None:
|
||||
"""
|
||||
Atomically write bytes to path by writing to a temp file in the same dir and then replacing.
|
||||
"""
|
||||
directory = os.path.dirname(path)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".tmp-", dir=directory)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as tmp_file:
|
||||
tmp_file.write(data)
|
||||
os.chmod(tmp_path, mode)
|
||||
os.replace(tmp_path, path)
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_unique_path(path: str) -> str:
|
||||
"""
|
||||
If path exists, append -2, -3, ... before extension to avoid collision.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
return path
|
||||
base, ext = os.path.splitext(path)
|
||||
i = 2
|
||||
while True:
|
||||
candidate = f"{base}-{i}{ext}"
|
||||
if not os.path.exists(candidate):
|
||||
return candidate
|
||||
i += 1
|
45
app/utils/slugs.py
Normal file
45
app/utils/slugs.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def slugify(title: str, max_length: int = 80) -> str:
|
||||
"""
|
||||
Convert a title to a filesystem-friendly slug:
|
||||
- normalize unicode to NFKD and strip accents
|
||||
- lowercase
|
||||
- replace non alphanum with hyphens
|
||||
- collapse duplicate hyphens
|
||||
- trim hyphens at edges
|
||||
- limit length (keeping whole segments if possible)
|
||||
"""
|
||||
if not title:
|
||||
return "untitled"
|
||||
|
||||
# Normalize accents
|
||||
normalized = unicodedata.normalize("NFKD", title)
|
||||
ascii_str = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
|
||||
# Lowercase and replace non-alphanum with hyphen
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", ascii_str.lower()).strip("-")
|
||||
|
||||
if not slug:
|
||||
slug = "untitled"
|
||||
|
||||
# Collapse multiple hyphens
|
||||
slug = re.sub(r"-{2,}", "-", slug)
|
||||
|
||||
# Respect max length, try not to cut segments
|
||||
if len(slug) > max_length:
|
||||
parts = slug.split("-")
|
||||
cut = []
|
||||
total = 0
|
||||
for p in parts:
|
||||
# +1 for the hyphen when joining (except first)
|
||||
add = len(p) + (1 if cut else 0)
|
||||
if total + add > max_length:
|
||||
break
|
||||
cut.append(p)
|
||||
total += add
|
||||
slug = "-".join(cut) if cut else slug[:max_length].rstrip("-")
|
||||
|
||||
return slug
|
19
app/utils/time.py
Normal file
19
app/utils/time.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def now_iso_utc() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def is_iso_utc(value: str) -> bool:
|
||||
try:
|
||||
# Accept the 'Z' suffix
|
||||
if value.endswith("Z"):
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
else:
|
||||
datetime.fromisoformat(value)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
Reference in New Issue
Block a user