Tag functionality added
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.app.services.link_service import create_link, list_public_links, update_link
|
||||
from backend.app.services.link_service import create_link, list_public_links, list_tags, update_link
|
||||
from backend.app.services.plugin_manager import plugin_manager
|
||||
from backend.app.services.token_service import validate_token
|
||||
|
||||
@@ -13,12 +13,19 @@ class LinkCreate(BaseModel):
|
||||
url: str
|
||||
comment: str = ''
|
||||
timestamp: str | None = None
|
||||
tags: list[str] = []
|
||||
|
||||
|
||||
class LinkUpdate(BaseModel):
|
||||
title: str
|
||||
url: str
|
||||
comment: str = ''
|
||||
tags: list[str] = []
|
||||
|
||||
|
||||
@router.get('/tags')
|
||||
def available_tags():
|
||||
return list_tags()
|
||||
|
||||
|
||||
@router.post('/links', status_code=status.HTTP_201_CREATED)
|
||||
@@ -30,7 +37,10 @@ def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header
|
||||
if info is None:
|
||||
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
||||
|
||||
record = create_link(info['user_id'], payload.title, payload.url, payload.comment, payload.timestamp)
|
||||
try:
|
||||
record = create_link(info['user_id'], payload.title, payload.url, payload.comment, payload.timestamp, payload.tags)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
plugin_manager.dispatch({'type': 'link_created', **record})
|
||||
return record
|
||||
|
||||
@@ -47,7 +57,10 @@ def update_link_endpoint(
|
||||
if info is None:
|
||||
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
||||
|
||||
record = update_link(link_id, info['user_id'], payload.title, payload.url, payload.comment)
|
||||
try:
|
||||
record = update_link(link_id, info['user_id'], payload.title, payload.url, payload.comment, payload.tags)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail='Link not found or not owned by user')
|
||||
return record
|
||||
|
||||
@@ -31,6 +31,7 @@ def public_feed(
|
||||
'title': item['title'],
|
||||
'url': item['url'],
|
||||
'comment': item['comment'],
|
||||
'tags': item['tags'],
|
||||
'user': {
|
||||
'username': item['username'],
|
||||
'avatar_url': item['avatar_url'],
|
||||
|
||||
@@ -2,6 +2,7 @@ import sqlite3
|
||||
import os
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DB_PATH = Path(os.getenv('LINKLOG_DATABASE_PATH', BASE_DIR / 'data' / 'linklog.db'))
|
||||
@@ -13,6 +14,8 @@ AVATARS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
def hash_password(password: str) -> str:
|
||||
return sha256(password.encode('utf-8')).hexdigest()
|
||||
|
||||
DEFAULT_TAGS = ('#Internet', '#Cybersecurity', '#Fediverse', '#Food', '#Photography', '#Music', '#AI')
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, '''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -71,6 +74,26 @@ CREATE TABLE IF NOT EXISTS user_plugin_config (
|
||||
UNIQUE(user_id, plugin_name),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
'''),
|
||||
(2, '''
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS link_tags (
|
||||
link_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
PRIMARY KEY (link_id, tag_id),
|
||||
FOREIGN KEY(link_id) REFERENCES links(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_link_tags_tag_id ON link_tags(tag_id);
|
||||
'''),
|
||||
(3, '''
|
||||
UPDATE tags SET name = '#' || name WHERE name NOT LIKE '#%';
|
||||
'''),
|
||||
]
|
||||
|
||||
@@ -96,6 +119,14 @@ def apply_migrations(conn: sqlite3.Connection) -> None:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def seed_default_tags(conn: sqlite3.Connection) -> None:
|
||||
for tag in DEFAULT_TAGS:
|
||||
conn.execute(
|
||||
'INSERT OR IGNORE INTO tags (id, name) VALUES (?, ?)',
|
||||
(str(uuid4()), tag),
|
||||
)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with get_connection() as conn:
|
||||
apply_migrations(conn)
|
||||
@@ -130,4 +161,5 @@ def init_db() -> None:
|
||||
''',
|
||||
('plugin-2', 'mastodon', '1.0.0', '{"enabled": true, "instance": "mastodon.social"}')
|
||||
)
|
||||
seed_default_tags(conn)
|
||||
conn.commit()
|
||||
|
||||
@@ -4,9 +4,71 @@ from uuid import uuid4
|
||||
from backend.app.core.security import clean_url
|
||||
from backend.app.database import get_connection
|
||||
|
||||
MAX_TAGS = 10
|
||||
|
||||
def create_link(user_id: str, title: str, url: str, comment: str, timestamp: str | None):
|
||||
|
||||
def normalize_tags(tags: list[str] | None) -> list[str]:
|
||||
normalized = []
|
||||
normalized_keys = set()
|
||||
for tag in tags or []:
|
||||
value = tag.strip()
|
||||
if value and not value.startswith('#'):
|
||||
value = f'#{value}'
|
||||
key = value.casefold()
|
||||
if value and key not in normalized_keys:
|
||||
normalized.append(value)
|
||||
normalized_keys.add(key)
|
||||
if len(normalized) > MAX_TAGS:
|
||||
raise ValueError(f'A link can have at most {MAX_TAGS} tags')
|
||||
return normalized
|
||||
|
||||
|
||||
def save_link_tags(conn, link_id: str, tags: list[str]) -> None:
|
||||
canonical_tags = []
|
||||
for tag in tags:
|
||||
tag_row = conn.execute(
|
||||
'SELECT id FROM tags WHERE lower(name) = lower(?)',
|
||||
(tag,),
|
||||
).fetchone()
|
||||
if tag_row is None:
|
||||
conn.execute(
|
||||
'INSERT INTO tags (id, name) VALUES (?, ?)',
|
||||
(str(uuid4()), tag),
|
||||
)
|
||||
tag_row = conn.execute('SELECT id FROM tags WHERE name = ?', (tag,)).fetchone()
|
||||
conn.execute(
|
||||
'INSERT OR IGNORE INTO link_tags (link_id, tag_id) VALUES (?, ?)',
|
||||
(link_id, tag_row['id']),
|
||||
)
|
||||
canonical_tags.append(conn.execute(
|
||||
'SELECT name FROM tags WHERE id = ?',
|
||||
(tag_row['id'],),
|
||||
).fetchone()['name'])
|
||||
return canonical_tags
|
||||
|
||||
|
||||
def get_link_tags(conn, link_id: str) -> list[str]:
|
||||
rows = conn.execute(
|
||||
'''
|
||||
SELECT tags.name FROM tags
|
||||
JOIN link_tags ON link_tags.tag_id = tags.id
|
||||
WHERE link_tags.link_id = ? ORDER BY tags.name
|
||||
''',
|
||||
(link_id,),
|
||||
).fetchall()
|
||||
return [row['name'] for row in rows]
|
||||
|
||||
|
||||
def create_link(
|
||||
user_id: str,
|
||||
title: str,
|
||||
url: str,
|
||||
comment: str,
|
||||
timestamp: str | None,
|
||||
tags: list[str] | None = None,
|
||||
):
|
||||
cleaned_url = clean_url(url)
|
||||
normalized_tags = normalize_tags(tags)
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
record = {
|
||||
'id': str(uuid4()),
|
||||
@@ -37,7 +99,9 @@ def create_link(user_id: str, title: str, url: str, comment: str, timestamp: str
|
||||
record['is_public'],
|
||||
),
|
||||
)
|
||||
stored_tags = save_link_tags(conn, record['id'], normalized_tags)
|
||||
conn.commit()
|
||||
record['tags'] = stored_tags
|
||||
return record
|
||||
|
||||
|
||||
@@ -54,11 +118,22 @@ def list_public_links(username: str | None = None):
|
||||
''',
|
||||
(username, username),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
records = [dict(row) for row in rows]
|
||||
for record in records:
|
||||
record['tags'] = get_link_tags(conn, record['id'])
|
||||
return records
|
||||
|
||||
|
||||
def update_link(link_id: str, user_id: str, title: str, url: str, comment: str):
|
||||
def update_link(
|
||||
link_id: str,
|
||||
user_id: str,
|
||||
title: str,
|
||||
url: str,
|
||||
comment: str,
|
||||
tags: list[str] | None = None,
|
||||
):
|
||||
cleaned_url = clean_url(url)
|
||||
normalized_tags = normalize_tags(tags)
|
||||
with get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
'''
|
||||
@@ -70,9 +145,13 @@ def update_link(link_id: str, user_id: str, title: str, url: str, comment: str):
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
return None
|
||||
conn.execute('DELETE FROM link_tags WHERE link_id = ?', (link_id,))
|
||||
stored_tags = save_link_tags(conn, link_id, normalized_tags)
|
||||
conn.commit()
|
||||
row = conn.execute('SELECT * FROM links WHERE id = ?', (link_id,)).fetchone()
|
||||
return dict(row)
|
||||
record = dict(row)
|
||||
record['tags'] = stored_tags
|
||||
return record
|
||||
|
||||
|
||||
def list_public_users():
|
||||
@@ -81,3 +160,15 @@ def list_public_users():
|
||||
'SELECT username FROM users ORDER BY username'
|
||||
).fetchall()
|
||||
return [row['username'] for row in rows]
|
||||
|
||||
|
||||
def list_tags():
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute('SELECT name FROM tags ORDER BY name').fetchall()
|
||||
tags = []
|
||||
seen = set()
|
||||
for row in rows:
|
||||
if row['name'].casefold() not in seen:
|
||||
tags.append(row['name'])
|
||||
seen.add(row['name'].casefold())
|
||||
return tags
|
||||
|
||||
@@ -56,7 +56,10 @@ class MastodonPlugin(BasePlugin):
|
||||
if post_prefix is None and config.get('hashtag'):
|
||||
post_prefix = f'#{str(config["hashtag"]).strip().lstrip("#")} '
|
||||
post_prefix = str(post_prefix if post_prefix is not None else DEFAULT_POST_PREFIX)
|
||||
status_parts.append(f'{post_prefix}{event.get("url", "")}'.strip())
|
||||
status = f'{post_prefix}{event.get("url", "")}'.strip()
|
||||
if event.get('tags'):
|
||||
status = f'{status} {" ".join(event["tags"])}'
|
||||
status_parts.append(status)
|
||||
|
||||
try:
|
||||
request = Request(
|
||||
|
||||
@@ -111,6 +111,42 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
|
||||
assert 'alice' in users_response.json()
|
||||
|
||||
|
||||
def test_links_support_tags_and_tag_filtering():
|
||||
headers = login_headers()
|
||||
response = client.post('/api/links', headers=headers, json={
|
||||
'title': 'Tagged page',
|
||||
'url': 'https://example.com/tagged',
|
||||
'tags': ['#CasePreserved', '#web', 'casepreserved'],
|
||||
})
|
||||
assert response.status_code == 201
|
||||
link_id = response.json()['id']
|
||||
assert response.json()['tags'] == ['#CasePreserved', '#web']
|
||||
|
||||
tags = client.get('/api/tags').json()
|
||||
assert any(tag.casefold() == '#casepreserved' for tag in tags) and '#web' in tags
|
||||
assert {
|
||||
'#Internet', '#Cybersecurity', '#Fediverse', '#Food', '#Photography', '#Music', '#AI'
|
||||
} <= set(tags)
|
||||
tagged_feed = client.get('/api/public/feed').json()
|
||||
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'}
|
||||
|
||||
too_many = client.post('/api/links', headers=headers, json={
|
||||
'title': 'Too many tags',
|
||||
'url': 'https://example.com/too-many',
|
||||
'tags': [f'tag-{index}' for index in range(11)],
|
||||
})
|
||||
assert too_many.status_code == 422
|
||||
|
||||
edited = client.put(f'/api/links/{link_id}', headers=headers, json={
|
||||
'title': 'Tagged page',
|
||||
'url': 'https://example.com/tagged',
|
||||
'tags': ['edited'],
|
||||
})
|
||||
assert edited.status_code == 200
|
||||
assert edited.json()['tags'] == ['#edited']
|
||||
|
||||
|
||||
def test_only_link_owner_can_edit_link():
|
||||
owner_headers = login_headers('alice')
|
||||
response = client.post('/api/links', headers=owner_headers, json={
|
||||
@@ -180,6 +216,7 @@ def test_public_and_admin_pages_render_html():
|
||||
assert 'id="auth-login-button" href="/login"' in root_page.text
|
||||
assert 'id="auth-profile-link" class="hidden"' in root_page.text
|
||||
assert 'id="auth-admin-link" class="hidden"' in root_page.text
|
||||
assert '<select id="tag-filter">' in root_page.text
|
||||
assert 'logout.js?v=3' in root_page.text
|
||||
assert client.get('/alice').status_code == 200
|
||||
assert client.get('/alice/').status_code == 200
|
||||
@@ -203,8 +240,13 @@ def test_public_and_admin_pages_render_html():
|
||||
assert 'id="auth-home-link" href="/">Home</a>' in admin_page
|
||||
assert '<a id="auth-username" class="user-name" href="/">' in admin_page
|
||||
assert 'admin.js?v=3' in admin_page
|
||||
feed_script = client.get('/static/feed.js?v=5').text
|
||||
feed_script = client.get('/static/feed.js?v=7').text
|
||||
assert 'if (item.is_owner)' in feed_script
|
||||
assert 'tag.toLowerCase() === pref.tag.toLowerCase()' in feed_script
|
||||
assert 'return `${date.getFullYear()} ${months[date.getMonth()]} ${date.getDate()} - ${hours}:${minutes}`' in feed_script
|
||||
assert 'edit-tag-options' in feed_script
|
||||
assert 'new_tags' in feed_script
|
||||
assert 'A link can have at most 10 tags.' in feed_script
|
||||
|
||||
|
||||
def test_link_submission_posts_to_enabled_mastodon_plugin():
|
||||
@@ -240,13 +282,14 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
|
||||
'title': 'A useful page',
|
||||
'url': 'https://example.com/useful',
|
||||
'comment': 'Worth sharing',
|
||||
'tags': ['#python', '#web'],
|
||||
})
|
||||
|
||||
assert response.status_code == 201
|
||||
assert received['path'] == '/api/v1/statuses'
|
||||
assert received['authorization'] == 'Bearer test-token'
|
||||
assert received['body'] == {
|
||||
'status': 'A useful page\nWorth sharing\nFrom my #LinkLog: "https://example.com/useful',
|
||||
'status': 'A useful page\nWorth sharing\nFrom my #LinkLog: "https://example.com/useful #python #web',
|
||||
}
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import sqlite3
|
||||
|
||||
from backend.app.database import apply_migrations, get_schema_version
|
||||
from backend.app.database import DEFAULT_TAGS, apply_migrations, get_schema_version, seed_default_tags
|
||||
|
||||
|
||||
def test_database_migrations_are_versioned_and_idempotent():
|
||||
connection = sqlite3.connect(':memory:')
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 1
|
||||
assert get_schema_version(connection) == 3
|
||||
tables = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
)
|
||||
}
|
||||
assert {'users', 'tokens', 'links', 'plugins', 'user_plugin_config'} <= tables
|
||||
assert {
|
||||
'users', 'tokens', 'links', 'plugins', 'user_plugin_config', 'tags', 'link_tags'
|
||||
} <= tables
|
||||
seed_default_tags(connection)
|
||||
seeded_tags = {row[0] for row in connection.execute('SELECT name FROM tags')}
|
||||
assert set(DEFAULT_TAGS) <= seeded_tags
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 1
|
||||
assert get_schema_version(connection) == 3
|
||||
|
||||
connection.close()
|
||||
Reference in New Issue
Block a user