Anki from Any Book Quote

very useful

Anki from Any Book Quote

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>