From 6ff90aae9a8e70d8bc7b0e72805e290baa842469 Mon Sep 17 00:00:00 2001 From: Kolkman Date: Sat, 5 Sep 2026 08:31:23 +0200 Subject: [PATCH] Rudimentary search --- VIBE/CHAT_LOG.md | 8 ++++ VIBE/PROMPTS.md | 1 + backend/tests/test_api.py | 6 +++ frontend/static/feed.js | 74 ++++++++++++++++++++++++++++++++++++ frontend/static/style.css | 9 +++++ frontend/templates/feed.html | 6 ++- 6 files changed, 103 insertions(+), 1 deletion(-) diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 1bfa563..2b21a15 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -1541,3 +1541,11 @@ Document the sql schema, include an ERD ### Assistant outcome Added `DATABASE.md` documenting the current SQLite schema at migration version 17, including tables, columns, constraints, indexes, migration history, and a Mermaid ERD. Linked it from the README and verified the focused database tests with `PYTHONPATH=. pytest backend/tests/test_database.py -q` (1 passed). + +### User +In the toolbar add a search functionality, which filters the log entries on keywords. + +use basic symbols and operators such as with google, and only 'site:' as advanced search operator + +### Assistant outcome +Added toolbar search filtering for link title, URL, comment, username, and tags. The query supports implicit AND terms, quoted phrases, `OR`, `-` exclusions, and the `site:` host operator; other advanced operators are treated as ordinary text. Search preferences persist with the existing feed cookie and the rendered-feed regression assertions cover the implementation. JavaScript syntax validation passed; the API suite had 35 passing tests and one unrelated existing Mastodon timestamp assertion expecting text without `UTC`. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index 139b819..2482d34 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -182,6 +182,7 @@ ## 2026-09-05 177. Document the sql schema, include an ERD +178. In the toolbar add a search functionality, which filters the log entries on keywords. use basic symbols and operators such as with google, and only 'site:' as advanced search operator 177. The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out. 178. The authenticated session text and sign-in block are still shown together. 179. Use the VIBE directory to log interactions. diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 1c440bf..afa4a32 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -786,6 +786,7 @@ def test_public_and_admin_pages_render_html(): assert 'id="auth-profile-link" class="hidden"' in root_page.text assert 'id="auth-admin-link" class="hidden"' in root_page.text assert '') assert 'logout.js?v=3' in root_page.text assert client.get('/alice').status_code == 200 @@ -829,6 +830,11 @@ def test_public_and_admin_pages_render_html(): assert 'class="panel-toggle-btn"' in profile_page assert 'profile.js?v=6' in profile_page feed_script = client.get('/static/feed.js?v=7').text + assert 'function parseSearchQuery(value)' in feed_script + assert 'site:(\\S+)' in feed_script + assert 'token.toUpperCase() === \'OR\'' in feed_script + assert 'token.startsWith(\'-\')' in feed_script + assert 'matchesSearch(item, pref.search || \'\')' in feed_script assert 'if (item.is_owner && !showIdentity)' in feed_script assert 'deleteEntry(item, deleteButton)' in feed_script assert 'postToMastodon(item, mastodonButton)' in feed_script diff --git a/frontend/static/feed.js b/frontend/static/feed.js index e0fb2a6..b4cb2b9 100644 --- a/frontend/static/feed.js +++ b/frontend/static/feed.js @@ -5,6 +5,7 @@ const feedEl = document.getElementById('feed'); const sortSelect = document.getElementById('sort-select'); const userFilter = document.getElementById('user-filter'); const tagFilter = document.getElementById('tag-filter'); +const searchInput = document.getElementById('search-input'); const cookieName = 'linklog-feed-preferences'; const accessToken = localStorage.getItem('linklogAccessToken'); @@ -51,6 +52,70 @@ function writePreferences(pref) { document.cookie = `${cookieName}=${value}; path=/; max-age=31536000`; } +function tokenizeSearch(value) { + const tokens = []; + const pattern = /"([^"]+)"|(\S+)/g; + let match; + while ((match = pattern.exec(value)) !== null) { + tokens.push(match[1] || match[2]); + } + return tokens; +} + +function parseSearchQuery(value) { + const groups = [[]]; + const excluded = []; + const sites = []; + + tokenizeSearch(value).forEach((token) => { + if (token.toUpperCase() === 'OR') { + groups.push([]); + return; + } + const isExcluded = token.startsWith('-') && token.length > 1; + const term = isExcluded ? token.slice(1) : token; + const siteMatch = term.match(/^site:(\S+)$/i); + if (siteMatch && !isExcluded) { + sites.push(siteMatch[1].toLowerCase()); + return; + } + if (isExcluded) { + excluded.push(term.toLowerCase()); + } else { + groups[groups.length - 1].push(term.toLowerCase()); + } + }); + + return {groups: groups.filter((group) => group.length), excluded, sites}; +} + +function searchableText(item) { + return [ + item.title, + item.url, + item.comment, + item.user?.username, + ...(item.tags || []), + ].filter(Boolean).join(' ').toLowerCase(); +} + +function matchesSearch(item, query) { + if (!query.trim()) return true; + const parsed = parseSearchQuery(query); + const text = searchableText(item); + const matchesSite = parsed.sites.every((site) => { + try { + const hostname = new URL(item.url).hostname.toLowerCase(); + return hostname === site || hostname.endsWith(`.${site}`); + } catch (error) { + return false; + } + }); + const matchesGroup = !parsed.groups.length || parsed.groups.some((group) => group.every((term) => text.includes(term))); + const excludesTerm = parsed.excluded.some((term) => text.includes(term)); + return matchesSite && matchesGroup && !excludesTerm; +} + function formatDate(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) return value || 'updated recently'; @@ -278,6 +343,8 @@ async function loadFeed(selectedTag = null) { items = items.filter((item) => item.tags?.some((tag) => tag.toLowerCase() === activeTag.toLowerCase())); } + items = items.filter((item) => matchesSearch(item, pref.search || '')); + if (pref.sort === 'oldest') { items = [...items].reverse(); } @@ -290,6 +357,7 @@ function syncPreferences() { sortSelect.value = pref.sort; userFilter.value = pref.user; tagFilter.value = pref.tag || ''; + searchInput.value = pref.search || ''; sortSelect.addEventListener('change', (event) => { const next = { ...readPreferences(), sort: event.target.value }; @@ -309,6 +377,12 @@ function syncPreferences() { writePreferences(next); loadFeed(event.target.value); }); + + searchInput.addEventListener('input', (event) => { + const next = { ...readPreferences(), search: event.target.value }; + writePreferences(next); + loadFeed(); + }); } syncPreferences(); diff --git a/frontend/static/style.css b/frontend/static/style.css index 8bbe94d..a183d21 100644 --- a/frontend/static/style.css +++ b/frontend/static/style.css @@ -221,11 +221,20 @@ body::selection { font-size: 0.68rem; } +.header-tools .toolbar .search-control { + flex-basis: 180px; +} + .header-tools .toolbar select { min-width: 0; padding: 6px 8px; } +.header-tools .toolbar input { + min-width: 0; + padding: 6px 8px; +} + .header-actions { display: flex; flex: 0 0 auto; diff --git a/frontend/templates/feed.html b/frontend/templates/feed.html index 53966bc..593c864 100644 --- a/frontend/templates/feed.html +++ b/frontend/templates/feed.html @@ -38,6 +38,10 @@
+