From ec9c7c89f44c781f032443400ae2761b5423c3a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 17:05:07 +0000 Subject: [PATCH] Drop Tag slug column and use URL-encoded names Tags now support full unicode with no restrictions. URL-encode the tag name wherever it previously used the slug (export filenames, lookups). - Remove `slug` field, `_generate_unique_slug`, and slug handling in save() - Add migration 0034 to drop the slug column - `get_tag_by_ref` now resolves by URL-decoded exact name match - Tag search/autocomplete/export filenames use the name directly - Drop slug from admin search_fields/readonly_fields/fieldsets - Remove slug display from similar-tag cards and client download filename --- archivebox/api/v1_core.py | 11 ++---- archivebox/core/admin_tags.py | 6 +-- .../core/migrations/0034_remove_tag_slug.py | 14 +++++++ archivebox/core/models.py | 39 ------------------- archivebox/core/tag_utils.py | 14 +++---- .../templates/admin/core/tag/change_form.html | 2 +- .../templates/admin/core/tag/change_list.html | 10 +---- archivebox/tests/test_migrations_fresh.py | 2 +- archivebox/tests/test_tag_admin.py | 11 ++++-- 9 files changed, 36 insertions(+), 73 deletions(-) create mode 100644 archivebox/core/migrations/0034_remove_tag_slug.py diff --git a/archivebox/api/v1_core.py b/archivebox/api/v1_core.py index 8f4b4ae0..cc9a7aa9 100644 --- a/archivebox/api/v1_core.py +++ b/archivebox/api/v1_core.py @@ -2,6 +2,7 @@ __package__ = "archivebox.api" import math from collections import defaultdict +from urllib.parse import quote from uuid import UUID from typing import Union, Any, Annotated from datetime import datetime @@ -447,7 +448,6 @@ class TagSchema(Schema): created_by_id: str created_by_username: str name: str - slug: str num_snapshots: int snapshots: list[SnapshotSchema] @@ -555,7 +555,6 @@ class TagSearchSnapshotSchema(Schema): class TagSearchCardSchema(Schema): id: int name: str - slug: str num_snapshots: int filter_url: str edit_url: str @@ -582,7 +581,6 @@ class TagUpdateResponseSchema(Schema): success: bool tag_id: int tag_name: str - slug: str class TagDeleteResponseSchema(Schema): @@ -665,7 +663,7 @@ def tags_autocomplete(request: HttpRequest, q: str = ""): tags = get_matching_tags(q)[: 50 if not q else 20] return { - "tags": [{"id": tag.pk, "name": tag.name, "slug": tag.slug, "num_snapshots": getattr(tag, "num_snapshots", 0)} for tag in tags], + "tags": [{"id": tag.pk, "name": tag.name, "num_snapshots": getattr(tag, "num_snapshots", 0)} for tag in tags], } @@ -701,7 +699,6 @@ def rename_tag(request: HttpRequest, tag_id: int, data: TagUpdateSchema): "success": True, "tag_id": tag.pk, "tag_name": tag.name, - "slug": tag.slug, } @@ -728,7 +725,7 @@ def tag_urls_export(request: HttpRequest, tag_id: int): raise HttpError(404, "Tag not found") from err response = HttpResponse(export_tag_urls(tag), content_type="text/plain; charset=utf-8") - response["Content-Disposition"] = f'attachment; filename="tag-{tag.slug}-urls.txt"' + response["Content-Disposition"] = f'attachment; filename="tag-{quote(tag.name, safe="")}-urls.txt"' return response @@ -740,7 +737,7 @@ def tag_snapshots_export(request: HttpRequest, tag_id: int): raise HttpError(404, "Tag not found") from err response = HttpResponse(export_tag_snapshots_jsonl(tag), content_type="application/x-ndjson; charset=utf-8") - response["Content-Disposition"] = f'attachment; filename="tag-{tag.slug}-snapshots.jsonl"' + response["Content-Disposition"] = f'attachment; filename="tag-{quote(tag.name, safe="")}-snapshots.jsonl"' return response diff --git a/archivebox/core/admin_tags.py b/archivebox/core/admin_tags.py index dfa456bb..e2ff20da 100644 --- a/archivebox/core/admin_tags.py +++ b/archivebox/core/admin_tags.py @@ -62,8 +62,8 @@ class TagAdmin(BaseModelAdmin): change_form_template = "admin/core/tag/change_form.html" list_display = ("name", "num_snapshots", "created_at", "created_by") list_filter = ("created_at", "created_by") - search_fields = ("id", "name", "slug") - readonly_fields = ("slug", "id", "created_at", "modified_at", "snapshots") + search_fields = ("id", "name") + readonly_fields = ("id", "created_at", "modified_at", "snapshots") actions = ["delete_selected"] ordering = ["name", "id"] @@ -71,7 +71,7 @@ class TagAdmin(BaseModelAdmin): ( "Tag", { - "fields": ("name", "slug"), + "fields": ("name",), "classes": ("card",), }, ), diff --git a/archivebox/core/migrations/0034_remove_tag_slug.py b/archivebox/core/migrations/0034_remove_tag_slug.py new file mode 100644 index 00000000..ed1581a1 --- /dev/null +++ b/archivebox/core/migrations/0034_remove_tag_slug.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0033_alter_archiveresult_status"), + ] + + operations = [ + migrations.RemoveField( + model_name="tag", + name="slug", + ), + ] diff --git a/archivebox/core/models.py b/archivebox/core/models.py index 4256584f..ebb892c6 100755 --- a/archivebox/core/models.py +++ b/archivebox/core/models.py @@ -15,7 +15,6 @@ from statemachine import State, registry from django.db import models from django.db.models import QuerySet from django.utils.functional import cached_property -from django.utils.text import slugify from django.utils import timezone from django.core.cache import cache from django.urls import reverse_lazy @@ -59,7 +58,6 @@ class Tag(ModelWithUUID): created_at = models.DateTimeField(default=timezone.now, db_index=True, null=True) modified_at = models.DateTimeField(auto_now=True) name = models.CharField(unique=True, blank=False, max_length=100) - slug = models.SlugField(unique=True, blank=False, max_length=100, editable=False) snapshot_set: models.Manager["Snapshot"] @@ -71,42 +69,6 @@ class Tag(ModelWithUUID): def __str__(self): return self.name - def _generate_unique_slug(self) -> str: - base_slug = slugify(self.name) or "tag" - existing = Tag.objects.filter(slug__startswith=base_slug) - if self.pk: - existing = existing.exclude(pk=self.pk) - existing_slugs = set(existing.values_list("slug", flat=True)) - - slug = base_slug - i = 1 - while slug in existing_slugs: - slug = f"{base_slug}_{i}" - i += 1 - return slug - - def save(self, *args, **kwargs): - existing_name = None - if self.pk: - existing_name = Tag.objects.filter(pk=self.pk).values_list("name", flat=True).first() - - if not self.slug or existing_name != self.name: - self.slug = self._generate_unique_slug() - super().save(*args, **kwargs) - - # if is_new: - # from archivebox.misc.logging_util import log_worker_event - # log_worker_event( - # worker_type='DB', - # event='Created Tag', - # indent_level=0, - # metadata={ - # 'id': self.id, - # 'name': self.name, - # 'slug': self.slug, - # }, - # ) - @property def api_url(self) -> str: return str(reverse_lazy("api-1:get_tag", args=[self.id])) @@ -122,7 +84,6 @@ class Tag(ModelWithUUID): "schema_version": VERSION, "id": str(self.id), "name": self.name, - "slug": self.slug, } @staticmethod diff --git a/archivebox/core/tag_utils.py b/archivebox/core/tag_utils.py index d0efd427..d27e7f56 100644 --- a/archivebox/core/tag_utils.py +++ b/archivebox/core/tag_utils.py @@ -3,9 +3,10 @@ from __future__ import annotations import json from collections import defaultdict from typing import Any +from urllib.parse import unquote from django.contrib.auth.models import User -from django.db.models import Count, F, Q, QuerySet +from django.db.models import Count, F, QuerySet from django.db.models.functions import Lower from django.http import HttpRequest from django.urls import reverse @@ -66,9 +67,7 @@ def get_matching_tags( query = normalize_tag_name(query) if query: - queryset = queryset.filter( - Q(name__icontains=query) | Q(slug__icontains=query), - ) + queryset = queryset.filter(name__icontains=query) created_by = normalize_created_by_filter(created_by) if created_by: @@ -124,10 +123,8 @@ def get_tag_by_ref(tag_ref: str | int) -> Tag: if ref.isdigit(): return Tag.objects.get(pk=int(ref)) - try: - return Tag.objects.get(slug__iexact=ref) - except Tag.DoesNotExist: - return Tag.objects.get(slug__icontains=ref) + decoded = unquote(ref) + return Tag.objects.get(name__iexact=decoded) def get_or_create_tag(name: str, created_by: User | None = None) -> tuple[Tag, bool]: @@ -233,7 +230,6 @@ def build_tag_card(tag: Tag, snapshot_previews: list[dict[str, Any]] | None = No return { "id": tag.pk, "name": tag.name, - "slug": tag.slug, "num_snapshots": count, "filter_url": f"{reverse('admin:core_snapshot_changelist')}?tags__id__exact={tag.pk}", "edit_url": reverse("admin:core_tag_change", args=[tag.pk]), diff --git a/archivebox/templates/admin/core/tag/change_form.html b/archivebox/templates/admin/core/tag/change_form.html index cde49905..3c24f485 100644 --- a/archivebox/templates/admin/core/tag/change_form.html +++ b/archivebox/templates/admin/core/tag/change_form.html @@ -238,7 +238,7 @@ document.addEventListener('DOMContentLoaded', function () { return '' + '' + '' + escapeHtml(card.name) + '' + - '' + escapeHtml(card.num_snapshots) + ' snapshots ยท slug: ' + escapeHtml(card.slug) + '' + + '' + escapeHtml(card.num_snapshots) + ' snapshots' + '
' + (snapshots || 'No snapshots') + '
' + '
'; }).join(''); diff --git a/archivebox/templates/admin/core/tag/change_list.html b/archivebox/templates/admin/core/tag/change_list.html index 183826d0..ffb2a952 100644 --- a/archivebox/templates/admin/core/tag/change_list.html +++ b/archivebox/templates/admin/core/tag/change_list.html @@ -551,14 +551,6 @@ document.addEventListener('DOMContentLoaded', function () { return div.innerHTML; } - function slugify(value) { - return String(value || '') - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') || 'tag'; - } - function getCSRFToken() { const input = document.querySelector('input[name="csrfmiddlewaretoken"]'); if (input) return input.value; @@ -983,7 +975,7 @@ document.addEventListener('DOMContentLoaded', function () { actionButton.disabled = true; try { const tagName = cardEl.querySelector('.tag-card__display strong')?.textContent || 'tag'; - await downloadFileFromUrl(cardEl.dataset.exportJsonlUrl, 'tag-' + slugify(tagName) + '-snapshots.jsonl'); + await downloadFileFromUrl(cardEl.dataset.exportJsonlUrl, 'tag-' + encodeURIComponent(tagName) + '-snapshots.jsonl'); } catch (error) { setToast(error.message || 'Failed to download JSONL.', 'error'); } finally { diff --git a/archivebox/tests/test_migrations_fresh.py b/archivebox/tests/test_migrations_fresh.py index 8c1eed4d..4d2a23da 100644 --- a/archivebox/tests/test_migrations_fresh.py +++ b/archivebox/tests/test_migrations_fresh.py @@ -197,7 +197,7 @@ class TestSchemaIntegrity(unittest.TestCase): columns = {row[1] for row in cursor.fetchall()} conn.close() - required = {"id", "name", "slug"} + required = {"id", "name"} for col in required: self.assertIn(col, columns, f"Missing column: {col}") diff --git a/archivebox/tests/test_tag_admin.py b/archivebox/tests/test_tag_admin.py index e8ca7bde..bcda61ea 100644 --- a/archivebox/tests/test_tag_admin.py +++ b/archivebox/tests/test_tag_admin.py @@ -154,7 +154,7 @@ def test_tag_search_api_respects_sort_and_filters(client, api_token, admin_user, assert [tag["name"] for tag in payload["tags"]] == ["Zulu Empty"] -def test_tag_rename_api_updates_slug(client, api_token, tagged_data): +def test_tag_rename_api_updates_name(client, api_token, tagged_data): tag, _ = tagged_data response = client.post( @@ -168,7 +168,6 @@ def test_tag_rename_api_updates_slug(client, api_token, tagged_data): tag.refresh_from_db() assert tag.name == "Alpha Archive" - assert tag.slug == "alpha-archive" def test_tag_snapshots_export_returns_jsonl(client, api_token, tagged_data): @@ -180,9 +179,11 @@ def test_tag_snapshots_export_returns_jsonl(client, api_token, tagged_data): HTTP_HOST=ADMIN_HOST, ) + from urllib.parse import quote + assert response.status_code == 200 assert response["Content-Type"].startswith("application/x-ndjson") - assert f"tag-{tag.slug}-snapshots.jsonl" in response["Content-Disposition"] + assert f"tag-{quote(tag.name, safe='')}-snapshots.jsonl" in response["Content-Disposition"] body = response.content.decode() assert '"type": "Snapshot"' in body assert '"tags": "Alpha Research"' in body @@ -197,8 +198,10 @@ def test_tag_urls_export_returns_plain_text_urls(client, api_token, tagged_data) HTTP_HOST=ADMIN_HOST, ) + from urllib.parse import quote + assert response.status_code == 200 assert response["Content-Type"].startswith("text/plain") - assert f"tag-{tag.slug}-urls.txt" in response["Content-Disposition"] + assert f"tag-{quote(tag.name, safe='')}-urls.txt" in response["Content-Disposition"] exported_urls = set(filter(None, response.content.decode().splitlines())) assert exported_urls == {snapshot.url for snapshot in snapshots}