__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'''
Regex patterns or domains to include, 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"), "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)