// Copyright © 2026 Olaf Kolkman // SPDX-License-Identifier: GPL-3.0-or-later const statusEl = document.getElementById('status'); const form = document.getElementById('link-form'); 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'); const feedLink = document.getElementById('feed-link'); const authWarning = document.getElementById('auth-warning'); const warningSettingsButton = document.getElementById('warning-settings'); const authSession = document.getElementById('auth-session'); const refetchTitleButton = document.getElementById('refetch-title'); const characterCountEl = document.getElementById('character-count'); const sessionStore = browser.storage.session; const t = window.linklogI18n; let maxPostCharacters = 500; let mastodonPostPrefix = 'From my #LinkLog: '; function normalizeBackendOrigin(backendUrl) { try { const url = new URL(backendUrl); if (!['http:', 'https:'].includes(url.protocol)) { return null; } return url.origin; } catch (error) { return null; } } async function hasBackendPermission(backendUrl) { const origin = normalizeBackendOrigin(backendUrl); if (!origin) return false; return browser.permissions.contains({origins: [`${origin}/*`]}); } // Splits on \n into real
line breaks without using innerHTML. function setStatus(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.classList.remove('hidden'); statusEl.classList.toggle('error', isError); statusEl.classList.toggle('success', !isError); } async function getSettings() { const [settings, sessionSettings] = await Promise.all([ browser.storage.local.get(['backendUrl', 'username']), sessionStore.get(['accessToken', 'refreshToken', 'tokenExpiresAt', 'deviceId']), ]); return {...settings, ...sessionSettings}; } async function persistSession(settings) { await sessionStore.set({ accessToken: settings.accessToken, refreshToken: settings.refreshToken, tokenExpiresAt: settings.tokenExpiresAt, deviceId: settings.deviceId, }); } async function clearSession() { await Promise.all([ sessionStore.remove(['accessToken', 'refreshToken', 'tokenExpiresAt', 'deviceId']), browser.storage.local.remove(['accessToken', 'tokenType', 'tokenExpiresAt', 'refreshToken', 'deviceId']), ]); } async function refreshAccessToken(settings) { if (!settings.backendUrl || !settings.refreshToken || !settings.deviceId) return null; try { const response = await fetch(`${settings.backendUrl}/api/auth/refresh`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({refresh_token: settings.refreshToken, device_id: settings.deviceId}), }); if (!response.ok) { await clearSession(); return null; } const data = await response.json(); const refreshed = {...settings, accessToken: data.access_token, refreshToken: data.refresh_token, tokenExpiresAt: data.expires_at, deviceId: data.device_id || settings.deviceId}; await persistSession(refreshed); return refreshed; } catch (error) { return null; } } async function validateSession(settings) { if (!settings.backendUrl || !settings.accessToken || !(await hasBackendPermission(settings.backendUrl))) return null; try { const response = await fetch(`${settings.backendUrl}/api/auth/me`, { headers: {'Authorization': `Bearer ${settings.accessToken}`}, }); if (!response.ok) { if (response.status === 401) { const refreshed = await refreshAccessToken(settings); if (refreshed) { const retry = await fetch(`${refreshed.backendUrl}/api/auth/me`, { headers: {'Authorization': `Bearer ${refreshed.accessToken}`}, }); if (retry.ok) return await retry.json(); } } await clearSession(); return null; } return await response.json(); } catch (error) { return null; } } function showSignedOutState() { form.classList.add('hidden'); authSession.hidden = true; authSession.classList.add('hidden'); authWarning.hidden = false; authWarning.classList.remove('hidden'); } function showSignedInState(user, backendUrl) { authWarning.hidden = true; authWarning.classList.add('hidden'); authSession.textContent = t('loggedInAt', [user.username || user.email, backendUrl]); authSession.hidden = false; authSession.classList.remove('hidden'); form.classList.remove('hidden'); } function showSavedState(message) { document.querySelector('header').classList.add('hidden'); document.querySelector('.extension-footer').classList.add('hidden'); authSession.hidden = true; authSession.classList.add('hidden'); authWarning.hidden = true; authWarning.classList.add('hidden'); form.classList.add('hidden'); statusEl.textContent = message; statusEl.classList.remove('hidden', 'error'); statusEl.classList.add('success'); } async function updateFeedLink() { const settings = await getSettings(); if (!settings.backendUrl || !settings.username || !settings.accessToken) return; try { const backend = new URL(settings.backendUrl); feedLink.href = `${backend.origin}/${encodeURIComponent(settings.username)}/`; feedLink.classList.remove('hidden'); } catch (error) { feedLink.classList.add('hidden'); } } async function loadExistingTags() { const settings = await getSettings(); if (!settings.backendUrl || !settings.accessToken) { showSignedOutState(); return; } const user = await validateSession(settings); if (!user) { showSignedOutState(); return; } const currentSettings = await getSettings(); showSignedInState(user, currentSettings.backendUrl); const response = await fetch(`${currentSettings.backendUrl}/api/tags`, { headers: {'Authorization': `Bearer ${currentSettings.accessToken}`}, }); if (!response.ok) { if (response.status === 401) { showSignedOutState(); return; } existingTags.textContent = t('loadTagsFailed'); 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; checkbox.addEventListener('change', updateCharacterCount); label.append(checkbox, document.createTextNode(` ${tag}`)); return label; })); updateCharacterCount(); } 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); } // Load public app settings (e.g. the Mastodon post length limit) once a backend is configured. async function loadConfig() { const settings = await getSettings(); if (!settings.backendUrl || !(await hasBackendPermission(settings.backendUrl))) return; try { const response = await fetch(`${settings.backendUrl}/api/public/config`); if (!response.ok) return; const config = await response.json(); if (config.max_post_characters) maxPostCharacters = config.max_post_characters; } catch (error) { // Character-count warning is advisory; ignore config load failures. } updateCharacterCount(); } // Load the user's configured Mastodon post prefix, so the estimated post length matches the server. async function loadMastodonPrefix() { const settings = await getSettings(); if (!settings.backendUrl || !settings.accessToken || !(await hasBackendPermission(settings.backendUrl))) return; try { const response = await fetch(`${settings.backendUrl}/api/user/plugins/mastodon`, { headers: {'Authorization': `Bearer ${settings.accessToken}`}, }); if (!response.ok) return; const config = await response.json(); if (config.post_prefix) { mastodonPostPrefix = config.post_prefix; } else if (config.hashtag) { mastodonPostPrefix = `#${String(config.hashtag).trim().replace(/^#/, '')} `; } } catch (error) { // Character-count warning is advisory; ignore config load failures. } updateCharacterCount(); } // Format a UTC timestamp the same way the Mastodon plugin does, so the estimated post length matches the server. function formatMastodonTimestamp(date) { const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const pad = (value) => String(value).padStart(2, '0'); return `${date.getUTCFullYear()} ${months[date.getUTCMonth()]} ${pad(date.getUTCDate())} - ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`; } // Estimate the assembled Mastodon post length, mirroring backend/app/services/plugin_manager.py. function estimatePostLength() { const title = titleInput.value.trim(); const comment = commentInput.value.trim(); const url = removeKnownTrackingParams(urlInput.value.trim()); const parts = [mastodonPostPrefix.trim()]; if (title) parts.push(title); if (comment) parts.push(comment); if (title) parts.push(`Logged on ${formatMastodonTimestamp(new Date())} UTC from: ${url}`); const tags = getTags(); if (tags.length) parts.push(tags.join(' ')); return parts.join('\n\n').length; } function updateCharacterCount() { const length = estimatePostLength(); const overLimit = length > maxPostCharacters; characterCountEl.textContent = overLimit ? t('characterCountOverLimit', [String(length), String(maxPostCharacters), String(length - maxPostCharacters)]) : t('characterCount', [String(length), String(maxPostCharacters)]); characterCountEl.classList.toggle('over-limit', overLimit); } [titleInput, urlInput, commentInput, newTagsInput].forEach((input) => { input.addEventListener('input', updateCharacterCount); }); 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; } } async function populateCurrentTab() { const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); if (!tab) return; titleInput.value = tab.title || ''; urlInput.value = tab.url || ''; } async function checkExistingLink() { const settings = await getSettings(); if (!settings.backendUrl || !settings.accessToken || !(await hasBackendPermission(settings.backendUrl)) || !titleInput.value || !urlInput.value) return; try { const url = removeKnownTrackingParams(urlInput.value); const response = await fetch(`${settings.backendUrl}/api/links/check?${new URLSearchParams({ title: titleInput.value, url, })}`, { headers: {'Authorization': `Bearer ${settings.accessToken}`}, }); 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); const message = urlMatches ? t('duplicateLinkSameUrl') : t('duplicateLinkDifferentUrl'); setStatus(message, true); } } catch (error) { // Duplicate checking is advisory; submission remains available. } } // Re-fetch the title from the backend scraper, for when the URL was edited after the initial tab-based fill. let lastScrapedUrl = null; async function fetchTitle(url, { force = false } = {}) { const settings = await getSettings(); if (!settings.backendUrl || !settings.accessToken || !(await hasBackendPermission(settings.backendUrl))) return; if (!url || (!force && (titleInput.value.trim() || url === lastScrapedUrl))) return; try { const response = await fetch(`${settings.backendUrl}/api/scrape?url=${encodeURIComponent(url)}`, { headers: {'Authorization': `Bearer ${settings.accessToken}`}, }); if (!response.ok) { if (force) setStatus(t('titleFetchFailed'), true); return; } lastScrapedUrl = url; const data = await response.json(); if (data.title) { titleInput.value = data.title; } else if (force) { setStatus(t('titleFetchFailed'), true); } } catch (error) { if (force) setStatus(t('titleFetchFailed'), true); } } async function handleSubmit(event) { event.preventDefault(); setStatus(t('submitting'), false); const settings = await getSettings(); const token = settings.accessToken; const backendUrl = settings.backendUrl; if (!token || !backendUrl || !(await hasBackendPermission(backendUrl))) { setStatus(t('configureAndLogIn'), true); browser.runtime.openOptionsPage(); return; } if (!await validateSession(settings)) { showSignedOutState(); setStatus(t('sessionExpired'), true); browser.runtime.openOptionsPage(); return; } const currentSettings = await getSettings(); try { const response = await fetch(`${currentSettings.backendUrl}/api/links`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${currentSettings.accessToken}`, }, body: JSON.stringify({ title: titleInput.value, url: removeKnownTrackingParams(urlInput.value), comment: commentInput.value, timestamp: new Date().toISOString(), tags: getTags(), }) }); if (response.status === 401) { showSignedOutState(); setStatus(t('sessionExpired'), true); browser.runtime.openOptionsPage(); return; } if (!response.ok) { throw new Error(t('submissionFailed')); } const result = await response.json(); const saveMessage = result.duplicate ? t('linkAlreadyExists') : t('linkSaved', currentSettings.backendUrl); if (result.plugin_errors?.length) { const errors = result.plugin_errors.map((error) => `${error.plugin}: ${error.reason}`).join(' '); showSavedState(`${saveMessage} ${t('publishingErrors', errors)}`); return; } showSavedState(saveMessage); } catch (error) { setStatus(t('submissionFailedConnection'), true); } } openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage()); warningSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage()); form.addEventListener('submit', handleSubmit); urlInput.addEventListener('blur', async () => { await fetchTitle(removeKnownTrackingParams(urlInput.value.trim())); checkExistingLink(); }); titleInput.addEventListener('blur', checkExistingLink); refetchTitleButton.addEventListener('click', (e) => { e.preventDefault(); const url = removeKnownTrackingParams(urlInput.value.trim()); if (!url) { setStatus(t('titleFetchFailed'), true); return; } titleInput.value = ''; fetchTitle(url, { force: true }); }); populateCurrentTab().then(checkExistingLink).then(updateCharacterCount); loadExistingTags(); updateFeedLink(); loadConfig(); loadMastodonPrefix();