mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Update search function
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LinksConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'links'
|
||||
|
||||
def ready(self):
|
||||
# Import signals to register them
|
||||
import links.signals
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from links.search_backend import search_backend
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Rebuild the full-text search index'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write('Rebuilding search index...')
|
||||
try:
|
||||
search_backend.rebuild_index()
|
||||
self.stdout.write(self.style.SUCCESS('Successfully rebuilt search index'))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'Failed to rebuild index: {e}'))
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 5.1.13 on 2025-11-05 10:14
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def remove_webpage_content_column(apps, schema_editor):
|
||||
"""Remove webpage_content column if it exists"""
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
# Check if column exists
|
||||
cursor.execute("PRAGMA table_info(links_page)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
|
||||
if 'webpage_content' in columns:
|
||||
# SQLite doesn't support DROP COLUMN directly, need to recreate table
|
||||
cursor.execute("""
|
||||
CREATE TABLE links_page_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url varchar(2000) NOT NULL,
|
||||
title varchar(200) NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at datetime NOT NULL,
|
||||
updated_at datetime NOT NULL,
|
||||
error_message TEXT NOT NULL,
|
||||
last_retry_at datetime,
|
||||
process_status varchar(20) NOT NULL,
|
||||
retry_count INTEGER NOT NULL,
|
||||
screenshot_error TEXT,
|
||||
screenshot_last_attempt datetime,
|
||||
screenshot_path varchar(255) NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Copy data
|
||||
cursor.execute("""
|
||||
INSERT INTO links_page_new
|
||||
SELECT id, url, title, summary, content, created_at, updated_at,
|
||||
error_message, last_retry_at, process_status, retry_count,
|
||||
screenshot_error, screenshot_last_attempt, screenshot_path
|
||||
FROM links_page
|
||||
""")
|
||||
|
||||
# Drop old table
|
||||
cursor.execute("DROP TABLE links_page")
|
||||
|
||||
# Rename new table
|
||||
cursor.execute("ALTER TABLE links_page_new RENAME TO links_page")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('links', '0034_post_summary'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(remove_webpage_content_column, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Full-text search backend using Whoosh
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from whoosh import index
|
||||
from whoosh.fields import Schema, TEXT, ID, DATETIME, KEYWORD, NUMERIC
|
||||
from whoosh.qparser import MultifieldParser, OrGroup
|
||||
from whoosh.analysis import StemmingAnalyzer
|
||||
from .models import Link, Page, Post
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define index directory
|
||||
INDEX_DIR = os.path.join(settings.BASE_DIR, 'data', 'search_index')
|
||||
|
||||
# Define schema for search index
|
||||
SCHEMA = Schema(
|
||||
id=ID(stored=True, unique=True),
|
||||
model_type=ID(stored=True),
|
||||
title=TEXT(stored=True, analyzer=StemmingAnalyzer()),
|
||||
content=TEXT(analyzer=StemmingAnalyzer()),
|
||||
url=TEXT(stored=True),
|
||||
alias=TEXT(stored=True),
|
||||
description=TEXT(stored=True, analyzer=StemmingAnalyzer()),
|
||||
summary=TEXT(stored=True, analyzer=StemmingAnalyzer()),
|
||||
tags=KEYWORD(stored=True, commas=True, scorable=True),
|
||||
created_at=DATETIME(stored=True, sortable=True),
|
||||
click_count=NUMERIC(stored=True, sortable=True)
|
||||
)
|
||||
|
||||
|
||||
class SearchBackend:
|
||||
"""
|
||||
Search backend powered by Whoosh for full-text indexing and searching
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.index_dir = INDEX_DIR
|
||||
self.ensure_index_exists()
|
||||
|
||||
def ensure_index_exists(self):
|
||||
"""Create index directory and index if they don't exist"""
|
||||
if not os.path.exists(self.index_dir):
|
||||
os.makedirs(self.index_dir, exist_ok=True)
|
||||
index.create_in(self.index_dir, SCHEMA)
|
||||
logger.info(f"Created new search index at {self.index_dir}")
|
||||
|
||||
def get_index(self):
|
||||
"""Get or create the search index"""
|
||||
if not index.exists_in(self.index_dir):
|
||||
return index.create_in(self.index_dir, SCHEMA)
|
||||
return index.open_dir(self.index_dir)
|
||||
|
||||
def index_link(self, link):
|
||||
"""Index a single link"""
|
||||
ix = self.get_index()
|
||||
writer = ix.writer()
|
||||
|
||||
try:
|
||||
# Get tags as comma-separated string
|
||||
tags = ','.join([tag.name for tag in link.tags.all()])
|
||||
|
||||
writer.update_document(
|
||||
id=f"link_{link.id}",
|
||||
model_type="link",
|
||||
title=link.alias or "",
|
||||
content=link.text or "",
|
||||
url=link.original_url or "",
|
||||
alias=link.alias or "",
|
||||
description=link.description or "",
|
||||
tags=tags,
|
||||
created_at=link.created_at,
|
||||
click_count=link.click_count
|
||||
)
|
||||
writer.commit()
|
||||
logger.debug(f"Indexed link: {link.id}")
|
||||
except Exception as e:
|
||||
writer.cancel()
|
||||
logger.error(f"Error indexing link {link.id}: {e}", exc_info=True)
|
||||
|
||||
def index_page(self, page):
|
||||
"""Index a single page"""
|
||||
ix = self.get_index()
|
||||
writer = ix.writer()
|
||||
|
||||
try:
|
||||
# Get tags as comma-separated string
|
||||
tags = ','.join([tag.name for tag in page.tags.all()])
|
||||
|
||||
writer.update_document(
|
||||
id=f"page_{page.id}",
|
||||
model_type="page",
|
||||
title=page.title or "",
|
||||
content=page.content or "",
|
||||
url=page.url or "",
|
||||
summary=page.summary or "",
|
||||
tags=tags,
|
||||
created_at=page.created_at
|
||||
)
|
||||
writer.commit()
|
||||
logger.debug(f"Indexed page: {page.id}")
|
||||
except Exception as e:
|
||||
writer.cancel()
|
||||
logger.error(f"Error indexing page {page.id}: {e}", exc_info=True)
|
||||
|
||||
def index_post(self, post):
|
||||
"""Index a single post"""
|
||||
ix = self.get_index()
|
||||
writer = ix.writer()
|
||||
|
||||
try:
|
||||
# Get tags as comma-separated string
|
||||
tags = ','.join([tag.name for tag in post.tags.all()])
|
||||
|
||||
writer.update_document(
|
||||
id=f"post_{post.id}",
|
||||
model_type="post",
|
||||
title=post.title or "",
|
||||
content=post.content or "",
|
||||
summary=post.summary or "",
|
||||
tags=tags,
|
||||
created_at=post.created_at
|
||||
)
|
||||
writer.commit()
|
||||
logger.debug(f"Indexed post: {post.id}")
|
||||
except Exception as e:
|
||||
writer.cancel()
|
||||
logger.error(f"Error indexing post {post.id}: {e}", exc_info=True)
|
||||
|
||||
def remove_from_index(self, model_type, model_id):
|
||||
"""Remove a document from the index"""
|
||||
ix = self.get_index()
|
||||
writer = ix.writer()
|
||||
|
||||
try:
|
||||
writer.delete_by_term('id', f"{model_type}_{model_id}")
|
||||
writer.commit()
|
||||
logger.debug(f"Removed {model_type} {model_id} from index")
|
||||
except Exception as e:
|
||||
writer.cancel()
|
||||
logger.error(f"Error removing {model_type} {model_id} from index: {e}", exc_info=True)
|
||||
|
||||
def rebuild_index(self):
|
||||
"""Rebuild the entire search index from scratch"""
|
||||
logger.info("Starting search index rebuild...")
|
||||
|
||||
# Delete and recreate index
|
||||
ix = index.create_in(self.index_dir, SCHEMA)
|
||||
writer = ix.writer()
|
||||
|
||||
try:
|
||||
# Index all links
|
||||
for link in Link.objects.all().prefetch_related('tags'):
|
||||
tags = ','.join([tag.name for tag in link.tags.all()])
|
||||
writer.add_document(
|
||||
id=f"link_{link.id}",
|
||||
model_type="link",
|
||||
title=link.alias or "",
|
||||
content=link.text or "",
|
||||
url=link.original_url or "",
|
||||
alias=link.alias or "",
|
||||
description=link.description or "",
|
||||
tags=tags,
|
||||
created_at=link.created_at,
|
||||
click_count=link.click_count
|
||||
)
|
||||
|
||||
# Index all pages
|
||||
for page in Page.objects.all().prefetch_related('tags'):
|
||||
tags = ','.join([tag.name for tag in page.tags.all()])
|
||||
writer.add_document(
|
||||
id=f"page_{page.id}",
|
||||
model_type="page",
|
||||
title=page.title or "",
|
||||
content=page.content or "",
|
||||
url=page.url or "",
|
||||
summary=page.summary or "",
|
||||
tags=tags,
|
||||
created_at=page.created_at
|
||||
)
|
||||
|
||||
# Index all posts
|
||||
for post in Post.objects.all().prefetch_related('tags'):
|
||||
tags = ','.join([tag.name for tag in post.tags.all()])
|
||||
writer.add_document(
|
||||
id=f"post_{post.id}",
|
||||
model_type="post",
|
||||
title=post.title or "",
|
||||
content=post.content or "",
|
||||
summary=post.summary or "",
|
||||
tags=tags,
|
||||
created_at=post.created_at
|
||||
)
|
||||
|
||||
writer.commit()
|
||||
logger.info("Search index rebuild completed successfully")
|
||||
except Exception as e:
|
||||
writer.cancel()
|
||||
logger.error(f"Error rebuilding index: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def search(self, query_string, model_type=None, limit=100, page=1, per_page=20):
|
||||
"""
|
||||
Search the index with the given query
|
||||
|
||||
Args:
|
||||
query_string: Search query
|
||||
model_type: Filter by model type (link, page, post) or None for all
|
||||
limit: Maximum number of results to return
|
||||
page: Page number for pagination
|
||||
per_page: Results per page
|
||||
|
||||
Returns:
|
||||
dict with results and metadata
|
||||
"""
|
||||
if not query_string.strip():
|
||||
return {'results': [], 'total': 0, 'page': page, 'per_page': per_page}
|
||||
|
||||
ix = self.get_index()
|
||||
|
||||
with ix.searcher() as searcher:
|
||||
# Create multifield parser - search across multiple fields
|
||||
fields = ['title', 'content', 'url', 'alias', 'description', 'summary', 'tags']
|
||||
parser = MultifieldParser(fields, schema=ix.schema, group=OrGroup)
|
||||
|
||||
try:
|
||||
query = parser.parse(query_string)
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing query '{query_string}': {e}")
|
||||
return {'results': [], 'total': 0, 'page': page, 'per_page': per_page}
|
||||
|
||||
# Apply model type filter if specified
|
||||
if model_type:
|
||||
from whoosh.query import And, Term
|
||||
query = And([query, Term('model_type', model_type)])
|
||||
|
||||
# Execute search
|
||||
results = searcher.search(query, limit=limit)
|
||||
|
||||
# Extract results with pagination
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
total = len(results)
|
||||
|
||||
result_list = []
|
||||
for hit in results[start:end]:
|
||||
result_dict = dict(hit)
|
||||
# Extract model ID from composite ID
|
||||
model_id = result_dict['id'].split('_')[1]
|
||||
result_dict['model_id'] = int(model_id)
|
||||
result_list.append(result_dict)
|
||||
|
||||
return {
|
||||
'results': result_list,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'has_next': end < total,
|
||||
'has_prev': page > 1
|
||||
}
|
||||
|
||||
|
||||
# Global search backend instance
|
||||
search_backend = SearchBackend()
|
||||
@@ -1,6 +1,8 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
from .search_views import SearchView, search, search_aliases
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.search_aliases, name='search_aliases'),
|
||||
path('', SearchView.as_view(), name='search'),
|
||||
path('api/', search, name='search_api'),
|
||||
path('aliases/', search_aliases, name='search_aliases'),
|
||||
]
|
||||
|
||||
+72
-62
@@ -6,6 +6,7 @@ from django.db.models import Q, Value, CharField
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from .models import Link, Page, Post
|
||||
from .search_backend import search_backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,77 +18,86 @@ class SearchView(ListView):
|
||||
def get_queryset(self):
|
||||
query = self.request.GET.get('q', '').strip()
|
||||
type_filter = self.request.GET.get('type', '')
|
||||
sort_order = self.request.GET.get('sort', 'newest')
|
||||
page_num = self.request.GET.get('page', 1)
|
||||
|
||||
try:
|
||||
page_num = int(page_num)
|
||||
except (ValueError, TypeError):
|
||||
page_num = 1
|
||||
|
||||
if query:
|
||||
results = []
|
||||
|
||||
# Apply type filter
|
||||
if type_filter:
|
||||
if type_filter == 'link':
|
||||
results = self._get_link_results(query)
|
||||
elif type_filter == 'page':
|
||||
results = self._get_page_results(query)
|
||||
elif type_filter == 'post':
|
||||
results = self._get_post_results(query)
|
||||
else:
|
||||
# Get all results
|
||||
results = self._get_all_results(query)
|
||||
|
||||
# Apply sorting
|
||||
results = self._sort_results(results, sort_order)
|
||||
|
||||
return results
|
||||
# Use Whoosh search backend for full-text search
|
||||
search_results = search_backend.search(
|
||||
query_string=query,
|
||||
model_type=type_filter if type_filter else None,
|
||||
page=page_num,
|
||||
per_page=self.paginate_by
|
||||
)
|
||||
|
||||
# Enrich results with actual model data
|
||||
enriched_results = []
|
||||
for result in search_results['results']:
|
||||
enriched_result = self._enrich_result(result)
|
||||
if enriched_result:
|
||||
enriched_results.append(enriched_result)
|
||||
|
||||
# Store search metadata for pagination
|
||||
self.search_total = search_results['total']
|
||||
self.search_has_next = search_results['has_next']
|
||||
self.search_has_prev = search_results['has_prev']
|
||||
|
||||
return enriched_results
|
||||
return []
|
||||
|
||||
def _get_link_results(self, query):
|
||||
return Link.objects.filter(
|
||||
Q(alias__icontains=query) |
|
||||
Q(original_url__icontains=query) |
|
||||
Q(description__icontains=query)
|
||||
).values('id', 'alias', 'original_url', 'click_count', 'created_at').annotate(
|
||||
model_type=Value('link', output_field=CharField())
|
||||
)
|
||||
|
||||
def _get_page_results(self, query):
|
||||
return Page.objects.filter(
|
||||
Q(url__icontains=query) |
|
||||
Q(title__icontains=query) |
|
||||
Q(summary__icontains=query) |
|
||||
Q(content__icontains=query)
|
||||
).values('id', 'url', 'title', 'summary', 'created_at').annotate(
|
||||
model_type=Value('page', output_field=CharField())
|
||||
)
|
||||
|
||||
def _get_post_results(self, query):
|
||||
return Post.objects.filter(
|
||||
Q(title__icontains=query) |
|
||||
Q(content__icontains=query)
|
||||
).values('id', 'title', 'created_at').annotate(
|
||||
model_type=Value('post', output_field=CharField())
|
||||
)
|
||||
|
||||
def _get_all_results(self, query):
|
||||
links = self._get_link_results(query)
|
||||
pages = self._get_page_results(query)
|
||||
posts = self._get_post_results(query)
|
||||
return list(chain(links, pages, posts))
|
||||
|
||||
def _sort_results(self, results, sort_order):
|
||||
if sort_order == 'newest':
|
||||
return sorted(results, key=lambda x: x.get('created_at', ''), reverse=True)
|
||||
elif sort_order == 'oldest':
|
||||
return sorted(results, key=lambda x: x.get('created_at', ''))
|
||||
elif sort_order == 'type':
|
||||
return sorted(results, key=lambda x: (x.get('model_type', ''), x.get('created_at', '')), reverse=True)
|
||||
return results
|
||||
|
||||
def _enrich_result(self, result):
|
||||
"""Fetch full model data for search result"""
|
||||
model_type = result['model_type']
|
||||
model_id = result['model_id']
|
||||
|
||||
try:
|
||||
if model_type == 'link':
|
||||
link = Link.objects.get(id=model_id)
|
||||
return {
|
||||
'id': link.id,
|
||||
'model_type': 'link',
|
||||
'alias': link.alias,
|
||||
'original_url': link.original_url,
|
||||
'description': link.description,
|
||||
'click_count': link.click_count,
|
||||
'created_at': link.created_at,
|
||||
'tags': list(link.tags.all())
|
||||
}
|
||||
elif model_type == 'page':
|
||||
page = Page.objects.get(id=model_id)
|
||||
return {
|
||||
'id': page.id,
|
||||
'model_type': 'page',
|
||||
'title': page.title,
|
||||
'url': page.url,
|
||||
'summary': page.summary,
|
||||
'created_at': page.created_at,
|
||||
'tags': list(page.tags.all())
|
||||
}
|
||||
elif model_type == 'post':
|
||||
post = Post.objects.get(id=model_id)
|
||||
return {
|
||||
'id': post.id,
|
||||
'model_type': 'post',
|
||||
'title': post.title,
|
||||
'summary': post.summary,
|
||||
'created_at': post.created_at,
|
||||
'tags': list(post.tags.all())
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error enriching result {model_type} {model_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update({
|
||||
'query': self.request.GET.get('q', ''),
|
||||
'selected_type': self.request.GET.get('type', ''),
|
||||
'sort_order': self.request.GET.get('sort', 'newest'),
|
||||
'total_results': getattr(self, 'search_total', 0),
|
||||
})
|
||||
return context
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Django signals for search index maintenance
|
||||
"""
|
||||
from django.db.models.signals import post_save, post_delete, m2m_changed
|
||||
from django.dispatch import receiver
|
||||
from .models import Link, Page, Post
|
||||
from .search_backend import search_backend
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Link)
|
||||
def update_link_index(sender, instance, created, **kwargs):
|
||||
"""Update search index when a link is saved"""
|
||||
try:
|
||||
search_backend.index_link(instance)
|
||||
except Exception as e:
|
||||
logger.error(f"Error indexing link {instance.id}: {e}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=Page)
|
||||
def update_page_index(sender, instance, created, **kwargs):
|
||||
"""Update search index when a page is saved"""
|
||||
try:
|
||||
search_backend.index_page(instance)
|
||||
except Exception as e:
|
||||
logger.error(f"Error indexing page {instance.id}: {e}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=Post)
|
||||
def update_post_index(sender, instance, created, **kwargs):
|
||||
"""Update search index when a post is saved"""
|
||||
try:
|
||||
search_backend.index_post(instance)
|
||||
except Exception as e:
|
||||
logger.error(f"Error indexing post {instance.id}: {e}")
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Link)
|
||||
def remove_link_from_index(sender, instance, **kwargs):
|
||||
"""Remove link from search index when deleted"""
|
||||
try:
|
||||
search_backend.remove_from_index('link', instance.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing link {instance.id} from index: {e}")
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Page)
|
||||
def remove_page_from_index(sender, instance, **kwargs):
|
||||
"""Remove page from search index when deleted"""
|
||||
try:
|
||||
search_backend.remove_from_index('page', instance.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing page {instance.id} from index: {e}")
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Post)
|
||||
def remove_post_from_index(sender, instance, **kwargs):
|
||||
"""Remove post from search index when deleted"""
|
||||
try:
|
||||
search_backend.remove_from_index('post', instance.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing post {instance.id} from index: {e}")
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=Link.tags.through)
|
||||
def update_link_tags_index(sender, instance, action, **kwargs):
|
||||
"""Update link index when tags change"""
|
||||
if action in ['post_add', 'post_remove', 'post_clear']:
|
||||
try:
|
||||
search_backend.index_link(instance)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating link {instance.id} tags in index: {e}")
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=Page.tags.through)
|
||||
def update_page_tags_index(sender, instance, action, **kwargs):
|
||||
"""Update page index when tags change"""
|
||||
if action in ['post_add', 'post_remove', 'post_clear']:
|
||||
try:
|
||||
search_backend.index_page(instance)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating page {instance.id} tags in index: {e}")
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=Post.tags.through)
|
||||
def update_post_tags_index(sender, instance, action, **kwargs):
|
||||
"""Update post index when tags change"""
|
||||
if action in ['post_add', 'post_remove', 'post_clear']:
|
||||
try:
|
||||
search_backend.index_post(instance)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating post {instance.id} tags in index: {e}")
|
||||
@@ -79,6 +79,11 @@
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
{% trans "Search results for" %}: <span class="font-semibold">{{ query }}</span>
|
||||
</h2>
|
||||
{% if total_results %}
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{% trans "Found" %} <span class="font-medium">{{ total_results }}</span> {% trans "results" %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if results %}
|
||||
@@ -127,7 +132,7 @@
|
||||
{{ result.title|default:"Untitled" }}
|
||||
</a>
|
||||
</h3>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{% trans "Page" %}
|
||||
</span>
|
||||
</div>
|
||||
@@ -138,6 +143,15 @@
|
||||
{% if result.summary %}
|
||||
<p class="mt-2 text-sm text-gray-600 line-clamp-2">{{ result.summary }}</p>
|
||||
{% endif %}
|
||||
{% if result.tags %}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{% for tag in result.tags %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0 flex space-x-2">
|
||||
|
||||
@@ -16,8 +16,6 @@ urlpatterns = [
|
||||
path('delete-selected/', views.delete_selected, name='delete_selected'),
|
||||
path('detail/<int:pk>/', views.LinkDetailView.as_view(), name='link_detail'),
|
||||
path('ui/search/', search_views.SearchView.as_view(), name='search'),
|
||||
path('search/', search_views.search, name='search-links'),
|
||||
path('search-aliases/', search_views.search_aliases, name='search-aliases'),
|
||||
path('link/<int:pk>/', views.LinkDetailView.as_view(), name='link_detail'),
|
||||
path('link/<int:pk>/edit/', views.LinkUpdateView.as_view(), name='link_update'),
|
||||
path('ui/tools/', views.ToolsView.as_view(), name='tools'),
|
||||
|
||||
@@ -22,6 +22,7 @@ dependencies = [
|
||||
"boto3>=1.35.0",
|
||||
"python-magic>=0.4.27",
|
||||
"pillow~=11.2.1",
|
||||
"whoosh==2.7.4",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -244,6 +244,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
|
||||
@@ -253,6 +255,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
|
||||
@@ -260,6 +264,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
|
||||
]
|
||||
|
||||
@@ -628,6 +634,7 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "whitenoise" },
|
||||
{ name = "whoosh" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -664,6 +671,7 @@ requires-dist = [
|
||||
{ name = "requests", specifier = "==2.32.4" },
|
||||
{ name = "sqlparse", specifier = "==0.5.1" },
|
||||
{ name = "whitenoise", specifier = "==5.3.0" },
|
||||
{ name = "whoosh", specifier = "==2.7.4" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
@@ -684,3 +692,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/78/29/84c808294f76d854e
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/1e/ec69984d05e570ec7c61d28cbce51b8f4623e4121ca57ac6ad76e4f5ffe8/whitenoise-5.3.0-py2.py3-none-any.whl", hash = "sha256:d963ef25639d1417e8a247be36e6aedd8c7c6f0a08adcb5a89146980a96b577c", size = 19822, upload-time = "2021-07-16T16:59:52.113Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoosh"
|
||||
version = "2.7.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/2b/6beed2107b148edc1321da0d489afc4617b9ed317ef7b72d4993cad9b684/Whoosh-2.7.4.tar.gz", hash = "sha256:7ca5633dbfa9e0e0fa400d3151a8a0c4bec53bd2ecedc0a67705b17565c31a83", size = 968741, upload-time = "2016-04-04T01:19:32.327Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/19/24d0f1f454a2c1eb689ca28d2f178db81e5024f42d82729a4ff6771155cf/Whoosh-2.7.4-py2.py3-none-any.whl", hash = "sha256:aa39c3c3426e3fd107dcb4bde64ca1e276a65a889d9085a6e4b54ba82420a852", size = 468790, upload-time = "2016-04-04T01:19:40.379Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user