Pagination added and search and filtering moved to backend
Build LinkLog Development Image / development-image (push) Successful in 22s
Build LinkLog Development Image / development-image (push) Successful in 22s
This commit is contained in:
@@ -187,4 +187,4 @@ def post_link_to_mastodon(
|
||||
|
||||
@router.get('/links')
|
||||
def list_links():
|
||||
return list_public_links()
|
||||
return list_public_links()['items']
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
## Copyright © 2026 Olaf Kolkman
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.services.link_service import list_public_links, list_public_users
|
||||
from backend.app.services.token_service import validate_token
|
||||
from backend.app.services.theme_service import THEMES, get_enabled_themes
|
||||
@@ -23,10 +26,20 @@ def public_themes():
|
||||
return [{'id': theme, **THEMES[theme]} for theme in enabled]
|
||||
|
||||
|
||||
@router.get('/config')
|
||||
def public_config():
|
||||
return {'feed_page_sizes': settings.feed_page_sizes, 'default_page_size': settings.feed_page_sizes[0]}
|
||||
|
||||
|
||||
@router.get('/feed')
|
||||
@router.get('/feed/{username}')
|
||||
def public_feed(
|
||||
username: str | None = None,
|
||||
tag: str | None = None,
|
||||
search: str | None = None,
|
||||
sort: str = 'newest',
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int | None = Query(None, ge=1),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||
):
|
||||
current_user_id = None
|
||||
@@ -34,8 +47,10 @@ def public_feed(
|
||||
token_data = validate_token(credentials.credentials)
|
||||
if token_data:
|
||||
current_user_id = token_data['user_id']
|
||||
items = list_public_links(username)
|
||||
return [
|
||||
allowed_sizes = settings.feed_page_sizes
|
||||
effective_page_size = page_size if page_size in allowed_sizes else allowed_sizes[0]
|
||||
result = list_public_links(username, tag=tag, search=search, sort=sort, page=page, page_size=effective_page_size)
|
||||
items = [
|
||||
{
|
||||
'id': item['id'],
|
||||
'title': item['title'],
|
||||
@@ -52,5 +67,14 @@ def public_feed(
|
||||
'created_at': item['created_at'],
|
||||
'mastodon_posted': bool(item['mastodon_posted']),
|
||||
}
|
||||
for item in items
|
||||
for item in result['items']
|
||||
]
|
||||
total = result['total']
|
||||
total_pages = max(1, math.ceil(total / effective_page_size))
|
||||
return {
|
||||
'items': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': effective_page_size,
|
||||
'total_pages': total_pages,
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ class Settings:
|
||||
mastodon_oauth_expiry_minutes: int = int(os.getenv('LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES', '10'))
|
||||
log_level: str = os.getenv('LINKLOG_LOG_LEVEL', 'INFO').upper()
|
||||
tracking_params: list[str] = None
|
||||
feed_page_sizes: list[int] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.tracking_params is None:
|
||||
@@ -69,6 +70,24 @@ class Settings:
|
||||
if configured_params
|
||||
else default_tracking_params
|
||||
)
|
||||
if self.feed_page_sizes is None:
|
||||
default_page_sizes = [25, 100, 250]
|
||||
configured_sizes = os.getenv('LINKLOG_FRONTEND_LOADPOSTS')
|
||||
parsed_sizes = []
|
||||
if configured_sizes:
|
||||
for item in configured_sizes.split(','):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
value = int(item)
|
||||
except ValueError:
|
||||
continue
|
||||
if value > 0 and value not in parsed_sizes:
|
||||
parsed_sizes.append(value)
|
||||
if len(parsed_sizes) == 5:
|
||||
break
|
||||
self.feed_page_sizes = parsed_sizes or default_page_sizes
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
+3
-5
@@ -20,7 +20,7 @@ from backend.app.api.setup import has_administrator
|
||||
from backend.app.api.user_config import router as user_config_router
|
||||
from backend.app.core.config import settings, validate_configuration
|
||||
from backend.app.database import AVATARS_DIR
|
||||
from backend.app.services.link_service import get_public_profile, list_public_links
|
||||
from backend.app.services.link_service import get_public_profile
|
||||
|
||||
logging.basicConfig(level=getattr(logging, settings.log_level, logging.INFO))
|
||||
validate_configuration(settings)
|
||||
@@ -53,8 +53,7 @@ templates.env.globals['app_version'] = settings.version
|
||||
async def public_root(request: Request):
|
||||
if not has_administrator():
|
||||
return RedirectResponse('/setup')
|
||||
feed = list_public_links()
|
||||
return templates.TemplateResponse(request, 'feed.html', {'feed': feed})
|
||||
return templates.TemplateResponse(request, 'feed.html', {})
|
||||
|
||||
|
||||
@app.get('/admin', response_class=HTMLResponse)
|
||||
@@ -117,10 +116,9 @@ def health_check():
|
||||
@app.get('/{username}', response_class=HTMLResponse)
|
||||
@app.get('/{username}/', response_class=HTMLResponse)
|
||||
async def public_user_feed(request: Request, username: str):
|
||||
feed = list_public_links(username)
|
||||
profile = get_public_profile(username)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
'feed.html',
|
||||
{'feed': feed, 'profile': profile, 'user_filter': username},
|
||||
{'profile': profile, 'user_filter': username},
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from backend.app.core.security import clean_url
|
||||
@@ -136,23 +138,93 @@ def find_owned_link_by_title(user_id: str, title: str) -> dict | None:
|
||||
return record
|
||||
|
||||
|
||||
def list_public_links(username: str | None = None):
|
||||
def _tokenize_search(value: str) -> list[str]:
|
||||
return [group1 or group2 for group1, group2 in re.findall(r'"([^"]+)"|(\S+)', value or '')]
|
||||
|
||||
|
||||
def _parse_search_query(value: str) -> dict:
|
||||
groups = [[]]
|
||||
excluded = []
|
||||
sites = []
|
||||
for token in _tokenize_search(value):
|
||||
if token.upper() == 'OR':
|
||||
groups.append([])
|
||||
continue
|
||||
is_excluded = token.startswith('-') and len(token) > 1
|
||||
term = token[1:] if is_excluded else token
|
||||
site_match = re.match(r'^site:(\S+)$', term, re.IGNORECASE)
|
||||
if site_match and not is_excluded:
|
||||
sites.append(site_match.group(1).lower())
|
||||
continue
|
||||
if is_excluded:
|
||||
excluded.append(term.lower())
|
||||
else:
|
||||
groups[-1].append(term.lower())
|
||||
return {'groups': [group for group in groups if group], 'excluded': excluded, 'sites': sites}
|
||||
|
||||
|
||||
def _searchable_text(record: dict) -> str:
|
||||
parts = [record.get('title'), record.get('url'), record.get('comment'), record.get('username'), *(record.get('tags') or [])]
|
||||
return ' '.join(part for part in parts if part).lower()
|
||||
|
||||
|
||||
def _matches_search(record: dict, query: str) -> bool:
|
||||
if not query or not query.strip():
|
||||
return True
|
||||
parsed = _parse_search_query(query)
|
||||
text = _searchable_text(record)
|
||||
|
||||
def site_matches(site: str) -> bool:
|
||||
hostname = (urlparse(record.get('url') or '').hostname or '').lower()
|
||||
return hostname == site or hostname.endswith(f'.{site}')
|
||||
|
||||
matches_site = all(site_matches(site) for site in parsed['sites'])
|
||||
matches_group = not parsed['groups'] or any(all(term in text for term in group) for group in parsed['groups'])
|
||||
excludes_term = any(term in text for term in parsed['excluded'])
|
||||
return matches_site and matches_group and not excludes_term
|
||||
|
||||
|
||||
def list_public_links(
|
||||
username: str | None = None,
|
||||
tag: str | None = None,
|
||||
search: str | None = None,
|
||||
sort: str = 'newest',
|
||||
page: int = 1,
|
||||
page_size: int | None = None,
|
||||
) -> dict:
|
||||
order = 'ASC' if sort == 'oldest' else 'DESC'
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
'''
|
||||
f'''
|
||||
SELECT links.*, users.username, users.avatar_url, users.bio
|
||||
FROM links
|
||||
JOIN users ON users.id = links.user_id
|
||||
WHERE links.is_public = 1
|
||||
AND (? IS NULL OR users.username = ?)
|
||||
ORDER BY created_at DESC
|
||||
AND (
|
||||
? IS NULL OR links.id IN (
|
||||
SELECT link_tags.link_id FROM link_tags
|
||||
JOIN tags ON tags.id = link_tags.tag_id
|
||||
WHERE lower(tags.name) = lower(?)
|
||||
)
|
||||
)
|
||||
ORDER BY links.created_at {order}
|
||||
''',
|
||||
(username, username),
|
||||
(username, username, tag, tag),
|
||||
).fetchall()
|
||||
records = [dict(row) for row in rows]
|
||||
for record in records:
|
||||
record['tags'] = get_link_tags(conn, record['id'])
|
||||
return records
|
||||
|
||||
if search:
|
||||
records = [record for record in records if _matches_search(record, search)]
|
||||
|
||||
total = len(records)
|
||||
if page_size:
|
||||
start = max(page - 1, 0) * page_size
|
||||
records = records[start:start + page_size]
|
||||
|
||||
return {'items': records, 'total': total}
|
||||
|
||||
|
||||
def get_public_profile(username: str) -> dict | None:
|
||||
|
||||
Reference in New Issue
Block a user