__package__ = 'archivebox.core' import json import re import hashlib from django import forms from django.utils.html import escape from django.utils.safestring import mark_safe class TagEditorWidget(forms.Widget): """ A widget that renders tags as clickable pills with inline editing. - Displays existing tags alphabetically as styled pills with X remove button - Text input with HTML5 datalist for autocomplete suggestions - Press Enter or Space to create new tags (auto-creates if doesn't exist) - Uses AJAX for autocomplete and tag creation """ template_name = None # We render manually class Media: css = {'all': []} js = [] def __init__(self, attrs=None, snapshot_id=None): self.snapshot_id = snapshot_id super().__init__(attrs) def _escape(self, value): """Escape HTML entities in value.""" return escape(str(value)) if value else '' def _normalize_id(self, value): """Normalize IDs for HTML + JS usage (letters, digits, underscore; JS-safe start).""" normalized = re.sub(r'[^A-Za-z0-9_]', '_', str(value)) if not normalized or not re.match(r'[A-Za-z_]', normalized): normalized = f't_{normalized}' return normalized def _tag_style(self, value): """Compute a stable pastel color style for a tag value.""" tag = (value or '').strip().lower() digest = hashlib.md5(tag.encode('utf-8')).hexdigest() hue = int(digest[:4], 16) % 360 bg = f'hsl({hue}, 70%, 92%)' border = f'hsl({hue}, 60%, 82%)' fg = f'hsl({hue}, 35%, 28%)' return f'--tag-bg: {bg}; --tag-border: {border}; --tag-fg: {fg};' def render(self, name, value, attrs=None, renderer=None): """ Render the tag editor widget. Args: name: Field name value: Can be: - QuerySet of Tag objects (from M2M field) - List of tag names - Comma-separated string of tag names - None attrs: HTML attributes renderer: Not used """ # Parse value to get list of tag names tags = [] if value: if hasattr(value, 'all'): # QuerySet tags = sorted([tag.name for tag in value.all()]) elif isinstance(value, (list, tuple)): if value and hasattr(value[0], 'name'): # List of Tag objects tags = sorted([tag.name for tag in value]) else: # List of strings or IDs # Could be tag IDs from form submission from archivebox.core.models import Tag tag_names = [] for v in value: if isinstance(v, str) and not v.isdigit(): tag_names.append(v) else: try: tag = Tag.objects.get(pk=v) tag_names.append(tag.name) except (Tag.DoesNotExist, ValueError): if isinstance(v, str): tag_names.append(v) tags = sorted(tag_names) elif isinstance(value, str): tags = sorted([t.strip() for t in value.split(',') if t.strip()]) widget_id_raw = attrs.get('id', name) if attrs else name widget_id = self._normalize_id(widget_id_raw) # Build pills HTML pills_html = '' for tag in tags: pills_html += f''' {self._escape(tag)} ''' # Build the widget HTML html = f'''