remarkbox/remarkbox/lib/mentions.py
russell@unturf.com f1cffe2e79 Resolve all 14 tracked tickets (T0-T13)
High priority fixes:
- T0: Profile page now filters comments by namespace (was leaking cross-site)
- T1: URI hostnames and namespace names normalized to lowercase (was causing
  duplicate threads and "stock comments" bug). Includes merge script.
- T2: Thread detail API now paginated with SQL-side filtering (was 502 on
  267+ reply threads)

Features:
- T3: GDPR account deletion (tombstone user with scrubbed PII) and data export
- T4: Customizable button text and comment labels per namespace
- T5: Self-service namespace deletion for owners
- T6: @mention notifications with profile links
- T7: Webmention receiving endpoint with h-card extraction
- T8: Configurable max nesting depth and collapse depth per namespace
- T9: AJAX thread title search to prevent duplicates
- T10: Browser push notification support (VAPID/service worker)

Docs and housekeeping:
- T11: Documented thread_uri behavior when moving embeds
- T12/T13: Drafted community replies for resolved feature requests
- Collapse depth defaults to infinite (load-more disabled unless configured)

364 tests pass, 4 skipped.
2026-02-01 20:02:47 -05:00

82 lines
2.8 KiB
Python

"""
Parse @username mentions from comment text and resolve them to User objects.
"""
import re
import logging
log = logging.getLogger(__name__)
# Match @username where username is alphanumeric with dashes (matching
# is_user_name_valid from models/user.py). Must be preceded by whitespace
# or start-of-string to avoid matching email addresses like foo@bar.
MENTION_RE = re.compile(r'(?:^|(?<=\s))@([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)')
def parse_mention_usernames(text):
"""Return a set of unique usernames mentioned in text (without the @)."""
if not text:
return set()
return set(MENTION_RE.findall(text))
def resolve_mentions(dbsession, text):
"""
Parse @mentions from text, look up each username in the database.
Returns a dict mapping lowercase username -> User object for users
that exist. Non-existent usernames are omitted.
"""
# Import here to avoid circular import (user -> node -> render -> mentions -> user).
from remarkbox.models.user import get_user_by_name
usernames = parse_mention_usernames(text)
resolved = {}
for username in usernames:
user = get_user_by_name(dbsession, username)
if user is not None:
resolved[username.lower()] = user
return resolved
def replace_mentions_with_links(html, resolved_users, link_prefix=""):
"""
In rendered HTML, replace @username with profile links for resolved users.
Non-existent usernames (not in resolved_users) are left as plain text.
Args:
html: the rendered HTML string
resolved_users: dict of lowercase username -> User object
link_prefix: URL prefix for profile links (e.g. "" or "/embed/ns/foo")
Returns:
HTML with @mentions converted to anchor tags for valid users.
"""
if not resolved_users:
return html
def _replace(match):
username = match.group(1)
user = resolved_users.get(username.lower())
if user is None:
# Not a real user, leave as plain text.
return match.group(0)
# Use the user's canonical name for the display and link.
return '<a href="{}/u/{}" class="mention">@{}</a>'.format(
link_prefix, user.name, user.name
)
# Replace @username patterns in HTML, but skip anything inside tags
# (e.g. inside href attributes). We use a two-pass approach:
# first split on HTML tags, then only do replacements in text segments.
parts = re.split(r'(<[^>]+>)', html)
result = []
for i, part in enumerate(parts):
if part.startswith('<'):
# This is an HTML tag, leave it alone.
result.append(part)
else:
# This is a text segment, apply mention replacement.
result.append(MENTION_RE.sub(_replace, part))
return ''.join(result)