Anki from Any Book Quote
very useful






Code
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Quote → Anki</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:var(--bg,#fafaf8);color:var(--tx,#1a1a1a);max-width:680px;margin:0 auto;padding:2rem 1.5rem}
@media(prefers-color-scheme:dark){:root{--bg:#1a1a18;--tx:#e8e8e2;--bg2:#242420;--bg3:#2e2e2a;--border:#3a3a34;--muted:#888}body{background:var(--bg);color:var(--tx)}}
:root{--bg:#fafaf8;--tx:#1a1a1a;--bg2:#f2f2ee;--bg3:#e8e8e2;--border:#e0e0d8;--muted:#888}
textarea{width:100%;min-height:90px;background:var(--bg2);border:0.5px solid var(--border);border-radius:10px;padding:12px 14px;font-size:14px;font-family:inherit;color:var(--tx);resize:vertical;outline:none;line-height:1.6}
textarea:focus{border-color:#888}
input[type=text],input[type=password]{width:100%;background:var(--bg2);border:0.5px solid var(--border);border-radius:8px;padding:9px 12px;font-size:13px;font-family:inherit;color:var(--tx);outline:none}
input[type=text]:focus,input[type=password]:focus{border-color:#888}
label{font-size:11px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);display:block;margin-bottom:6px}
.field{margin-bottom:14px}
button.primary{background:var(--tx);color:var(--bg);border:none;border-radius:8px;padding:10px 20px;font-size:13px;font-weight:500;font-family:inherit;cursor:pointer;transition:opacity .15s}
button.primary:hover{opacity:.85}
.sm-btn{font-size:11px;background:transparent;border:0.5px solid var(--border);color:var(--muted);border-radius:6px;padding:3px 10px;cursor:pointer;font-family:inherit}
.sm-btn:hover{background:var(--bg3)}
.sec{font-size:10px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:10px}
.card-group{margin-bottom:20px}
.group-head{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.group-label{font-size:12px;font-weight:600;color:var(--muted)}
.group-type{font-size:11px;background:var(--bg3);color:var(--muted);border-radius:20px;padding:2px 9px}
.card{background:var(--bg2);border:0.5px solid var(--border);border-radius:10px;padding:12px 14px;margin-bottom:6px}
.card-front{font-size:13px;color:var(--muted);margin-bottom:6px;line-height:1.5}
.card-back{font-size:13px;color:var(--tx);line-height:1.55;padding-top:8px;border-top:0.5px solid var(--border)}
.blank{display:inline-block;background:var(--bg3);border-radius:4px;padding:1px 18px;color:transparent;border:0.5px solid var(--border);min-width:60px;vertical-align:middle}
.export-box{background:var(--bg2);border:0.5px solid var(--border);border-radius:10px;padding:14px;margin-top:20px}
.export-text{font-family:monospace;font-size:11px;color:var(--muted);line-height:1.7;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow-y:auto}
.divider{height:0.5px;background:var(--border);margin:20px 0}
.loading{font-size:13px;color:var(--muted);padding:20px 0}
.dot{display:inline-block;animation:blink 1.2s infinite}
.dot:nth-child(2){animation-delay:.2s}.dot:nth-child(3){animation-delay:.4s}
@keyframes blink{0%,80%,100%{opacity:0}40%{opacity:1}}
.tag{font-size:11px;background:var(--bg3);color:var(--muted);border-radius:20px;padding:2px 9px;display:inline-block;margin:2px}
.err{background:#fff0f0;border:0.5px solid #f5c1c1;border-radius:8px;padding:12px 14px;font-size:12px;color:#a33;line-height:1.6;margin-top:12px;white-space:pre-wrap;font-family:monospace}
.key-saved{background:var(--bg2);border:0.5px solid var(--border);border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:12px;color:var(--muted);display:flex;align-items:center;justify-content:space-between}
.dot-key{display:inline-block;width:6px;height:6px;border-radius:50%;background:#3B6D11;margin-right:6px}
.ac-drop{position:absolute;top:100%;left:0;right:0;background:var(--bg);border:0.5px solid var(--border);border-radius:8px;margin-top:3px;z-index:100;overflow:hidden;box-shadow:0 4px 12px rgba(0,0,0,0.08)}
.ac-item{padding:8px 12px;font-size:13px;color:var(--tx);cursor:pointer;border-bottom:0.5px solid var(--border)}
.ac-item:last-child{border-bottom:none}
.ac-item:hover{background:var(--bg2)}
.ac-item .ac-sub{font-size:11px;color:var(--muted);margin-top:1px}
</style>
</head>
<body>
<div style="margin-bottom:24px">
<div style="font-size:20px;font-weight:500;margin-bottom:4px">Quote → Anki</div>
<div style="font-size:13px;color:var(--muted)">Paste a quote. Get a full spaced repetition card set.</div>
</div>
<div id="key-area"></div>
<div class="field">
<label>Quote</label>
<textarea id="quote" placeholder="Paste the full quote here..."></textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px">
<div class="field" style="margin:0;position:relative">
<label>Author</label>
<input type="text" id="author" placeholder="e.g. Montaigne" autocomplete="off" oninput="onAuthorInput()" onblur="hideDropdown('author-drop')" onfocus="onAuthorInput()">
<div id="author-drop" class="ac-drop" style="display:none"></div>
</div>
<div class="field" style="margin:0;position:relative">
<label>Source</label>
<input type="text" id="source" placeholder="e.g. Essays, Book II" autocomplete="off" oninput="onSourceInput()" onblur="hideDropdown('source-drop')" onfocus="onSourceInput()">
<div id="source-drop" class="ac-drop" style="display:none"></div>
</div>
</div>
<div class="field">
<label>Your note (optional)</label>
<input type="text" id="note" placeholder="Why this quote matters, or what it connects to">
</div>
<button class="primary" onclick="generate()">Generate cards</button>
<div id="loading" style="display:none" class="loading">
Generating<span class="dot">.</span><span class="dot">.</span><span class="dot">.</span>
</div>
<div id="error" style="display:none"></div>
<div id="output" style="display:none">
<div class="divider"></div>
<div id="cards-area"></div>
<div class="export-box">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div class="sec" style="margin:0">Cloze cards</div>
<div style="display:flex;gap:6px">
<button class="sm-btn" onclick="copyExportType('cloze')">Copy</button>
<button class="sm-btn" onclick="downloadTxtType('cloze')">Download .txt</button>
</div>
</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Import with note type: <strong style="color:var(--tx);font-weight:500">Cloze</strong> — fields: Text / Extra / Tags</div>
<div class="export-text" id="export-cloze"></div>
</div>
<div class="export-box" style="margin-top:10px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div class="sec" style="margin:0">Basic cards</div>
<div style="display:flex;gap:6px">
<button class="sm-btn" onclick="copyExportType('basic')">Copy</button>
<button class="sm-btn" onclick="downloadTxtType('basic')">Download .txt</button>
</div>
</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Import with note type: <strong style="color:var(--tx);font-weight:500">Basic</strong> — fields: Front / Back / Tags</div>
<div class="export-text" id="export-basic"></div>
</div>
<div style="margin-top:12px;font-size:12px;color:var(--muted);line-height:1.6">Import each file separately in Anki → File → Import → select the correct note type for each.</div>
</div>
<script>
// ─── PASTE YOUR API KEY HERE ──────────────────────────────────────────────────
const HARDCODED_KEY = '';
// ─────────────────────────────────────────────────────────────────────────────
function getKey() {
if (HARDCODED_KEY) return HARDCODED_KEY;
try { return localStorage.getItem('anthropic_key') || ''; } catch(e) { return ''; }
}
function renderKeyArea() {
const area = document.getElementById('key-area');
const key = getKey();
if (key) {
const masked = key.slice(0,8) + '••••••••••••••••' + key.slice(-4);
area.innerHTML = `<div class="key-saved"><span><span class="dot-key"></span>API key loaded (${masked})</span><button class="sm-btn" onclick="clearKey()">Change</button></div>`;
} else {
area.innerHTML = `<div class="field">
<label>Anthropic API key</label>
<input type="password" id="keyinput" placeholder="sk-ant-api03-..." autocomplete="off">
<div style="font-size:11px;color:var(--muted);margin-top:5px;line-height:1.6">
Enter once, click Save — stored in this file and localStorage.<br>
Get yours at <a href="https://console.anthropic.com/settings/keys" target="_blank" style="color:var(--muted);text-decoration:underline">console.anthropic.com</a>
</div>
<button class="sm-btn" style="margin-top:8px" onclick="saveKey()">Save key</button>
</div>`;
}
}
function saveKey() {
const val = document.getElementById('keyinput').value.trim();
if (!val.startsWith('sk-')) { alert('That doesn\'t look like an Anthropic key — should start with sk-'); return; }
try { localStorage.setItem('anthropic_key', val); } catch(e) {}
renderKeyArea();
}
function clearKey() {
try { localStorage.removeItem('anthropic_key'); } catch(e) {}
renderKeyArea();
}
renderKeyArea();
async function generate() {
const quote = document.getElementById('quote').value.trim();
const author = document.getElementById('author').value.trim();
const source = document.getElementById('source').value.trim();
const note = document.getElementById('note').value.trim();
const apiKey = getKey();
if (!quote) { alert('Paste a quote first.'); return; }
if (!apiKey) { alert('Add your API key first.'); return; }
document.getElementById('output').style.display = 'none';
document.getElementById('error').style.display = 'none';
document.getElementById('loading').style.display = 'block';
Object.keys(cardImages).forEach(k => delete cardImages[k]);
const attribution = [author, source].filter(Boolean).join(', ');
const prompt = `You are building Anki flashcard sets for a voracious reader who wants to:
1. Recall quotes accurately word-for-word
2. Paraphrase and riff on the underlying ideas in conversation
Quote: "${quote}"
${attribution ? `Attribution: ${attribution}` : ''}
${note ? `Reader's note: ${note}` : ''}
Return ONLY a valid JSON object — no markdown, no backticks, no explanation. Be concise in all card text — backs should be 1-3 sentences max. Use this exact structure:
{
"summary": "one sentence, the core idea in plain language",
"tags": ["tag1", "tag2", "tag3"],
"cloze_cards": [
{"front": "full quote with ONE key phrase replaced by [...]", "back": "the missing phrase", "hint": "", "image_query": "2-4 word google image search that would find a vivid image representing this card's concept"},
{"front": "full quote with a DIFFERENT key phrase replaced by [...]", "back": "the missing phrase", "hint": "", "image_query": "2-4 word google image search that would find a vivid image representing this card's concept"},
{"front": "full quote with a THIRD key phrase replaced by [...]", "back": "the missing phrase", "hint": "", "image_query": "2-4 word google image search that would find a vivid image representing this card's concept"}
],
"recall_cards": [
{"front": "One sentence: what did [author] say about [the topic of the quote]?", "back": "a single natural sentence you could say aloud — not the full quote, the gist of it in your own words", "image_query": "2-4 word google image search for a vivid image representing this concept"},
{"front": "How does [author]'s line about [the topic] go?", "back": "the exact quote, word for word", "image_query": "2-4 word google image search for a vivid image representing this concept"}
],
"idea_cards": [
{"front": "[author] wrote: \"[full quote]\" — what does that actually mean?", "back": "plain explanation, 2-3 sentences", "image_query": "2-4 word google image search for a vivid image representing this concept"},
{"front": "Someone says X [a situation the quote speaks to directly]. What does [author] say that's relevant?", "back": "how you'd bring up the quote naturally — the setup line and then the quote or paraphrase", "image_query": "2-4 word google image search for a vivid image representing this concept"},
{"front": "[author]: \"[full quote]\" — where does this actually show up in real life?", "back": "a concrete situation or example", "image_query": "2-4 word google image search for a vivid image representing this concept"},
{"front": "[author]: \"[full quote]\" — what would someone argue against this?", "back": "the pushback or tension, 1-2 sentences", "image_query": "2-4 word google image search for a vivid image representing this concept"}
]
}`;
const headers = {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true'
};
try {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers,
body: JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: 4000,
messages: [{role: 'user', content: prompt}]
})
});
const data = await res.json();
if (data.error) throw new Error(`API: ${data.error.type} — ${data.error.message}`);
if (!data.content?.[0]?.text) throw new Error(`Unexpected response:\n${JSON.stringify(data, null, 2).slice(0,400)}`);
const raw = data.content[0].text.trim().replace(/^```(?:json)?\s*/,'').replace(/\s*```$/,'').trim();
let parsed;
try {
parsed = JSON.parse(raw);
} catch(e) {
// Try to recover truncated JSON by closing open structures
let fixed = raw;
// Close any open string
const opens = (fixed.match(/"/g) || []).length;
if (opens % 2 !== 0) fixed += '"';
// Close open objects/arrays by counting braces
const openBraces = (fixed.match(/{/g) || []).length - (fixed.match(/}/g) || []).length;
const openBrackets = (fixed.match(/\[/g) || []).length - (fixed.match(/]/g) || []).length;
// Remove trailing incomplete entry (last comma or incomplete field)
fixed = fixed.replace(/,\s*$/, '').replace(/,\s*{[^}]*$/, '');
for (let i = 0; i < openBrackets; i++) fixed += ']';
for (let i = 0; i < openBraces; i++) fixed += '}';
try {
parsed = JSON.parse(fixed);
} catch(e2) {
throw new Error(`JSON parse failed — try a shorter quote.\n\nModel returned:\n${raw.slice(0,400)}`);
}
}
addToLibrary(author, source);
renderCards(parsed);
} catch(e) {
document.getElementById('loading').style.display = 'none';
const el = document.getElementById('error');
el.style.display = 'block';
el.innerHTML = `<div class="err">${e.message}</div>`;
}
}
function renderCards(data) {
document.getElementById('loading').style.display = 'none';
document.getElementById('output').style.display = 'block';
const area = document.getElementById('cards-area');
let html = `<div style="margin-bottom:20px">
<div class="sec">Core idea</div>
<div style="font-size:14px;line-height:1.6;margin-bottom:10px">${data.summary}</div>
<div>${(data.tags||[]).map(t=>`<span class="tag">${t}</span>`).join('')}</div>
</div><div class="divider"></div>`;
let cardIdx = 0;
html += cardGroup('Cloze cards','exact recall', data.cloze_cards||[], c => {
const front = c.front.replace('[...]',`<span class="blank"></span>`);
const hint = c.hint ? ` <span style="font-size:11px;color:var(--muted)">(hint: ${c.hint})</span>` : '';
return `<div class="card-front">${front}${hint}</div><div class="card-back">${c.back}</div>${imgLink(c.image_query, cardIdx++)}`;
});
html += cardGroup('Recall cards','word-for-word', data.recall_cards||[], c =>
`<div class="card-front">${c.front}</div><div class="card-back">${c.back}</div>${imgLink(c.image_query, cardIdx++)}`);
html += cardGroup('Idea cards','riff + apply', data.idea_cards||[], c =>
`<div class="card-front">${c.front}</div><div class="card-back">${c.back}</div>${imgLink(c.image_query, cardIdx++)}`);
area.innerHTML = html;
window._lastCardData = data;
rebuildExport();
}
function rebuildExport() {
const data = window._lastCardData;
if (!data) return;
const tagStr = (data.tags||[]).join(' ');
const clozeLines = [];
const basicLines = [];
let idx = 0;
(data.cloze_cards||[]).forEach(c => {
const img = cardImages[idx] ? `<img src="${cardImages[idx]}">` : '';
clozeLines.push(`${c.front.replace('[...]','{{c1::'+c.back+'}}')} ${img} bram::quotes ${tagStr}`);
idx++;
});
(data.recall_cards||[]).forEach(c => {
const img = cardImages[idx] ? `<img src="${cardImages[idx]}">` : '';
basicLines.push(`${c.front} ${c.back}${img ? ' ' + img : ''} bram::quotes ${tagStr}`);
idx++;
});
(data.idea_cards||[]).forEach(c => {
const img = cardImages[idx] ? `<img src="${cardImages[idx]}">` : '';
basicLines.push(`${c.front} ${c.back}${img ? ' ' + img : ''} bram::ideas ${tagStr}`);
idx++;
});
document.getElementById('export-cloze').textContent = clozeLines.join('\n');
document.getElementById('export-basic').textContent = basicLines.join('\n');
}
const cardImages = {};
function imgLink(query, cardIdx) {
if (!query) return '';
const searchUrl = 'https://www.google.com/search?tbm=isch&q=' + encodeURIComponent(query);
return `<div style="margin-top:8px;padding-top:8px;border-top:0.5px solid var(--border);display:flex;flex-direction:column;gap:6px">
<a href="${searchUrl}" target="_blank" style="font-size:11px;color:var(--muted);text-decoration:none;display:inline-flex;align-items:center;gap:4px">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>${query}
</a>
<div style="display:flex;align-items:center;gap:6px">
<input type="text" id="img-${cardIdx}" placeholder="Paste image URL to attach..." autocomplete="off"
style="flex:1;font-size:11px;padding:5px 8px;border-radius:6px;background:var(--bg3);border:0.5px solid var(--border);color:var(--tx);font-family:inherit;outline:none"
oninput="setCardImage(${cardIdx}, this.value)">
<span id="img-status-${cardIdx}" style="font-size:10px;color:var(--muted);min-width:36px"></span>
</div>
</div>`;
}
function setCardImage(idx, url) {
cardImages[idx] = url.trim();
const status = document.getElementById('img-status-' + idx);
if (status) status.textContent = url.trim() ? 'attached' : '';
rebuildExport();
}
function cardGroup(label, type, cards, render) {
let h = `<div class="card-group"><div class="group-head">
<div class="group-label">${label}</div><div class="group-type">${type}</div>
</div>`;
cards.forEach(c => { h += `<div class="card">${render(c)}</div>`; });
return h + `</div>`;
}
function copyExportType(type) {
const id = type === 'cloze' ? 'export-cloze' : 'export-basic';
navigator.clipboard.writeText(document.getElementById(id).textContent).then(() => {
event.target.textContent = 'Copied';
setTimeout(() => event.target.textContent = 'Copy', 1500);
});
}
function downloadTxtType(type) {
const id = type === 'cloze' ? 'export-cloze' : 'export-basic';
const text = document.getElementById(id).textContent;
const author = document.getElementById('author').value.trim().replace(/\s+/g,'_') || 'quote';
const blob = new Blob([text], {type:'text/plain'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'anki_' + type + '_' + author + '_' + Date.now() + '.txt';
a.click();
URL.revokeObjectURL(a.href);
}
// ── Author/source library ─────────────────────────────────────────────────────
function loadLibrary() {
try { return JSON.parse(localStorage.getItem('author_library') || '{}'); } catch(e) { return {}; }
}
function saveLibrary(lib) {
try { localStorage.setItem('author_library', JSON.stringify(lib)); } catch(e) {}
}
function addToLibrary(author, source) {
if (!author) return;
const lib = loadLibrary();
if (!lib[author]) lib[author] = [];
if (source && !lib[author].includes(source)) lib[author].push(source);
saveLibrary(lib);
}
function hideDropdown(id) {
setTimeout(() => { const d = document.getElementById(id); if(d) d.style.display='none'; }, 150);
}
function onAuthorInput() {
const val = document.getElementById('author').value.trim().toLowerCase();
const lib = loadLibrary();
const authors = Object.keys(lib).filter(a => a.toLowerCase().includes(val));
const drop = document.getElementById('author-drop');
if (!authors.length) { drop.style.display='none'; return; }
drop.innerHTML = authors.map(a => {
const books = lib[a];
const sub = books.length ? books.join(', ') : '';
return `<div class="ac-item" onmousedown="selectAuthor('${a.replace(/'/g,"\'")}')">
${a}${sub ? `<div class="ac-sub">${sub}</div>` : ''}
</div>`;
}).join('');
drop.style.display = 'block';
}
function selectAuthor(author) {
document.getElementById('author').value = author;
document.getElementById('author-drop').style.display = 'none';
// pre-fill source dropdown for this author
document.getElementById('source').focus();
onSourceInput();
}
function onSourceInput() {
const author = document.getElementById('author').value.trim();
const val = document.getElementById('source').value.trim().toLowerCase();
const lib = loadLibrary();
const books = (lib[author] || []).filter(b => b.toLowerCase().includes(val));
const drop = document.getElementById('source-drop');
if (!books.length) { drop.style.display='none'; return; }
drop.innerHTML = books.map(b =>
`<div class="ac-item" onmousedown="selectSource('${b.replace(/'/g,"\'")}')">` + b + `</div>`
).join('');
drop.style.display = 'block';
}
function selectSource(source) {
document.getElementById('source').value = source;
document.getElementById('source-drop').style.display = 'none';
}
</script>
</body>
</html>