Tag functionality added

This commit is contained in:
Olaf
2026-08-24 20:42:15 +02:00
parent f96a4ab3ad
commit 2810fd914d
16 changed files with 448 additions and 18 deletions
+16 -3
View File
@@ -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
+1
View File
@@ -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'],
+32
View File
@@ -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()
+95 -4
View File
@@ -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
+4 -1
View File
@@ -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(