## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later from datetime import datetime, timezone import json from uuid import uuid4 from backend.app.core.security import clean_url from backend.app.database import get_connection MAX_TAGS = 10 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], user_id: str | None = None) -> list[str]: 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, created_by) VALUES (?, ?, ?)', (str(uuid4()), tag, user_id), ) 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()), 'user_id': user_id, 'title': title, 'url': cleaned_url, 'comment': comment, 'timestamp': timestamp or created_at, 'created_at': created_at, 'updated_at': created_at, 'is_public': 1, } with get_connection() as conn: conn.execute( ''' INSERT INTO links (id, user_id, title, url, comment, timestamp, created_at, updated_at, is_public) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ''', ( record['id'], record['user_id'], record['title'], record['url'], record['comment'], record['timestamp'], record['created_at'], record['updated_at'], record['is_public'], ), ) stored_tags = save_link_tags(conn, record['id'], normalized_tags, user_id=user_id) conn.commit() record['tags'] = stored_tags return record def find_owned_link_by_title_url(user_id: str, title: str, url: str) -> dict | None: cleaned_url = clean_url(url) with get_connection() as conn: row = conn.execute( 'SELECT * FROM links WHERE user_id = ? AND title = ? AND url = ? ORDER BY created_at DESC LIMIT 1', (user_id, title, cleaned_url), ).fetchone() if row is None: return None record = dict(row) record['tags'] = get_link_tags(conn, record['id']) return record def list_public_links(username: str | None = None): with get_connection() as conn: rows = conn.execute( ''' 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 ''', (username, username), ).fetchall() records = [dict(row) for row in rows] for record in records: record['tags'] = get_link_tags(conn, record['id']) return records def get_public_profile(username: str) -> dict | None: with get_connection() as conn: row = conn.execute( 'SELECT username, avatar_url, bio FROM users WHERE username = ?', (username,), ).fetchone() return dict(row) if row else None 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( ''' UPDATE links SET title = ?, url = ?, comment = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? ''', (title, cleaned_url, comment, link_id, user_id), ) 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, user_id=user_id) conn.commit() row = conn.execute('SELECT * FROM links WHERE id = ?', (link_id,)).fetchone() record = dict(row) record['tags'] = stored_tags return record def delete_link(link_id: str, user_id: str) -> bool: with get_connection() as conn: cursor = conn.execute( 'DELETE FROM links WHERE id = ? AND user_id = ?', (link_id, user_id), ) conn.commit() return cursor.rowcount > 0 def get_owned_link(link_id: str, user_id: str) -> dict | None: with get_connection() as conn: row = conn.execute( 'SELECT * FROM links WHERE id = ? AND user_id = ?', (link_id, user_id), ).fetchone() return dict(row) if row else None def mark_mastodon_posted(link_id: str, user_id: str, post_id: str | None) -> bool: with get_connection() as conn: current = conn.execute( 'SELECT mastodon_post_ids FROM links WHERE id = ? AND user_id = ?', (link_id, user_id), ).fetchone() post_ids = json.loads(current['mastodon_post_ids']) if current and current['mastodon_post_ids'] else [] if post_id and post_id not in post_ids: post_ids.append(post_id) cursor = conn.execute( '''UPDATE links SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_post_ids = ?, mastodon_posted_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?''', (post_id, json.dumps(post_ids), link_id, user_id), ) conn.commit() return cursor.rowcount > 0 def list_public_users(): with get_connection() as conn: rows = conn.execute( '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 def list_user_labels(user_id: str): with get_connection() as conn: rows = conn.execute( ''' SELECT tags.id, tags.name, tags.created_by, users.username AS creator FROM tags LEFT JOIN users ON users.id = tags.created_by WHERE tags.created_by IS NULL OR tags.created_by = ? OR users.is_admin = 1 ORDER BY tags.name ''', (user_id,), ).fetchall() return [dict(row) for row in rows] def create_label(user_id: str, name: str): normalized = normalize_tags([name]) label = normalized[0] if normalized else '' if not label: raise ValueError('Label cannot be empty') with get_connection() as conn: existing = conn.execute( 'SELECT id FROM tags WHERE lower(name) = lower(?)', (label,) ).fetchone() if existing: raise ValueError('Label already exists') label_id = str(uuid4()) conn.execute( 'INSERT INTO tags (id, name, created_by) VALUES (?, ?, ?)', (label_id, label, user_id), ) conn.commit() return {'id': label_id, 'name': label, 'created_by': user_id} def update_label(label_id: str, user_id: str | None = None, name: str = '', is_admin: bool = False): normalized = normalize_tags([name]) if not normalized: raise ValueError('Label cannot be empty') with get_connection() as conn: current = conn.execute( 'SELECT id, name, created_by FROM tags WHERE id = ?', (label_id,) ).fetchone() if current is None or (not is_admin and current['created_by'] != user_id): return None duplicate = conn.execute( 'SELECT id FROM tags WHERE lower(name) = lower(?) AND id != ?', (normalized[0], label_id), ).fetchone() if duplicate: raise ValueError('Label already exists') conn.execute('UPDATE tags SET name = ? WHERE id = ?', (normalized[0], label_id)) conn.commit() return {'id': label_id, 'name': normalized[0], 'created_by': current['created_by']} def delete_label(label_id: str, user_id: str | None = None, is_admin: bool = False): with get_connection() as conn: current = conn.execute( 'SELECT id, created_by FROM tags WHERE id = ?', (label_id,) ).fetchone() if current is None or (not is_admin and current['created_by'] != user_id): return False conn.execute('DELETE FROM tags WHERE id = ?', (label_id,)) conn.commit() return True