Tag functionality added
This commit is contained in:
@@ -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.
|
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.
|
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:
|
Admin plugin requests must include the administrator's token:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -325,3 +325,45 @@ where the hamburger menu shows the avatar and username, replace by username only
|
|||||||
|
|
||||||
### Assistant outcome
|
### Assistant outcome
|
||||||
Replaced the hamburger menu avatar/username row with a username-only link. After authentication, the link dynamically targets `/<username>/`; 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.
|
Replaced the hamburger menu avatar/username row with a username-only link. After authentication, the link dynamically targets `/<username>/`; 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.
|
||||||
|
|||||||
@@ -57,6 +57,13 @@
|
|||||||
53. The menu should not show the profile, admin and signout items when nobody is authenticated.
|
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.
|
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
|
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
|
## Future entries
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Header, HTTPException, status
|
from fastapi import APIRouter, Header, HTTPException, status
|
||||||
from pydantic import BaseModel
|
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.plugin_manager import plugin_manager
|
||||||
from backend.app.services.token_service import validate_token
|
from backend.app.services.token_service import validate_token
|
||||||
|
|
||||||
@@ -13,12 +13,19 @@ class LinkCreate(BaseModel):
|
|||||||
url: str
|
url: str
|
||||||
comment: str = ''
|
comment: str = ''
|
||||||
timestamp: str | None = None
|
timestamp: str | None = None
|
||||||
|
tags: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class LinkUpdate(BaseModel):
|
class LinkUpdate(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
url: str
|
url: str
|
||||||
comment: str = ''
|
comment: str = ''
|
||||||
|
tags: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/tags')
|
||||||
|
def available_tags():
|
||||||
|
return list_tags()
|
||||||
|
|
||||||
|
|
||||||
@router.post('/links', status_code=status.HTTP_201_CREATED)
|
@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:
|
if info is None:
|
||||||
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
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})
|
plugin_manager.dispatch({'type': 'link_created', **record})
|
||||||
return record
|
return record
|
||||||
|
|
||||||
@@ -47,7 +57,10 @@ def update_link_endpoint(
|
|||||||
if info is None:
|
if info is None:
|
||||||
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
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:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail='Link not found or not owned by user')
|
raise HTTPException(status_code=404, detail='Link not found or not owned by user')
|
||||||
return record
|
return record
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ def public_feed(
|
|||||||
'title': item['title'],
|
'title': item['title'],
|
||||||
'url': item['url'],
|
'url': item['url'],
|
||||||
'comment': item['comment'],
|
'comment': item['comment'],
|
||||||
|
'tags': item['tags'],
|
||||||
'user': {
|
'user': {
|
||||||
'username': item['username'],
|
'username': item['username'],
|
||||||
'avatar_url': item['avatar_url'],
|
'avatar_url': item['avatar_url'],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import sqlite3
|
|||||||
import os
|
import os
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
DB_PATH = Path(os.getenv('LINKLOG_DATABASE_PATH', BASE_DIR / 'data' / 'linklog.db'))
|
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:
|
def hash_password(password: str) -> str:
|
||||||
return sha256(password.encode('utf-8')).hexdigest()
|
return sha256(password.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
DEFAULT_TAGS = ('#Internet', '#Cybersecurity', '#Fediverse', '#Food', '#Photography', '#Music', '#AI')
|
||||||
|
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
(1, '''
|
(1, '''
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -71,6 +74,26 @@ CREATE TABLE IF NOT EXISTS user_plugin_config (
|
|||||||
UNIQUE(user_id, plugin_name),
|
UNIQUE(user_id, plugin_name),
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
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()
|
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:
|
def init_db() -> None:
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
apply_migrations(conn)
|
apply_migrations(conn)
|
||||||
@@ -130,4 +161,5 @@ def init_db() -> None:
|
|||||||
''',
|
''',
|
||||||
('plugin-2', 'mastodon', '1.0.0', '{"enabled": true, "instance": "mastodon.social"}')
|
('plugin-2', 'mastodon', '1.0.0', '{"enabled": true, "instance": "mastodon.social"}')
|
||||||
)
|
)
|
||||||
|
seed_default_tags(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -4,9 +4,71 @@ from uuid import uuid4
|
|||||||
from backend.app.core.security import clean_url
|
from backend.app.core.security import clean_url
|
||||||
from backend.app.database import get_connection
|
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)
|
cleaned_url = clean_url(url)
|
||||||
|
normalized_tags = normalize_tags(tags)
|
||||||
created_at = datetime.now(timezone.utc).isoformat()
|
created_at = datetime.now(timezone.utc).isoformat()
|
||||||
record = {
|
record = {
|
||||||
'id': str(uuid4()),
|
'id': str(uuid4()),
|
||||||
@@ -37,7 +99,9 @@ def create_link(user_id: str, title: str, url: str, comment: str, timestamp: str
|
|||||||
record['is_public'],
|
record['is_public'],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
stored_tags = save_link_tags(conn, record['id'], normalized_tags)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
record['tags'] = stored_tags
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
@@ -54,11 +118,22 @@ def list_public_links(username: str | None = None):
|
|||||||
''',
|
''',
|
||||||
(username, username),
|
(username, username),
|
||||||
).fetchall()
|
).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)
|
cleaned_url = clean_url(url)
|
||||||
|
normalized_tags = normalize_tags(tags)
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
cursor = conn.execute(
|
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:
|
if cursor.rowcount == 0:
|
||||||
return None
|
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()
|
conn.commit()
|
||||||
row = conn.execute('SELECT * FROM links WHERE id = ?', (link_id,)).fetchone()
|
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():
|
def list_public_users():
|
||||||
@@ -81,3 +160,15 @@ def list_public_users():
|
|||||||
'SELECT username FROM users ORDER BY username'
|
'SELECT username FROM users ORDER BY username'
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [row['username'] for row in rows]
|
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'):
|
if post_prefix is None and config.get('hashtag'):
|
||||||
post_prefix = f'#{str(config["hashtag"]).strip().lstrip("#")} '
|
post_prefix = f'#{str(config["hashtag"]).strip().lstrip("#")} '
|
||||||
post_prefix = str(post_prefix if post_prefix is not None else DEFAULT_POST_PREFIX)
|
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:
|
try:
|
||||||
request = Request(
|
request = Request(
|
||||||
|
|||||||
@@ -111,6 +111,42 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
|
|||||||
assert 'alice' in users_response.json()
|
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():
|
def test_only_link_owner_can_edit_link():
|
||||||
owner_headers = login_headers('alice')
|
owner_headers = login_headers('alice')
|
||||||
response = client.post('/api/links', headers=owner_headers, json={
|
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-login-button" href="/login"' in root_page.text
|
||||||
assert 'id="auth-profile-link" class="hidden"' 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 '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 'logout.js?v=3' in root_page.text
|
||||||
assert client.get('/alice').status_code == 200
|
assert client.get('/alice').status_code == 200
|
||||||
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 'id="auth-home-link" href="/">Home</a>' in admin_page
|
||||||
assert '<a id="auth-username" class="user-name" href="/">' in admin_page
|
assert '<a id="auth-username" class="user-name" href="/">' in admin_page
|
||||||
assert 'admin.js?v=3' 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 '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():
|
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',
|
'title': 'A useful page',
|
||||||
'url': 'https://example.com/useful',
|
'url': 'https://example.com/useful',
|
||||||
'comment': 'Worth sharing',
|
'comment': 'Worth sharing',
|
||||||
|
'tags': ['#python', '#web'],
|
||||||
})
|
})
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
assert received['path'] == '/api/v1/statuses'
|
assert received['path'] == '/api/v1/statuses'
|
||||||
assert received['authorization'] == 'Bearer test-token'
|
assert received['authorization'] == 'Bearer test-token'
|
||||||
assert received['body'] == {
|
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:
|
finally:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import sqlite3
|
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():
|
def test_database_migrations_are_versioned_and_idempotent():
|
||||||
connection = sqlite3.connect(':memory:')
|
connection = sqlite3.connect(':memory:')
|
||||||
|
|
||||||
apply_migrations(connection)
|
apply_migrations(connection)
|
||||||
assert get_schema_version(connection) == 1
|
assert get_schema_version(connection) == 3
|
||||||
tables = {
|
tables = {
|
||||||
row[0]
|
row[0]
|
||||||
for row in connection.execute(
|
for row in connection.execute(
|
||||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
"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)
|
apply_migrations(connection)
|
||||||
assert get_schema_version(connection) == 1
|
assert get_schema_version(connection) == 3
|
||||||
|
|
||||||
connection.close()
|
connection.close()
|
||||||
+77
-3
@@ -1,9 +1,11 @@
|
|||||||
const feedEl = document.getElementById('feed');
|
const feedEl = document.getElementById('feed');
|
||||||
const sortSelect = document.getElementById('sort-select');
|
const sortSelect = document.getElementById('sort-select');
|
||||||
const userFilter = document.getElementById('user-filter');
|
const userFilter = document.getElementById('user-filter');
|
||||||
|
const tagFilter = document.getElementById('tag-filter');
|
||||||
|
|
||||||
const cookieName = 'linklog-feed-preferences';
|
const cookieName = 'linklog-feed-preferences';
|
||||||
const accessToken = localStorage.getItem('linklogAccessToken');
|
const accessToken = localStorage.getItem('linklogAccessToken');
|
||||||
|
let availableTags = [];
|
||||||
|
|
||||||
function createAvatar(user) {
|
function createAvatar(user) {
|
||||||
const avatar = document.createElement('div');
|
const avatar = document.createElement('div');
|
||||||
@@ -46,6 +48,18 @@ function writePreferences(pref) {
|
|||||||
document.cookie = `${cookieName}=${value}; path=/; max-age=31536000`;
|
document.cookie = `${cookieName}=${value}; path=/; max-age=31536000`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value || 'updated recently';
|
||||||
|
const months = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()} ${months[date.getMonth()]} ${date.getDate()} - ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
const response = await fetch('/api/public/users');
|
const response = await fetch('/api/public/users');
|
||||||
if (!response.ok) throw new Error('Could not load users');
|
if (!response.ok) throw new Error('Could not load users');
|
||||||
@@ -55,6 +69,16 @@ async function loadUsers() {
|
|||||||
userFilter.value = users.includes(selectedUser) ? selectedUser : '';
|
userFilter.value = users.includes(selectedUser) ? selectedUser : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadTags() {
|
||||||
|
const response = await fetch('/api/tags');
|
||||||
|
if (!response.ok) throw new Error('Could not load tags');
|
||||||
|
const tags = await response.json();
|
||||||
|
availableTags = tags;
|
||||||
|
const selectedTag = readPreferences().tag || '';
|
||||||
|
tagFilter.replaceChildren(new Option('All tags', ''), ...tags.map((tag) => new Option(tag, tag)));
|
||||||
|
tagFilter.value = tags.includes(selectedTag) ? selectedTag : '';
|
||||||
|
}
|
||||||
|
|
||||||
function renderFeed(items, showIdentity = true) {
|
function renderFeed(items, showIdentity = true) {
|
||||||
feedEl.innerHTML = '';
|
feedEl.innerHTML = '';
|
||||||
|
|
||||||
@@ -79,9 +103,16 @@ function renderFeed(items, showIdentity = true) {
|
|||||||
comment.className = 'comment';
|
comment.className = 'comment';
|
||||||
comment.textContent = item.comment || 'No comment provided';
|
comment.textContent = item.comment || 'No comment provided';
|
||||||
|
|
||||||
|
if (item.tags?.length) {
|
||||||
|
const tags = document.createElement('div');
|
||||||
|
tags.className = 'entry-tags';
|
||||||
|
tags.textContent = item.tags.join(' ');
|
||||||
|
article.appendChild(tags);
|
||||||
|
}
|
||||||
|
|
||||||
const meta = document.createElement('div');
|
const meta = document.createElement('div');
|
||||||
meta.className = 'meta';
|
meta.className = 'meta';
|
||||||
meta.textContent = item.created_at || 'updated recently';
|
meta.textContent = formatDate(item.created_at);
|
||||||
|
|
||||||
if (item.is_owner) {
|
if (item.is_owner) {
|
||||||
const editButton = document.createElement('button');
|
const editButton = document.createElement('button');
|
||||||
@@ -118,17 +149,49 @@ function showEditForm(article, item) {
|
|||||||
<label>Title <input name="title" value=""></label>
|
<label>Title <input name="title" value=""></label>
|
||||||
<label>URL <input name="url" type="url" value=""></label>
|
<label>URL <input name="url" type="url" value=""></label>
|
||||||
<label>Comment <textarea name="comment"></textarea></label>
|
<label>Comment <textarea name="comment"></textarea></label>
|
||||||
|
<fieldset class="edit-tags">
|
||||||
|
<legend>Tags</legend>
|
||||||
|
<div class="edit-tag-options"></div>
|
||||||
|
<input name="new_tags" type="text" placeholder="New tags, separated by commas">
|
||||||
|
<p class="edit-tag-status" role="alert"></p>
|
||||||
|
</fieldset>
|
||||||
<button type="submit">Save changes</button>
|
<button type="submit">Save changes</button>
|
||||||
`;
|
`;
|
||||||
form.elements.title.value = item.title || '';
|
form.elements.title.value = item.title || '';
|
||||||
form.elements.url.value = item.url || '';
|
form.elements.url.value = item.url || '';
|
||||||
form.elements.comment.value = item.comment || '';
|
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) => {
|
form.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
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)}`, {
|
const response = await fetch(`/api/links/${encodeURIComponent(item.id)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`},
|
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();
|
if (response.ok) loadFeed();
|
||||||
});
|
});
|
||||||
@@ -152,6 +215,10 @@ async function loadFeed() {
|
|||||||
items = items.filter((item) => (item.user?.username || '').toLowerCase() === pref.user.toLowerCase());
|
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') {
|
if (pref.sort === 'oldest') {
|
||||||
items = [...items].reverse();
|
items = [...items].reverse();
|
||||||
}
|
}
|
||||||
@@ -163,6 +230,7 @@ function syncPreferences() {
|
|||||||
const pref = readPreferences();
|
const pref = readPreferences();
|
||||||
sortSelect.value = pref.sort;
|
sortSelect.value = pref.sort;
|
||||||
userFilter.value = pref.user;
|
userFilter.value = pref.user;
|
||||||
|
tagFilter.value = pref.tag || '';
|
||||||
|
|
||||||
sortSelect.addEventListener('change', (event) => {
|
sortSelect.addEventListener('change', (event) => {
|
||||||
const next = { ...readPreferences(), sort: event.target.value };
|
const next = { ...readPreferences(), sort: event.target.value };
|
||||||
@@ -176,7 +244,13 @@ function syncPreferences() {
|
|||||||
writePreferences(next);
|
writePreferences(next);
|
||||||
window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : '/');
|
window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : '/');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tagFilter.addEventListener('change', (event) => {
|
||||||
|
const next = { ...readPreferences(), tag: event.target.value };
|
||||||
|
writePreferences(next);
|
||||||
|
loadFeed();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
syncPreferences();
|
syncPreferences();
|
||||||
loadUsers().then(loadFeed).catch(() => loadFeed());
|
Promise.all([loadUsers(), loadTags()]).then(loadFeed).catch(() => loadFeed());
|
||||||
|
|||||||
@@ -398,6 +398,41 @@ button:disabled {
|
|||||||
border-top: 1px solid var(--border);
|
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 {
|
.feed {
|
||||||
padding-bottom: 40px;
|
padding-bottom: 40px;
|
||||||
}
|
}
|
||||||
@@ -474,6 +509,13 @@ button:disabled {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-tags {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--mauve);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.container {
|
.container {
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
|
|||||||
@@ -61,6 +61,12 @@
|
|||||||
<option value="">All users</option>
|
<option value="">All users</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Tag filter
|
||||||
|
<select id="tag-filter">
|
||||||
|
<option value="">All tags</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="feed" class="feed" aria-live="polite"></section>
|
<section id="feed" class="feed" aria-live="polite"></section>
|
||||||
@@ -68,6 +74,6 @@
|
|||||||
|
|
||||||
<script src="/static/auth-header.js?v=3"></script>
|
<script src="/static/auth-header.js?v=3"></script>
|
||||||
<script src="/static/logout.js?v=3"></script>
|
<script src="/static/logout.js?v=3"></script>
|
||||||
<script src="/static/feed.js?v=5"></script>
|
<script src="/static/feed.js?v=7"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -43,6 +43,33 @@ textarea {
|
|||||||
resize: vertical;
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
<textarea id="comment" name="comment" rows="3" placeholder="Your comment"></textarea>
|
<textarea id="comment" name="comment" rows="3" placeholder="Your comment"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Tags</legend>
|
||||||
|
<div id="existing-tags" class="tag-options">Loading tags...</div>
|
||||||
|
<input id="new-tags" type="text" pattern="#[^, ]+(,\s*#[^, ]+)*" placeholder="#new-tag, #another-tag" />
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" id="submit-link">Save link</button>
|
<button type="submit" id="submit-link">Save link</button>
|
||||||
<button type="button" id="open-settings" class="secondary">Settings</button>
|
<button type="button" id="open-settings" class="secondary">Settings</button>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const titleInput = document.getElementById('title');
|
|||||||
const urlInput = document.getElementById('url');
|
const urlInput = document.getElementById('url');
|
||||||
const commentInput = document.getElementById('comment');
|
const commentInput = document.getElementById('comment');
|
||||||
const openSettingsButton = document.getElementById('open-settings');
|
const openSettingsButton = document.getElementById('open-settings');
|
||||||
|
const existingTags = document.getElementById('existing-tags');
|
||||||
|
const newTagsInput = document.getElementById('new-tags');
|
||||||
|
|
||||||
function setStatus(message, isError = false) {
|
function setStatus(message, isError = false) {
|
||||||
statusEl.textContent = message;
|
statusEl.textContent = message;
|
||||||
@@ -21,6 +23,36 @@ async function getSettings() {
|
|||||||
return result;
|
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) {
|
function removeKnownTrackingParams(urlString) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(urlString);
|
const url = new URL(urlString);
|
||||||
@@ -72,6 +104,7 @@ async function handleSubmit(event) {
|
|||||||
url: removeKnownTrackingParams(urlInput.value),
|
url: removeKnownTrackingParams(urlInput.value),
|
||||||
comment: commentInput.value,
|
comment: commentInput.value,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
tags: getTags(),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,3 +127,4 @@ async function handleSubmit(event) {
|
|||||||
openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
|
openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
|
||||||
form.addEventListener('submit', handleSubmit);
|
form.addEventListener('submit', handleSubmit);
|
||||||
populateCurrentTab();
|
populateCurrentTab();
|
||||||
|
loadExistingTags();
|
||||||
|
|||||||
Reference in New Issue
Block a user