Search user's Linkwarden bookmarks across multiple search engines
// ==UserScript== // @name Linkwarden Search // @namespace https://mjyai.com // @version 1.3.0 // @description Search user's Linkwarden bookmarks across multiple search engines // @author MA Junyi // @match https://www.google.com/search* // @match https://www.bing.com/search* // @match https://duckduckgo.com/* // @match https://www.baidu.com/s* // @match https://search.brave.com/search* // @match https://yandex.com/search* // @match https://presearch.com/search* // @match *://*/search* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @connect cloud.linkwarden.app // @license GPL-3.0 // ==/UserScript== (function () { 'use strict'; const ITEMS_PER_PAGE = 10; let currentPage = 1; let totalItems = []; const styles = ` :root { --md-primary: #1976d2; --md-primary-dark: #1565c0; --md-surface: #ffffff; --md-on-surface: #1f1f1f; --md-outline: rgba(0, 0, 0, 0.12); --md-text-primary: rgba(0, 0, 0, 0.87); --md-text-secondary: rgba(0, 0, 0, 0.6); --md-text-disabled: rgba(0, 0, 0, 0.38); --md-hover-overlay: rgba(0, 0, 0, 0.04); --md-shadow-1: 0 2px 4px -1px rgba(0,0,0,.2), 0 4px 5px 0 rgba(0,0,0,.14), 0 1px 10px 0 rgba(0,0,0,.12); --md-shadow-2: 0 5px 5px -3px rgba(0,0,0,.2), 0 8px 10px 1px rgba(0,0,0,.14), 0 3px 14px 2px rgba(0,0,0,.12); } @media (prefers-color-scheme: dark) { :root { --md-primary: #90caf9; --md-primary-dark: #82b1ff; --md-surface: #1e1e1e; --md-on-surface: #ffffff; --md-outline: rgba(255, 255, 255, 0.12); --md-text-primary: rgba(255, 255, 255, 0.87); --md-text-secondary: rgba(255, 255, 255, 0.6); --md-text-disabled: rgba(255, 255, 255, 0.38); --md-hover-overlay: rgba(255, 255, 255, 0.04); --md-shadow-1: 0 2px 4px -1px rgba(0,0,0,.4), 0 4px 5px 0 rgba(0,0,0,.34), 0 1px 10px 0 rgba(0,0,0,.32); --md-shadow-2: 0 5px 5px -3px rgba(0,0,0,.4), 0 8px 10px 1px rgba(0,0,0,.34), 0 3px 14px 2px rgba(0,0,0,.32); } } #linkwarden-panel { position: fixed !important; top: 100px !important; right: 20px !important; width: 360px !important; min-height: 100px !important; max-height: 80vh !important; background: var(--md-surface) !important; border-radius: 8px !important; box-shadow: var(--md-shadow-1) !important; padding: 16px !important; overflow-y: auto !important; z-index: 99999 !important; font-family: Roboto, Arial, sans-serif !important; transition: box-shadow 0.3s ease !important; opacity: 0.95 !important; color: var(--md-text-primary) !important; } #linkwarden-panel:hover { opacity: 1 !important; box-shadow: var(--md-shadow-2) !important; } #linkwarden-panel h3 { color: var(--md-on-surface) !important; font-size: 20px !important; font-weight: 500 !important; margin: 0 0 16px 0 !important; padding-right: 24px !important; } .gear-icon { position: absolute !important; top: 16px !important; right: 16px !important; cursor: pointer !important; color: var(--md-text-secondary) !important; opacity: 0.87 !important; transition: opacity 0.2s ease !important; padding: 8px !important; border-radius: 50% !important; background: transparent !important; } .gear-icon:hover { opacity: 1 !important; background: var(--md-hover-overlay) !important; } .item-div { margin-bottom: 16px !important; padding: 12px !important; border-radius: 4px !important; border: 1px solid var(--md-outline) !important; transition: all 0.2s ease !important; color: var(--md-text-primary) !important; cursor: pointer !important; text-decoration: none !important; display: block !important; } .item-div:hover { border-color: var(--md-primary) !important; box-shadow: 0 1px 3px rgba(0,0,0,0.12) !important; background: var(--md-hover-overlay) !important; } .item-div strong { display: block !important; font-size: 16px !important; color: var(--md-primary) !important; margin-bottom: 8px !important; } .item-div div:nth-child(2) { font-size: 14px !important; color: var(--md-text-primary) !important; line-height: 1.5 !important; margin-bottom: 8px !important; } .pagination { display: flex !important; justify-content: space-between !important; align-items: center !important; margin-top: 16px !important; padding: 8px 0 !important; border-top: 1px solid var(--md-outline) !important; } .pagination button { background: transparent !important; color: var(--md-primary) !important; border: none !important; padding: 8px 16px !important; border-radius: 4px !important; font-size: 14px !important; font-weight: 500 !important; text-transform: uppercase !important; cursor: pointer !important; transition: background-color 0.2s ease !important; } .pagination button:hover:not(:disabled) { background: var(--md-hover-overlay) !important; } .pagination button:disabled { color: var(--md-text-disabled) !important; cursor: default !important; } .page-info { color: var(--md-text-secondary) !important; font-size: 14px !important; } #settings-panel { position: fixed !important; top: 50% !important; left: 50% !important; transform: translate(-50%, -50%) !important; background: var(--md-surface) !important; border-radius: 8px !important; box-shadow: var(--md-shadow-2) !important; padding: 24px !important; z-index: 100001 !important; min-width: 320px !important; max-width: 400px !important; color: var(--md-text-primary) !important; } #settings-panel h3 { color: var(--md-on-surface) !important; font-size: 20px !important; font-weight: 500 !important; margin: 0 0 24px 0 !important; } #settings-panel label { color: var(--md-text-primary) !important; font-size: 14px !important; margin-bottom: 4px !important; display: block !important; } #settings-panel input { width: 100% !important; padding: 8px 12px !important; margin: 4px 0 16px 0 !important; border: 1px solid var(--md-outline) !important; border-radius: 4px !important; font-size: 16px !important; transition: border-color 0.2s ease !important; box-sizing: border-box; background: var(--md-surface) !important; color: var(--md-text-primary) !important; } #settings-panel input:focus { outline: none !important; border-color: var(--md-primary) !important; } #settings-panel button { background: var(--md-primary) !important; color: var(--md-surface) !important; border: none !important; padding: 8px 16px !important; border-radius: 4px !important; font-size: 14px !important; font-weight: 500 !important; text-transform: uppercase !important; cursor: pointer !important; margin-left: 8px !important; transition: background-color 0.2s ease !important; } #settings-panel button:hover { background: var(--md-primary-dark) !important; } #settings-panel button:first-child { margin-left: 0 !important; } #settings-panel button#closeSettings { background: transparent !important; color: var(--md-primary) !important; } #settings-panel button#closeSettings:hover { background: var(--md-hover-overlay) !important; } .checkbox-container { display: flex !important; align-items: center !important; gap: 8px !important; margin-bottom: 16px !important; padding: 8px 0 !important; } .checkbox-container input[type="checkbox"] { width: auto !important; margin: 0 !important; } .checkbox-container label { display: inline !important; margin: 0 !important; cursor: pointer !important; } `; const getQueryParameter = (param) => new URLSearchParams(window.location.search).get(param); const isSearxNG = () => { return ( document.querySelector('meta[name="generator"][content*="searxng"]') !== null || document.querySelector('a[href*="searx/preferences"]') !== null || document.querySelector('a[href*="searx/settings"]') !== null || document.querySelector('label[for="time_range"]') !== null ); }; const searchEngines = { 'google.com': { getQuery: () => getQueryParameter('q') }, 'bing.com': { getQuery: () => getQueryParameter('q') }, 'duckduckgo.com': { getQuery: () => getQueryParameter('q') }, 'baidu.com': { getQuery: () => getQueryParameter('wd') }, 'brave.com': { getQuery: () => getQueryParameter('q') }, 'yandex.com': { getQuery: () => getQueryParameter('text') }, 'presearch.com': { getQuery: () => getQueryParameter('q') } }; GM_addStyle(styles); const getCurrentSearchQuery = () => { const searxngEnabled = GM_getValue('searxngEnabled', false); if (searxngEnabled && isSearxNG()) { return getQueryParameter('q'); } const currentDomain = Object.keys(searchEngines).find(domain => window.location.hostname.includes(domain)); return currentDomain ? searchEngines[currentDomain].getQuery() : null; }; const getSettings = () => ({ baseUrl: GM_getValue('linkwardenUrl', 'https://cloud.linkwarden.app'), apiToken: GM_getValue('linkwardenApiToken', ''), searxngEnabled: GM_getValue('searxngEnabled', false) }); const saveSettings = (baseUrl, apiToken) => { GM_setValue('linkwardenUrl', baseUrl); GM_setValue('linkwardenApiToken', apiToken); GM_setValue('searxngEnabled', searxngEnabled); }; const addSettingsIcon = () => { const panel = document.getElementById('linkwarden-panel'); if (!panel) return; let gear = panel.querySelector('.gear-icon'); if (!gear) { gear = document.createElement('span'); gear.className = 'gear-icon'; gear.textContent = '⚙️'; gear.title = 'Settings'; gear.addEventListener('click', openSettingsPanel); panel.appendChild(gear); } }; const openSettingsPanel = () => { let settingsPanel = document.getElementById('settings-panel'); if (settingsPanel) return; const settings = getSettings(); settingsPanel = document.createElement('div'); settingsPanel.id = 'settings-panel'; settingsPanel.innerHTML = ` <h3>Linkwarden Settings</h3> <label for="baseUrl">Url(*):</label><br> <input type="text" id="baseUrl"><br> <label for="apiToken">API Token(*):</label><br> <input type="text" id="apiToken"><br> <div class="checkbox-container"> <label for="searxngEnabled">SearXNG Support</label> <input type="checkbox" id="searxngEnabled" ${settings.searxngEnabled ? 'checked' : ''}> </div> <button id="saveSettings">Save</button> <button id="closeSettings">Close</button> `; document.body.appendChild(settingsPanel); document.getElementById('baseUrl').value = settings.baseUrl; document.getElementById('apiToken').value = settings.apiToken; document.getElementById('searxngEnabled').checked = settings.searxngEnabled; document.getElementById('saveSettings').onclick = () => { const baseUrl = document.getElementById('baseUrl').value; const apiToken = document.getElementById('apiToken').value; const searxngEnabled = document.getElementById('searxngEnabled').checked; saveSettings(baseUrl, apiToken, searxngEnabled); alert('Settings saved!'); document.body.removeChild(settingsPanel); fetchItems(getCurrentSearchQuery()); }; document.getElementById('closeSettings').onclick = () => { document.body.removeChild(settingsPanel); }; }; const createLinkwardenPanel = () => { const panel = document.createElement('div'); panel.id = 'linkwarden-panel'; panel.innerHTML = '<h3>Linkwarden Bookmarks</h3><div id="items-content"></div>'; document.body.appendChild(panel); addSettingsIcon(); return panel; }; const displayItems = () => { let panel = document.getElementById('linkwarden-panel'); if (!panel) { panel = createLinkwardenPanel(); } const contentDiv = document.getElementById('items-content'); if (!contentDiv) { console.error('Content div not found'); return; } contentDiv.innerHTML = ''; if (totalItems.length > 0) { const startIdx = (currentPage - 1) * ITEMS_PER_PAGE; const endIdx = Math.min(startIdx + ITEMS_PER_PAGE, totalItems.length); const currentItems = totalItems.slice(startIdx, endIdx); currentItems.forEach(item => { const itemDiv = document.createElement('a'); itemDiv.className = 'item-div'; itemDiv.href = item.url; itemDiv.target = '_blank'; itemDiv.innerHTML = ` <div><strong>${item.name || 'Untitled'}</strong></div> <div>${item.description ? item.description : ''}</div> `; contentDiv.appendChild(itemDiv); }); const totalPages = Math.ceil(totalItems.length / ITEMS_PER_PAGE); const paginationDiv = document.createElement('div'); paginationDiv.className = 'pagination'; const prevButton = document.createElement('button'); prevButton.textContent = 'Previous'; prevButton.disabled = currentPage === 1; prevButton.onclick = () => { if (currentPage > 1) { currentPage--; displayItems(); } }; const nextButton = document.createElement('button'); nextButton.textContent = 'Next'; nextButton.disabled = currentPage >= totalPages; nextButton.onclick = () => { if (currentPage < totalPages) { currentPage++; displayItems(); } }; const pageInfo = document.createElement('span'); pageInfo.className = 'page-info'; pageInfo.textContent = `Page ${currentPage} of ${totalPages}`; paginationDiv.appendChild(prevButton); paginationDiv.appendChild(pageInfo); paginationDiv.appendChild(nextButton); contentDiv.appendChild(paginationDiv); } else { contentDiv.innerHTML = '<p>No bookmarks found for this query.</p>'; } }; const fetchItems = (query) => { const settings = getSettings(); if (!settings.baseUrl || !settings.apiToken) { const contentDiv = document.getElementById('items-content'); if (contentDiv) { contentDiv.innerHTML = '<p>Please configure your Linkwarden baseUrl and API token.</p>'; } openSettingsPanel(); return; } GM_xmlhttpRequest({ method: 'GET', url: `${settings.baseUrl}/api/v1/links?searchByName=true&searchByUrl=true&searchByDescription=true&searchByTextContent=true&searchByTags=true&searchQueryString=${encodeURIComponent(query)}`, headers: { 'Authorization': `Bearer ${settings.apiToken}` }, onload: function (response) { const data = JSON.parse(response.responseText); data.response.forEach(item => { totalItems.push(item); }); displayItems(); }, onerror: function (err) { console.error('Failed to fetch bookmarks', err); const contentDiv = document.getElementById('items-content'); if (contentDiv) { contentDiv.innerHTML = '<p>Failed to fetch bookmarks. Please check your settings and try again.</p>'; } } }); }; const query = getCurrentSearchQuery(); if (query) { fetchItems(query); } })();