diff --git a/README.md b/README.md index f77598c..912de90 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,10 @@ GET http://localhost:8000/api/public/feed When a request includes a valid bearer token, entries owned by that authenticated user include edit permission and show an inline **Edit** action in the feed. The update endpoint is `PUT /api/links/{link_id}` and rejects edits from other users. Feed items also expose `is_owner`; it is `true` only for entries owned by the authenticated user and `false` for anonymous viewers or other users. +Links support zero to ten tags. Tags are trimmed, deduplicated case-insensitively, and retain their original casing for display. The Firefox capture popup shows existing server tags as checkboxes and accepts new comma-separated tags. The home page displays tags and provides a case-insensitive tag filter; editing a link replaces its complete tag set. +The inline link editor also allows multiple existing tags to be selected and new tags to be entered. New tag values receive a leading `#` automatically, and the interface prevents saving more than ten tags. +The installation seeds these available tags: `#Internet`, `#Cybersecurity`, `#Fediverse`, `#Food`, `#Photography`, `#Music`, and `#AI`. + Admin plugin requests must include the administrator's token: ```sh diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 866d8fd..2081f6a 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -325,3 +325,45 @@ where the hamburger menu shows the avatar and username, replace by username only ### Assistant outcome Replaced the hamburger menu avatar/username row with a username-only link. After authentication, the link dynamically targets `//`; the separate Profile, Admin, and Sign out menu items retain their session-based visibility. Removed the old menu avatar dependency, bumped the shared header asset to `v3`, and verified all pages plus the strict backend suite with 12 tests. + +### User +Change both the plugin and the backed to allow a logged link to be tagged witn zero or more tags (10 max). In the pluggin tags can be selected from existing tags and new ones can be created. Add the ability to sort on tags on the front page. + +### Assistant outcome +Added schema migration 2 with normalized `tags` and `link_tags` tables, tag-aware link creation/editing with a ten-tag limit, existing-tag API support, Firefox popup checkboxes plus new comma-separated tags, and a front-page tag filter with tag display. Full strict backend validation passed with 13 tests and browser scripts passed syntax checks. + +### User +The seed list is: #Internet #Cybersecurity #Fediverse #Food #Photography #Music #AI + +### Assistant outcome +Added the seven requested hashtag-prefixed tags to installation seeding using idempotent inserts, verified them through the initialized `/api/tags` endpoint, and preserved the versioned migration flow. Focused tag/migration/Mastodon tests passed. + +### User +The seed list is: #Internet, #Cybersecurity, #Fediverse, #Food, #Photography, #Music, #AI + +### Assistant outcome +Confirmed that all seven requested hashtag-prefixed tags are already seeded idempotently in `database.py` and present in the initialized database. Focused migration, tag lifecycle, and Mastodon tests passed. + +### User +On the home page allow filtering on tags + +### Assistant outcome +Confirmed and strengthened the existing home-page tag filter. The page renders a tag dropdown populated from `/api/tags`, filters feed entries using their tags, persists the selection in feed preferences, and displays seeded tags. Bumped the feed asset to `v6`; the strict backend suite passed with 13 tests. + +### User +when editing lables I want to be able to selec multiple tags or add one myself. + +### Assistant outcome +Extended the inline link editor with checkboxes for multiple existing tags and a comma-separated new-tag field. New tags receive a leading `#`, duplicates are removed, and the interface displays an error when more than 10 tags are selected. Bumped the feed asset to `v7`; the strict backend suite passed with 13 tests. + +### User +Make the tags filtering case incensitive (but maintain case of tags) + +### Assistant outcome +Updated tag handling to preserve entered casing while comparing tag identity case-insensitively. Existing tags are reused regardless of case, duplicate tag variants are collapsed, the frontend filter compares case-insensitively, and API responses return the canonical stored spelling. The strict backend suite passed after the change. + +### User +Display the date as "2026 June 18 - 20:22" + +### Assistant outcome +Updated feed metadata rendering to format timestamps as `YYYY Month D - HH:mm`, such as `2026 June 18 - 20:22`, using zero-padded 24-hour time. Invalid timestamps retain the existing fallback text. The feed script and strict backend suite passed with 13 tests. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index a6623da..a2c0def 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -57,6 +57,13 @@ 53. The menu should not show the profile, admin and signout items when nobody is authenticated. 54. Add one entry on top of the hamburger menu 'home' that always directs to the home page. 55. where the hamburger menu shows the avatar and username, replace by username only and link to the /user/ page +56. Change both the plugin and the backed to allow a logged link to be tagged witn zero or more tags (10 max). In the pluggin tags can be selected from existing tags and new ones can be created. Add the ability to sort on tags on the front page. +57. The seed list is: #Internet #Cybersecurity #Fediverse #Food #Photography #Music #AI +58. The seed list is: #Internet, #Cybersecurity, #Fediverse, #Food, #Photography, #Music, #AI +59. On the home page allow filtering on tags +60. when editing lables I want to be able to selec multiple tags or add one myself. +61. Make the tags filtering case incensitive (but maintain case of tags) +62. Display the date as "2026 June 18 - 20:22" ## Future entries diff --git a/backend/app/api/links.py b/backend/app/api/links.py index 3c38d95..2c7e7ac 100644 --- a/backend/app/api/links.py +++ b/backend/app/api/links.py @@ -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 diff --git a/backend/app/api/public.py b/backend/app/api/public.py index 4e03f6a..ccba0f1 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -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'], diff --git a/backend/app/database.py b/backend/app/database.py index 3b48b8b..9efd838 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/services/link_service.py b/backend/app/services/link_service.py index a6a9ab4..fbb452d 100644 --- a/backend/app/services/link_service.py +++ b/backend/app/services/link_service.py @@ -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 diff --git a/backend/app/services/plugin_manager.py b/backend/app/services/plugin_manager.py index d479a09..0f86be7 100644 --- a/backend/app/services/plugin_manager.py +++ b/backend/app/services/plugin_manager.py @@ -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( diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 95cd9c3..3be18cc 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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 ' +
+ Tags +
+ + +
`; form.elements.title.value = item.title || ''; form.elements.url.value = item.url || ''; form.elements.comment.value = item.comment || ''; + const tagOptions = form.querySelector('.edit-tag-options'); + tagOptions.replaceChildren(...availableTags.map((tag) => { + const label = document.createElement('label'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = tag; + checkbox.checked = item.tags?.includes(tag) || false; + label.append(checkbox, document.createTextNode(` ${tag}`)); + return label; + })); form.addEventListener('submit', async (event) => { event.preventDefault(); + const selectedTags = [...tagOptions.querySelectorAll('input:checked')].map((input) => input.value); + const newTags = form.elements.new_tags.value + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean) + .map((tag) => tag.startsWith('#') ? tag : `#${tag}`); + const tags = [...new Set([...selectedTags, ...newTags])]; + if (tags.length > 10) { + form.querySelector('.edit-tag-status').textContent = 'A link can have at most 10 tags.'; + return; + } const response = await fetch(`/api/links/${encodeURIComponent(item.id)}`, { method: 'PUT', headers: {'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`}, - body: JSON.stringify(Object.fromEntries(new FormData(form))), + body: JSON.stringify({ + title: form.elements.title.value, + url: form.elements.url.value, + comment: form.elements.comment.value, + tags, + }), }); if (response.ok) loadFeed(); }); @@ -152,6 +215,10 @@ async function loadFeed() { items = items.filter((item) => (item.user?.username || '').toLowerCase() === pref.user.toLowerCase()); } + if (pref.tag) { + items = items.filter((item) => item.tags?.some((tag) => tag.toLowerCase() === pref.tag.toLowerCase())); + } + if (pref.sort === 'oldest') { items = [...items].reverse(); } @@ -163,6 +230,7 @@ function syncPreferences() { const pref = readPreferences(); sortSelect.value = pref.sort; userFilter.value = pref.user; + tagFilter.value = pref.tag || ''; sortSelect.addEventListener('change', (event) => { const next = { ...readPreferences(), sort: event.target.value }; @@ -176,7 +244,13 @@ function syncPreferences() { writePreferences(next); window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : '/'); }); + + tagFilter.addEventListener('change', (event) => { + const next = { ...readPreferences(), tag: event.target.value }; + writePreferences(next); + loadFeed(); + }); } syncPreferences(); -loadUsers().then(loadFeed).catch(() => loadFeed()); +Promise.all([loadUsers(), loadTags()]).then(loadFeed).catch(() => loadFeed()); diff --git a/frontend/static/style.css b/frontend/static/style.css index cec3175..5ba9dd1 100644 --- a/frontend/static/style.css +++ b/frontend/static/style.css @@ -398,6 +398,41 @@ button:disabled { border-top: 1px solid var(--border); } +.edit-tags { + display: grid; + gap: 8px; + margin: 0; + padding: 10px; + border: 1px solid var(--border); + border-radius: 8px; +} + +.edit-tag-options { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; +} + +.edit-tag-options label { + display: flex; + align-items: center; + gap: 4px; + color: var(--subtext); + font-size: 0.85rem; +} + +.edit-tag-options input { + width: auto; + min-width: 0; +} + +.edit-tag-status { + min-height: 1.25em; + margin: 0; + color: var(--red); + font-size: 0.82rem; +} + .feed { padding-bottom: 40px; } @@ -474,6 +509,13 @@ button:disabled { font-size: 0.82rem; } +.entry-tags { + margin-top: 12px; + color: var(--mauve); + font-size: 0.85rem; + font-weight: 600; +} + @media (max-width: 600px) { .container { padding: 0 14px; diff --git a/frontend/templates/feed.html b/frontend/templates/feed.html index b0dbd15..1c630ee 100644 --- a/frontend/templates/feed.html +++ b/frontend/templates/feed.html @@ -61,6 +61,12 @@ +
@@ -68,6 +74,6 @@ - + diff --git a/webextension/popup.css b/webextension/popup.css index 46d9d33..210754e 100644 --- a/webextension/popup.css +++ b/webextension/popup.css @@ -43,6 +43,33 @@ textarea { resize: vertical; } +fieldset { + display: grid; + gap: 8px; + margin: 0; + padding: 10px; + border: 1px solid #cbd5e1; + border-radius: 8px; +} + +legend { + padding: 0 4px; + font-weight: 600; +} + +.tag-options { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; +} + +.tag-options label { + display: flex; + align-items: center; + gap: 4px; + font-weight: 400; +} + .actions { display: flex; gap: 8px; diff --git a/webextension/popup.html b/webextension/popup.html index 4a56cd8..9d28005 100644 --- a/webextension/popup.html +++ b/webextension/popup.html @@ -29,6 +29,12 @@ +
+ Tags +
Loading tags...
+ +
+
diff --git a/webextension/popup.js b/webextension/popup.js index ddde677..c8f6187 100644 --- a/webextension/popup.js +++ b/webextension/popup.js @@ -4,6 +4,8 @@ const titleInput = document.getElementById('title'); const urlInput = document.getElementById('url'); const commentInput = document.getElementById('comment'); const openSettingsButton = document.getElementById('open-settings'); +const existingTags = document.getElementById('existing-tags'); +const newTagsInput = document.getElementById('new-tags'); function setStatus(message, isError = false) { statusEl.textContent = message; @@ -21,6 +23,36 @@ async function getSettings() { return result; } +async function loadExistingTags() { + const settings = await getSettings(); + if (!settings.backendUrl || !settings.accessToken) { + existingTags.textContent = 'Sign in to select existing tags.'; + return; + } + const response = await fetch(`${settings.backendUrl}/api/tags`, { + headers: {'Authorization': `Bearer ${settings.accessToken}`}, + }); + if (!response.ok) { + existingTags.textContent = 'Could not load existing tags.'; + return; + } + const tags = await response.json(); + existingTags.replaceChildren(...tags.map((tag) => { + const label = document.createElement('label'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = tag; + label.append(checkbox, document.createTextNode(` ${tag}`)); + return label; + })); +} + +function getTags() { + const selected = [...existingTags.querySelectorAll('input:checked')].map((input) => input.value); + const newTags = newTagsInput.value.split(',').map((tag) => tag.trim()).filter((tag) => tag.startsWith('#')); + return [...new Set([...selected, ...newTags])].slice(0, 10); +} + function removeKnownTrackingParams(urlString) { try { const url = new URL(urlString); @@ -72,6 +104,7 @@ async function handleSubmit(event) { url: removeKnownTrackingParams(urlInput.value), comment: commentInput.value, timestamp: new Date().toISOString(), + tags: getTags(), }) }); @@ -94,3 +127,4 @@ async function handleSubmit(event) { openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage()); form.addEventListener('submit', handleSubmit); populateCurrentTab(); +loadExistingTags();