Pagination added and search and filtering moved to backend
Build LinkLog Development Image / development-image (push) Successful in 22s

This commit is contained in:
2026-09-06 10:01:09 +02:00
parent 9c0416f4bb
commit 71f4451b34
16 changed files with 361 additions and 124 deletions
+1 -1
View File
@@ -187,4 +187,4 @@ def post_link_to_mastodon(
@router.get('/links')
def list_links():
return list_public_links()
return list_public_links()['items']
+28 -4
View File
@@ -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,
}
+19
View File
@@ -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
View File
@@ -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},
)
+77 -5
View File
@@ -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:
+2
View File
@@ -11,6 +11,8 @@ TEST_DATABASE_DIRECTORY = tempfile.TemporaryDirectory(prefix='linklog-tests-')
TEST_DATABASE_PATH = os.path.join(TEST_DATABASE_DIRECTORY.name, 'linklog.db')
os.environ['LINKLOG_DATABASE_PATH'] = TEST_DATABASE_PATH
os.environ['LINKLOG_DATA_ENCRYPTION_KEY'] = 'L5M4sQYVjD1N7pT2Xk8R0aBcDeFgHiJkLmNoPqRsTuV='
# Use a large page size so existing tests that expect the full feed keep working.
os.environ['LINKLOG_FRONTEND_LOADPOSTS'] = '1000'
@pytest.fixture(scope='session', autouse=True)
+13 -16
View File
@@ -607,7 +607,7 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
data = response.json()
assert data['url'] == 'https://example.com/path?keep=yes'
feed = client.get('/api/public/feed').json()
feed = client.get('/api/public/feed').json()['items']
matching = next(item for item in feed if item['id'] == data['id'])
assert matching['comment'] == 'Interesting read'
assert matching['user']['username'] == 'alice'
@@ -615,8 +615,8 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
filtered_response = client.get('/api/public/feed/alice')
assert filtered_response.status_code == 200
assert all(item['user']['username'] == 'alice' for item in filtered_response.json())
assert client.get('/api/public/feed/does-not-exist').json() == []
assert all(item['user']['username'] == 'alice' for item in filtered_response.json()['items'])
assert client.get('/api/public/feed/does-not-exist').json()['items'] == []
users_response = client.get('/api/public/users')
assert users_response.status_code == 200
assert 'alice' in users_response.json()
@@ -645,7 +645,7 @@ def test_duplicate_link_updates_comment_tags_and_retriggers_plugins():
dispatch.assert_called_once()
assert dispatch.call_args.args[0]['comment'] == 'updated comment'
assert dispatch.call_args.args[0]['tags'] == ['#Second']
feed_item = next(item for item in client.get('/api/public/feed').json() if item['id'] == first.json()['id'])
feed_item = next(item for item in client.get('/api/public/feed').json()['items'] if item['id'] == first.json()['id'])
assert feed_item['comment'] == 'updated comment'
assert feed_item['tags'] == ['#Second']
@@ -689,7 +689,7 @@ def test_links_support_tags_and_tag_filtering():
assert {
'#Internet', '#Cybersecurity', '#Fediverse', '#Food', '#Photography', '#Music', '#AI'
} <= set(tags)
tagged_feed = client.get('/api/public/feed').json()
tagged_feed = client.get('/api/public/feed').json()['items']
tagged_item = next(item for item in tagged_feed if item['id'] == link_id)
assert {tag.casefold() for tag in tagged_item['tags']} == {'#casepreserved', '#web'}
@@ -725,12 +725,12 @@ def test_only_link_owner_can_edit_link():
})
assert unauthenticated.status_code == 401
anonymous_feed = client.get('/api/public/feed').json()
anonymous_feed = client.get('/api/public/feed').json()['items']
anonymous_link = next(item for item in anonymous_feed if item['id'] == link_id)
assert anonymous_link['is_owner'] is False
assert anonymous_link['can_edit'] is False
owner_feed = client.get('/api/public/feed', headers=owner_headers).json()
owner_feed = client.get('/api/public/feed', headers=owner_headers).json()['items']
owner_link = next(item for item in owner_feed if item['id'] == link_id)
assert owner_link['is_owner'] is True
assert owner_link['can_edit'] is True
@@ -830,17 +830,14 @@ def test_public_and_admin_pages_render_html():
assert 'class="panel-toggle-btn"' in profile_page
assert 'profile.js?v=6' in profile_page
feed_script = client.get('/static/feed.js?v=7').text
assert 'function parseSearchQuery(value)' in feed_script
assert 'site:(\\S+)' in feed_script
assert 'token.toUpperCase() === \'OR\'' in feed_script
assert 'token.startsWith(\'-\')' in feed_script
assert 'matchesSearch(item, pref.search || \'\')' in feed_script
assert 'function loadFeed()' in feed_script
assert 'function renderPagination(page, totalPages)' in feed_script
assert 'if (item.is_owner && !showIdentity)' in feed_script
assert 'deleteEntry(item, deleteButton)' in feed_script
assert 'postToMastodon(item, mastodonButton)' in feed_script
assert 'tag.toLowerCase() === activeTag.toLowerCase()' in feed_script
assert 'loadFeed(event.target.value)' in feed_script
assert 'Promise.all([loadUsers(), loadTags()]).then(() => loadFeed())' in feed_script
assert "params.set('tag', pref.tag)" in feed_script
assert 'currentPage = 1' in feed_script
assert "Promise.all([loadUsers(), loadTags()])" in feed_script
assert "fetch('/api/tags', {" in feed_script
assert 'Authorization: `Bearer ${accessToken}`' in feed_script
assert 'return `${date.getFullYear()} ${months[date.getMonth()]} ${date.getDate()} - ${hours}:${minutes}`' in feed_script
@@ -900,7 +897,7 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
assert received['authorization'] == 'Bearer test-token'
assert received['content_type'] == 'application/x-www-form-urlencoded'
assert received['body'] == {'status': ['From my #LinkLog:\n\nA useful page\n\nWorth sharing\n\nLogged on 2026 August 29 - 21:10 UTC from: https://example.com/useful\n\n#python #web']}
posted_item = next(item for item in client.get('/api/public/feed/alice', headers=headers).json() if item['id'] == response.json()['id'])
posted_item = next(item for item in client.get('/api/public/feed/alice', headers=headers).json()['items'] if item['id'] == response.json()['id'])
assert posted_item['mastodon_posted'] is True
finally:
server.shutdown()