Skip to content

Instantly share code, notes, and snippets.

@schuhwerk
Last active June 24, 2025 22:30
Show Gist options
  • Save schuhwerk/fe21d0118d885c7697e1962a9696c3f0 to your computer and use it in GitHub Desktop.
Save schuhwerk/fe21d0118d885c7697e1962a9696c3f0 to your computer and use it in GitHub Desktop.
Search to Anki (Airtable Edition)
// ==UserScript==
// @name Search to Anki (Airtable Edition) - Incognito Aware
// @namespace https://violentmonkey.github.io/
// @version 4.72
// @description Automatically detects recurring Google/DuckDuckGo/etc searches and prompts you to save them as flashcards in an Airtable base. Does not track in Incognito/Private mode.
// @author Vitus Schuhwerk (Refactored by AI)
// @homepageURL https://gist.github.com/schuhwerk/fe21d0118d885c7697e1962a9696c3f0
// @updateURL https://gist.github.com/schuhwerk/fe21d0118d885c7697e1962a9696c3f0/raw
// @match https://www.google.com/search*
// @match https://duckduckgo.com/
// @match https://www.bing.com/search*
// @match https://search.brave.com/search*
// @match https://www.ecosia.org/search*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_info
// @connect api.airtable.com
// ==/UserScript==
(async function() {
'use strict';
// =================================================================================
// SETUP GUIDE (AIRTABLE)
// =================================================================================
//
// 1. PREREQUISITES:
// - ⚠️ Be careful who to trust with your data! This stores all your searches.
// - Violentmonkey Browser Extension (or another userscript manager).
// - An Airtable account.
//
// 2. AIRTABLE SETUP:
// a. Create a new Base in Airtable (e.g., "My Anki Cards").
// b. Inside the base, you'll have a default table. Rename it if you like (e.g., "Searches").
// c. Your table MUST have fields (columns) with these exact names and types:
// - "Question" (Field type: Single line text or Long text)
// - "Answer" (Field type: Single line text or Long text)
// - "Tags" (Field type: Single line text or Multiple select)
//
// 3. GET YOUR AIRTABLE CREDENTIALS:
// a. API Key: Go to your Airtable Account page (https://airtable.com/account) to find your API key.
// b. Base ID: Go to the API documentation for your base (https://airtable.com/api), select your base, and you'll find the ID in the introduction section (it starts with "app...").
// c. Table Name: This is simply the name of the table you created in step 2b.
//
// 4. INITIAL SCRIPT CONFIGURATION:
// - After installing this script, go to a supported search engine (like Google).
// - When you search for something repeatedly, the script will prompt you to configure it.
// - Alternatively, click the Violentmonkey extension icon, and select "⚙️ Configure Settings".
//
// =================================================================================
// --- SCRIPT CONSTANTS ---
const DEBUG = false;
// --- DEFAULT CONFIGURATION ---
const defaultConfig = {
searchRepetitionThreshold: 3,
searchPeriodInMonths: 12,
fuzzinessThreshold: 0.8,
ankiCooldownInMonths: 6,
airtableApiKey: '',
airtableBaseId: '',
airtableTableName: '',
};
let config = {};
// --- LOGGER ---
const log = (...args) => { if (DEBUG) console.log('[SearchToAnki]', ...args); };
const logError = (...args) => { console.error('[SearchToAnki]', ...args); };
// --- INCOGNITO MODE CHECK ---
const isIncognito = async () => {
// WebExtensions API (Chrome, Firefox)
if (typeof browser !== 'undefined' && browser.extension && typeof browser.extension.isAllowedIncognitoAccess === 'function') {
const hasAccess = await browser.extension.isAllowedIncognitoAccess();
// If the extension has access, we need to check if the window is actually incognito
if (hasAccess) {
return browser.extension.inIncognitoContext;
}
}
// Fallback for other userscript managers (e.g., Tampermonkey)
if (typeof GM_info !== 'undefined' && GM_info.isIncognito) {
return true;
}
return false; // If the API is blocked or absent
};
// --- CONFIGURATION LOADER & MENU ---
const loadConfig = async () => {
const savedConfig = await GM_getValue('config', {});
config = { ...defaultConfig, ...savedConfig };
log('Configuration loaded:', config);
};
const showConfigModal = async () => {
if (document.getElementById('anki-config-modal')) return;
const currentConfig = { ...defaultConfig, ...(await GM_getValue('config', {})) };
const modalHTML = `
<div id="anki-config-overlay"></div>
<div id="anki-config-modal">
<h3>⚙️ Search to Anki Configuration</h3>
<form id="anki-config-form">
<p>Enter your Airtable credentials and adjust settings.</p>
<div class="form-group">
<label for="cfg-airtableApiKey">Airtable API Key</label>
<input type="password" id="cfg-airtableApiKey" value="${currentConfig.airtableApiKey}" required>
</div>
<div class="form-group">
<label for="cfg-airtableBaseId">Airtable Base ID</label>
<input type="text" id="cfg-airtableBaseId" value="${currentConfig.airtableBaseId}" placeholder="appXXXXXXXXXXXXXX" required>
</div>
<div class="form-group">
<label for="cfg-airtableTableName">Airtable Table Name</label>
<input type="text" id="cfg-airtableTableName" value="${currentConfig.airtableTableName}" required>
</div>
<hr>
<div class="form-group">
<label for="cfg-searchRepetitionThreshold">Search Repetition Threshold</label>
<input type="number" id="cfg-searchRepetitionThreshold" value="${currentConfig.searchRepetitionThreshold}" min="1" required>
</div>
<div class="form-group">
<label for="cfg-fuzzinessThreshold">Fuzziness Threshold (0.1 - 1.0)</label>
<input type="number" id="cfg-fuzzinessThreshold" value="${currentConfig.fuzzinessThreshold}" min="0.1" max="1.0" step="0.05" required>
</div>
<div class="form-group">
<label for="cfg-ankiCooldownInMonths">Cooldown in Months</label>
<input type="number" id="cfg-ankiCooldownInMonths" value="${currentConfig.ankiCooldownInMonths}" min="0" required>
</div>
<div class="button-group">
<button type="submit" id="config-save">Save & Close</button>
<button type="button" id="config-cancel">Cancel</button>
</div>
</form>
</div>`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
const closeModal = () => {
document.getElementById('anki-config-modal')?.remove();
document.getElementById('anki-config-overlay')?.remove();
};
document.getElementById('anki-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const newConfig = {
airtableApiKey: document.getElementById('cfg-airtableApiKey').value.trim(),
airtableBaseId: document.getElementById('cfg-airtableBaseId').value.trim(),
airtableTableName: document.getElementById('cfg-airtableTableName').value.trim(),
searchRepetitionThreshold: parseInt(document.getElementById('cfg-searchRepetitionThreshold').value, 10),
fuzzinessThreshold: parseFloat(document.getElementById('cfg-fuzzinessThreshold').value),
ankiCooldownInMonths: parseInt(document.getElementById('cfg-ankiCooldownInMonths').value, 10),
};
await GM_setValue('config', newConfig);
await loadConfig();
closeModal();
});
document.getElementById('config-cancel').addEventListener('click', closeModal);
document.getElementById('anki-config-overlay').addEventListener('click', closeModal);
};
const resetConfig = async () => {
if (confirm('Are you sure you want to reset all settings? This will also clear your cooldown tracker for already-added cards.')) {
await GM_deleteValue('config');
await GM_deleteValue('addedAnkiCards');
await loadConfig();
alert('Configuration has been reset. Please reload the page.');
}
};
await loadConfig();
GM_registerMenuCommand('⚙️ Configure Settings', showConfigModal);
GM_registerMenuCommand('🔥 Reset to Defaults', resetConfig);
// --- UI STYLES ---
GM_addStyle(`
:root{--bg-color:#fff;--text-color:#24292f;--field-bg-color:#f6f8fa;--field-text-color:#24292f;--border-color:#d0d7de;--primary-color:#0969da;--primary-text-color:#fff;--shadow-color:rgba(140,149,159,.3);--success-color:#238636;--error-color:#cf222e;}
@media (prefers-color-scheme:dark){:root{--bg-color:#1c2128;--text-color:#c9d1d9;--field-bg-color:#0d1117;--field-text-color:#c9d1d9;--border-color:#30363d;--primary-color:#238636;--success-color:#3fb950;--error-color:#f85149;--shadow-color:rgba(0,0,0,.4)}}
@keyframes fadeIn{from{opacity:0;transform:scale(0.95)}to{opacity:1;transform:scale(1)}}
#anki-dialog{position:fixed;top:25px;right:25px;background-color:var(--bg-color);color:var(--text-color);border:1px solid var(--border-color);padding:1.5rem;z-index:9999;box-shadow:0 8px 24px var(--shadow-color);max-width:420px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;border-radius:12px;animation:fadeIn .2s ease-out;display:flex;flex-direction:column;gap:1rem;min-height:150px;box-sizing:border-box;}
#anki-dialog-form-content{display:flex;flex-direction:column;gap:1rem;}
#anki-dialog h3{margin:0;font-size:1.1rem;font-weight:600}
#anki-dialog ul{max-height:150px;overflow-y:auto;padding:.75rem 1rem;border:1px solid var(--border-color);background-color:var(--field-bg-color);border-radius:8px;list-style-position:inside}
#anki-dialog li b{font-weight:500}
#anki-dialog .anki-form-group{display:flex;flex-direction:column;gap:.5rem}
#anki-dialog input,#anki-dialog textarea{width:100%;padding:8px 12px;background-color:var(--field-bg-color);color:var(--field-text-color);border:1px solid var(--border-color);border-radius:6px;box-sizing:border-box;}
#anki-dialog .anki-button-group{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.5rem}
#anki-dialog button{padding:5px 12px;font-size:.85rem;font-weight:500;border:1px solid var(--border-color);border-radius:6px;cursor:pointer;background-color:var(--field-bg-color);color:var(--text-color);box-sizing:border-box;}
#anki-dialog button:disabled{opacity:0.6;cursor:not-allowed;}
#anki-dialog button#anki-yes{background-color:var(--primary-color);color:var(--primary-text-color);border-color:var(--primary-color)}
/* Feedback states */
.feedback-container{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:0.5rem;flex-grow:1;}
.feedback-container .feedback-icon{width:48px;height:48px;}
.feedback-container.success .feedback-icon{stroke:var(--success-color);}
.feedback-container p{font-size:1rem;margin:0;}
.feedback-container button{margin-top:1rem;}
/* Configuration Modal Styles... */
#anki-config-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0,0,0,0.5);z-index:10000;animation:fadeIn 0.2s linear;}
#anki-config-modal{position:fixed;top:50%;left:50%;transform:translate(-50%, -50%);background-color:var(--bg-color);color:var(--text-color);padding:2rem;border-radius:12px;box-shadow:0 8px 24px var(--shadow-color);z-index:10001;width:90%;max-width:500px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;animation:fadeIn .2s ease-out;}
#anki-config-modal h3{margin-top:0;} #anki-config-modal p{margin-top:0;margin-bottom:1.5rem;} #anki-config-modal hr{border:none;border-top:1px solid var(--border-color);margin:1.5rem 0;}
#anki-config-form .form-group{display:flex;flex-direction:column;margin-bottom:1rem;} #anki-config-form label{margin-bottom:0.5rem;font-weight:500;}
#anki-config-form input{padding:8px 12px;border:1px solid var(--border-color);border-radius:6px;background-color:var(--field-bg-color);color:var(--field-text-color);}
#anki-config-modal .button-group{display:flex;gap:1rem;justify-content:flex-end;margin-top:1.5rem;} #anki-config-modal button{padding:8px 16px;border-radius:6px;border:1px solid var(--border-color);cursor:pointer;font-weight:500;}
#anki-config-modal #config-save{background-color:var(--primary-color);color:var(--primary-text-color);border-color:var(--primary-color);}
`);
// --- CORE LOGIC & HELPER FUNCTIONS ---
const levenshtein = (a, b) => { const m = Array(b.length+1).fill(null).map(()=>Array(a.length+1).fill(null));for(let i=0;i<=a.length;i+=1){m[0][i]=i}for(let j=0;j<=b.length;j+=1){m[j][0]=j}for(let j=1;j<=b.length;j+=1){for(let i=1;i<=a.length;i+=1){const ind=a[i-1]===b[j-1]?0:1;m[j][i]=Math.min(m[j][i-1]+1,m[j-1][i]+1,m[j-1][i-1]+ind)}}return m[b.length][a.length]};
const calculateSimilarity = (a, b) => {if(!a||!b)return 0;const d=levenshtein(a.toLowerCase(),b.toLowerCase());const maxL=Math.max(a.length,b.length);if(maxL===0)return 1;return 1-(d/maxL)};
const logSearch = async (query) => {let h=await GM_getValue('searchHistory',{});const n=new Date().toISOString();const r=Object.keys(h).slice(-10);for(const p of r){if(calculateSimilarity(query,p)>0.95){h[p].push(n);await GM_setValue('searchHistory',h);return}}if(!h[query]){h[query]=[]}h[query].push(n);await GM_setValue('searchHistory',h)};
const getSimilarSearches = async (q) => {const h=await GM_getValue('searchHistory',{});const s=[];let t=[];for(const p in h){if(calculateSimilarity(q,p)>=config.fuzzinessThreshold){const ts=h[p];s.push({query:p,timestamps:ts});t=t.concat(ts)}}return{similarSearches:s,allTimestamps:t}};
const isNthSearch = (ts,n,p) => {const N=new Date();const s=new Date(N.setMonth(N.getMonth()-p));const r=ts.filter(t=>new Date(t)>=s);log(`Found ${r.length} similar searches in the last ${p} months.`);return r.length>=n};
const isRecentlyAdded = async (q) => {const c=await GM_getValue('addedAnkiCards',{});if(Object.keys(c).length===0)return!1;const N=new Date();const s=new Date(new Date().setMonth(N.getMonth()-config.ankiCooldownInMonths));for(const a in c){if(calculateSimilarity(q,a)>=config.fuzzinessThreshold){const d=new Date(c[a]);if(d>=s){log(`Query is similar to a recently added card ("${a}") from ${d.toLocaleDateString()}. Cooldown is active.`);return!0}}}return!1};
const showAnkiDialog = (currentQuery, similarSearchResults) => {
const dialogId = 'anki-dialog';
if (document.getElementById(dialogId)) return;
let pastSearchesList = similarSearchResults.map(result => `<li><b>${result.query}</b> (${result.timestamps.length} time(s))</li>`).join('');
const dialogHTML = `
<div id="${dialogId}">
<div id="anki-dialog-form-content">
<h3>You've asked similar questions...</h3>
<ul>${pastSearchesList}</ul>
<div class="anki-form-group">
<label for="anki-query">Do you want to add this to your Airtable?</label>
<input type="text" id="anki-query" placeholder="Question" value="${currentQuery}">
</div>
<div class="anki-form-group">
<label for="anki-answer">Answer:</label>
<textarea id="anki-answer" rows="4" placeholder="Answer"></textarea>
</div>
<div class="anki-button-group">
<button id="anki-yes">Yes, Add to Airtable</button>
<button id="anki-not-now">Not Now</button>
<button id="anki-dont-ask-again">Never for this search</button>
</div>
</div>
<div id="anki-dialog-feedback-content" style="display: none;"></div>
</div>`;
document.body.insertAdjacentHTML('beforeend', dialogHTML);
const dialogElement = document.getElementById(dialogId);
const formContent = document.getElementById('anki-dialog-form-content');
const feedbackContent = document.getElementById('anki-dialog-feedback-content');
const yesButton = document.getElementById('anki-yes');
const otherButtons = dialogElement.querySelectorAll('.anki-button-group button:not(#anki-yes)');
// Capture the initial, ideal size of the dialog window.
const initialDialogWidth = dialogElement.offsetWidth;
const initialDialogHeight = dialogElement.offsetHeight;
const showLoadingState = () => {
// Lock the dialog's size to its initial state to prevent resizing.
dialogElement.style.width = `${initialDialogWidth}px`;
dialogElement.style.height = `${initialDialogHeight}px`;
// Set the button's min-width to its current width before changing the text.
yesButton.style.minWidth = `${yesButton.offsetWidth}px`;
yesButton.disabled = true;
yesButton.textContent = 'Saving...';
otherButtons.forEach(btn => btn.disabled = true);
};
const showSuccessState = () => {
formContent.style.display = 'none';
feedbackContent.innerHTML = `
<div class="feedback-container success">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feedback-icon"><polyline points="20 6 9 17 4 12"></polyline></svg>
<p>Card saved!</p>
</div>`;
feedbackContent.style.display = 'flex';
setTimeout(() => dialogElement.remove(), 1500);
};
const showErrorState = (message) => {
formContent.style.display = 'none';
feedbackContent.innerHTML = `
<div class="feedback-container error">
<p><b>Error:</b> ${message}</p>
<button id="anki-try-again">Try Again</button>
</div>`;
feedbackContent.style.display = 'flex';
document.getElementById('anki-try-again').addEventListener('click', () => {
feedbackContent.style.display = 'none';
formContent.style.display = 'flex';
// Reset dialog and button size properties before restoring form
dialogElement.style.width = '';
dialogElement.style.height = '';
yesButton.style.minWidth = '';
yesButton.disabled = false;
yesButton.textContent = 'Yes, Add to Airtable';
otherButtons.forEach(btn => btn.disabled = false);
});
};
yesButton.addEventListener('click', async () => {
const modifiedQuery = document.getElementById('anki-query').value;
const answer = document.getElementById('anki-answer').value;
if (!modifiedQuery || !answer) {
alert('Please provide both a Question and an Answer.');
return;
}
showLoadingState();
const result = await addSearchToAirtable(modifiedQuery, answer, currentQuery);
if (result.success) {
showSuccessState();
} else {
showErrorState(result.error);
}
});
document.getElementById('anki-not-now').addEventListener('click', () => dialogElement.remove());
document.getElementById('anki-dont-ask-again').addEventListener('click', async () => {
let ignoredSearches = await GM_getValue('ignoredSearches', []);
ignoredSearches.push(currentQuery);
await GM_setValue('ignoredSearches', ignoredSearches);
dialogElement.remove();
});
document.getElementById('anki-answer').focus();
};
// --- AIRTABLE API FUNCTION ---
const addCardToAirtableApi = (question, answer) => {
const url = `https://api.airtable.com/v0/${config.airtableBaseId}/${config.airtableTableName}`;
const record = { fields: { "Question": question, "Answer": answer, "Tags": "from_search" } };
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: url,
headers: { 'Authorization': `Bearer ${config.airtableApiKey}`, 'Content-Type': 'application/json' },
data: JSON.stringify({ records: [record] }),
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
resolve(JSON.parse(response.responseText));
} else {
reject({ status: response.status, responseText: response.responseText });
}
},
onerror: (error) => reject({ status: 'NETWORK_ERROR', responseText: 'Could not connect to Airtable.'})
});
});
};
const addSearchToAirtable = async (query, answer, originalQuery) => {
try {
await addCardToAirtableApi(query, answer);
const addedAnkiCards = await GM_getValue('addedAnkiCards', {});
// Add the final, user-approved query to the cooldown list
addedAnkiCards[query] = new Date().toISOString();
log(`Added "${query}" to the cooldown list.`);
// If the user edited the question, also add the original query to the cooldown list.
if (originalQuery && query !== originalQuery) {
addedAnkiCards[originalQuery] = new Date().toISOString();
log(`Also added original trigger "${originalQuery}" to the cooldown list.`);
}
await GM_setValue('addedAnkiCards', addedAnkiCards);
return { success: true };
} catch (error) {
logError('Failed to add card to Airtable:', error);
let message = 'An unknown error occurred.';
if (error.status === 401) {
message = 'Authentication failed. Is your API Key correct?';
} else if (error.status === 404) {
message = 'Base or Table not found. Check your Base ID and Table Name.';
} else if (error.status === 422) {
message = 'Invalid field data. Do "Question", "Answer", and "Tags" fields exist in your table?';
} else if (error.status) {
message = `API returned status ${error.status}. Check console for details.`;
} else {
message = `Could not connect to Airtable. Check your network.`;
}
return { success: false, error: message };
}
};
// --- MAIN EXECUTION ---
const main = async () => {
if (await isIncognito()) {
log('Incognito mode detected. Tracking is disabled.');
return;
}
const searchEngines = [
{ name: 'Google', host: 'google.com', queryParam: 'q', paginationParam: 'start' },
{ name: 'DuckDuckGo', host: 'duckduckgo.com', queryParam: 'q', paginationParam: 's' },
{ name: 'Bing', host: 'bing.com', queryParam: 'q', paginationParam: 'first' },
{ name: 'Brave', host: 'search.brave.com', queryParam: 'q', paginationParam: 'offset' },
{ name: 'Ecosia', host: 'ecosia.org', queryParam: 'q', paginationParam: 'p' }
];
const currentHost = window.location.hostname.replace('www.', '');
const currentEngine = searchEngines.find(e => currentHost === e.host);
if (!currentEngine) return;
log(`Running on ${currentEngine.name}.`);
const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get(currentEngine.queryParam)?.trim();
if (!query) { log('No search query found in URL.'); return; }
log(`Detected query: "${query}"`);
// --- PAGINATION CHECK ---
const pageParam = currentEngine.paginationParam;
if (pageParam && urlParams.has(pageParam)) {
const pageValue = parseInt(urlParams.get(pageParam), 10);
if (!isNaN(pageValue) && pageValue > 0 && pageParam !== 's') { // DDG uses 's' for offsets, not pages
log(`Pagination detected (${pageParam}=${urlParams.get(pageParam)}). Not tracking.`);
return;
}
}
// ------------------------
await logSearch(query);
if ((await GM_getValue('ignoredSearches', [])).includes(query)) { log('Query is on the manual ignore list. Aborting.'); return; }
if (await isRecentlyAdded(query)) { log('Query is on cooldown from a recent add. Aborting.'); return; }
const { similarSearches, allTimestamps } = await getSimilarSearches(query);
if (isNthSearch(allTimestamps, config.searchRepetitionThreshold, config.searchPeriodInMonths)) {
const isConfigured = config.airtableApiKey && config.airtableBaseId && config.airtableTableName;
if (isConfigured) {
showAnkiDialog(query, similarSearches);
} else {
log('Threshold met, but script is not configured. Opening settings.');
alert("It looks like you're searching for this often! Please configure the Airtable connection to start saving cards.");
showConfigModal();
}
} else {
log('Repetition threshold not met. Not showing dialog.');
}
};
main();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment