__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 = "" # 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'''
{pills_html}
''' return mark_safe(html) class URLFiltersWidget(forms.Widget): """Render URL allowlist / denylist controls with same-domain autofill.""" template_name = "" def __init__(self, attrs=None, *, source_selector='textarea[name="url"]'): self.source_selector = source_selector super().__init__(attrs) def render(self, name, value, attrs=None, renderer=None): value = value if isinstance(value, dict) else {} widget_id_raw = attrs.get("id", name) if attrs else name widget_id = re.sub(r"[^A-Za-z0-9_]", "_", str(widget_id_raw)) or name allowlist = escape(value.get("allowlist", "") or "") denylist = escape(value.get("denylist", "") or "") return mark_safe(f'''
Regex patterns or domains to exclude, one pattern per line.
Regex patterns or domains to exclude, one pattern per line.
These values can be one regex pattern or domain per line. URL_DENYLIST takes precedence over URL_ALLOWLIST.
''') def value_from_datadict(self, data, files, name): return { "allowlist": data.get(f"{name}_allowlist", ""), "denylist": data.get(f"{name}_denylist", ""), "same_domain_only": data.get(f"{name}_same_domain_only") in ("1", "on", "true"), } class InlineTagEditorWidget(TagEditorWidget): """ Inline version of TagEditorWidget for use in list views. Includes AJAX save functionality for immediate persistence. """ def __init__(self, attrs=None, snapshot_id=None, editable=True): super().__init__(attrs, snapshot_id) self.snapshot_id = snapshot_id self.editable = editable def render(self, name, value, attrs=None, renderer=None, snapshot_id=None): """Render inline tag editor with AJAX save.""" # Use snapshot_id from __init__ or from render call snapshot_id = snapshot_id or self.snapshot_id # Parse value to get list of tag dicts with id and name tag_data = [] if value: if hasattr(value, "all"): # QuerySet for tag in value.all(): tag_data.append({"id": tag.pk, "name": tag.name}) tag_data.sort(key=lambda x: x["name"].lower()) elif isinstance(value, (list, tuple)): if value and hasattr(value[0], "name"): for tag in value: tag_data.append({"id": tag.pk, "name": tag.name}) tag_data.sort(key=lambda x: x["name"].lower()) widget_id_raw = f"inline_tags_{snapshot_id}" if snapshot_id else (attrs.get("id", name) if attrs else name) widget_id = self._normalize_id(widget_id_raw) # Build pills HTML with filter links pills_html = "" for td in tag_data: remove_button = "" if self.editable: remove_button = ( f'' ) pills_html += f''' {self._escape(td["name"])} {remove_button} ''' tags_json = escape(json.dumps(tag_data)) input_html = "" readonly_class = " readonly" if not self.editable else "" if self.editable: input_html = f''' ''' html = f''' {pills_html} {input_html} ''' return mark_safe(html)