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
This commit is contained in:
Claude 2026-04-20 17:05:07 +00:00
parent b68ff3ed29
commit ec9c7c89f4
No known key found for this signature in database
9 changed files with 36 additions and 73 deletions

View File

@ -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

View File

@ -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",),
},
),

View File

@ -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",
),
]

View File

@ -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

View File

@ -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]),

View File

@ -238,7 +238,7 @@ document.addEventListener('DOMContentLoaded', function () {
return '' +
'<a class="tag-similar-card" href="' + escapeHtml(card.filter_url) + '">' +
'<strong>' + escapeHtml(card.name) + '</strong>' +
'<span>' + escapeHtml(card.num_snapshots) + ' snapshots · slug: ' + escapeHtml(card.slug) + '</span>' +
'<span>' + escapeHtml(card.num_snapshots) + ' snapshots</span>' +
'<div class="tag-similar-card__snapshots">' + (snapshots || '<span class="tag-similar-snapshot">No snapshots</span>') + '</div>' +
'</a>';
}).join('');

View File

@ -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 {

View File

@ -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}")

View File

@ -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}