mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Add bookmarks
This commit is contained in:
@@ -81,6 +81,25 @@ Bookmarked pages with auto-extracted metadata:
|
||||
|
||||
#### Tag
|
||||
Hierarchical tagging system:
|
||||
|
||||
#### Bookmark
|
||||
Archived social-media posts (originally x.com / Twitter bookmarks), each saved with full preview data so a viewer page renders offline without further x.com API calls:
|
||||
- **tweet_id**: Source post id (unique, indexed)
|
||||
- **url**: Canonical x.com post URL
|
||||
- **author_screen_name / author_name / author_profile_image_url**: Denormalised author
|
||||
- **text**: Full post text (searchable)
|
||||
- **summary**: Optional human/agent-written summary (writable via API `PATCH`)
|
||||
- **tweet_created_at / bookmark_created_at**: Parsed source dates (indexed)
|
||||
- **view_count / favorite_count / retweet_count / reply_count / bookmark_count / quote_count**: Engagement counters
|
||||
- **has_video / has_photo / has_card / is_quote / is_retweet**: Indexed boolean flags for fast filter pages
|
||||
- **media**: JSON list of media objects (photo URL variants; video/animated_gif with `poster`, `best_mp4`, `duration_ms`, `variants[]`)
|
||||
- **card_data**: JSON link-preview card (title/description/site/image/url)
|
||||
- **quoted_data**: JSON nested quoted post (recursive shape)
|
||||
- **data**: The complete original normalised export object (kept for round-tripping / the offline viewer)
|
||||
- UI: `/ui/bookmarks/` (list, `BookmarkListView`) + `/ui/bookmarks/<pk>/` (detail). Top-menu item "Bookmarks" in `templates/base.html`.
|
||||
- Import: `python manage.py import_x_bookmarks <dir|file.json> [--clear]` (loads `x-bookmarks-exporter` JSON) or `POST /api/bookmarks/import`.
|
||||
- **No `<video>` streaming** in the viewer — videos render as poster thumbnails linking to x.com (performance + ToS).
|
||||
- **Search**: bookmarks are indexed in the Whoosh full-text index (`search_backend.index_bookmark`, rebuilt via `python manage.py rebuild_search_index`), wired into signals (`post_save`/`post_delete` on `Bookmark` auto-updates the index — e.g. when an agent `PATCH`es a summary). They appear in `/ui/search/` (React app, type filter "Bookmarks"), `/search/api/v2/?type=bookmark`, and the simple `/search/api/?q=` lookup.
|
||||
- **name**: Tag name
|
||||
- **slug**: URL-friendly slug
|
||||
- **description**: Optional description
|
||||
@@ -125,6 +144,15 @@ Located in various `*_views.py` files with corresponding `*_urls.py`:
|
||||
- `screenshot` action: Trigger screenshot capture
|
||||
- `extract_metadata` action: Re-extract page metadata
|
||||
|
||||
#### Bookmark API (`bookmark_views.py` → `BookmarkViewSet`, mounted at `/api/bookmarks`)
|
||||
- `BookmarkViewSet`: Full CRUD for archived social-media bookmarks
|
||||
- `GET /api/bookmarks`: list with filters `?q=`, `?has_video=true`, `?has_photo=`, `?has_card=`, `?is_quote=`, `?is_retweet=`, `?author=`, `?ordering=`, `?page_size=` (default 24, max 200)
|
||||
- `POST /api/bookmarks`: create one (flat `Bookmark`-shaped body)
|
||||
- `GET/PATCH/DELETE /api/bookmarks/{id}`: read / partial-update (e.g. write an AI `summary`) / delete
|
||||
- `POST /api/bookmarks/import`: bulk upsert from `{"bookmarks":[ {export obj}, ... ]}` — accepts the full `x-bookmarks-exporter` normalised object (nested author/media/card/quoted/retweet + x.com-style `created_at` date); matches by `tweet_id`. Returns `{created, updated, skipped, total}`
|
||||
- `PUT|POST /api/bookmarks/bulk`: bulk upsert from a bare JSON array of flat `Bookmark`-shaped objects
|
||||
- `GET /api/bookmarks/stats`: `{total, has_video, has_photo, has_card, is_quote, is_retweet}` (used by viewer filter chips)
|
||||
|
||||
#### Post API (`post_views.py`)
|
||||
- `PostViewSet`: Blog post management with markdown rendering
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from . import page_views
|
||||
from . import post_views
|
||||
from . import api_views
|
||||
from . import file_views
|
||||
from . import bookmark_views
|
||||
|
||||
# Create a router and register our viewsets with it
|
||||
router = DefaultRouter(trailing_slash=False)
|
||||
@@ -12,6 +13,7 @@ router.register('pages', page_views.PageViewSet, basename='api-pages')
|
||||
router.register('posts', post_views.PostViewSet, basename='api-posts')
|
||||
router.register('music', api_views.MusicViewSet, basename='api-music')
|
||||
router.register('files', file_views.FileUploadViewSet, basename='api-files')
|
||||
router.register('bookmarks', bookmark_views.BookmarkViewSet, basename='api-bookmarks')
|
||||
|
||||
# The API URLs are determined automatically by the router
|
||||
urlpatterns = [
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Views for the Bookmark feature — REST viewset + UI list view."""
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.views.generic import ListView, DetailView
|
||||
from django.db import models
|
||||
from rest_framework import viewsets, status, filters as drf_filters
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
from .models import Bookmark
|
||||
from .serializers import (
|
||||
BookmarkSerializer,
|
||||
BookmarkImportSerializer,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkPagination(PageNumberPagination):
|
||||
page_size = 24
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 200
|
||||
|
||||
|
||||
class BookmarkViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
CRUD for archived bookmarks, plus bulk import + filterable listing.
|
||||
|
||||
List filters (query params):
|
||||
q full-text search on text/author/summary
|
||||
has_video true/false
|
||||
has_photo true/false
|
||||
has_card true/false
|
||||
is_quote true/false
|
||||
is_retweet true/false
|
||||
author exact screen name
|
||||
Bulk import: POST /api/bookmarks/import/ body {"bookmarks": [ {...}, ... ]}
|
||||
Bulk upsert: PUT /api/bookmarks/bulk/ body list of bookmark dicts (key: tweet_id)
|
||||
"""
|
||||
queryset = Bookmark.objects.all()
|
||||
serializer_class = BookmarkSerializer
|
||||
pagination_class = BookmarkPagination
|
||||
filter_backends = [drf_filters.OrderingFilter]
|
||||
ordering_fields = ['tweet_created_at', 'bookmark_created_at',
|
||||
'created_at', 'favorite_count', 'view_count']
|
||||
ordering = ['-tweet_created_at', '-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Bookmark.objects.all()
|
||||
req = self.request
|
||||
q = req.query_params.get('q')
|
||||
if q:
|
||||
qs = qs.filter(
|
||||
models.Q(text__icontains=q)
|
||||
| models.Q(author_screen_name__icontains=q)
|
||||
| models.Q(author_name__icontains=q)
|
||||
| models.Q(summary__icontains=q)
|
||||
)
|
||||
for flag in ('has_video', 'has_photo', 'has_card',
|
||||
'is_quote', 'is_retweet'):
|
||||
v = req.query_params.get(flag)
|
||||
if v is not None:
|
||||
qs = qs.filter(**{flag: _truthy(v)})
|
||||
author = req.query_params.get('author')
|
||||
if author:
|
||||
qs = qs.filter(author_screen_name__iexact=author)
|
||||
return qs
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='import')
|
||||
def import_bookmarks(self, request):
|
||||
"""Bulk upsert bookmarks from a normalised export payload.
|
||||
|
||||
Body: {"bookmarks": [ {normalised tweet obj}, ... ]} → matches the
|
||||
JSON files produced by x-bookmarks-exporter.
|
||||
Returns {created, updated, skipped, total}.
|
||||
"""
|
||||
ser = BookmarkImportSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
result = ser.save() if hasattr(ser, 'save') else ser.create(ser.validated_data)
|
||||
result['total'] = Bookmark.objects.count()
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=['put', 'post'], url_path='bulk')
|
||||
def bulk_upsert(self, request):
|
||||
"""Bulk create/update from a bare JSON array of bookmark dicts.
|
||||
|
||||
Each item may be either a full normalised export object OR a flat
|
||||
Bookmark-shaped object (matching BookmarkSerializer fields).
|
||||
"""
|
||||
items = request.data
|
||||
if not isinstance(items, list):
|
||||
return Response({'detail': 'expected a JSON array'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
created = updated = skipped = 0
|
||||
for raw in items:
|
||||
tid = str(raw.get('tweet_id') or raw.get('id') or '')
|
||||
if not tid:
|
||||
skipped += 1
|
||||
continue
|
||||
obj = Bookmark.objects.filter(tweet_id=tid).first()
|
||||
if obj is None:
|
||||
BookmarkSerializer().create(_to_db_payload(raw))
|
||||
created += 1
|
||||
else:
|
||||
ser = BookmarkSerializer(obj, data=_to_db_payload(raw), partial=True)
|
||||
ser.is_valid(raise_exception=True)
|
||||
ser.save()
|
||||
updated += 1
|
||||
return Response({'created': created, 'updated': updated,
|
||||
'skipped': skipped, 'total': Bookmark.objects.count()},
|
||||
status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='stats')
|
||||
def stats(self, request):
|
||||
"""Aggregate counts for the viewer header/filter chips."""
|
||||
qs = Bookmark.objects.all()
|
||||
return Response({
|
||||
'total': qs.count(),
|
||||
'has_video': qs.filter(has_video=True).count(),
|
||||
'has_photo': qs.filter(has_photo=True).count(),
|
||||
'has_card': qs.filter(has_card=True).count(),
|
||||
'is_quote': qs.filter(is_quote=True).count(),
|
||||
'is_retweet': qs.filter(is_retweet=True).count(),
|
||||
})
|
||||
|
||||
|
||||
def _truthy(v):
|
||||
return str(v).lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def _to_db_payload(raw):
|
||||
"""Accept either an export object ({'id','author':{'screen_name'},'media',...})
|
||||
or a flat Bookmark-shaped dict and return a serializer-compatible dict."""
|
||||
if 'author' in raw or 'media' in raw or 'created_at' in raw \
|
||||
and not any(k in raw for k in ('tweet_created_at',)):
|
||||
# Looks like a normalised export object → re-map it.
|
||||
author = raw.get('author') or {}
|
||||
media = raw.get('media') or []
|
||||
from datetime import datetime
|
||||
tca = raw.get('created_at')
|
||||
tweet_at = None
|
||||
if isinstance(tca, str) and tca:
|
||||
try:
|
||||
tweet_at = datetime.strptime(tca, '%a %b %d %H:%M:%S %z %Y')
|
||||
except ValueError:
|
||||
tweet_at = None
|
||||
return {
|
||||
'tweet_id': str(raw.get('id') or raw.get('tweet_id')),
|
||||
'url': raw.get('url') or '',
|
||||
'author_screen_name': author.get('screen_name') or '',
|
||||
'author_name': author.get('name') or '',
|
||||
'author_profile_image_url': author.get('profile_image_url') or '',
|
||||
'text': raw.get('text') or '',
|
||||
'summary': raw.get('summary') or '',
|
||||
'tweet_created_at': tweet_at,
|
||||
'bookmark_created_at': raw.get('bookmark_created_at'),
|
||||
'view_count': raw.get('view_count') or 0,
|
||||
'favorite_count': raw.get('favorite_count') or 0,
|
||||
'retweet_count': raw.get('retweet_count') or 0,
|
||||
'reply_count': raw.get('reply_count') or 0,
|
||||
'bookmark_count': raw.get('bookmark_count') or 0,
|
||||
'quote_count': raw.get('quote_count') or 0,
|
||||
'has_video': any(m.get('type') in ('video', 'animated_gif') for m in media),
|
||||
'has_photo': any(m.get('type') == 'photo' for m in media),
|
||||
'has_card': bool(raw.get('card')),
|
||||
'is_quote': bool(raw.get('quoted')),
|
||||
'is_retweet': bool(raw.get('retweet')),
|
||||
'media': media,
|
||||
'card_data': raw.get('card'),
|
||||
'quoted_data': raw.get('quoted'),
|
||||
'data': raw,
|
||||
}
|
||||
# Already flat/serializer-shaped.
|
||||
return raw
|
||||
|
||||
|
||||
# ---------- UI views ----------
|
||||
|
||||
class BookmarkListView(ListView):
|
||||
"""Render the full bookmark archive page.
|
||||
|
||||
All bookmarks are rendered server-side in one response (with
|
||||
`content-visibility:auto` cards so off-screen cards are cheap), and
|
||||
client-side JS handles instant search + filtering — matching the UX of the
|
||||
standalone x-bookmarks-exporter viewer. Server-side query params still work
|
||||
for deep-linking / no-JS.
|
||||
"""
|
||||
model = Bookmark
|
||||
template_name = 'links/bookmark_list.html'
|
||||
context_object_name = 'bookmarks'
|
||||
paginate_by = None # render all; performance via content-visibility + lazy imgs
|
||||
|
||||
def get_queryset(self):
|
||||
# Server-side filtering only honours q + flag params for deep-links.
|
||||
# The interactive page filters client-side for instant UX.
|
||||
qs = Bookmark.objects.all().order_by('-tweet_created_at', '-created_at')
|
||||
q = self.request.GET.get('q')
|
||||
if q:
|
||||
qs = qs.filter(
|
||||
models.Q(text__icontains=q)
|
||||
| models.Q(author_screen_name__icontains=q)
|
||||
| models.Q(author_name__icontains=q)
|
||||
| models.Q(summary__icontains=q)
|
||||
)
|
||||
for flag in ('has_video', 'has_photo', 'has_card', 'is_quote', 'is_retweet'):
|
||||
v = self.request.GET.get(flag)
|
||||
if v is not None and _truthy(v):
|
||||
qs = qs.filter(**{flag: True})
|
||||
author = self.request.GET.get('author')
|
||||
if author:
|
||||
qs = qs.filter(author_screen_name__iexact=author)
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
qs = Bookmark.objects.all()
|
||||
ctx['stats'] = {
|
||||
'total': qs.count(),
|
||||
'has_video': qs.filter(has_video=True).count(),
|
||||
'has_photo': qs.filter(has_photo=True).count(),
|
||||
'has_card': qs.filter(has_card=True).count(),
|
||||
'is_quote': qs.filter(is_quote=True).count(),
|
||||
'is_retweet': qs.filter(is_retweet=True).count(),
|
||||
}
|
||||
return ctx
|
||||
|
||||
|
||||
class BookmarkDetailView(DetailView):
|
||||
model = Bookmark
|
||||
template_name = 'links/bookmark_detail.html'
|
||||
context_object_name = 'bookmark'
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Import bookmarks from x-bookmarks-exporter JSON dump.
|
||||
|
||||
Usage:
|
||||
python manage.py import_x_bookmarks /path/to/data/bookmarks
|
||||
python manage.py import_x_bookmarks /path/to/data/bookmarks_index.json
|
||||
python manage.py import_x_bookmarks /path/to/single_file.json
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from links.models import Bookmark
|
||||
from links.serializers import _norm_bookmark_for_import
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Import bookmarks from x-bookmarks-exporter JSON files'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('source',
|
||||
help='Directory of <tweet_id>.json files, a '
|
||||
'bookmarks_index.json, or a single bookmark JSON')
|
||||
parser.add_argument('--clear', action='store_true',
|
||||
help='Delete all existing bookmarks before import')
|
||||
parser.add_argument('--update', action='store_true', default=True,
|
||||
help='Upsert by tweet_id (default behaviour)')
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
src = opts['source']
|
||||
if not os.path.exists(src):
|
||||
raise CommandError(f'source not found: {src}')
|
||||
|
||||
files = []
|
||||
if os.path.isdir(src):
|
||||
files = sorted(Path(src).glob('*.json'))
|
||||
elif os.path.isfile(src):
|
||||
files = [Path(src)]
|
||||
else:
|
||||
raise CommandError(f'invalid source: {src}')
|
||||
|
||||
if opts['clear']:
|
||||
n = Bookmark.objects.count()
|
||||
Bookmark.objects.all().delete()
|
||||
self.stdout.write(f'cleared {n} existing bookmarks')
|
||||
|
||||
created = updated = skipped = 0
|
||||
for fp in files:
|
||||
try:
|
||||
with open(fp, 'r', encoding='utf-8') as f:
|
||||
payload = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
self.stderr.write(f'skip {fp.name}: {e}')
|
||||
skipped += 1
|
||||
continue
|
||||
# allow either a single object or the index file
|
||||
if isinstance(payload, dict) and 'bookmarks' in payload:
|
||||
items = payload['bookmarks']
|
||||
# index entries don't have full data; only import if they
|
||||
# look like full objects (have 'media' or 'author').
|
||||
if items and not all(isinstance(i, dict) and
|
||||
('media' in i or 'author' in i) for i in items):
|
||||
self.stdout.write(f'skip index {fp.name} (no full payloads)')
|
||||
skipped += len(items)
|
||||
continue
|
||||
elif isinstance(payload, dict):
|
||||
items = [payload]
|
||||
elif isinstance(payload, list):
|
||||
items = payload
|
||||
else:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
for raw in items:
|
||||
tid = str(raw.get('id') or raw.get('tweet_id') or '')
|
||||
if not tid:
|
||||
skipped += 1
|
||||
continue
|
||||
obj = Bookmark.objects.filter(tweet_id=tid).first()
|
||||
try:
|
||||
b = _norm_bookmark_for_import(raw, obj)
|
||||
b.save()
|
||||
except Exception as e:
|
||||
self.stderr.write(f'err {tid}: {e}')
|
||||
skipped += 1
|
||||
continue
|
||||
if obj is None:
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'import done: created={created} updated={updated} '
|
||||
f'skipped={skipped} total_in_db={Bookmark.objects.count()}'))
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.2.16 on 2026-07-30 01:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('links', '0049_sitesettings_telegram_bot_token_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Bookmark',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('tweet_id', models.CharField(db_index=True, max_length=40, unique=True)),
|
||||
('url', models.URLField(blank=True, max_length=500)),
|
||||
('author_screen_name', models.CharField(blank=True, db_index=True, max_length=80)),
|
||||
('author_name', models.CharField(blank=True, max_length=120)),
|
||||
('author_profile_image_url', models.URLField(blank=True, max_length=500)),
|
||||
('text', models.TextField(blank=True, help_text='Full post text, used for search')),
|
||||
('summary', models.CharField(blank=True, help_text='Optional short human/agent-written summary', max_length=300)),
|
||||
('tweet_created_at', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||
('bookmark_created_at', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||
('view_count', models.IntegerField(default=0)),
|
||||
('favorite_count', models.IntegerField(default=0)),
|
||||
('retweet_count', models.IntegerField(default=0)),
|
||||
('reply_count', models.IntegerField(default=0)),
|
||||
('bookmark_count', models.IntegerField(default=0)),
|
||||
('quote_count', models.IntegerField(default=0)),
|
||||
('has_video', models.BooleanField(db_index=True, default=False)),
|
||||
('has_photo', models.BooleanField(db_index=True, default=False)),
|
||||
('has_card', models.BooleanField(db_index=True, default=False)),
|
||||
('is_quote', models.BooleanField(db_index=True, default=False)),
|
||||
('is_retweet', models.BooleanField(db_index=True, default=False)),
|
||||
('media', models.JSONField(blank=True, default=list, help_text='List of media objects (photo/video w/ poster)')),
|
||||
('card_data', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('quoted_data', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('data', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-tweet_created_at', '-bookmark_created_at', '-created_at'],
|
||||
'indexes': [models.Index(fields=['has_video'], name='links_bookm_has_vid_ce96ca_idx'), models.Index(fields=['has_photo'], name='links_bookm_has_pho_870a67_idx'), models.Index(fields=['is_quote'], name='links_bookm_is_quot_7a9c4f_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -269,6 +269,70 @@ class Screenshot(models.Model):
|
||||
return os.path.join(settings.MEDIA_URL, self.path)
|
||||
return None
|
||||
|
||||
class Bookmark(models.Model):
|
||||
"""
|
||||
A bookmarked social-media post (initially x.com / Twitter tweets), imported
|
||||
from an external archive (see the x-bookmarks-exporter). Stores the full
|
||||
normalised preview payload so the archive page renders offline without
|
||||
hitting the source API, plus denormalised index columns for fast list/filter.
|
||||
"""
|
||||
tweet_id = models.CharField(max_length=40, unique=True, db_index=True)
|
||||
url = models.URLField(max_length=500, blank=True)
|
||||
|
||||
author_screen_name = models.CharField(max_length=80, blank=True, db_index=True)
|
||||
author_name = models.CharField(max_length=120, blank=True)
|
||||
author_profile_image_url = models.URLField(max_length=500, blank=True)
|
||||
|
||||
text = models.TextField(blank=True, help_text='Full post text, used for search')
|
||||
summary = models.CharField(max_length=300, blank=True,
|
||||
help_text='Optional short human/agent-written summary')
|
||||
|
||||
tweet_created_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
bookmark_created_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
|
||||
# engagement counters
|
||||
view_count = models.IntegerField(default=0)
|
||||
favorite_count = models.IntegerField(default=0)
|
||||
retweet_count = models.IntegerField(default=0)
|
||||
reply_count = models.IntegerField(default=0)
|
||||
bookmark_count = models.IntegerField(default=0)
|
||||
quote_count = models.IntegerField(default=0)
|
||||
|
||||
# filter flags (indexed for fast filter pages)
|
||||
has_video = models.BooleanField(default=False, db_index=True)
|
||||
has_photo = models.BooleanField(default=False, db_index=True)
|
||||
has_card = models.BooleanField(default=False, db_index=True)
|
||||
is_quote = models.BooleanField(default=False, db_index=True)
|
||||
is_retweet = models.BooleanField(default=False, db_index=True)
|
||||
|
||||
# structured payloads for rich rendering (kept separate from full data so
|
||||
# the list view can load only what it needs)
|
||||
media = models.JSONField(default=list, blank=True,
|
||||
help_text='List of media objects (photo/video w/ poster)')
|
||||
card_data = models.JSONField(default=dict, blank=True, null=True)
|
||||
quoted_data = models.JSONField(default=dict, blank=True, null=True)
|
||||
|
||||
# the complete normalised export object (for detail view / round-tripping)
|
||||
data = models.JSONField(default=dict, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-tweet_created_at', '-bookmark_created_at', '-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['has_video']),
|
||||
models.Index(fields=['has_photo']),
|
||||
models.Index(fields=['is_quote']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'@{self.author_screen_name} {self.tweet_id}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('bookmark-detail', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
class Post(models.Model):
|
||||
title = models.CharField(_('Title'), max_length=200)
|
||||
summary = models.TextField(_('Summary'), blank=True, help_text=_('A brief summary of the post'))
|
||||
|
||||
+54
-4
@@ -8,7 +8,7 @@ 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
|
||||
from .models import Link, Page, Post, Bookmark
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -119,11 +119,11 @@ class SearchBackend:
|
||||
"""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",
|
||||
@@ -138,6 +138,39 @@ class SearchBackend:
|
||||
except Exception as e:
|
||||
writer.cancel()
|
||||
logger.error(f"Error indexing post {post.id}: {e}", exc_info=True)
|
||||
|
||||
def index_bookmark(self, bookmark):
|
||||
"""Index a single archived social-media bookmark.
|
||||
|
||||
The `title` field holds the author handle + first line of text (so
|
||||
keyword search and the result list both surface useful context); the
|
||||
`content` field holds the full post text; `url` is the canonical post
|
||||
URL; `summary` is any agent-written AI summary.
|
||||
"""
|
||||
ix = self.get_index()
|
||||
writer = ix.writer()
|
||||
|
||||
try:
|
||||
text = (bookmark.text or '').strip()
|
||||
first_line = text.split('\n', 1)[0][:200] if text else ''
|
||||
handle = f"@{bookmark.author_screen_name}" if bookmark.author_screen_name else ''
|
||||
title = f"{handle} {first_line}".strip() or (bookmark.tweet_id or '')
|
||||
|
||||
writer.update_document(
|
||||
id=f"bookmark_{bookmark.id}",
|
||||
model_type="bookmark",
|
||||
title=title,
|
||||
content=text,
|
||||
url=bookmark.url or '',
|
||||
summary=bookmark.summary or '',
|
||||
tags='',
|
||||
created_at=bookmark.tweet_created_at or bookmark.created_at
|
||||
)
|
||||
writer.commit()
|
||||
logger.debug(f"Indexed bookmark: {bookmark.id}")
|
||||
except Exception as e:
|
||||
writer.cancel()
|
||||
logger.error(f"Error indexing bookmark {bookmark.id}: {e}", exc_info=True)
|
||||
|
||||
def remove_from_index(self, model_type, model_id):
|
||||
"""Remove a document from the index"""
|
||||
@@ -203,7 +236,24 @@ class SearchBackend:
|
||||
tags=tags,
|
||||
created_at=post.created_at
|
||||
)
|
||||
|
||||
|
||||
# Index all archived bookmarks
|
||||
for b in Bookmark.objects.all().iterator():
|
||||
text = (b.text or '').strip()
|
||||
first_line = text.split('\n', 1)[0][:200] if text else ''
|
||||
handle = f"@{b.author_screen_name}" if b.author_screen_name else ''
|
||||
title = f"{handle} {first_line}".strip() or (b.tweet_id or '')
|
||||
writer.add_document(
|
||||
id=f"bookmark_{b.id}",
|
||||
model_type="bookmark",
|
||||
title=title,
|
||||
content=text,
|
||||
url=b.url or "",
|
||||
summary=b.summary or "",
|
||||
tags="",
|
||||
created_at=b.tweet_created_at or b.created_at
|
||||
)
|
||||
|
||||
writer.commit()
|
||||
logger.info("Search index rebuild completed successfully")
|
||||
except Exception as e:
|
||||
|
||||
+52
-1
@@ -6,7 +6,7 @@ from django.views.generic.base import TemplateView
|
||||
from django.db.models import Q, Value, CharField, IntegerField, Case, When
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from .models import Link, Page, Post
|
||||
from .models import Link, Page, Post, Bookmark
|
||||
from .search_backend import search_backend
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
@@ -126,6 +126,25 @@ class SearchView(ListView):
|
||||
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())}
|
||||
elif model_type == 'bookmark':
|
||||
b = Bookmark.objects.get(id=model_id)
|
||||
return {
|
||||
'id': b.id,
|
||||
'model_type': 'bookmark',
|
||||
'title': f"@{b.author_screen_name} {(b.text or '').split(chr(10),1)[0][:120]}",
|
||||
'text': (b.text or '')[:500],
|
||||
'summary': b.summary or '',
|
||||
'author_screen_name': b.author_screen_name,
|
||||
'author_name': b.author_name,
|
||||
'author_profile_image_url': b.author_profile_image_url,
|
||||
'url': b.url,
|
||||
'detail_url': f'/ui/bookmarks/{b.id}/',
|
||||
'has_video': b.has_video,
|
||||
'has_photo': b.has_photo,
|
||||
'tweet_created_at': b.tweet_created_at.isoformat() if b.tweet_created_at else '',
|
||||
'created_at': (b.tweet_created_at or b.created_at).isoformat(),
|
||||
'tags': [],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error enriching result {model_type} {model_id}: {e}")
|
||||
return None
|
||||
@@ -143,6 +162,19 @@ def search(request):
|
||||
results.extend([{'type': 'link', 'alias': link.alias, 'url': link.original_url} for link in links])
|
||||
posts = Post.objects.filter(Q(title__icontains=query) | Q(summary__icontains=query) | Q(content__icontains=query))[:5]
|
||||
results.extend([{'type': 'post', 'title': post.title, 'url': f'/ui/posts/{post.id}'} for post in posts])
|
||||
bms = Bookmark.objects.filter(
|
||||
Q(text__icontains=query)
|
||||
| Q(author_screen_name__icontains=query)
|
||||
| Q(author_name__icontains=query)
|
||||
| Q(summary__icontains=query)
|
||||
)[:5]
|
||||
results.extend([{
|
||||
'type': 'bookmark',
|
||||
'title': f"@{b.author_screen_name} {(b.text or '').split(chr(10),1)[0][:80]}",
|
||||
'url': b.url or f'/ui/bookmarks/{b.id}/',
|
||||
'summary': b.summary or '',
|
||||
'detail_url': f'/ui/bookmarks/{b.id}/',
|
||||
} for b in bms])
|
||||
else:
|
||||
results = []
|
||||
return JsonResponse(results, safe=False)
|
||||
@@ -180,6 +212,25 @@ def search_api_v2(request):
|
||||
elif model_type == 'post':
|
||||
post = Post.objects.get(id=model_id)
|
||||
enriched_results.append({'id': post.id, 'type': 'post', 'title': post.title, 'url': f'/ui/posts/{post.id}/', 'summary': post.summary or '', 'created_at': post.created_at.isoformat(), 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in post.tags.all()]})
|
||||
elif model_type == 'bookmark':
|
||||
b = Bookmark.objects.get(id=model_id)
|
||||
enriched_results.append({
|
||||
'id': b.id,
|
||||
'type': 'bookmark',
|
||||
'title': f"@{b.author_screen_name} {(b.text or '').split(chr(10),1)[0][:120]}",
|
||||
'text': (b.text or '')[:500],
|
||||
'summary': b.summary or '',
|
||||
'author_screen_name': b.author_screen_name,
|
||||
'author_name': b.author_name,
|
||||
'author_profile_image_url': b.author_profile_image_url,
|
||||
'url': b.url or '',
|
||||
'detail_url': f'/ui/bookmarks/{b.id}/',
|
||||
'has_video': b.has_video,
|
||||
'has_photo': b.has_photo,
|
||||
'tweet_created_at': b.tweet_created_at.isoformat() if b.tweet_created_at else '',
|
||||
'created_at': (b.tweet_created_at or b.created_at).isoformat(),
|
||||
'tags': [],
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error enriching {model_type} {model_id}: {e}")
|
||||
continue
|
||||
|
||||
+122
-1
@@ -1,5 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Link, Page, Post, ImageCollection, Image, Tag, FileUpload
|
||||
from .models import Link, Page, Post, ImageCollection, Image, Tag, FileUpload, Bookmark
|
||||
|
||||
|
||||
class LinkSerializer(serializers.ModelSerializer):
|
||||
@@ -124,3 +124,124 @@ class FileUploadSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_is_expired(self, obj):
|
||||
return obj.is_expired
|
||||
|
||||
|
||||
class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"""Full bookmark for read / single-create / update."""
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
'id', 'tweet_id', 'url',
|
||||
'author_screen_name', 'author_name', 'author_profile_image_url',
|
||||
'text', 'summary',
|
||||
'tweet_created_at', 'bookmark_created_at',
|
||||
'view_count', 'favorite_count', 'retweet_count', 'reply_count',
|
||||
'bookmark_count', 'quote_count',
|
||||
'has_video', 'has_photo', 'has_card', 'is_quote', 'is_retweet',
|
||||
'media', 'card_data', 'quoted_data',
|
||||
'data',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
def _norm_bookmark_for_import(raw, obj=None):
|
||||
"""
|
||||
Map a normalised x-bookmarks-exporter object (or any compatible dict) onto
|
||||
a Bookmark instance so it can be upserted. Reused by API import + mgmt cmd.
|
||||
"""
|
||||
author = raw.get('author') or {}
|
||||
media = raw.get('media') or []
|
||||
|
||||
tweet_id = raw.get('id') or raw.get('tweet_id')
|
||||
if not tweet_id:
|
||||
raise serializers.ValidationError('missing id/tweet_id')
|
||||
|
||||
b = obj or Bookmark(tweet_id=tweet_id)
|
||||
b.tweet_id = str(tweet_id)
|
||||
b.url = raw.get('url') or ''
|
||||
b.author_screen_name = author.get('screen_name') or ''
|
||||
b.author_name = author.get('name') or ''
|
||||
b.author_profile_image_url = author.get('profile_image_url') or ''
|
||||
b.text = raw.get('text') or ''
|
||||
b.summary = raw.get('summary') or ''
|
||||
|
||||
# parse tweet created_at ("Wed Jul 30 12:00:00 +0000 2025" style) — leave
|
||||
# None if not parseable, view layer handles null.
|
||||
from datetime import datetime
|
||||
tca = raw.get('created_at')
|
||||
if isinstance(tca, str) and tca:
|
||||
try:
|
||||
b.tweet_created_at = datetime.strptime(tca, '%a %b %d %H:%M:%S %z %Y')
|
||||
except ValueError:
|
||||
b.tweet_created_at = None
|
||||
elif isinstance(tca, datetime):
|
||||
b.tweet_created_at = tca
|
||||
|
||||
bca = raw.get('bookmark_created_at')
|
||||
if isinstance(bca, str) and bca:
|
||||
try:
|
||||
b.bookmark_created_at = datetime.strptime(
|
||||
bca, '%a %b %d %H:%M:%S %z %Y')
|
||||
except ValueError:
|
||||
b.bookmark_created_at = None
|
||||
elif isinstance(bca, datetime):
|
||||
b.bookmark_created_at = bca
|
||||
|
||||
b.view_count = raw.get('view_count') or 0
|
||||
b.favorite_count = raw.get('favorite_count') or 0
|
||||
b.retweet_count = raw.get('retweet_count') or 0
|
||||
b.reply_count = raw.get('reply_count') or 0
|
||||
b.bookmark_count = raw.get('bookmark_count') or 0
|
||||
b.quote_count = raw.get('quote_count') or 0
|
||||
|
||||
b.has_video = any(m.get('type') in ('video', 'animated_gif') for m in media)
|
||||
b.has_photo = any(m.get('type') == 'photo' for m in media)
|
||||
b.has_card = bool(raw.get('card'))
|
||||
b.is_quote = bool(raw.get('quoted'))
|
||||
b.is_retweet = bool(raw.get('retweet'))
|
||||
|
||||
b.media = media
|
||||
b.card_data = raw.get('card')
|
||||
b.quoted_data = raw.get('quoted')
|
||||
b.data = raw
|
||||
return b
|
||||
|
||||
|
||||
class BookmarkImportItemSerializer(serializers.Serializer):
|
||||
"""One element of a bulk-import payload. Accepts the full export object."""
|
||||
def to_internal_value(self, data):
|
||||
# we don't validate strictly — _norm_bookmark_for_import does the mapping
|
||||
return data
|
||||
|
||||
def create(self, validated):
|
||||
tweet_id = str(validated.get('id') or validated.get('tweet_id'))
|
||||
obj = Bookmark.objects.filter(tweet_id=tweet_id).first()
|
||||
b = _norm_bookmark_for_import(validated, obj)
|
||||
b.save()
|
||||
return b
|
||||
|
||||
def update(self, instance, validated):
|
||||
return _norm_bookmark_for_import(validated, instance).save() or instance
|
||||
|
||||
|
||||
class BookmarkImportSerializer(serializers.Serializer):
|
||||
"""Top-level bulk-import payload: {bookmarks: [ ... ]}."""
|
||||
bookmarks = BookmarkImportItemSerializer(many=True)
|
||||
|
||||
def create(self, validated):
|
||||
items = validated.get('bookmarks', [])
|
||||
created = updated = skipped = 0
|
||||
for raw in items:
|
||||
tweet_id = str(raw.get('id') or raw.get('tweet_id') or '')
|
||||
if not tweet_id:
|
||||
skipped += 1
|
||||
continue
|
||||
existed = Bookmark.objects.filter(tweet_id=tweet_id).exists()
|
||||
BookmarkImportItemSerializer().create(raw)
|
||||
if existed:
|
||||
updated += 1
|
||||
else:
|
||||
created += 1
|
||||
return {'created': created, 'updated': updated, 'skipped': skipped}
|
||||
|
||||
+20
-1
@@ -3,7 +3,7 @@ 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 .models import Link, Page, Post, Bookmark
|
||||
from .search_backend import search_backend
|
||||
import logging
|
||||
|
||||
@@ -37,6 +37,16 @@ def update_post_index(sender, instance, created, **kwargs):
|
||||
logger.error(f"Error indexing post {instance.id}: {e}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=Bookmark)
|
||||
def update_bookmark_index(sender, instance, created, **kwargs):
|
||||
"""Update search index when a bookmark is saved (e.g. bulk import /
|
||||
AI summary update via API)."""
|
||||
try:
|
||||
search_backend.index_bookmark(instance)
|
||||
except Exception as e:
|
||||
logger.error(f"Error indexing bookmark {instance.id}: {e}")
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Link)
|
||||
def remove_link_from_index(sender, instance, **kwargs):
|
||||
"""Remove link from search index when deleted"""
|
||||
@@ -64,6 +74,15 @@ def remove_post_from_index(sender, instance, **kwargs):
|
||||
logger.error(f"Error removing post {instance.id} from index: {e}")
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Bookmark)
|
||||
def remove_bookmark_from_index(sender, instance, **kwargs):
|
||||
"""Remove bookmark from search index when deleted"""
|
||||
try:
|
||||
search_backend.remove_from_index('bookmark', instance.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing bookmark {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"""
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto px-4 py-6 mt-16">
|
||||
<a href="{% url 'bookmark-list' %}" class="text-sm text-blue-600 hover:underline">← {% trans "Back to Bookmarks" %}</a>
|
||||
|
||||
<article class="bg-white shadow rounded-lg p-5 mt-3">
|
||||
<div class="flex items-start gap-3">
|
||||
{% if bookmark.author_profile_image_url %}
|
||||
<img src="{{ bookmark.author_profile_image_url }}" alt="" class="w-12 h-12 rounded-full border border-gray-200" loading="lazy">
|
||||
{% endif %}
|
||||
<div>
|
||||
<a href="https://x.com/{{ bookmark.author_screen_name }}" target="_blank" rel="noopener" class="font-semibold text-gray-900 hover:underline">{{ bookmark.author_name|default:bookmark.author_screen_name }}</a>
|
||||
<div class="text-sm text-gray-500">@{{ bookmark.author_screen_name }}</div>
|
||||
{% if bookmark.tweet_created_at %}<div class="text-sm text-gray-500">{{ bookmark.tweet_created_at|date:"M j, Y H:i" }}</div>{% endif %}
|
||||
</div>
|
||||
<a href="{{ bookmark.url|default:'#' }}" target="_blank" rel="noopener" class="ml-auto text-blue-600 text-sm hover:underline">{% trans "Open on X" %} ↗</a>
|
||||
</div>
|
||||
|
||||
{% if bookmark.text %}
|
||||
<div class="mt-4 text-gray-800 whitespace-pre-wrap break-words">{{ bookmark.text }}</div>
|
||||
{% endif %}
|
||||
{% if bookmark.summary %}
|
||||
<div class="mt-3 text-sm text-emerald-700 bg-emerald-50 border border-emerald-100 rounded px-3 py-2">{{ bookmark.summary }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if bookmark.media %}
|
||||
<div class="grid gap-2 mt-4">
|
||||
{% for m in bookmark.media %}
|
||||
{% if m.type == 'photo' %}
|
||||
<a href="{{ m.media_large|default:m.media_url }}" target="_blank" rel="noopener">
|
||||
<img src="{{ m.media_large|default:m.media_url }}" alt="" class="w-full rounded-lg" loading="lazy">
|
||||
</a>
|
||||
{% elif m.type == 'video' or m.type == 'animated_gif' %}
|
||||
{% with v=m.video %}
|
||||
<a href="{{ bookmark.url|default:'#' }}" target="_blank" rel="noopener" class="relative block rounded-lg overflow-hidden">
|
||||
<img src="{{ v.poster|default:m.media_thumb }}" alt="" class="w-full" loading="lazy">
|
||||
<span class="absolute left-3 top-3 bg-black/60 text-white text-xs px-2 py-0.5 rounded">{% if m.type == 'animated_gif' %}GIF — open to play{% else %}VIDEO — open to play{% endif %}</span>
|
||||
<span class="absolute right-3 top-3 bg-blue-500 text-white text-xs px-2 py-0.5 rounded font-bold">X</span>
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<dl class="mt-4 text-sm text-gray-600 grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
{% if bookmark.reply_count %}<div><dt class="text-gray-400">Replies</dt><dd>{{ bookmark.reply_count }}</dd></div>{% endif %}
|
||||
{% if bookmark.retweet_count %}<div><dt class="text-gray-400">Retweets</dt><dd>{{ bookmark.retweet_count }}</dd></div>{% endif %}
|
||||
{% if bookmark.favorite_count %}<div><dt class="text-gray-400">Likes</dt><dd>{{ bookmark.favorite_count }}</dd></div>{% endif %}
|
||||
{% if bookmark.bookmark_count %}<div><dt class="text-gray-400">Bookmarks</dt><dd>{{ bookmark.bookmark_count }}</dd></div>{% endif %}
|
||||
{% if bookmark.view_count %}<div><dt class="text-gray-400">Views</dt><dd>{{ bookmark.view_count }}</dd></div>{% endif %}
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,238 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load bookmark_extras %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
:root{
|
||||
--bm-bg:#f6f8fa; --bm-card:#ffffff; --bm-bd:#e3e8ed; --bm-bd2:#d7dde3;
|
||||
--bm-txt:#0f1419; --bm-txt2:#3d434c; --bm-muted:#697384; --bm-muted2:#8a94a3;
|
||||
--bm-accent:#1d9bf0; --bm-accent2:#0a84dc; --bm-soft:#e8f3fd;
|
||||
--bm-radius:18px; --bm-shadow:0 1px 3px rgba(15,20,25,.06),0 1px 2px rgba(15,20,25,.04);
|
||||
--bm-shadow-hover:0 4px 14px rgba(15,20,25,.08),0 2px 6px rgba(15,20,25,.05);
|
||||
}
|
||||
#bm-page{max-width:680px;margin:0 auto;padding:14px 14px 100px}
|
||||
/* sub-header scrolls with content (no longer sticky) */
|
||||
#bm-header{
|
||||
position:relative;
|
||||
background:rgba(255,255,255,.88); backdrop-filter:saturate(180%) blur(12px);
|
||||
-webkit-backdrop-filter:saturate(180%) blur(12px);
|
||||
border:1px solid var(--bm-bd); border-radius:var(--bm-radius);
|
||||
padding:13px 16px 11px; margin:0 14px 14px;
|
||||
box-shadow:var(--bm-shadow);
|
||||
}
|
||||
#bm-header .htop{display:flex;flex-direction:column;gap:10px}
|
||||
#bm-header .brand-row{display:flex;align-items:center;gap:9px}
|
||||
#bm-header h1{margin:0;font-size:18px;font-weight:800;letter-spacing:-.2px;color:var(--bm-txt)}
|
||||
#bm-header .logo{
|
||||
width:30px;height:30px;border-radius:50%;background:#0f1419;color:#fff;
|
||||
display:flex;align-items:center;justify-content:center;font-weight:800;
|
||||
font-size:15px;font-family:Georgia,serif;
|
||||
}
|
||||
#bm-header .count-sub{margin-left:auto;font-size:12.5px;color:var(--bm-muted);font-weight:500}
|
||||
.bm-search-wrap{position:relative;flex:1}
|
||||
.bm-search-wrap svg{position:absolute;left:13px;top:50%;transform:translateY(-50%);color:var(--bm-muted2)}
|
||||
#bm-q{width:100%;background:var(--bm-bg);border:1px solid transparent;color:var(--bm-txt);
|
||||
border-radius:999px;padding:9px 16px 9px 38px;font-size:14.5px;outline:none;transition:.18s}
|
||||
#bm-q:focus{background:#fff;border-color:var(--bm-accent);box-shadow:0 0 0 3px var(--bm-soft)}
|
||||
.bm-filters{display:flex;gap:6px;flex-wrap:wrap}
|
||||
.bm-chip{
|
||||
display:inline-flex;align-items:center;gap:6px;background:#fff;border:1px solid var(--bm-bd);
|
||||
color:var(--bm-muted);border-radius:999px;padding:6px 12px;font-size:13px;font-weight:600;
|
||||
cursor:pointer;user-select:none;transition:.15s;white-space:nowrap;
|
||||
}
|
||||
.bm-chip:hover{background:var(--bm-bg);color:var(--bm-txt2);border-color:var(--bm-bd2)}
|
||||
.bm-chip.active{background:var(--bm-accent);border-color:var(--bm-accent);color:#fff;box-shadow:var(--bm-shadow)}
|
||||
.bm-chip .n{opacity:.65;font-weight:600;margin-left:2px}
|
||||
.bm-chip.active .n{opacity:.9}
|
||||
#bm-meta{font-size:12px;color:var(--bm-muted);padding:6px 2px 0}
|
||||
/* cards */
|
||||
.bm-tweet{
|
||||
background:var(--bm-card);border:1px solid var(--bm-bd);border-radius:var(--bm-radius);
|
||||
padding:16px 18px;margin-bottom:14px;box-shadow:var(--bm-shadow);
|
||||
content-visibility:auto;contain-intrinsic-size:0 420px;transition:box-shadow .18s,border-color .18s;
|
||||
}
|
||||
.bm-tweet:hover{box-shadow:var(--bm-shadow-hover);border-color:var(--bm-bd2)}
|
||||
.bm-tweet a{color:var(--bm-accent);text-decoration:none}
|
||||
.bm-tweet a:hover{text-decoration:underline}
|
||||
.bm-head{display:flex;align-items:flex-start;gap:11px}
|
||||
.bm-avatar{width:44px;height:44px;border-radius:50%;object-fit:cover;flex:0 0 auto;
|
||||
background:var(--bm-bg);border:1px solid var(--bm-bd)}
|
||||
.bm-who{line-height:1.22;font-size:15px;min-width:0;flex:1}
|
||||
.bm-who .top{display:flex;align-items:center;gap:4px;flex-wrap:wrap}
|
||||
.bm-who .name{font-weight:700;color:var(--bm-txt)}
|
||||
.bm-who .handle,.bm-who .sep,.bm-who .time{color:var(--bm-muted);font-size:14px}
|
||||
.bm-ext{color:var(--bm-muted2);display:inline-flex;margin-left:auto}
|
||||
.bm-ext:hover{color:var(--bm-accent)}
|
||||
.bm-text{font-size:16px;line-height:1.5;margin:11px 0 0;white-space:pre-wrap;
|
||||
word-wrap:break-word;unicode-bidi:plaintext;color:var(--bm-txt)}
|
||||
.bm-summary{margin-top:9px;font-size:13.5px;color:#067647;background:#ecfdf5;
|
||||
border:1px solid #d1fae5;border-radius:8px;padding:6px 10px}
|
||||
/* media */
|
||||
.bm-media{margin-top:13px;display:grid;gap:3px}
|
||||
.bm-media.one{grid-template-columns:1fr}
|
||||
.bm-media.two{grid-template-columns:1fr 1fr}
|
||||
.bm-media.three{grid-template-columns:2fr 1fr;grid-template-rows:1fr 1fr}
|
||||
.bm-media.three .bm-cell:nth-child(1){grid-row:1 / span 2}
|
||||
.bm-media.four{grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr}
|
||||
.bm-cell{position:relative;overflow:hidden;border-radius:14px;cursor:pointer;
|
||||
background:#0d1b27;min-height:130px;display:block;text-decoration:none}
|
||||
.bm-cell img{width:100%;height:100%;object-fit:cover;display:block;transition:filter .2s}
|
||||
.bm-cell:hover img{filter:brightness(.92)}
|
||||
.bm-vbadge{position:absolute;left:10px;top:10px;background:rgba(0,0,0,.65);color:#fff;
|
||||
font-size:10.5px;padding:2px 8px;border-radius:6px;pointer-events:none;font-weight:600}
|
||||
.bm-play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none}
|
||||
.bm-play .ring{width:54px;height:54px;border-radius:50%;background:rgba(0,0,0,.55);
|
||||
display:flex;align-items:center;justify-content:center;color:#fff;backdrop-filter:blur(2px)}
|
||||
.bm-dur{position:absolute;right:8px;bottom:8px;background:rgba(0,0,0,.7);color:#fff;
|
||||
font-size:11px;padding:1px 6px;border-radius:4px;pointer-events:none;font-weight:600}
|
||||
.bm-xbadge{position:absolute;right:8px;top:8px;background:rgba(29,155,240,.92);color:#fff;
|
||||
font-size:10px;padding:1px 7px;border-radius:5px;font-weight:700;pointer-events:none}
|
||||
/* card link */
|
||||
.bm-cardlink{border:1px solid var(--bm-bd);border-radius:14px;overflow:hidden;display:flex;
|
||||
text-decoration:none;color:var(--bm-txt);margin-top:13px;transition:border-color .15s,background .15s}
|
||||
.bm-cardlink:hover{border-color:var(--bm-bd2);background:var(--bm-bg);text-decoration:none}
|
||||
.bm-cardlink .ci{flex:1;padding:11px 13px;min-width:0}
|
||||
.bm-cardlink .csite{color:var(--bm-muted2);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.bm-cardlink .ct{font-weight:700;margin:3px 0 2px;font-size:15px;line-height:1.25}
|
||||
.bm-cardlink .cd{color:var(--bm-muted);font-size:13px;line-height:1.35;
|
||||
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.bm-cardlink img{width:140px;max-height:200px;object-fit:cover;flex:0 0 140px}
|
||||
/* quote */
|
||||
.bm-quote{margin-top:13px;border:1px solid var(--bm-bd);border-radius:14px;padding:13px 14px;
|
||||
cursor:pointer;background:#f7f9fb;transition:border-color .15s,background .15s}
|
||||
.bm-quote:hover{border-color:var(--bm-bd2);background:#eef2f6}
|
||||
.bm-quote .qhead{display:flex;gap:8px;align-items:center;font-size:14px}
|
||||
.bm-quote .qhead img{width:22px;height:22px;border-radius:50%;border:1px solid var(--bm-bd)}
|
||||
.bm-quote .qhead .name{font-weight:700}
|
||||
.bm-quote .qhead .handle,.bm-quote .qhead .sep{color:var(--bm-muted);font-size:13px}
|
||||
.bm-quote .qtext{font-size:15px;margin-top:9px;white-space:pre-wrap;unicode-bidi:plaintext;
|
||||
line-height:1.45;color:var(--bm-txt2)}
|
||||
.bm-quote .media{margin-top:9px}
|
||||
/* metrics */
|
||||
.bm-metrics{margin-top:13px;display:flex;gap:6px;flex-wrap:wrap;color:var(--bm-muted);
|
||||
font-size:13px;padding-top:11px;border-top:1px solid var(--bm-bd)}
|
||||
.bm-metric{display:inline-flex;align-items:center;gap:5px;padding:1px 8px 1px 0}
|
||||
.bm-metric.bkm{color:var(--bm-accent2);font-weight:600}
|
||||
/* empty */
|
||||
.bm-empty{color:var(--bm-muted);text-align:center;padding:80px 20px;font-size:15px}
|
||||
.bm-empty .tle{font-size:18px;color:var(--bm-txt2);margin-bottom:6px;font-weight:600}
|
||||
/* to-top */
|
||||
#bm-totop{position:fixed;right:22px;bottom:22px;width:44px;height:44px;border-radius:50%;
|
||||
background:var(--bm-accent);color:#fff;border:none;cursor:pointer;z-index:60;
|
||||
box-shadow:0 4px 14px rgba(29,155,240,.4);display:none;align-items:center;justify-content:center;
|
||||
transition:transform .15s}
|
||||
#bm-totop:hover{transform:translateY(-2px);background:var(--bm-accent2)}
|
||||
#bm-totop.show{display:flex}
|
||||
/* lightbox */
|
||||
#bm-lightbox{position:fixed;inset:0;background:rgba(13,27,39,.94);display:none;
|
||||
align-items:center;justify-content:center;z-index:100;cursor:zoom-out;padding:30px}
|
||||
#bm-lightbox img{max-width:96vw;max-height:94vh;border-radius:10px;
|
||||
box-shadow:0 10px 40px rgba(0,0,0,.5)}
|
||||
</style>
|
||||
|
||||
<!-- sticky sub-header -->
|
||||
<div id="bm-header">
|
||||
<div class="htop">
|
||||
<div class="brand-row">
|
||||
<div class="logo">X</div>
|
||||
<h1>{% trans "Bookmarks Archive" %}</h1>
|
||||
<span class="count-sub" id="bm-count">{{ stats.total }} {% trans "bookmarks" %}</span>
|
||||
</div>
|
||||
<div class="bm-search-wrap">
|
||||
<svg width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input id="bm-q" type="search" placeholder="{% trans 'Search text, author, handle…' %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="bm-filters" id="bm-filters">
|
||||
<button class="bm-chip active" data-f="all">{% trans "All" %}<span class="n">{{ stats.total }}</span></button>
|
||||
<button class="bm-chip" data-f="photo">{% trans "Photos" %}<span class="n">{{ stats.has_photo }}</span></button>
|
||||
<button class="bm-chip" data-f="video">{% trans "Videos" %}<span class="n">{{ stats.has_video }}</span></button>
|
||||
<button class="bm-chip" data-f="text">{% trans "Text" %}<span class="n"></span></button>
|
||||
<button class="bm-chip" data-f="quote">{% trans "Quotes" %}<span class="n">{{ stats.is_quote }}</span></button>
|
||||
<button class="bm-chip" data-f="card">{% trans "Links" %}<span class="n">{{ stats.has_card }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bm-page">
|
||||
<div id="bm-meta"></div>
|
||||
<div id="bm-list">
|
||||
{% include "links/includes/bookmark_list_items.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="bm-totop" title="Back to top" aria-label="Back to top">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
|
||||
</button>
|
||||
<div id="bm-lightbox" onclick="this.style.display='none';document.getElementById('bm-lb-img').src='';">
|
||||
<img id="bm-lb-img" src="" alt="">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ---- client-side instant search + filter ----
|
||||
(function(){
|
||||
const list = document.getElementById('bm-list');
|
||||
const qInput = document.getElementById('bm-q');
|
||||
const meta = document.getElementById('bm-meta');
|
||||
let currentFilter = 'all';
|
||||
let qs = '';
|
||||
|
||||
// compute text-only count
|
||||
const allCards = list.querySelectorAll('.bm-tweet');
|
||||
let textCount = 0;
|
||||
allCards.forEach(c => { if(!c.dataset.media) textCount++; });
|
||||
document.querySelector('.bm-chip[data-f=text] .n').textContent = textCount;
|
||||
const total = allCards.length;
|
||||
|
||||
const filters = document.getElementById('bm-filters');
|
||||
filters.addEventListener('click', e => {
|
||||
const b = e.target.closest('.bm-chip'); if(!b) return;
|
||||
filters.querySelectorAll('.bm-chip').forEach(x=>x.classList.remove('active'));
|
||||
b.classList.add('active'); currentFilter = b.dataset.f; render();
|
||||
});
|
||||
qInput.addEventListener('input', e => { qs = e.target.value.toLowerCase().trim(); render(); });
|
||||
|
||||
function passes(c){
|
||||
if(currentFilter==='photo') return c.dataset.photo==='1';
|
||||
if(currentFilter==='video') return c.dataset.video==='1';
|
||||
if(currentFilter==='text') return !c.dataset.media;
|
||||
if(currentFilter==='quote') return c.dataset.quote==='1';
|
||||
if(currentFilter==='card') return c.dataset.card==='1';
|
||||
return true;
|
||||
}
|
||||
function render(){
|
||||
let shown=0;
|
||||
for(const c of allCards){
|
||||
let show = passes(c);
|
||||
if(show && qs){
|
||||
show = (c.dataset.blob||'').includes(qs);
|
||||
}
|
||||
c.style.display = show ? '' : 'none';
|
||||
if(show) shown++;
|
||||
}
|
||||
meta.textContent = 'Showing '+shown+' of '+total+' bookmarks · '
|
||||
+ (qs||currentFilter!=='all' ? 'filtered' : 'all');
|
||||
}
|
||||
render();
|
||||
})();
|
||||
|
||||
// ---- lightbox ----
|
||||
window.bmLightbox = function(el){
|
||||
const full = el.getAttribute('data-full') || (el.querySelector('img')&&el.querySelector('img').src);
|
||||
if(!full) return;
|
||||
const lb = document.getElementById('bm-lightbox');
|
||||
document.getElementById('bm-lb-img').src = full;
|
||||
lb.style.display = 'flex';
|
||||
};
|
||||
|
||||
// ---- back to top ----
|
||||
(function(){
|
||||
const b = document.getElementById('bm-totop'); let t=false;
|
||||
window.addEventListener('scroll', ()=>{
|
||||
if(!t){ requestAnimationFrame(()=>{ b.classList.toggle('show', window.scrollY>600); t=false; }); t=true; }
|
||||
});
|
||||
b.onclick = ()=>window.scrollTo({top:0,behavior:'smooth'});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,123 @@
|
||||
{% load i18n %}
|
||||
{% load bookmark_extras %}
|
||||
|
||||
{% for b in bookmarks %}
|
||||
<article class="bm-tweet"
|
||||
data-media="{% if b.media %}1{% endif %}"
|
||||
data-photo="{% if b.has_photo %}1{% else %}0{% endif %}"
|
||||
data-video="{% if b.has_video %}1{% else %}0{% endif %}"
|
||||
data-card="{% if b.has_card %}1{% else %}0{% endif %}"
|
||||
data-quote="{% if b.is_quote %}1{% else %}0{% endif %}"
|
||||
data-blob="{{ b.text|lower|escapejs }} {{ b.author_name|lower|escapejs }} @{{ b.author_screen_name|lower|escapejs }} {{ b.summary|lower|escapejs }}">
|
||||
|
||||
<!-- head -->
|
||||
<div class="bm-head">
|
||||
{% if b.author_profile_image_url %}
|
||||
<a href="https://x.com/{{ b.author_screen_name }}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
|
||||
<img class="bm-avatar" src="{{ b.author_profile_image_url }}" alt="" loading="lazy">
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="bm-avatar"></div>
|
||||
{% endif %}
|
||||
<div class="bm-who">
|
||||
<div class="top">
|
||||
<a href="https://x.com/{{ b.author_screen_name }}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
|
||||
<span class="name">{{ b.author_name|default:b.author_screen_name }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span class="handle">@{{ b.author_screen_name }}</span>
|
||||
{% if b.tweet_created_at %}<span class="sep">·</span><span class="time">{{ b.tweet_created_at|date:"M j, Y" }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<a class="bm-ext" href="{{ b.url|default:'https://x.com/i/status/'|add:b.tweet_id }}"
|
||||
target="_blank" rel="noopener" title="{% trans 'Open on X' %}" onclick="event.stopPropagation()">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" viewBox="0 0 24 24"><path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- text -->
|
||||
{% if b.text %}
|
||||
<div class="bm-text">{{ b.text|truncatechars:600|urlizetrunc:40|linebreaksbr }}</div>
|
||||
{% endif %}
|
||||
{% if b.summary %}
|
||||
<div class="bm-summary">{{ b.summary }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if b.media %}
|
||||
<div class="bm-media {{ b.media|length|media_grid_class }}">
|
||||
{% for m in b.media %}
|
||||
{% if m.type == 'photo' %}
|
||||
<div class="bm-cell" onclick="bmLightbox(this)" data-full="{{ m.media_large|default:m.media_url }}">
|
||||
<img loading="lazy" src="{{ m.media_thumb|default:m.media_large|default:m.media_url }}" alt="{{ m.alt_text|default:'' }}">
|
||||
</div>
|
||||
{% elif m.type == 'video' or m.type == 'animated_gif' %}
|
||||
{% with v=m.video %}
|
||||
<a class="bm-cell" href="{{ b.url|default:'https://x.com/i/status/'|add:b.tweet_id }}"
|
||||
target="_blank" rel="noopener" title="{% trans 'Open on X to play' %}" onclick="event.stopPropagation()">
|
||||
<span class="bm-vbadge">{% if m.type == 'animated_gif' %}GIF{% else %}VIDEO{% endif %}</span>
|
||||
<img loading="lazy" src="{{ v.poster|default:m.media_thumb|default:m.media_large|default:m.media_url }}" alt="">
|
||||
<span class="bm-play"><span class="ring"><svg width="22" height="22" fill="#fff" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></span></span>
|
||||
{% if v.duration_ms %}<span class="bm-dur">{{ v.duration_ms|ms_to_duration }}</span>{% endif %}
|
||||
<span class="bm-xbadge">X</span>
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- link card -->
|
||||
{% if b.has_card and b.card_data %}
|
||||
{% with c=b.card_data %}
|
||||
{% if c.title or c.description or c.image %}
|
||||
<a class="bm-cardlink" href="{{ c.url|default:'#' }}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
|
||||
{% if c.image %}<img src="{{ c.image }}" loading="lazy" alt="">{% endif %}
|
||||
<div class="ci">
|
||||
<div class="csite">{{ c.site|default:'' }}</div>
|
||||
{% if c.title %}<div class="ct">{{ c.title }}</div>{% endif %}
|
||||
{% if c.description %}<div class="cd">{{ c.description }}</div>{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
<!-- quote tweet -->
|
||||
{% if b.is_quote and b.quoted_data %}
|
||||
{% with q=b.quoted_data %}
|
||||
{% if q.tombstone or q.unavailable %}
|
||||
<div class="bm-quote">{% trans "This post is unavailable." %}</div>
|
||||
{% else %}
|
||||
{% with qa=q.author %}
|
||||
<a class="bm-quote" href="{{ q.url|default:'https://x.com/i/status/'|add:q.id }}"
|
||||
target="_blank" rel="noopener" onclick="event.stopPropagation()">
|
||||
<div class="qhead">
|
||||
{% if qa.profile_image_url %}<img src="{{ qa.profile_image_url }}" loading="lazy" alt="">{% endif %}
|
||||
<span class="name">{{ qa.name|default:'' }}</span>
|
||||
<span class="handle">@{{ qa.screen_name|default:'' }}</span>
|
||||
</div>
|
||||
<div class="qtext">{{ q.text|default:''|truncatechars:280|linebreaksbr }}</div>
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
<!-- metrics -->
|
||||
<div class="bm-metrics">
|
||||
{% if b.reply_count %}<span class="bm-metric">💬 {{ b.reply_count }}</span>{% endif %}
|
||||
{% if b.retweet_count %}<span class="bm-metric">🔁 {{ b.retweet_count }}</span>{% endif %}
|
||||
{% if b.favorite_count %}<span class="bm-metric">❤ {{ b.favorite_count }}</span>{% endif %}
|
||||
{% if b.bookmark_count %}<span class="bm-metric bkm">🔖 {{ b.bookmark_count }}</span>{% endif %}
|
||||
{% if b.view_count %}<span class="bm-metric">📊 {{ b.view_count }}</span>{% endif %}
|
||||
<a href="{{ b.url|default:'#' }}" target="_blank" rel="noopener" class="bm-metric"
|
||||
style="color:var(--bm-accent);margin-left:auto" onclick="event.stopPropagation()">{% trans "View on X" %} ↗</a>
|
||||
</div>
|
||||
</article>
|
||||
{% empty %}
|
||||
<div class="bm-empty">
|
||||
<div class="tle">{% trans "No bookmarks found" %}</div>
|
||||
{% trans "Try a different search or filter." %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -25,7 +25,7 @@
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 min-h-screen">
|
||||
<div id="root"></div>
|
||||
<script src="{% static 'dist/search.js' %}"></script>
|
||||
<script type="module" src="{% static 'dist/search.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
const { useState, useEffect, useCallback, useMemo } = React;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Template tags for the Bookmark viewer."""
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def media_grid_class(media_count):
|
||||
"""Return the CSS grid class for a media block based on item count."""
|
||||
n = int(media_count or 0)
|
||||
if n <= 1:
|
||||
return 'one'
|
||||
if n == 2:
|
||||
return 'two'
|
||||
if n == 3:
|
||||
return 'three'
|
||||
return 'four'
|
||||
|
||||
|
||||
@register.filter
|
||||
def ms_to_duration(ms):
|
||||
"""Convert milliseconds to a short M:SS duration string."""
|
||||
try:
|
||||
secs = int(int(ms) / 1000)
|
||||
except (TypeError, ValueError):
|
||||
return ''
|
||||
if secs >= 3600:
|
||||
h = secs // 3600
|
||||
m = (secs % 3600) // 60
|
||||
s = secs % 60
|
||||
return f'{h}:{m:02d}:{s:02d}'
|
||||
m = secs // 60
|
||||
s = secs % 60
|
||||
return f'{m}:{s:02d}'
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def bookmark_chip(flag, label, count, request_get):
|
||||
"""Render a filter chip button that toggles a boolean query param,
|
||||
preserving the current `q` search query."""
|
||||
active = False
|
||||
val = ''
|
||||
try:
|
||||
val = request_get.get(flag)
|
||||
except AttributeError:
|
||||
val = None
|
||||
if val is not None:
|
||||
active = True
|
||||
q = ''
|
||||
try:
|
||||
q = request_get.get('q') or ''
|
||||
except AttributeError:
|
||||
q = ''
|
||||
base = 'inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium border cursor-pointer select-none transition '
|
||||
cls = base + ('bg-blue-600 text-white border-blue-600'
|
||||
if active else 'bg-white text-gray-600 border-gray-300 hover:bg-gray-100')
|
||||
# When active, clicking the chip goes to a URL with that flag removed
|
||||
# (toggle off). When inactive, clicking adds the flag=on.
|
||||
params = []
|
||||
if q:
|
||||
params.append(('q', q))
|
||||
# keep other flag params
|
||||
KEEP = ('has_video', 'has_photo', 'has_card', 'is_quote', 'is_retweet')
|
||||
for k in KEEP:
|
||||
if k == flag:
|
||||
continue
|
||||
v = None
|
||||
try:
|
||||
v = request_get.get(k)
|
||||
except AttributeError:
|
||||
v = None
|
||||
if v:
|
||||
params.append((k, 'on'))
|
||||
if not active:
|
||||
params.append((flag, 'on'))
|
||||
href = '?' + '&'.join(f'{k}={v}' for k, v in params) if params else './'
|
||||
return mark_safe(
|
||||
f'<a href="{href}" hx-get="?{"&".join(f"{k}={v}" for k,v in params)}" '
|
||||
f'hx-target="#bm-content" hx-swap="innerHTML" hx-push-url="true" '
|
||||
f'class="{cls}"><span>{label}</span>'
|
||||
f'<span class="opacity-75">{count}</span></a>'
|
||||
)
|
||||
@@ -5,6 +5,7 @@ from . import search_views
|
||||
from . import post_views
|
||||
from . import collection_views
|
||||
from . import mini_apps_views
|
||||
from . import bookmark_views
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
urlpatterns = [
|
||||
@@ -36,6 +37,10 @@ urlpatterns = [
|
||||
path('fetch-page-info/', page_views.fetch_page_info, name='fetch-page-info'),
|
||||
path('ui/screenshots/', page_views.ScreenshotGalleryView.as_view(), name='screenshot-gallery'),
|
||||
|
||||
# Bookmarks (x.com archive)
|
||||
path('ui/bookmarks/', bookmark_views.BookmarkListView.as_view(), name='bookmark-list'),
|
||||
path('ui/bookmarks/<int:pk>/', bookmark_views.BookmarkDetailView.as_view(), name='bookmark-detail'),
|
||||
|
||||
# Link task toggle API
|
||||
path('link/<int:pk>/toggle_task/', views.toggle_link_task, name='link-toggle-task'),
|
||||
|
||||
|
||||
Vendored
+8
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -13,6 +13,7 @@ info:
|
||||
| Resource | What it is |
|
||||
|---|---|
|
||||
| **Page** | A bookmarked URL. When created, the server asynchronously fetches its title, description, and a screenshot. |
|
||||
| **Bookmark** | An archived social-media post (e.g. an x.com / Twitter tweet) saved with full preview data — text, author, images, video poster thumbnails, link cards, quotes — so the archive page renders offline. Bulk-import via `/api/bookmarks/import`. |
|
||||
| **Post** | A Markdown blog post, optionally tagged. |
|
||||
| **Collection** | A named group of uploaded images (e.g. a photo album). |
|
||||
| **Image** | An image file belonging to a Collection. |
|
||||
@@ -41,6 +42,20 @@ info:
|
||||
no authentication is required for the recipient. Set an expiry with
|
||||
`POST /api/files/{id}/set-expiry` to time-limit the link.
|
||||
|
||||
### Bookmarks bulk import
|
||||
Import (or upsert) many archived posts in one request. Each item accepts the
|
||||
full normalised export object from `x-bookmarks-exporter` (with nested
|
||||
`author`, `media`, `card`, `quoted`, `retweet`, counters); matching is by
|
||||
`tweet_id`:
|
||||
```
|
||||
POST /api/bookmarks/import
|
||||
{ "bookmarks": [ { "id": "123", "author": {"screen_name":"u"}, "text": "...",
|
||||
"media": [...], ... }, ... ] }
|
||||
→ { "created": 10, "updated": 0, "skipped": 0, "total": 769 }
|
||||
```
|
||||
Use `PUT /api/bookmarks/bulk` with a bare JSON array of flat
|
||||
`Bookmark`-shaped objects for the same upsert behaviour.
|
||||
|
||||
## Authentication
|
||||
No authentication is currently required on any endpoint.
|
||||
version: 1.0.0
|
||||
@@ -60,6 +75,12 @@ tags:
|
||||
the page title, description, and screenshot automatically.
|
||||
- name: Posts
|
||||
description: Markdown blog posts with optional tag categorisation.
|
||||
- name: Bookmarks
|
||||
description: |
|
||||
Archived social-media posts (originally x.com / Twitter bookmarks), each saved with
|
||||
full preview data — text, author, media poster thumbnails (no embedded video streams),
|
||||
link cards, quotes — so the archive viewer page renders offline without further API calls.
|
||||
Use `POST /api/bookmarks/import` for bulk upsert by `tweet_id`.
|
||||
- name: Collections
|
||||
description: Named groups of images. Each collection can hold many uploaded image files.
|
||||
- name: Images
|
||||
@@ -176,6 +197,266 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Page'
|
||||
|
||||
/api/bookmarks:
|
||||
get:
|
||||
operationId: listBookmarks
|
||||
tags: [Bookmarks]
|
||||
summary: List archived bookmarks
|
||||
description: |
|
||||
Paginated list of archived social-media bookmarks, newest first. Supports
|
||||
full-text search (`q`), boolean media/type filters, author filter and
|
||||
ordering. Default page size is 24; use `?page_size=` up to 200.
|
||||
parameters:
|
||||
- in: query
|
||||
name: page
|
||||
schema: { type: integer }
|
||||
description: 1-based page number
|
||||
- in: query
|
||||
name: page_size
|
||||
schema: { type: integer, maximum: 200 }
|
||||
description: Items per page (default 24)
|
||||
- in: query
|
||||
name: q
|
||||
schema: { type: string }
|
||||
description: Case-insensitive search across `text`, `summary`, `author_screen_name`, `author_name`
|
||||
- in: query
|
||||
name: has_video
|
||||
schema: { type: boolean }
|
||||
description: Filter to bookmarks containing a video/animated GIF
|
||||
- in: query
|
||||
name: has_photo
|
||||
schema: { type: boolean }
|
||||
- in: query
|
||||
name: has_card
|
||||
schema: { type: boolean }
|
||||
description: Has an attached link preview card
|
||||
- in: query
|
||||
name: is_quote
|
||||
schema: { type: boolean }
|
||||
- in: query
|
||||
name: is_retweet
|
||||
schema: { type: boolean }
|
||||
- in: query
|
||||
name: author
|
||||
schema: { type: string }
|
||||
description: Exact (case-insensitive) screen name
|
||||
- in: query
|
||||
name: ordering
|
||||
schema:
|
||||
type: string
|
||||
enum: [tweet_created_at, -tweet_created_at, bookmark_created_at,
|
||||
-bookmark_created_at, favorite_count, -favorite_count,
|
||||
view_count, -view_count, created_at, -created_at]
|
||||
description: Sort field (prefix `-` for descending). Default `-tweet_created_at`
|
||||
responses:
|
||||
'200':
|
||||
description: Paginated bookmarks
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BookmarkList'
|
||||
post:
|
||||
operationId: createBookmark
|
||||
tags: [Bookmarks]
|
||||
summary: Create a single bookmark
|
||||
description: |
|
||||
Create one archived bookmark from a flat `Bookmark`-shaped object.
|
||||
For importing many at once (e.g. from x-bookmarks-exporter), prefer
|
||||
`POST /api/bookmarks/import`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BookmarkCreate'
|
||||
responses:
|
||||
'201':
|
||||
description: Bookmark created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Bookmark'
|
||||
|
||||
/api/bookmarks/stats:
|
||||
get:
|
||||
operationId: bookmarkStats
|
||||
tags: [Bookmarks]
|
||||
summary: Aggregate bookmark counts
|
||||
description: Returns total count plus per-flag counts (used by the viewer's filter chips).
|
||||
responses:
|
||||
'200':
|
||||
description: Aggregate stats
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
total: { type: integer }
|
||||
has_video: { type: integer }
|
||||
has_photo: { type: integer }
|
||||
has_card: { type: integer }
|
||||
is_quote: { type: integer }
|
||||
is_retweet: { type: integer }
|
||||
|
||||
/api/bookmarks/import:
|
||||
post:
|
||||
operationId: importBookmarks
|
||||
tags: [Bookmarks]
|
||||
summary: Bulk upsert bookmarks from export objects
|
||||
description: |
|
||||
Upserts many bookmarks in one request. Each item in `bookmarks` accepts the
|
||||
full normalised export object from `x-bookmarks-exporter` — with nested
|
||||
`author`, `media`, `card`, `quoted`, `retweet`, engagement counters and
|
||||
`created_at` (x.com-style RFC date string). Matching is by `tweet_id`
|
||||
(top-level `id` alias also accepted); existing entries are updated,
|
||||
new ones created. Returns `{ created, updated, skipped, total }`.
|
||||
|
||||
Example body:
|
||||
```json
|
||||
{
|
||||
"bookmarks": [
|
||||
{
|
||||
"id": "1234567890",
|
||||
"url": "https://x.com/user/status/1234567890",
|
||||
"author": {"screen_name":"user","name":"User","profile_image_url":"..."},
|
||||
"text": "Hello world",
|
||||
"media": [{"type":"video","media_url":"...","video":{"poster":"...","best_mp4":"...","duration_ms":12000}}],
|
||||
"favorite_count": 5, "view_count": 99
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [bookmarks]
|
||||
properties:
|
||||
bookmarks:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BookmarkExportItem'
|
||||
responses:
|
||||
'200':
|
||||
description: Upsert summary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
created: { type: integer }
|
||||
updated: { type: integer }
|
||||
skipped: { type: integer }
|
||||
total: { type: integer, description: Total bookmarks now in DB }
|
||||
|
||||
/api/bookmarks/bulk:
|
||||
put:
|
||||
operationId: bulkUpsertBookmarks
|
||||
tags: [Bookmarks]
|
||||
summary: Bulk upsert from a flat JSON array
|
||||
description: |
|
||||
Like `/import` but accepts a bare JSON array (not wrapped in `{bookmarks:[]}`).
|
||||
Each item can be a flat `Bookmark`-shaped object (matching
|
||||
`BookmarkCreate`/`Bookmark`) or a normalised export object; `tweet_id`
|
||||
(or top-level `id`) is used as the upsert key.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BookmarkCreate'
|
||||
responses:
|
||||
'200':
|
||||
description: Upsert summary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
created: { type: integer }
|
||||
updated: { type: integer }
|
||||
skipped: { type: integer }
|
||||
total: { type: integer }
|
||||
post:
|
||||
operationId: bulkUpsertBookmarksPost
|
||||
tags: [Bookmarks]
|
||||
summary: Bulk upsert from a flat JSON array (POST alias)
|
||||
description: Alias of `PUT /api/bookmarks/bulk` for clients that cannot send PUT.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BookmarkCreate'
|
||||
responses:
|
||||
'200':
|
||||
description: Upsert summary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
created: { type: integer }
|
||||
updated: { type: integer }
|
||||
skipped: { type: integer }
|
||||
total: { type: integer }
|
||||
|
||||
/api/bookmarks/{id}:
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
description: Internal DB primary key (NOT the tweet_id)
|
||||
get:
|
||||
operationId: retrieveBookmark
|
||||
tags: [Bookmarks]
|
||||
summary: Retrieve a single bookmark
|
||||
responses:
|
||||
'200':
|
||||
description: Bookmark detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Bookmark'
|
||||
'404':
|
||||
description: Not found
|
||||
patch:
|
||||
operationId: updateBookmark
|
||||
tags: [Bookmarks]
|
||||
summary: Partially update a bookmark
|
||||
description: |
|
||||
Update any subset of fields. Common use by agents: write an AI-generated
|
||||
`summary` for the post via `{"summary": "..."}`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BookmarkCreate'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated bookmark
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Bookmark'
|
||||
delete:
|
||||
operationId: deleteBookmark
|
||||
tags: [Bookmarks]
|
||||
summary: Delete a bookmark
|
||||
responses:
|
||||
'204':
|
||||
description: Deleted
|
||||
'404':
|
||||
description: Not found
|
||||
|
||||
/api/posts:
|
||||
get:
|
||||
operationId: listPosts
|
||||
@@ -1182,3 +1463,147 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Link'
|
||||
|
||||
Bookmark:
|
||||
type: object
|
||||
description: |
|
||||
An archived social-media post. The `data` field holds the complete original
|
||||
normalised export object (kept for round-tripping / the offline viewer page);
|
||||
the top-level denormalised fields (text, media, card_data, quoted_data,
|
||||
flags, counters) are the indexed/fast-path versions used by list & filter.
|
||||
properties:
|
||||
id: { type: integer, description: Internal DB primary key }
|
||||
tweet_id: { type: string, description: Source post id (unique) }
|
||||
url: { type: string, format: uri, description: Canonical x.com post URL }
|
||||
author_screen_name: { type: string }
|
||||
author_name: { type: string }
|
||||
author_profile_image_url: { type: string, format: uri }
|
||||
text: { type: string, description: Full post text }
|
||||
summary: { type: string, description: Optional human/agent-written summary }
|
||||
tweet_created_at: { type: string, format: date-time, nullable: true }
|
||||
bookmark_created_at: { type: string, format: date-time, nullable: true }
|
||||
view_count: { type: integer }
|
||||
favorite_count: { type: integer }
|
||||
retweet_count: { type: integer }
|
||||
reply_count: { type: integer }
|
||||
bookmark_count: { type: integer }
|
||||
quote_count: { type: integer }
|
||||
has_video: { type: boolean }
|
||||
has_photo: { type: boolean }
|
||||
has_card: { type: boolean }
|
||||
is_quote: { type: boolean }
|
||||
is_retweet: { type: boolean }
|
||||
media:
|
||||
type: array
|
||||
description: List of media objects (photo or video/animated_gif with poster)
|
||||
items: { type: object }
|
||||
card_data:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Link preview card (title, description, site, image, url)
|
||||
quoted_data:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Nested quoted post (same shape as an export item)
|
||||
data:
|
||||
type: object
|
||||
description: The complete normalised export object from x-bookmarks-exporter
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
|
||||
BookmarkCreate:
|
||||
type: object
|
||||
required: [tweet_id]
|
||||
properties:
|
||||
tweet_id: { type: string }
|
||||
url: { type: string, format: uri }
|
||||
author_screen_name: { type: string }
|
||||
author_name: { type: string }
|
||||
author_profile_image_url: { type: string, format: uri }
|
||||
text: { type: string }
|
||||
summary: { type: string }
|
||||
tweet_created_at: { type: string, format: date-time, nullable: true }
|
||||
bookmark_created_at: { type: string, format: date-time, nullable: true }
|
||||
view_count: { type: integer }
|
||||
favorite_count: { type: integer }
|
||||
retweet_count: { type: integer }
|
||||
reply_count: { type: integer }
|
||||
bookmark_count: { type: integer }
|
||||
quote_count: { type: integer }
|
||||
has_video: { type: boolean }
|
||||
has_photo: { type: boolean }
|
||||
has_card: { type: boolean }
|
||||
is_quote: { type: boolean }
|
||||
is_retweet: { type: boolean }
|
||||
media:
|
||||
type: array
|
||||
items: { type: object }
|
||||
card_data:
|
||||
type: object
|
||||
nullable: true
|
||||
quoted_data:
|
||||
type: object
|
||||
nullable: true
|
||||
data:
|
||||
type: object
|
||||
|
||||
BookmarkExportItem:
|
||||
type: object
|
||||
description: |
|
||||
One element of a bulk-import payload, as produced by x-bookmarks-exporter.
|
||||
All nested fields are optional except the source post id; the server maps
|
||||
the nested `author`, `media`, `card`, `quoted`, `retweet`, counters and
|
||||
`created_at` date string into the flat Bookmark columns and the `data`
|
||||
JSON blob.
|
||||
properties:
|
||||
id: { type: string, description: Source tweet id (also accepted as `tweet_id`) }
|
||||
url: { type: string, format: uri }
|
||||
author:
|
||||
type: object
|
||||
properties:
|
||||
screen_name: { type: string }
|
||||
name: { type: string }
|
||||
profile_image_url: { type: string, format: uri }
|
||||
verified: { type: boolean }
|
||||
description: { type: string }
|
||||
text: { type: string }
|
||||
summary: { type: string }
|
||||
created_at: { type: string, description: x.com-style "Wed Jul 30 12:00:00 +0000 2025" }
|
||||
bookmark_created_at: { type: string }
|
||||
view_count: { type: integer }
|
||||
favorite_count: { type: integer }
|
||||
retweet_count: { type: integer }
|
||||
reply_count: { type: integer }
|
||||
bookmark_count: { type: integer }
|
||||
quote_count: { type: integer }
|
||||
media:
|
||||
type: array
|
||||
description: |
|
||||
Each media object carries `type` (photo|video|animated_gif), image
|
||||
URL variants (`media_url`, `media_thumb`, `media_large`, `media_orig`)
|
||||
and, for video/gif, a `video` object with `poster`, `best_mp4`,
|
||||
`duration_ms`, and `variants[]`.
|
||||
items: { type: object }
|
||||
card:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Link preview with title/description/site/image/url
|
||||
quoted:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Nested quoted post (recursive BookmarkExportItem shape)
|
||||
retweet:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Nested retweeted post
|
||||
|
||||
BookmarkList:
|
||||
type: object
|
||||
properties:
|
||||
count: { type: integer }
|
||||
next: { type: string, nullable: true }
|
||||
previous: { type: string, nullable: true }
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Bookmark'
|
||||
|
||||
+115
-63
@@ -7,7 +7,8 @@ const Badge = ({ children, variant = "default", className = "" }) => {
|
||||
default: "bg-gray-100 text-gray-800",
|
||||
link: "bg-blue-100 text-blue-800",
|
||||
page: "bg-green-100 text-green-800",
|
||||
post: "bg-purple-100 text-purple-800"
|
||||
post: "bg-purple-100 text-purple-800",
|
||||
bookmark: "bg-sky-100 text-sky-800"
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${variants[variant]} ${className}`}>
|
||||
@@ -68,7 +69,8 @@ const SearchResultItem = ({ result }) => {
|
||||
const typeIcons = {
|
||||
link: "fa-link",
|
||||
page: "fa-file-alt",
|
||||
post: "fa-newspaper"
|
||||
post: "fa-newspaper",
|
||||
bookmark: "fa-bookmark"
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
@@ -79,86 +81,135 @@ const SearchResultItem = ({ result }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const isBookmark = result.type === 'bookmark';
|
||||
const badgeColor = result.type === 'link' ? 'bg-blue-100 text-blue-600' :
|
||||
result.type === 'page' ? 'bg-green-100 text-green-600' :
|
||||
result.type === 'post' ? 'bg-purple-100 text-purple-600' :
|
||||
result.type === 'bookmark' ? 'bg-sky-100 text-sky-600' :
|
||||
'bg-gray-100 text-gray-600';
|
||||
const titleHref = isBookmark ? (result.detail_url || result.url) :
|
||||
result.type === 'link' ? result.url : (result.detail_url || result.url);
|
||||
const titleTarget = (result.type === 'link' || isBookmark) ? '_blank' : '_self';
|
||||
const titleRel = (result.type === 'link' || isBookmark) ? 'noopener noreferrer' : '';
|
||||
|
||||
return (
|
||||
<Card hover className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
result.type === 'link' ? 'bg-blue-100 text-blue-600' :
|
||||
result.type === 'page' ? 'bg-green-100 text-green-600' :
|
||||
'bg-purple-100 text-purple-600'
|
||||
}`}>
|
||||
<i className={`fas ${typeIcons[result.type]}`}></i>
|
||||
</div>
|
||||
{isBookmark && result.author_profile_image_url ? (
|
||||
<img
|
||||
src={result.author_profile_image_url}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-full object-cover border border-gray-200"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${badgeColor}`}>
|
||||
<i className={`fas ${typeIcons[result.type] || 'fa-circle'}`}></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<a
|
||||
href={result.type === 'link' ? result.url : result.detail_url || result.url}
|
||||
href={titleHref}
|
||||
className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors truncate"
|
||||
target={result.type === 'link' ? '_blank' : '_self'}
|
||||
rel={result.type === 'link' ? 'noopener noreferrer' : ''}
|
||||
target={titleTarget}
|
||||
rel={titleRel}
|
||||
>
|
||||
{result.title}
|
||||
{isBookmark ? `@${result.author_screen_name}` : result.title}
|
||||
</a>
|
||||
<Badge variant={result.type}>
|
||||
{result.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{result.type === 'link' && result.original_url && (
|
||||
{isBookmark ? (
|
||||
<>
|
||||
{(result.text || result.summary || result.title) && (
|
||||
<p className="text-sm text-gray-700 line-clamp-3 mb-2 whitespace-pre-wrap">
|
||||
{result.summary ? result.summary : (result.text || result.title)}
|
||||
</p>
|
||||
)}
|
||||
{result.has_photo && (
|
||||
<span className="inline-flex items-center text-xs text-gray-500 mr-3">
|
||||
<i className="fas fa-image mr-1"></i>photo
|
||||
</span>
|
||||
)}
|
||||
{result.has_video && (
|
||||
<span className="inline-flex items-center text-xs text-gray-500 mr-3">
|
||||
<i className="fas fa-video mr-1"></i>video
|
||||
</span>
|
||||
)}
|
||||
<a href={result.url || result.detail_url}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-xs text-blue-600 hover:underline mr-3">
|
||||
<i className="fas fa-external-link-alt mr-1"></i>Open on X
|
||||
</a>
|
||||
<a href={result.detail_url}
|
||||
className="inline-flex items-center text-xs text-gray-500 hover:text-gray-800">
|
||||
<i className="fas fa-list mr-1"></i>Detail
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{result.type === 'link' && result.original_url && (
|
||||
<a
|
||||
href={result.original_url}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 truncate block mb-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i className="fas fa-external-link-alt mr-1"></i>
|
||||
{result.original_url}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{(result.description || result.summary) && (
|
||||
<p className="text-sm text-gray-600 line-clamp-2 mb-2">
|
||||
{result.description || result.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
<i className="fas fa-calendar mr-1"></i>
|
||||
{formatDate(result.created_at)}
|
||||
</span>
|
||||
{result.click_count !== undefined && (
|
||||
<span>
|
||||
<i className="fas fa-mouse-pointer mr-1"></i>
|
||||
{result.click_count} clicks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.tags && result.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{result.tags.map(tag => (
|
||||
<Badge key={tag.id} variant="default" className="text-xs">
|
||||
<i className="fas fa-tag mr-1"></i>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isBookmark && (
|
||||
<div className="flex-shrink-0">
|
||||
<a
|
||||
href={result.original_url}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 truncate block mb-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={result.type === 'link' ? `/link/${result.id}/edit/` : result.type === 'page' ? `/ui/pages/${result.id}/edit/` : `/ui/posts/${result.id}/edit/`}
|
||||
className="text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<i className="fas fa-external-link-alt mr-1"></i>
|
||||
{result.original_url}
|
||||
<i className="fas fa-edit"></i>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{(result.description || result.summary) && (
|
||||
<p className="text-sm text-gray-600 line-clamp-2 mb-2">
|
||||
{result.description || result.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
<i className="fas fa-calendar mr-1"></i>
|
||||
{formatDate(result.created_at)}
|
||||
</span>
|
||||
{result.click_count !== undefined && (
|
||||
<span>
|
||||
<i className="fas fa-mouse-pointer mr-1"></i>
|
||||
{result.click_count} clicks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.tags && result.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{result.tags.map(tag => (
|
||||
<Badge key={tag.id} variant="default" className="text-xs">
|
||||
<i className="fas fa-tag mr-1"></i>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<a
|
||||
href={result.type === 'link' ? `/link/${result.id}/edit/` : result.type === 'page' ? `/ui/pages/${result.id}/edit/` : `/ui/posts/${result.id}/edit/`}
|
||||
className="text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<i className="fas fa-edit"></i>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -266,7 +317,7 @@ const SearchApp = () => {
|
||||
Advanced Search
|
||||
</h1>
|
||||
</a>
|
||||
<p className="text-gray-600">Search through all Links, Pages, and Posts</p>
|
||||
<p className="text-gray-600">Search through all Links, Pages, Posts and Bookmarks</p>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
@@ -300,6 +351,7 @@ const SearchApp = () => {
|
||||
<option value="link">Links</option>
|
||||
<option value="page">Pages</option>
|
||||
<option value="post">Posts</option>
|
||||
<option value="bookmark">Bookmarks</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -460,7 +512,7 @@ const SearchApp = () => {
|
||||
<i className="fas fa-search text-6xl text-blue-200 mb-4"></i>
|
||||
<h3 className="text-xl font-medium text-gray-900 mb-2">Start Searching</h3>
|
||||
<p className="text-gray-600 max-w-md mx-auto">
|
||||
Enter a search query to find links, pages, and posts.
|
||||
Enter a search query to find links, pages, posts, and bookmarks.
|
||||
You can search by title, content, URL, or tags.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
@@ -83,6 +83,14 @@
|
||||
{% trans "Pages" %}
|
||||
</div>
|
||||
</a>
|
||||
<a href="{% url 'bookmark-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-5-7 5V5z"/>
|
||||
</svg>
|
||||
{% trans "Bookmarks" %}
|
||||
</div>
|
||||
</a>
|
||||
<a href="{% url 'post-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Integration tests for the Bookmarks REST API (/api/bookmarks)."""
|
||||
|
||||
import pytest
|
||||
|
||||
# a minimal full-export object as produced by x-bookmarks-exporter
|
||||
SAMPLE_EXPORT = {
|
||||
"id": "1234567890",
|
||||
"url": "https://x.com/user/status/1234567890",
|
||||
"author": {
|
||||
"screen_name": "testuser",
|
||||
"name": "Test User",
|
||||
"profile_image_url": "https://example.com/avatar.jpg",
|
||||
},
|
||||
"text": "Hello world",
|
||||
"media": [{"type": "photo", "media_url": "https://example.com/p.jpg",
|
||||
"media_large": "https://example.com/p.jpg"}],
|
||||
"card": None,
|
||||
"quoted": None,
|
||||
"retweet": None,
|
||||
"favorite_count": 7, "view_count": 21, "bookmark_count": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBookmarksAPI:
|
||||
BASE = "/api/bookmarks"
|
||||
|
||||
def test_list_empty(self, api_client):
|
||||
r = api_client.get(f"{self.BASE}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["count"] == 0
|
||||
|
||||
def test_create_then_list_and_detail(self, api_client):
|
||||
r = api_client.post(self.BASE, {
|
||||
"tweet_id": "111",
|
||||
"url": "https://x.com/u/status/111",
|
||||
"author_screen_name": "u",
|
||||
"text": "hi", "has_photo": True,
|
||||
}, format="json")
|
||||
assert r.status_code == 201, r.content
|
||||
created = r.json()
|
||||
assert created["tweet_id"] == "111"
|
||||
|
||||
list_r = api_client.get(self.BASE)
|
||||
assert list_r.json()["count"] == 1
|
||||
|
||||
det = api_client.get(f"{self.BASE}/{created['id']}")
|
||||
assert det.status_code == 200
|
||||
assert det.json()["has_photo"] is True
|
||||
|
||||
def test_filter_has_video(self, api_client):
|
||||
api_client.post(self.BASE, {"tweet_id": "v1", "has_video": True}, format="json")
|
||||
api_client.post(self.BASE, {"tweet_id": "p1", "has_photo": True}, format="json")
|
||||
r = api_client.get(f"{self.BASE}", {"has_video": "true"})
|
||||
assert r.json()["count"] == 1
|
||||
assert r.json()["results"][0]["tweet_id"] == "v1"
|
||||
|
||||
def test_search_q(self, api_client):
|
||||
api_client.post(self.BASE, {"tweet_id": "a1", "text": "python tips"}, format="json")
|
||||
api_client.post(self.BASE, {"tweet_id": "a2", "text": "rust tricks"}, format="json")
|
||||
r = api_client.get(f"{self.BASE}", {"q": "python"})
|
||||
assert r.json()["count"] == 1
|
||||
assert r.json()["results"][0]["tweet_id"] == "a1"
|
||||
|
||||
def test_update_summary(self, api_client):
|
||||
created = api_client.post(self.BASE, {"tweet_id": "u1"}, format="json").json()
|
||||
r = api_client.patch(f"{self.BASE}/{created['id']}", {"summary": "AI summary"},
|
||||
format="json")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["summary"] == "AI summary"
|
||||
|
||||
def test_delete(self, api_client):
|
||||
created = api_client.post(self.BASE, {"tweet_id": "d1"}, format="json").json()
|
||||
r = api_client.delete(f"{self.BASE}/{created['id']}")
|
||||
assert r.status_code == 204
|
||||
|
||||
def test_bulk_import_upserts(self, api_client):
|
||||
r = api_client.post(f"{self.BASE}/import",
|
||||
{"bookmarks": [SAMPLE_EXPORT]},
|
||||
format="json")
|
||||
assert r.status_code == 200, r.content
|
||||
assert r.json()["created"] == 1
|
||||
# second import updates, not duplicates
|
||||
r2 = api_client.post(f"{self.BASE}/import",
|
||||
{"bookmarks": [
|
||||
{**SAMPLE_EXPORT, "text": "updated"}]},
|
||||
format="json")
|
||||
assert r2.json()["updated"] == 1
|
||||
assert r2.json()["created"] == 0
|
||||
from links.models import Bookmark
|
||||
assert Bookmark.objects.count() == 1
|
||||
assert Bookmark.objects.first().text == "updated"
|
||||
|
||||
def test_bulk_upsert_array(self, api_client):
|
||||
r = api_client.post(f"{self.BASE}/bulk",
|
||||
[{"tweet_id": "b1", "text": "one"},
|
||||
{"tweet_id": "b2", "text": "two"}],
|
||||
format="json")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["created"] == 2
|
||||
r2 = api_client.post(f"{self.BASE}/bulk",
|
||||
[{"tweet_id": "b1", "text": "one-edit"}],
|
||||
format="json")
|
||||
assert r2.json()["updated"] == 1
|
||||
from links.models import Bookmark
|
||||
assert Bookmark.objects.get(tweet_id="b1").text == "one-edit"
|
||||
|
||||
def test_stats(self, api_client):
|
||||
api_client.post(self.BASE, {"tweet_id": "s1", "has_video": True}, format="json")
|
||||
api_client.post(self.BASE, {"tweet_id": "s2", "has_photo": True}, format="json")
|
||||
r = api_client.get(f"{self.BASE}/stats")
|
||||
assert r.status_code == 200
|
||||
s = r.json()
|
||||
assert s["total"] == 2
|
||||
assert s["has_video"] == 1
|
||||
assert s["has_photo"] == 1
|
||||
Reference in New Issue
Block a user