__package__ = "archivebox.core" import json import re import hashlib from django import forms from django.db.models.manager import BaseManager from django.db.models.query import QuerySet 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 _json_for_inline_script(self, value): """Serialize JSON so it cannot close the surrounding inline ''' 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 value = value or {} allowlist = escape(value.get("allowlist", "") or "") denylist = escape(value.get("denylist", "") or "") same_domain_checked = " checked" if value.get("same_domain_only") else "" subpaths_checked = " checked" if value.get("subpaths_only") else "" only_new_checked = " checked" if value.get("only_new") else "" return mark_safe(f'''
''') 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"), "subpaths_only": data.get(f"{name}_subpaths_only") in ("1", "on", "true"), "only_new": data.get(f"{name}_only_new") 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 isinstance(value, (BaseManager, 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)): from archivebox.core.models import Tag if value and isinstance(value[0], Tag): 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)