mirror of
https://github.com/ArchiveBox/ArchiveBox.git
synced 2026-09-12 19:50:57 +05:00
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:
parent
b68ff3ed29
commit
ec9c7c89f4
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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",),
|
||||
},
|
||||
),
|
||||
|
||||
14
archivebox/core/migrations/0034_remove_tag_slug.py
Normal file
14
archivebox/core/migrations/0034_remove_tag_slug.py
Normal 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",
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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]),
|
||||
|
||||
@ -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('');
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user