// Copyright © 2026 Olaf Kolkman // SPDX-License-Identifier: GPL-3.0-or-later (() => { const entryForm = document.getElementById('entry-form'); const authRequired = document.getElementById('auth-required'); const urlInput = document.getElementById('url-input'); const titleInput = document.getElementById('title-input'); const commentInput = document.getElementById('comment-input'); const newTagsInput = document.getElementById('new-tags-input'); const existingTagsEl = document.getElementById('existing-tags'); const mastodonPublishing = document.getElementById('mastodon-publishing'); const mastodonEnabledCheckbox = document.getElementById('mastodon-enabled'); const scrapeStatus = document.getElementById('scrape-status'); const duplicateStatus = document.getElementById('duplicate-status'); const refetchTitleButton = document.getElementById('refetch-title-button'); const submitButton = document.getElementById('submit-button'); const submitStatus = document.getElementById('submit-status'); const token = localStorage.getItem('linklogAccessToken'); let availableTags = []; let selectedTags = new Set(); let currentUser = null; // Utility function to add status messages; splits on \n into real
line breaks without using innerHTML. function setStatus(statusEl, message, isError = false) { const lines = message.split('\n'); statusEl.replaceChildren( ...lines.flatMap((line, index) => ( index === 0 ? [document.createTextNode(line)] : [document.createElement('br'), document.createTextNode(line)] )), ); statusEl.className = `status ${isError ? 'error' : 'success'}`; statusEl.classList.remove('hidden'); if (!isError) { setTimeout(() => statusEl.classList.add('hidden'), 4000); } } // Check authentication if (!token) { authRequired.classList.remove('hidden'); entryForm.classList.add('hidden'); return; } // Verify token is still valid fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } }) .then((response) => { if (!response.ok) throw new Error('Not authenticated'); return response.json(); }) .then((user) => { currentUser = user; entryForm.classList.remove('hidden'); Promise.all([loadTags(), loadMastodonPublishing()]); }) .catch(() => { localStorage.removeItem('linklogAccessToken'); authRequired.classList.remove('hidden'); entryForm.classList.add('hidden'); }); // Load available tags async function loadTags() { try { const response = await fetch('/api/tags'); if (!response.ok) throw new Error('Could not load tags'); availableTags = await response.json(); renderTags(); } catch (error) { console.error('Error loading tags:', error); } } async function loadMastodonPublishing() { try { const response = await fetch('/api/user/plugins/mastodon', { headers: { Authorization: `Bearer ${token}` }, }); if (!response.ok) return; const config = await response.json(); mastodonEnabledCheckbox.checked = Boolean(config.configured); mastodonPublishing.classList.toggle('hidden', !config.configured); } catch (error) { console.error('Could not load Mastodon configuration:', error); } } // Render tag checkboxes function renderTags() { existingTagsEl.innerHTML = ''; availableTags.forEach((tag) => { const label = document.createElement('label'); label.className = 'tag-checkbox'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.value = tag; checkbox.checked = selectedTags.has(tag); checkbox.addEventListener('change', () => { if (checkbox.checked) { selectedTags.add(tag); } else { selectedTags.delete(tag); } }); label.appendChild(checkbox); label.append(` ${tag}`); existingTagsEl.appendChild(label); }); } // Strip known tracking parameters so duplicate detection and scraping ignore them, mirroring the browser extension. function removeKnownTrackingParams(urlString) { try { const url = new URL(urlString); const known = new Set([ 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id', 'utm_name', 'gclid', 'fbclid', 'dclid', 'msclkid', ]); for (const key of known) { url.searchParams.delete(key); } return url.toString(); } catch (error) { return urlString; } } // Fetch the page title for the given URL and fill it in, unless the user already typed one. let lastScrapedUrl = null; async function fetchTitle(url, { force = false } = {}) { if (!url || (!force && (titleInput.value.trim() || url === lastScrapedUrl))) return; setStatus(scrapeStatus, 'Looking up title...', false); try { const response = await fetch(`/api/scrape?url=${encodeURIComponent(url)}`, { headers: { Authorization: `Bearer ${token}` }, }); if (!response.ok) { if (response.status === 401) { localStorage.removeItem('linklogAccessToken'); location.reload(); return; } throw new Error(`HTTP ${response.status}`); } lastScrapedUrl = url; const data = await response.json(); if (data.title) { titleInput.value = data.title; setStatus(scrapeStatus, 'Title loaded!', false); } else { setStatus(scrapeStatus, 'No title found', true); } } catch (error) { console.error('Scrape error:', error); setStatus(scrapeStatus, `Error: ${error.message}`, true); } } urlInput.addEventListener('blur', async () => { await fetchTitle(removeKnownTrackingParams(urlInput.value.trim())); checkDuplicate(); }); refetchTitleButton.addEventListener('click', (e) => { e.preventDefault(); const url = removeKnownTrackingParams(urlInput.value.trim()); if (!url) { setStatus(scrapeStatus, 'Please enter a URL', true); return; } titleInput.value = ''; fetchTitle(url, { force: true }); }); // Warn when the URL/title combination already exists for this user, mirroring the browser extension. titleInput.addEventListener('blur', checkDuplicate); urlInput.addEventListener('input', () => duplicateStatus.classList.add('hidden')); titleInput.addEventListener('input', () => duplicateStatus.classList.add('hidden')); async function checkDuplicate() { const url = removeKnownTrackingParams(urlInput.value.trim()); const title = titleInput.value.trim(); if (!url || !title) return; try { const response = await fetch(`/api/links/check?${new URLSearchParams({ title, url })}`, { headers: { Authorization: `Bearer ${token}` }, }); if (!response.ok) return; const data = await response.json(); if (data.exists) { // Re-derive the match locally: browser URL normalization (e.g. trailing slashes) can // make the server's raw string comparison disagree even when the URLs are equivalent. const urlMatches = data.url_matches || (data.stored_url && removeKnownTrackingParams(data.stored_url) === url); let message = 'This link already exists. '; if (!urlMatches) { message += 'But, the stored link has a different URL (missing or different parameters).'; message += '\nWhen you save the entry you risk a duplicate entry.'; } else { message += '\nYou can still save the entry, which will update the existing entry\'s comment and or tags.'; } setStatus(duplicateStatus, message, true); } else { duplicateStatus.classList.add('hidden'); } } catch (error) { // Duplicate checking is advisory; submission remains available. } } // Handle form submission entryForm.addEventListener('submit', async (e) => { e.preventDefault(); const url = removeKnownTrackingParams(urlInput.value.trim()); const title = titleInput.value.trim(); const comment = commentInput.value.trim(); const newTags = newTagsInput.value.trim(); if (!url || !title) { setStatus(submitStatus, 'URL and title are required', true); return; } // Combine selected tags and new tags const tags = Array.from(selectedTags); if (newTags) { const newTagsList = newTags .split(',') .map((t) => t.trim()) .filter((t) => t); tags.push(...newTagsList); } submitButton.disabled = true; setStatus(submitStatus, 'Saving...', false); try { const response = await fetch('/api/links', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ title, url, comment, tags, post_to_mastodon: mastodonEnabledCheckbox.checked, }), }); if (!response.ok) { if (response.status === 401) { localStorage.removeItem('linklogAccessToken'); location.reload(); return; } const error = await response.json().catch(() => ({})); throw new Error(error.detail || `HTTP ${response.status}`); } const data = await response.json(); setStatus(submitStatus, `Link saved to LinkLog${data.duplicate ? ' (updated)' : ''}!`, false); // Redirect to user page after 1 second if (currentUser) { setTimeout(() => { window.location.href = `/${encodeURIComponent(currentUser.username)}/`; }, 1000); } } catch (error) { console.error('Submit error:', error); setStatus(submitStatus, `Error: ${error.message}`, true); } finally { submitButton.disabled = false; } }); })();