mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 15:47:50 +09:00
Comments, docstrings and a few strings cited plan numbers ("plan 29
#9", "plan 86 §B2") from a git-ignored folder, so they pointed at
nothing; one reached users in a bottleneck hint. Code and frontend
strings are unchanged apart from those citations (checked by AST and
token comparison). A vhost still carrying the old asset comment reads
as undrifted, and a guard test keeps new citations out.
145 lines
5.1 KiB
Python
145 lines
5.1 KiB
Python
"""Slug generation utilities."""
|
|
import re
|
|
|
|
|
|
SLUG_PATTERN = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$')
|
|
|
|
|
|
def slugify(text):
|
|
"""Convert a string to a URL-safe slug.
|
|
|
|
Examples:
|
|
'Hello World' -> 'hello-world'
|
|
'My Queue Group!' -> 'my-queue-group'
|
|
' Multiple spaces ' -> 'multiple-spaces'
|
|
"""
|
|
if not text:
|
|
return ''
|
|
text = str(text).lower().strip()
|
|
# Replace any run of non-alphanumeric characters with a single hyphen
|
|
text = re.sub(r'[^a-z0-9]+', '-', text)
|
|
# Strip leading/trailing hyphens
|
|
text = text.strip('-')
|
|
return text
|
|
|
|
|
|
def unique_slug(base, exists, default='untitled', start=1):
|
|
"""Return a unique slug based on `base`.
|
|
|
|
`exists` is a callable that takes a slug and returns True if it is already
|
|
in use. If `base` is taken, appends `-1`, `-2`, etc. until a free slug is
|
|
found.
|
|
|
|
Args:
|
|
base: The text or slug to start from.
|
|
exists: Callable(slug: str) -> bool.
|
|
default: Fallback string when `base` slugifies to something empty.
|
|
start: First suffix to try. `saved_view_service` numbered its
|
|
duplicates from 2 and kept a private copy of this loop for that one
|
|
difference; a parameter is cheaper than a second
|
|
implementation, and changing its numbering would have renamed
|
|
handles users already had.
|
|
|
|
Returns:
|
|
A unique slug string.
|
|
"""
|
|
base_slug = slugify(base) or default
|
|
if not exists(base_slug):
|
|
return base_slug
|
|
|
|
counter = start
|
|
while True:
|
|
candidate = f'{base_slug}-{counter}'
|
|
if not exists(candidate):
|
|
return candidate
|
|
counter += 1
|
|
|
|
|
|
def is_valid_slug(value):
|
|
"""Return True if `value` is a non-empty, well-formed slug."""
|
|
return bool(value and SLUG_PATTERN.match(value))
|
|
|
|
|
|
def validate_slug(value):
|
|
"""Validate a slug and return a normalized value or raise ValueError.
|
|
|
|
This is stricter than `slugify`: it rejects empty values and values that
|
|
contain characters outside `[a-z0-9-]`, rather than converting them.
|
|
"""
|
|
slug = (value or '').strip().lower()
|
|
slug = re.sub(r'[\s_]+', '-', slug)
|
|
slug = re.sub(r'[^a-z0-9-]', '', slug)
|
|
slug = re.sub(r'-{2,}', '-', slug).strip('-')
|
|
if not slug:
|
|
raise ValueError('Slug is required')
|
|
if not SLUG_PATTERN.match(slug):
|
|
raise ValueError('Slug can only contain lowercase letters, numbers, and hyphens')
|
|
return slug
|
|
|
|
|
|
# Common "app/service" name pattern: lowercase alphanumeric with internal hyphens,
|
|
# must start/end with alphanumeric (a single alphanumeric char is allowed).
|
|
_APP_NAME_RE = re.compile(r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?$')
|
|
|
|
|
|
def validate_app_name(name, min_length=3, max_length=63,
|
|
allow_consecutive_hyphens=False, reserved=None):
|
|
"""Validate an application or service name.
|
|
|
|
Args:
|
|
name: The name to validate.
|
|
min_length: Minimum allowed length.
|
|
max_length: Maximum allowed length.
|
|
allow_consecutive_hyphens: If False, reject names containing ``--``.
|
|
reserved: Optional iterable of reserved names (case-insensitive).
|
|
|
|
Returns:
|
|
Tuple of ``(is_valid, error_message)``. ``error_message`` is ``None``
|
|
when the name is valid.
|
|
|
|
Examples:
|
|
>>> validate_app_name('my-app')
|
|
(True, None)
|
|
>>> validate_app_name('My App')
|
|
(False, 'Name must contain only lowercase letters, numbers, and hyphens...')
|
|
"""
|
|
if not name:
|
|
return False, 'Name is required'
|
|
if len(name) < min_length:
|
|
return False, f'Name must be at least {min_length} characters'
|
|
if len(name) > max_length:
|
|
return False, f'Name cannot exceed {max_length} characters'
|
|
if not _APP_NAME_RE.match(name):
|
|
return False, (
|
|
'Name must contain only lowercase letters, numbers, and hyphens '
|
|
'(cannot start or end with hyphen)'
|
|
)
|
|
if not allow_consecutive_hyphens and '--' in name:
|
|
return False, 'Name cannot contain consecutive hyphens'
|
|
if reserved and name.lower() in {r.lower() for r in reserved}:
|
|
return False, f'Name "{name}" is reserved'
|
|
return True, None
|
|
|
|
|
|
def app_name_clash_error(existing, name, suffix=''):
|
|
"""Message for a taken application name, or ``None`` when it is free.
|
|
|
|
A soft-deleted app deliberately keeps reserving its name (a restore must
|
|
never collide), but "already exists" sends the operator hunting for an app
|
|
that is not in any list — so a tombstone names the Recycle Bin instead.
|
|
|
|
Args:
|
|
existing: The Application row holding the name (may be a tombstone),
|
|
or ``None``.
|
|
name: The requested name, echoed into the message.
|
|
suffix: Optional clause appended to the live-app message, e.g.
|
|
``' on this target server'``.
|
|
"""
|
|
if existing is None:
|
|
return None
|
|
if getattr(existing, 'deleted_at', None):
|
|
return (f'An application named "{name}" is in the Recycle Bin — '
|
|
'restore it, or purge it from Settings → Recycle Bin to '
|
|
'reuse the name')
|
|
return f'An application named "{name}" already exists{suffix}'
|