| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393 |
- __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'''
- <span class="tag-pill" data-tag="{self._escape(tag)}" style="{self._tag_style(tag)}">
- {self._escape(tag)}
- <button type="button" class="tag-remove-btn" data-tag-name="{self._escape(tag)}">×</button>
- </span>
- '''
- # Build the widget HTML
- html = f'''
- <div id="{widget_id}_container" class="tag-editor-container" onclick="focusTagInput_{widget_id}(event)">
- <div id="{widget_id}_pills" class="tag-pills">
- {pills_html}
- </div>
- <input type="text"
- id="{widget_id}_input"
- class="tag-inline-input"
- list="{widget_id}_datalist"
- placeholder="Add tag..."
- autocomplete="off"
- onkeydown="handleTagKeydown_{widget_id}(event)"
- onkeypress="if(event.key==='Enter' || event.keyCode===13){{event.preventDefault(); event.stopPropagation();}}"
- oninput="fetchTagAutocomplete_{widget_id}(this.value)"
- >
- <datalist id="{widget_id}_datalist"></datalist>
- <input type="hidden" name="{name}" id="{widget_id}" value="{self._escape(','.join(tags))}">
- </div>
- <script>
- (function() {{
- var currentTags_{widget_id} = {json.dumps(tags)};
- var autocompleteTimeout_{widget_id} = null;
- window.focusTagInput_{widget_id} = function(event) {{
- if (event.target.classList.contains('tag-remove-btn')) return;
- document.getElementById('{widget_id}_input').focus();
- }};
- window.updateHiddenInput_{widget_id} = function() {{
- document.getElementById('{widget_id}').value = currentTags_{widget_id}.join(',');
- }};
- function computeTagStyle_{widget_id}(tagName) {{
- var hash = 0;
- var name = String(tagName || '').toLowerCase();
- for (var i = 0; i < name.length; i++) {{
- hash = (hash * 31 + name.charCodeAt(i)) % 360;
- }}
- var bg = 'hsl(' + hash + ', 70%, 92%)';
- var border = 'hsl(' + hash + ', 60%, 82%)';
- var fg = 'hsl(' + hash + ', 35%, 28%)';
- return {{ bg: bg, border: border, fg: fg }};
- }}
- function applyTagStyle_{widget_id}(el, tagName) {{
- var colors = computeTagStyle_{widget_id}(tagName);
- el.style.setProperty('--tag-bg', colors.bg);
- el.style.setProperty('--tag-border', colors.border);
- el.style.setProperty('--tag-fg', colors.fg);
- }}
- function getApiKey() {{
- return (window.ARCHIVEBOX_API_KEY || '').trim();
- }}
- function buildApiUrl(path) {{
- var apiKey = getApiKey();
- if (!apiKey) return path;
- var sep = path.indexOf('?') !== -1 ? '&' : '?';
- return path + sep + 'api_key=' + encodeURIComponent(apiKey);
- }}
- function buildApiHeaders() {{
- var headers = {{
- 'Content-Type': 'application/json',
- }};
- var apiKey = getApiKey();
- if (apiKey) headers['X-ArchiveBox-API-Key'] = apiKey;
- var csrfToken = getCSRFToken();
- if (csrfToken) headers['X-CSRFToken'] = csrfToken;
- return headers;
- }}
- window.addTag_{widget_id} = function(tagName) {{
- tagName = tagName.trim();
- if (!tagName) return;
- // Check if tag already exists (case-insensitive)
- var exists = currentTags_{widget_id}.some(function(t) {{
- return t.toLowerCase() === tagName.toLowerCase();
- }});
- if (exists) {{
- document.getElementById('{widget_id}_input').value = '';
- return;
- }}
- // Add to current tags
- currentTags_{widget_id}.push(tagName);
- currentTags_{widget_id}.sort(function(a, b) {{
- return a.toLowerCase().localeCompare(b.toLowerCase());
- }});
- // Rebuild pills
- rebuildPills_{widget_id}();
- updateHiddenInput_{widget_id}();
- // Clear input
- document.getElementById('{widget_id}_input').value = '';
- // Create tag via API if it doesn't exist (fire and forget)
- fetch(buildApiUrl('/api/v1/core/tags/create/'), {{
- method: 'POST',
- headers: buildApiHeaders(),
- body: JSON.stringify({{ name: tagName }})
- }}).catch(function(err) {{
- console.log('Tag creation note:', err);
- }});
- }};
- window.removeTag_{widget_id} = function(tagName) {{
- currentTags_{widget_id} = currentTags_{widget_id}.filter(function(t) {{
- return t.toLowerCase() !== tagName.toLowerCase();
- }});
- rebuildPills_{widget_id}();
- updateHiddenInput_{widget_id}();
- }};
- window.rebuildPills_{widget_id} = function() {{
- var container = document.getElementById('{widget_id}_pills');
- container.innerHTML = '';
- currentTags_{widget_id}.forEach(function(tag) {{
- var pill = document.createElement('span');
- pill.className = 'tag-pill';
- pill.setAttribute('data-tag', tag);
- applyTagStyle_{widget_id}(pill, tag);
- var tagText = document.createTextNode(tag);
- pill.appendChild(tagText);
- var removeBtn = document.createElement('button');
- removeBtn.type = 'button';
- removeBtn.className = 'tag-remove-btn';
- removeBtn.setAttribute('data-tag-name', tag);
- removeBtn.innerHTML = '×';
- pill.appendChild(removeBtn);
- container.appendChild(pill);
- }});
- }};
- // Add event delegation for remove buttons
- document.getElementById('{widget_id}_pills').addEventListener('click', function(event) {{
- if (event.target.classList.contains('tag-remove-btn')) {{
- var tagName = event.target.getAttribute('data-tag-name');
- if (tagName) {{
- removeTag_{widget_id}(tagName);
- }}
- }}
- }});
- window.handleTagKeydown_{widget_id} = function(event) {{
- var input = event.target;
- var value = input.value.trim();
- if (event.key === 'Enter' || event.keyCode === 13 || event.key === ' ' || event.key === ',') {{
- event.preventDefault();
- event.stopPropagation();
- if (value) {{
- // Handle comma-separated values
- value.split(',').forEach(function(tag) {{
- addTag_{widget_id}(tag.trim());
- }});
- }}
- return false;
- }} else if (event.key === 'Backspace' && !value && currentTags_{widget_id}.length > 0) {{
- // Remove last tag on backspace when input is empty
- var lastTag = currentTags_{widget_id}.pop();
- rebuildPills_{widget_id}();
- updateHiddenInput_{widget_id}();
- }}
- }};
- window.fetchTagAutocomplete_{widget_id} = function(query) {{
- if (autocompleteTimeout_{widget_id}) {{
- clearTimeout(autocompleteTimeout_{widget_id});
- }}
- autocompleteTimeout_{widget_id} = setTimeout(function() {{
- if (!query || query.length < 1) {{
- document.getElementById('{widget_id}_datalist').innerHTML = '';
- return;
- }}
- fetch(buildApiUrl('/api/v1/core/tags/autocomplete/?q=' + encodeURIComponent(query)))
- .then(function(response) {{ return response.json(); }})
- .then(function(data) {{
- var datalist = document.getElementById('{widget_id}_datalist');
- datalist.innerHTML = '';
- (data.tags || []).forEach(function(tag) {{
- var option = document.createElement('option');
- option.value = tag.name;
- datalist.appendChild(option);
- }});
- }})
- .catch(function(err) {{
- console.log('Autocomplete error:', err);
- }});
- }}, 150);
- }};
- function escapeHtml(text) {{
- var div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }}
- function getCSRFToken() {{
- var cookies = document.cookie.split(';');
- for (var i = 0; i < cookies.length; i++) {{
- var cookie = cookies[i].trim();
- if (cookie.startsWith('csrftoken=')) {{
- return cookie.substring('csrftoken='.length);
- }}
- }}
- // Fallback to hidden input
- var input = document.querySelector('input[name="csrfmiddlewaretoken"]');
- return input ? input.value : '';
- }}
- }})();
- </script>
- '''
- return mark_safe(html)
- 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):
- super().__init__(attrs, snapshot_id)
- self.snapshot_id = snapshot_id
- 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
- tags = []
- 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())
- tags = [t['name'] for t in tag_data]
- 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())
- tags = [t['name'] for t in tag_data]
- 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:
- pills_html += f'''
- <span class="tag-pill" data-tag="{self._escape(td['name'])}" data-tag-id="{td['id']}" style="{self._tag_style(td['name'])}">
- <a href="/admin/core/snapshot/?tags__id__exact={td['id']}" class="tag-link">{self._escape(td['name'])}</a>
- <button type="button" class="tag-remove-btn" data-tag-id="{td['id']}" data-tag-name="{self._escape(td['name'])}">×</button>
- </span>
- '''
- tags_json = escape(json.dumps(tag_data))
- html = f'''
- <span id="{widget_id}_container" class="tag-editor-inline" data-snapshot-id="{snapshot_id}" data-tags="{tags_json}">
- <span id="{widget_id}_pills" class="tag-pills-inline">
- {pills_html}
- </span>
- <input type="text"
- id="{widget_id}_input"
- class="tag-inline-input-sm"
- list="{widget_id}_datalist"
- placeholder="+"
- autocomplete="off"
- data-inline-tag-input="1"
- >
- <datalist id="{widget_id}_datalist"></datalist>
- </span>
- '''
- return mark_safe(html)
|