From 202e5b2e59c29cb2a20c36265167871436872646 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 02:18:08 +0000 Subject: [PATCH 1/3] Add interactive tags editor widget for Django admin Implement a sleek inline tag editor with autocomplete and AJAX support: - Create TagEditorWidget and InlineTagEditorWidget in core/widgets.py - Pills display with X remove button, sorted alphabetically - Text input with HTML5 datalist autocomplete - Enter/Space/Comma to add tags, auto-creates if doesn't exist - Backspace removes last tag when input is empty - Add API endpoints in api/v1_core.py - GET /tags/autocomplete/ - search tags by name - POST /tags/create/ - get_or_create tag - POST /tags/add-to-snapshot/ - add tag to snapshot via AJAX - POST /tags/remove-from-snapshot/ - remove tag from snapshot - Update admin_snapshots.py - Replace FilteredSelectMultiple with TagEditorWidget in bulk actions - Create SnapshotAdminForm with tags_editor field - Update title_str() to render inline tag editor in list view - Remove TagInline, use widget instead - Add CSS styles in templates/admin/base.html - Blue gradient pill styling matching admin theme - Focus ring and hover states - Compact inline variant for list view --- archivebox/api/v1_core.py | 157 +++++++++ archivebox/core/admin_snapshots.py | 139 ++++++-- archivebox/core/widgets.py | 474 +++++++++++++++++++++++++++ archivebox/templates/admin/base.html | 183 +++++++++++ 4 files changed, 931 insertions(+), 22 deletions(-) create mode 100644 archivebox/core/widgets.py diff --git a/archivebox/api/v1_core.py b/archivebox/api/v1_core.py index 766ee9c6..f49f05af 100644 --- a/archivebox/api/v1_core.py +++ b/archivebox/api/v1_core.py @@ -300,3 +300,160 @@ def get_any(request, id: str): pass raise HttpError(404, 'Object with given ID not found') + + +### Tag Editor API Endpoints ######################################################################### + +class TagAutocompleteSchema(Schema): + tags: List[dict] + + +class TagCreateSchema(Schema): + name: str + + +class TagCreateResponseSchema(Schema): + success: bool + tag_id: int + tag_name: str + created: bool + + +class TagSnapshotRequestSchema(Schema): + snapshot_id: str + tag_name: Optional[str] = None + tag_id: Optional[int] = None + + +class TagSnapshotResponseSchema(Schema): + success: bool + tag_id: int + tag_name: str + + +@router.get("/tags/autocomplete/", response=TagAutocompleteSchema, url_name="tags_autocomplete") +def tags_autocomplete(request, q: str = ""): + """Return tags matching the query for autocomplete.""" + if not q: + # Return all tags if no query (limited to 50) + tags = Tag.objects.all().order_by('name')[:50] + else: + tags = Tag.objects.filter(name__icontains=q).order_by('name')[:20] + + return { + 'tags': [{'id': tag.pk, 'name': tag.name, 'slug': tag.slug} for tag in tags] + } + + +@router.post("/tags/create/", response=TagCreateResponseSchema, url_name="tags_create") +def tags_create(request, data: TagCreateSchema): + """Create a new tag or return existing one.""" + name = data.name.strip() + if not name: + raise HttpError(400, 'Tag name is required') + + tag, created = Tag.objects.get_or_create( + name__iexact=name, + defaults={ + 'name': name, + 'created_by': request.user if request.user.is_authenticated else None, + } + ) + + # If found by case-insensitive match, use that tag + if not created: + tag = Tag.objects.filter(name__iexact=name).first() + + return { + 'success': True, + 'tag_id': tag.pk, + 'tag_name': tag.name, + 'created': created, + } + + +@router.post("/tags/add-to-snapshot/", response=TagSnapshotResponseSchema, url_name="tags_add_to_snapshot") +def tags_add_to_snapshot(request, data: TagSnapshotRequestSchema): + """Add a tag to a snapshot. Creates the tag if it doesn't exist.""" + # Get the snapshot + try: + snapshot = Snapshot.objects.get( + Q(id__startswith=data.snapshot_id) | Q(timestamp__startswith=data.snapshot_id) + ) + except Snapshot.DoesNotExist: + raise HttpError(404, 'Snapshot not found') + except Snapshot.MultipleObjectsReturned: + snapshot = Snapshot.objects.filter( + Q(id__startswith=data.snapshot_id) | Q(timestamp__startswith=data.snapshot_id) + ).first() + + # Get or create the tag + if data.tag_name: + name = data.tag_name.strip() + if not name: + raise HttpError(400, 'Tag name is required') + + tag, _ = Tag.objects.get_or_create( + name__iexact=name, + defaults={ + 'name': name, + 'created_by': request.user if request.user.is_authenticated else None, + } + ) + # If found by case-insensitive match, use that tag + tag = Tag.objects.filter(name__iexact=name).first() or tag + elif data.tag_id: + try: + tag = Tag.objects.get(pk=data.tag_id) + except Tag.DoesNotExist: + raise HttpError(404, 'Tag not found') + else: + raise HttpError(400, 'Either tag_name or tag_id is required') + + # Add the tag to the snapshot + snapshot.tags.add(tag) + + return { + 'success': True, + 'tag_id': tag.pk, + 'tag_name': tag.name, + } + + +@router.post("/tags/remove-from-snapshot/", response=TagSnapshotResponseSchema, url_name="tags_remove_from_snapshot") +def tags_remove_from_snapshot(request, data: TagSnapshotRequestSchema): + """Remove a tag from a snapshot.""" + # Get the snapshot + try: + snapshot = Snapshot.objects.get( + Q(id__startswith=data.snapshot_id) | Q(timestamp__startswith=data.snapshot_id) + ) + except Snapshot.DoesNotExist: + raise HttpError(404, 'Snapshot not found') + except Snapshot.MultipleObjectsReturned: + snapshot = Snapshot.objects.filter( + Q(id__startswith=data.snapshot_id) | Q(timestamp__startswith=data.snapshot_id) + ).first() + + # Get the tag + if data.tag_id: + try: + tag = Tag.objects.get(pk=data.tag_id) + except Tag.DoesNotExist: + raise HttpError(404, 'Tag not found') + elif data.tag_name: + try: + tag = Tag.objects.get(name__iexact=data.tag_name.strip()) + except Tag.DoesNotExist: + raise HttpError(404, 'Tag not found') + else: + raise HttpError(400, 'Either tag_name or tag_id is required') + + # Remove the tag from the snapshot + snapshot.tags.remove(tag) + + return { + 'success': True, + 'tag_id': tag.pk, + 'tag_name': tag.name, + } diff --git a/archivebox/core/admin_snapshots.py b/archivebox/core/admin_snapshots.py index f8662fc3..55ecddbf 100644 --- a/archivebox/core/admin_snapshots.py +++ b/archivebox/core/admin_snapshots.py @@ -11,7 +11,6 @@ from django.utils import timezone from django import forms from django.template import Template, RequestContext from django.contrib.admin.helpers import ActionForm -from django.contrib.admin.widgets import FilteredSelectMultiple from archivebox.config import DATA_DIR from archivebox.config.common import SERVER_CONFIG @@ -24,8 +23,8 @@ from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin from archivebox.workers.tasks import bg_archive_snapshots, bg_add from archivebox.core.models import Tag, Snapshot -from archivebox.core.admin_tags import TagInline from archivebox.core.admin_archiveresults import ArchiveResultInline, render_archiveresults_list +from archivebox.core.widgets import TagEditorWidget, InlineTagEditorWidget # GLOBAL_CONTEXT = {'VERSION': VERSION, 'VERSIONS_AVAILABLE': [], 'CAN_UPGRADE': False} @@ -36,16 +35,30 @@ class SnapshotActionForm(ActionForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Define tags field in __init__ to avoid database access during app initialization - self.fields['tags'] = forms.ModelMultipleChoiceField( + self.fields['tags'] = forms.CharField( label='Edit tags', - queryset=Tag.objects.all(), required=False, - widget=FilteredSelectMultiple( - 'core_tag__name', - False, - ), + widget=TagEditorWidget(), ) + def clean_tags(self): + """Parse comma-separated tag names into Tag objects.""" + tags_str = self.cleaned_data.get('tags', '') + if not tags_str: + return [] + + tag_names = [name.strip() for name in tags_str.split(',') if name.strip()] + tags = [] + for name in tag_names: + tag, _ = Tag.objects.get_or_create( + name__iexact=name, + defaults={'name': name} + ) + # Use the existing tag if found by case-insensitive match + tag = Tag.objects.filter(name__iexact=name).first() or tag + tags.append(tag) + return tags + # TODO: allow selecting actions for specific extractor plugins? is this useful? # plugin = forms.ChoiceField( # choices=ArchiveResult.PLUGIN_CHOICES, @@ -54,10 +67,59 @@ class SnapshotActionForm(ActionForm): # ) +class SnapshotAdminForm(forms.ModelForm): + """Custom form for Snapshot admin with tag editor widget.""" + tags_editor = forms.CharField( + label='Tags', + required=False, + widget=TagEditorWidget(), + help_text='Type tag names and press Enter or Space to add. Click × to remove.', + ) + + class Meta: + model = Snapshot + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Initialize tags_editor with current tags + if self.instance and self.instance.pk: + self.initial['tags_editor'] = ','.join( + sorted(tag.name for tag in self.instance.tags.all()) + ) + + def save(self, commit=True): + instance = super().save(commit=False) + + # Handle tags_editor field + if commit: + instance.save() + self._save_m2m() + + # Parse and save tags from tags_editor + tags_str = self.cleaned_data.get('tags_editor', '') + if tags_str: + tag_names = [name.strip() for name in tags_str.split(',') if name.strip()] + tags = [] + for name in tag_names: + tag, _ = Tag.objects.get_or_create( + name__iexact=name, + defaults={'name': name} + ) + tag = Tag.objects.filter(name__iexact=name).first() or tag + tags.append(tag) + instance.tags.set(tags) + else: + instance.tags.clear() + + return instance + + class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): + form = SnapshotAdminForm list_display = ('created_at', 'title_str', 'status', 'files', 'size', 'url_str') sort_fields = ('title_str', 'url_str', 'created_at', 'status', 'crawl') - readonly_fields = ('admin_actions', 'status_info', 'tags_str', 'imported_timestamp', 'created_at', 'modified_at', 'downloaded_at', 'output_dir', 'archiveresults_list') + readonly_fields = ('admin_actions', 'status_info', 'imported_timestamp', 'created_at', 'modified_at', 'downloaded_at', 'output_dir', 'archiveresults_list') search_fields = ('id', 'url', 'timestamp', 'title', 'tags__name') list_filter = ('created_at', 'downloaded_at', 'archiveresult__status', 'crawl__created_by', 'tags__name') @@ -66,6 +128,10 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): 'fields': ('url', 'title'), 'classes': ('card', 'wide'), }), + ('Tags', { + 'fields': ('tags_editor',), + 'classes': ('card',), + }), ('Status', { 'fields': ('status', 'retry_at', 'status_info'), 'classes': ('card',), @@ -75,7 +141,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): 'classes': ('card',), }), ('Relations', { - 'fields': ('crawl', 'tags_str'), + 'fields': ('crawl',), 'classes': ('card',), }), ('Config', { @@ -98,7 +164,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): ordering = ['-created_at'] actions = ['add_tags', 'remove_tags', 'update_titles', 'update_snapshots', 'resnapshot_snapshot', 'overwrite_snapshots', 'delete_snapshots'] - inlines = [TagInline] # Removed ArchiveResultInline, using custom renderer instead + inlines = [] # Removed TagInline, using TagEditorWidget instead list_per_page = min(max(5, SERVER_CONFIG.SNAPSHOTS_PER_PAGE), 5000) action_form = SnapshotActionForm @@ -257,11 +323,15 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): ordering='title', ) def title_str(self, obj): - tags = ''.join( - format_html('{} ', tag.pk, tag.name) - for tag in obj.tags.all() - if str(tag.name).strip() + # Render inline tag editor widget + widget = InlineTagEditorWidget(snapshot_id=str(obj.pk)) + tags_html = widget.render( + name=f'tags_{obj.pk}', + value=obj.tags.all(), + attrs={'id': f'tags_{obj.pk}'}, + snapshot_id=str(obj.pk), ) + # Show title if available, otherwise show URL display_text = obj.title or obj.url css_class = 'fetched' if obj.title else 'pending' @@ -278,7 +348,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): obj.archive_path, css_class, urldecode(htmldecode(display_text))[:128] - ) + mark_safe(f' {tags}') + ) + mark_safe(f' {tags_html}') @admin.display( description='Files Saved', @@ -428,13 +498,29 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): description="+" ) def add_tags(self, request, queryset): - tags = request.POST.getlist('tags') - print('[+] Adding tags', tags, 'to Snapshots', queryset) + # Get tags from the form - now comma-separated string + tags_str = request.POST.get('tags', '') + if not tags_str: + messages.warning(request, "No tags specified.") + return + + # Parse comma-separated tag names and get/create Tag objects + tag_names = [name.strip() for name in tags_str.split(',') if name.strip()] + tags = [] + for name in tag_names: + tag, _ = Tag.objects.get_or_create( + name__iexact=name, + defaults={'name': name} + ) + tag = Tag.objects.filter(name__iexact=name).first() or tag + tags.append(tag) + + print('[+] Adding tags', [t.name for t in tags], 'to Snapshots', queryset) for obj in queryset: obj.tags.add(*tags) messages.success( request, - f"Added {len(tags)} tags to {queryset.count()} Snapshots.", + f"Added {len(tags)} tag(s) to {queryset.count()} Snapshot(s).", ) @@ -442,11 +528,20 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): description="–" ) def remove_tags(self, request, queryset): - tags = request.POST.getlist('tags') - print('[-] Removing tags', tags, 'to Snapshots', queryset) + # Get tags from the form - now comma-separated string + tags_str = request.POST.get('tags', '') + if not tags_str: + messages.warning(request, "No tags specified.") + return + + # Parse comma-separated tag names and find matching Tag objects + tag_names = [name.strip() for name in tags_str.split(',') if name.strip()] + tags = list(Tag.objects.filter(name__in=tag_names)) + + print('[-] Removing tags', [t.name for t in tags], 'from Snapshots', queryset) for obj in queryset: obj.tags.remove(*tags) messages.success( request, - f"Removed {len(tags)} tags from {queryset.count()} Snapshots.", + f"Removed {len(tags)} tag(s) from {queryset.count()} Snapshot(s).", ) diff --git a/archivebox/core/widgets.py b/archivebox/core/widgets.py new file mode 100644 index 00000000..124e6728 --- /dev/null +++ b/archivebox/core/widgets.py @@ -0,0 +1,474 @@ +__package__ = 'archivebox.core' + +import json +from django import forms +from django.utils.html import escape + + +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 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 = attrs.get('id', name) if attrs else name + + # Build pills HTML + pills_html = '' + for tag in tags: + pills_html += f''' + + {self._escape(tag)} + + + ''' + + # Build the widget HTML + html = f''' +
+
+ {pills_html} +
+ + + +
+ + + ''' + + return 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 = f"inline_tags_{snapshot_id}" if snapshot_id else (attrs.get('id', name) if attrs else name) + + # Build pills HTML with filter links + pills_html = '' + for td in tag_data: + pills_html += f''' + + {self._escape(td['name'])} + + + ''' + + html = f''' + + + {pills_html} + + + + + + + ''' + + return html diff --git a/archivebox/templates/admin/base.html b/archivebox/templates/admin/base.html index bbcb0a3b..bde628a4 100644 --- a/archivebox/templates/admin/base.html +++ b/archivebox/templates/admin/base.html @@ -1059,6 +1059,189 @@ color: #2563eb; margin-right: 8px; } + + /* ============================================ + Tag Editor Widget Styles + ============================================ */ + + /* Main container - acts as input field */ + .tag-editor-container { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 12px; + min-height: 42px; + background: #fff; + border: 1px solid #d1d5db; + border-radius: 8px; + cursor: text; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + } + + .tag-editor-container:focus-within { + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + + /* Pills container */ + .tag-pills { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + } + + /* Individual tag pill */ + .tag-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px 4px 10px; + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: #fff; + font-size: 13px; + font-weight: 500; + border-radius: 16px; + white-space: nowrap; + transition: all 0.15s ease; + -webkit-font-smoothing: antialiased; + } + + .tag-pill:hover { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + } + + .tag-pill a.tag-link { + color: #fff; + text-decoration: none; + } + + .tag-pill a.tag-link:hover { + text-decoration: underline; + } + + /* Remove button on pills */ + .tag-remove-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + margin: 0; + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 50%; + color: #fff; + font-size: 14px; + font-weight: 600; + line-height: 1; + cursor: pointer; + opacity: 0.7; + transition: all 0.15s ease; + } + + .tag-remove-btn:hover { + background: rgba(255, 255, 255, 0.4); + opacity: 1; + } + + /* Inline input for adding tags */ + .tag-inline-input { + flex: 1; + min-width: 120px; + padding: 4px 0; + border: none; + outline: none; + font-size: 14px; + font-family: inherit; + background: transparent; + color: #1e293b; + } + + .tag-inline-input::placeholder { + color: #94a3b8; + } + + /* Inline editor for list view - more compact */ + .tag-editor-inline { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 2px 4px; + background: transparent; + border-radius: 4px; + cursor: text; + vertical-align: middle; + } + + .tag-pills-inline { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; + } + + .tag-editor-inline .tag-pill { + padding: 2px 6px 2px 8px; + font-size: 11px; + border-radius: 12px; + } + + .tag-editor-inline .tag-remove-btn { + width: 14px; + height: 14px; + font-size: 12px; + } + + .tag-inline-input-sm { + width: 24px; + min-width: 24px; + max-width: 100px; + padding: 2px 4px; + border: none; + outline: none; + font-size: 11px; + font-family: inherit; + background: transparent; + color: #64748b; + transition: width 0.15s ease; + } + + .tag-inline-input-sm:focus { + width: 80px; + color: #1e293b; + } + + .tag-inline-input-sm::placeholder { + color: #94a3b8; + } + + /* Container in list view title column */ + .tags-inline-editor { + display: inline; + margin-left: 8px; + } + + /* Existing tag styles (keep for backwards compat) */ + .tags .tag { + display: inline-block; + padding: 2px 8px; + margin: 1px 2px; + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: #fff; + font-size: 11px; + font-weight: 500; + border-radius: 12px; + text-decoration: none; + transition: all 0.15s ease; + } + + .tags .tag:hover { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + } {% endblock %} From 03b96ef4cee163d4a4f8f1b348d8a489af12abb1 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:18:41 +0000 Subject: [PATCH 2/3] Fix security issues in tag editor widgets - Fix case-sensitivity mismatch in remove_tags (use name__iexact) - Fix XSS vulnerability by removing onclick attributes - Use data attributes and event delegation instead - Apply DOM APIs to prevent injection attacks Co-authored-by: Nick Sweeting --- archivebox/core/admin_snapshots.py | 8 +++- archivebox/core/widgets.py | 66 +++++++++++++++++++++++------- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/archivebox/core/admin_snapshots.py b/archivebox/core/admin_snapshots.py index 55ecddbf..816535bb 100644 --- a/archivebox/core/admin_snapshots.py +++ b/archivebox/core/admin_snapshots.py @@ -534,9 +534,13 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): messages.warning(request, "No tags specified.") return - # Parse comma-separated tag names and find matching Tag objects + # Parse comma-separated tag names and find matching Tag objects (case-insensitive) tag_names = [name.strip() for name in tags_str.split(',') if name.strip()] - tags = list(Tag.objects.filter(name__in=tag_names)) + tags = [] + for name in tag_names: + tag = Tag.objects.filter(name__iexact=name).first() + if tag: + tags.append(tag) print('[-] Removing tags', [t.name for t in tags], 'from Snapshots', queryset) for obj in queryset: diff --git a/archivebox/core/widgets.py b/archivebox/core/widgets.py index 124e6728..433f5c93 100644 --- a/archivebox/core/widgets.py +++ b/archivebox/core/widgets.py @@ -75,7 +75,7 @@ class TagEditorWidget(forms.Widget): pills_html += f''' {self._escape(tag)} - + ''' @@ -151,7 +151,7 @@ class TagEditorWidget(forms.Widget): }}); }}; - window.removeTag_{widget_id} = function(btn, tagName) {{ + window.removeTag_{widget_id} = function(tagName) {{ currentTags_{widget_id} = currentTags_{widget_id}.filter(function(t) {{ return t.toLowerCase() !== tagName.toLowerCase(); }}); @@ -166,13 +166,31 @@ class TagEditorWidget(forms.Widget): var pill = document.createElement('span'); pill.className = 'tag-pill'; pill.setAttribute('data-tag', tag); - pill.innerHTML = escapeHtml(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(); @@ -285,7 +303,7 @@ class InlineTagEditorWidget(TagEditorWidget): pills_html += f''' {self._escape(td['name'])} - + ''' @@ -362,10 +380,7 @@ class InlineTagEditorWidget(TagEditorWidget): document.getElementById('{widget_id}_input').value = ''; }}; - window.removeInlineTag_{widget_id} = function(event, tagId, tagName) {{ - event.stopPropagation(); - event.preventDefault(); - + window.removeInlineTag_{widget_id} = function(tagId) {{ fetch('/api/v1/core/tags/remove-from-snapshot/', {{ method: 'POST', headers: {{ @@ -399,14 +414,37 @@ class InlineTagEditorWidget(TagEditorWidget): pill.className = 'tag-pill'; pill.setAttribute('data-tag', td.name); pill.setAttribute('data-tag-id', td.id); - pill.innerHTML = '' + - escapeHtml(td.name) + '' + - ''; + + var link = document.createElement('a'); + link.href = '/admin/core/snapshot/?tags__id__exact=' + td.id; + link.className = 'tag-link'; + link.textContent = td.name; + pill.appendChild(link); + + var removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'tag-remove-btn'; + removeBtn.setAttribute('data-tag-id', td.id); + removeBtn.setAttribute('data-tag-name', td.name); + 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')) {{ + event.stopPropagation(); + event.preventDefault(); + var tagId = parseInt(event.target.getAttribute('data-tag-id'), 10); + if (tagId) {{ + removeInlineTag_{widget_id}(tagId); + }} + }} + }}); + window.handleInlineTagKeydown_{widget_id} = function(event) {{ event.stopPropagation(); var input = event.target; From 0dee662f41184e4d28508d9ff1c6477bef488b85 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 19:29:23 +0000 Subject: [PATCH 3/3] Use bulk operations for add/remove tags actions - add_tags: Uses SnapshotTag.objects.bulk_create() with ignore_conflicts Instead of N calls to obj.tags.add(), now makes 1 query per tag - remove_tags: Uses single SnapshotTag.objects.filter().delete() Instead of N calls to obj.tags.remove(), now makes 1 query total Works correctly with "select all across pages" via queryset.values_list() --- archivebox/core/admin_snapshots.py | 44 ++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/archivebox/core/admin_snapshots.py b/archivebox/core/admin_snapshots.py index 816535bb..e5f972da 100644 --- a/archivebox/core/admin_snapshots.py +++ b/archivebox/core/admin_snapshots.py @@ -498,6 +498,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): description="+" ) def add_tags(self, request, queryset): + from archivebox.core.models import SnapshotTag + # Get tags from the form - now comma-separated string tags_str = request.POST.get('tags', '') if not tags_str: @@ -515,12 +517,22 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): tag = Tag.objects.filter(name__iexact=name).first() or tag tags.append(tag) - print('[+] Adding tags', [t.name for t in tags], 'to Snapshots', queryset) - for obj in queryset: - obj.tags.add(*tags) + # Get snapshot IDs efficiently (works with select_across for all pages) + snapshot_ids = list(queryset.values_list('id', flat=True)) + num_snapshots = len(snapshot_ids) + + print('[+] Adding tags', [t.name for t in tags], 'to', num_snapshots, 'Snapshots') + + # Bulk create M2M relationships (1 query per tag, not per snapshot) + for tag in tags: + SnapshotTag.objects.bulk_create( + [SnapshotTag(snapshot_id=sid, tag=tag) for sid in snapshot_ids], + ignore_conflicts=True # Skip if relationship already exists + ) + messages.success( request, - f"Added {len(tags)} tag(s) to {queryset.count()} Snapshot(s).", + f"Added {len(tags)} tag(s) to {num_snapshots} Snapshot(s).", ) @@ -528,6 +540,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): description="–" ) def remove_tags(self, request, queryset): + from archivebox.core.models import SnapshotTag + # Get tags from the form - now comma-separated string tags_str = request.POST.get('tags', '') if not tags_str: @@ -542,10 +556,24 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): if tag: tags.append(tag) - print('[-] Removing tags', [t.name for t in tags], 'from Snapshots', queryset) - for obj in queryset: - obj.tags.remove(*tags) + if not tags: + messages.warning(request, "No matching tags found.") + return + + # Get snapshot IDs efficiently (works with select_across for all pages) + snapshot_ids = list(queryset.values_list('id', flat=True)) + num_snapshots = len(snapshot_ids) + tag_ids = [t.pk for t in tags] + + print('[-] Removing tags', [t.name for t in tags], 'from', num_snapshots, 'Snapshots') + + # Bulk delete M2M relationships (1 query total, not per snapshot) + deleted_count, _ = SnapshotTag.objects.filter( + snapshot_id__in=snapshot_ids, + tag_id__in=tag_ids + ).delete() + messages.success( request, - f"Removed {len(tags)} tag(s) from {queryset.count()} Snapshot(s).", + f"Removed {len(tags)} tag(s) from {num_snapshots} Snapshot(s) ({deleted_count} associations deleted).", )