diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 852b63b..3b3aae4 100644 Binary files a/data/db.sqlite3 and b/data/db.sqlite3 differ diff --git a/links/apps.py b/links/apps.py new file mode 100644 index 0000000..e133061 --- /dev/null +++ b/links/apps.py @@ -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 diff --git a/links/management/__init__.py b/links/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/links/management/commands/__init__.py b/links/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/links/management/commands/rebuild_search_index.py b/links/management/commands/rebuild_search_index.py new file mode 100644 index 0000000..11b36fe --- /dev/null +++ b/links/management/commands/rebuild_search_index.py @@ -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}')) diff --git a/links/migrations/0035_remove_webpage_content.py b/links/migrations/0035_remove_webpage_content.py new file mode 100644 index 0000000..1718fd3 --- /dev/null +++ b/links/migrations/0035_remove_webpage_content.py @@ -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), + ] diff --git a/links/search_backend.py b/links/search_backend.py new file mode 100644 index 0000000..fa49896 --- /dev/null +++ b/links/search_backend.py @@ -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() diff --git a/links/search_urls.py b/links/search_urls.py index a3ce020..314ec95 100644 --- a/links/search_urls.py +++ b/links/search_urls.py @@ -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'), ] diff --git a/links/search_views.py b/links/search_views.py index 6f190c7..2b666f6 100644 --- a/links/search_views.py +++ b/links/search_views.py @@ -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 diff --git a/links/signals.py b/links/signals.py new file mode 100644 index 0000000..2a62cf1 --- /dev/null +++ b/links/signals.py @@ -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}") diff --git a/links/templates/links/search.html b/links/templates/links/search.html index ec5861b..9c746a8 100644 --- a/links/templates/links/search.html +++ b/links/templates/links/search.html @@ -79,6 +79,11 @@

{% trans "Search results for" %}: {{ query }}

+ {% if total_results %} +

+ {% trans "Found" %} {{ total_results }} {% trans "results" %} +

+ {% endif %} {% if results %} @@ -127,7 +132,7 @@ {{ result.title|default:"Untitled" }} - + {% trans "Page" %} @@ -138,6 +143,15 @@ {% if result.summary %}

{{ result.summary }}

{% endif %} + {% if result.tags %} +
+ {% for tag in result.tags %} + + {{ tag.name }} + + {% endfor %} +
+ {% endif %} {% endif %}
diff --git a/links/urls.py b/links/urls.py index 1b71e9d..48d2604 100644 --- a/links/urls.py +++ b/links/urls.py @@ -16,8 +16,6 @@ urlpatterns = [ path('delete-selected/', views.delete_selected, name='delete_selected'), path('detail//', 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//', views.LinkDetailView.as_view(), name='link_detail'), path('link//edit/', views.LinkUpdateView.as_view(), name='link_update'), path('ui/tools/', views.ToolsView.as_view(), name='tools'), diff --git a/pyproject.toml b/pyproject.toml index 7975a7c..e301fa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "boto3>=1.35.0", "python-magic>=0.4.27", "pillow~=11.2.1", + "whoosh==2.7.4", ] [build-system] diff --git a/uv.lock b/uv.lock index 3da1be1..5dd95af 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, +]