Add bookmarks

This commit is contained in:
2026-07-30 13:25:36 +10:00
parent d5fd6b5cac
commit d857eb58c5
22 changed files with 1895 additions and 72 deletions
+28
View File
@@ -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
+2
View File
@@ -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 = [
+230
View File
@@ -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()}'))
+49
View File
@@ -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')],
},
),
]
+64
View File
@@ -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'))
+51 -1
View File
@@ -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__)
@@ -139,6 +139,39 @@ class SearchBackend:
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"""
ix = self.get_index()
@@ -204,6 +237,23 @@ class SearchBackend:
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
View File
@@ -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
View File
@@ -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
View File
@@ -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 %}
+238
View File
@@ -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 %}
+1 -1
View File
@@ -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;
+83
View File
@@ -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
View File
@@ -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'),
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+425
View File
@@ -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'
+66 -14
View File
@@ -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,34 +81,79 @@ 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>
{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>
{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}
@@ -148,8 +195,11 @@ const SearchResultItem = ({ result }) => {
))}
</div>
)}
</>
)}
</div>
{!isBookmark && (
<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/`}
@@ -159,6 +209,7 @@ const SearchResultItem = ({ result }) => {
<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>
+8
View File
@@ -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">
+116
View File
@@ -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