@iantay/anki-mcp-server
Version:
A Model Context Protocol (MCP) server that enables LLMs to interact with Anki flashcard software through AnkiConnect
910 lines (797 loc) • 53.3 kB
JavaScript
#!/usr/bin/env node
import{Server as U}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as J}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as W,ErrorCode as G,ListResourceTemplatesRequestSchema as V,ListResourcesRequestSchema as Y,ListToolsRequestSchema as K,McpError as X,ReadResourceRequestSchema as Q}from"@modelcontextprotocol/sdk/types.js";var E="0.1.28";import{ErrorCode as k,McpError as x}from"@modelcontextprotocol/sdk/types.js";import{ErrorCode as y,McpError as v}from"@modelcontextprotocol/sdk/types.js";import{YankiConnect as $}from"yanki-connect";var f=class extends Error{constructor(e){super(e),this.name="AnkiConnectionError"}},w=class extends Error{constructor(e){super(e),this.name="AnkiTimeoutError"}},b=class extends Error{constructor(t,r){super(t);this.code=r;this.name="AnkiApiError"}},j={ankiConnectUrl:"http://localhost:8765",apiVersion:6,timeout:5e3,retryTimeout:1e4,defaultDeck:"Default"},p=class{constructor(e={}){this.config={...j,...e};let t=new URL(this.config.ankiConnectUrl);this.client=new $({host:`${t.protocol}//${t.hostname}`,port:parseInt(t.port,10)})}async executeWithRetry(e,t=1){let r=null;for(let i=0;i<=t;i++)try{return await e()}catch(o){if(r=this.normalizeError(o),i<t){let s=Math.min(1e3*2**i,this.config.retryTimeout);await new Promise(a=>setTimeout(a,s))}}throw r||new f("Unknown error occurred")}normalizeError(e){return e instanceof Error?e.message.includes("ECONNREFUSED")?new f("Anki is not running. Please start Anki and ensure AnkiConnect plugin is enabled."):e.message.includes("timeout")||e.message.includes("ETIMEDOUT")?new w("Connection to Anki timed out. Please check if Anki is responsive."):e.message.includes("collection unavailable")?new b("Anki collection is unavailable. Please close any open dialogs in Anki."):e:new Error(String(e))}wrapError(e){return e instanceof f?new v(y.InternalError,e.message):e instanceof w?new v(y.InternalError,e.message):e instanceof b?new v(y.InternalError,e.message):new v(y.InternalError,`Anki error: ${e.message}`)}async checkConnection(){try{return await this.executeWithRetry(()=>this.client.invoke("version")),!0}catch(e){throw this.wrapError(e instanceof Error?e:new Error(String(e)))}}async getDeckNames(){try{return await this.executeWithRetry(()=>this.client.deck.deckNames())}catch(e){throw this.wrapError(e instanceof Error?e:new Error(String(e)))}}async createDeck(e){try{let t=await this.executeWithRetry(()=>this.client.deck.createDeck({deck:e}));return typeof t=="number"?t:0}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async getModelNames(){try{return await this.executeWithRetry(()=>this.client.model.modelNames())}catch(e){throw this.wrapError(e instanceof Error?e:new Error(String(e)))}}async getModelFieldNames(e){try{return await this.executeWithRetry(()=>this.client.model.modelFieldNames({modelName:e}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async getModelTemplates(e){try{return await this.executeWithRetry(()=>this.client.model.modelTemplates({modelName:e}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async getModelStyling(e){try{return await this.executeWithRetry(()=>this.client.model.modelStyling({modelName:e}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async addNote(e){try{return await this.executeWithRetry(()=>this.client.note.addNote({note:{deckName:e.deckName,modelName:e.modelName,fields:e.fields,tags:e.tags||[],options:{allowDuplicate:e.options?.allowDuplicate||!1,duplicateScope:"deck"}}}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async addNotes(e){try{return await this.executeWithRetry(()=>this.client.note.addNotes({notes:e.map(t=>({deckName:t.deckName,modelName:t.modelName,fields:t.fields,tags:t.tags||[],options:{allowDuplicate:!1,duplicateScope:"deck"}}))}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async findNotes(e){try{let t=await this.executeWithRetry(()=>this.client.note.findNotes({query:e}));return Array.isArray(t)?t.filter(r=>typeof r=="number"):[]}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async notesInfo(e){try{let t=await this.executeWithRetry(()=>this.client.note.notesInfo({notes:e}));return Array.isArray(t)?t:[]}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async updateNoteFields(e){try{await this.executeWithRetry(()=>this.client.note.updateNoteFields({note:{id:e.id,fields:e.fields}}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async deleteNotes(e){try{await this.executeWithRetry(()=>this.client.note.deleteNotes({notes:e}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async createModel(e){try{let t=e.cardTemplates.map(r=>({name:r.name,Front:r.front,Back:r.back}));await this.executeWithRetry(()=>this.client.model.createModel({modelName:e.modelName,inOrderFields:e.inOrderFields,css:e.css,cardTemplates:t}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async guiSelectedNotes(){try{let e=await this.executeWithRetry(()=>this.client.graphical.guiSelectedNotes());return Array.isArray(e)?e:[]}catch(e){throw this.wrapError(e instanceof Error?e:new Error(String(e)))}}async guiCurrentCard(){try{return await this.executeWithRetry(()=>this.client.graphical.guiCurrentCard())||null}catch(e){throw this.wrapError(e instanceof Error?e:new Error(String(e)))}}};var C=class{constructor(){this.ankiClient=new p,this.modelSchemaCache=new Map,this.allModelSchemasCache=null,this.cacheExpiry=300*1e3,this.lastCacheUpdate=0}async listResources(){return await this.ankiClient.checkConnection(),{resources:[{uri:"anki://decks/all",name:"All Decks",description:"List of all available decks in Anki",mimeType:"application/json"}]}}async listResourceTemplates(){return await this.ankiClient.checkConnection(),{resourceTemplates:[{uriTemplate:"anki://note-types/{modelName}",name:"Note Type Schema",description:"Detailed structure information for a specific note type",mimeType:"application/json"},{uriTemplate:"anki://note-types/all",name:"All Note Types",description:"List of all available note types",mimeType:"application/json"},{uriTemplate:"anki://note-types/all-with-schemas",name:"All Note Types with Schemas",description:"Detailed structure information for all note types",mimeType:"application/json"},{uriTemplate:"anki://decks/all",name:"All Decks",description:"Complete list of available decks",mimeType:"application/json"}]}}async readResource(e){if(await this.ankiClient.checkConnection(),e==="anki://decks/all"){let r=await this.ankiClient.getDeckNames();return{contents:[{uri:e,mimeType:"application/json",text:JSON.stringify({decks:r,count:r.length},null,2)}]}}if(e==="anki://note-types/all"){let r=await this.ankiClient.getModelNames();return{contents:[{uri:e,mimeType:"application/json",text:JSON.stringify({noteTypes:r,count:r.length},null,2)}]}}if(e==="anki://note-types/all-with-schemas"){let r=await this.getAllModelSchemas();return{contents:[{uri:e,mimeType:"application/json",text:JSON.stringify({noteTypes:r,count:r.length},null,2)}]}}let t=e.match(/^anki:\/\/note-types\/(.+)$/);if(t){let r=decodeURIComponent(t[1]);try{let i=await this.getModelSchema(r);return{contents:[{uri:e,mimeType:"application/json",text:JSON.stringify({modelName:i.modelName,fields:i.fields,templates:i.templates,css:i.css,createTool:`create_${r.replace(/\s+/g,"_")}_note`},null,2)}]}}catch{throw new x(k.InvalidParams,`Note type '${r}' does not exist`)}}throw new x(k.InvalidParams,`Unknown resource: ${e}`)}async getModelSchema(e){if(!e)throw new x(k.InvalidParams,"Model name is required");let t=Date.now(),r=this.modelSchemaCache.get(e);if(r&&t-this.lastCacheUpdate<this.cacheExpiry)return r;if(!(await this.ankiClient.getModelNames()).includes(e))throw new x(k.InvalidParams,`Note type not found: ${e}`);let[o,s,a]=await Promise.all([this.ankiClient.getModelFieldNames(e),this.ankiClient.getModelTemplates(e),this.ankiClient.getModelStyling(e)]),d={modelName:e,fields:o,templates:s,css:a.css};return this.modelSchemaCache.set(e,d),this.lastCacheUpdate=t,d}async getAllModelSchemas(){let e=Date.now();if(this.allModelSchemasCache&&e-this.lastCacheUpdate<this.cacheExpiry)return this.allModelSchemasCache;let t=await this.ankiClient.getModelNames(),r=await Promise.all(t.map(i=>this.getModelSchema(i)));return this.allModelSchemasCache=r,this.lastCacheUpdate=e,r}clearCache(){this.modelSchemaCache.clear(),this.allModelSchemasCache=null,this.lastCacheUpdate=0}};import{ErrorCode as l,McpError as c}from"@modelcontextprotocol/sdk/types.js";function u(n){return n.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function P(n){let e="";return n.type==="Basic"?e=n.fields.Front||n.fields.front||"":e=n.fields.Text||n.fields.text||"",e=e.replace(/<[^>]*>/g,""),e.length>60?`${e.substring(0,60)}...`:e}function M(n){let e=/\{\{c(\d+)::([^:}]+)(?:::([^}]+))?\}\}/g;return n.replace(e,(t,r,i,o)=>{let s=o?`[${o}]`:"[...]";return`<span class="cloze" data-cloze="${r}">
<span class="cloze-hidden">${s}</span>
<span class="cloze-revealed" style="display: none;">${i}</span>
</span>`})}function R(n){let e=/\{\{c(\d+)::([^:}]+)(?:::([^}]+))?\}\}/g,t=new Set,r=e.exec(n);for(;r!==null;)t.add(r[1]),r=e.exec(n);return t.size}function D(n,e,t){let r=n.fields.Front||n.fields.front||"",i=n.fields.Back||n.fields.back||"",o=n.tags?n.tags.join(", "):"",s=P(n),a=n.id??e,d=Object.entries(n.fields).filter(([m])=>!["Front","front","Back","back"].includes(m)).map(([m,h])=>`
<div class="field-group extra">
<div class="field-label">${u(m)}</div>
<div class="field-content">${h}</div>
</div>
`).join("");return`
<div class="card" data-index="${e}" data-id="${a}" data-preview="${u(s)}">
<div class="card-header">
<div class="card-meta">
<span class="card-number">${e+1}/${t}</span>
<span class="card-type">Basic</span>
</div>
<span class="card-deck">${u(n.deck)}</span>
</div>
<div class="card-body">
<div class="field-group">
<div class="field-label">Front</div>
<div class="field-content">${r}</div>
</div>
<div class="divider"></div>
<div class="field-group">
<div class="field-label">Back</div>
<div class="field-content">${i}</div>
</div>
${d}
</div>
${o?`<div class="card-footer"><span class="tags">${u(o)}</span></div>`:""}
<div class="card-actions">
<button class="action-btn accept-btn" onclick="acceptCard(${e})">\u2713 Accept</button>
<button class="action-btn reject-btn" onclick="rejectCard(${e})">\u2717 Reject</button>
<button class="action-btn comment-btn" onclick="toggleComment(${e})">\u{1F4AC} Comment</button>
</div>
<div class="comment-section" id="comment-${e}" style="display: none;">
<textarea
class="comment-input"
placeholder="Add your feedback or reason for rejection..."
oninput="updateComment(${e}, this.value)"
></textarea>
</div>
</div>
`}function _(n,e,t){let r=n.fields.Text||n.fields.text||"",i=n.fields.Extra||n.fields.extra||"",o=n.tags?n.tags.join(", "):"",s=P(n),a=n.id??e,d=R(r),m=Object.entries(n.fields).filter(([h])=>!["Text","text","Extra","extra"].includes(h)).map(([h,g])=>`
<div class="field-group extra">
<div class="field-label">${u(h)}</div>
<div class="field-content">${g}</div>
</div>
`).join("");return`
<div class="card" data-index="${e}" data-id="${a}" data-preview="${u(s)}">
<div class="card-header">
<div class="card-meta">
<span class="card-number">${e+1}/${t}</span>
<span class="card-type">Cloze ${d>0?`(${d})`:""}</span>
</div>
<span class="card-deck">${u(n.deck)}</span>
</div>
<div class="card-body">
<div class="field-group">
<div class="field-content cloze-text" onclick="toggleAllClozes(this)">
${M(r)}
</div>
<div class="cloze-hint">Click on [...] to reveal \u2022 Click anywhere to toggle all</div>
</div>
${i?`
<div class="divider"></div>
<div class="field-group">
<div class="field-label">Extra</div>
<div class="field-content">${i}</div>
</div>
`:""}
${m}
</div>
${o?`<div class="card-footer"><span class="tags">${u(o)}</span></div>`:""}
<div class="card-actions">
<button class="action-btn accept-btn" onclick="acceptCard(${e})">\u2713 Accept</button>
<button class="action-btn reject-btn" onclick="rejectCard(${e})">\u2717 Reject</button>
<button class="action-btn comment-btn" onclick="toggleComment(${e})">\u{1F4AC} Comment</button>
</div>
<div class="comment-section" id="comment-${e}" style="display: none;">
<textarea
class="comment-input"
placeholder="Add your feedback or reason for rejection..."
oninput="updateComment(${e}, this.value)"
></textarea>
</div>
</div>
`}function O(n){return JSON.stringify(n.map((e,t)=>({index:t,id:e.id??t,preview:P(e)})))}function I(n){let e=n.map((r,i)=>r.type==="Basic"?D(r,i,n.length):_(r,i,n.length)).join(`
`),t=O(n);return`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anki Card Preview (${n.length} card${n.length!==1?"s":""})</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--border: 240 5.9% 90%;
--success: 142 76% 36%;
--destructive: 0 84% 60%;
--radius: 0.5rem;
}
.dark-mode {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 8%;
--card-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--border: 240 3.7% 15.9%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: hsl(var(--background));
color: hsl(var(--foreground));
min-height: 100vh;
padding: 2rem;
padding-top: 6rem;
line-height: 1.5;
}
/* Decision Summary Bar */
.summary-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: hsl(var(--card));
border-bottom: 1px solid hsl(var(--border));
padding: 1rem;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.summary-content {
max-width: 56rem;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.summary-stats {
display: flex;
gap: 1.5rem;
align-items: center;
flex-wrap: wrap;
}
.stat {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
}
.stat-total { color: hsl(var(--foreground)); }
.stat-accepted { color: hsl(var(--success)); }
.stat-rejected { color: hsl(var(--destructive)); }
.stat-commented { color: hsl(217 91% 60%); }
.export-btn {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
font-size: 0.875rem;
transition: opacity 150ms;
}
.export-btn:hover {
opacity: 0.9;
}
.export-btn.success {
background: hsl(var(--success));
}
.container {
max-width: 56rem;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.header h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.subtitle {
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
.controls {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
padding: 0.5rem 1rem;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--foreground));
cursor: pointer;
transition: all 150ms;
}
.btn:hover {
background: hsl(var(--muted));
}
/* Card Styles */
.card {
background: hsl(var(--card));
border: 2px solid hsl(var(--border));
border-radius: var(--radius);
margin-bottom: 1.5rem;
overflow: hidden;
transition: all 150ms;
position: relative;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dark-mode .card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.card.accepted {
border-color: hsl(var(--success));
border-left-width: 4px;
}
.card.rejected {
border-color: hsl(var(--destructive));
border-left-width: 4px;
}
.card.commented::after {
content: '\u{1F4AC}';
position: absolute;
top: 1rem;
right: 1rem;
font-size: 1.25rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.3);
}
.card-meta {
display: flex;
gap: 0.75rem;
align-items: center;
}
.card-number {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
.card-type {
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
background: hsl(var(--muted));
border-radius: calc(var(--radius) - 2px);
}
.card-deck {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
.card-body {
padding: 1.5rem;
}
.field-group {
margin-bottom: 1rem;
}
.field-group:last-child {
margin-bottom: 0;
}
.field-group.extra {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid hsl(var(--border));
}
.field-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--muted-foreground));
margin-bottom: 0.5rem;
}
.field-content {
font-size: 1rem;
line-height: 1.6;
}
.divider {
height: 1px;
background: hsl(var(--border));
margin: 1.25rem 0;
}
/* Cloze Styles */
.cloze-text {
cursor: pointer;
user-select: none;
}
.cloze {
display: inline;
cursor: pointer;
transition: all 150ms;
}
.cloze-hidden {
color: hsl(217 91% 60%);
font-weight: 600;
background: hsl(217 91% 60% / 0.1);
padding: 0.125rem 0.5rem;
border-radius: calc(var(--radius) - 2px);
}
.cloze-revealed {
color: hsl(142 76% 36%);
font-weight: 600;
background: hsl(142 76% 36% / 0.1);
padding: 0.125rem 0.5rem;
border-radius: calc(var(--radius) - 2px);
}
.dark-mode .cloze-hidden {
color: hsl(217 91% 70%);
}
.dark-mode .cloze-revealed {
color: hsl(142 76% 56%);
}
.cloze:hover .cloze-hidden {
background: hsl(217 91% 60% / 0.2);
}
.cloze-hint {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin-top: 0.5rem;
font-style: italic;
}
.card-footer {
padding: 0.75rem 1rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.3);
}
.tags {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
/* Action Buttons */
.card-actions {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.2);
}
.action-btn {
flex: 1;
padding: 0.5rem 0.75rem;
border: 2px solid hsl(var(--border));
border-radius: var(--radius);
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 150ms;
background: hsl(var(--card));
color: hsl(var(--foreground));
}
.accept-btn:hover {
border-color: hsl(var(--success));
color: hsl(var(--success));
}
.accept-btn.active {
background: hsl(var(--success));
border-color: hsl(var(--success));
color: white;
}
.reject-btn:hover {
border-color: hsl(var(--destructive));
color: hsl(var(--destructive));
}
.reject-btn.active {
background: hsl(var(--destructive));
border-color: hsl(var(--destructive));
color: white;
}
.comment-btn:hover {
border-color: hsl(217 91% 60%);
color: hsl(217 91% 60%);
}
.comment-btn.active {
background: hsl(217 91% 60%);
border-color: hsl(217 91% 60%);
color: white;
}
/* Comment Section */
.comment-section {
padding: 1rem;
background: hsl(var(--muted) / 0.3);
border-top: 1px solid hsl(var(--border));
}
.comment-input {
width: 100%;
min-height: 80px;
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
background: hsl(var(--card));
color: hsl(var(--foreground));
transition: border-color 150ms;
}
.comment-input:focus {
outline: none;
border-color: hsl(217 91% 60%);
}
@media (max-width: 768px) {
body {
padding: 1rem;
padding-top: 8rem;
}
.summary-content {
flex-direction: column;
align-items: stretch;
}
.summary-stats {
justify-content: space-around;
}
.export-btn {
width: 100%;
}
.header h1 {
font-size: 1.5rem;
}
.card-body {
padding: 1rem;
}
.card-actions {
flex-direction: column;
}
}
</style>
</head>
<body>
<!-- Decision Summary Bar -->
<div class="summary-bar">
<div class="summary-content">
<div class="summary-stats">
<div class="stat stat-total">
<span>Total:</span>
<strong id="stat-total">${n.length}</strong>
</div>
<div class="stat stat-accepted">
<span>\u2713 Accepted:</span>
<strong id="stat-accepted">0</strong>
</div>
<div class="stat stat-rejected">
<span>\u2717 Rejected:</span>
<strong id="stat-rejected">0</strong>
</div>
<div class="stat stat-commented">
<span>\u{1F4AC} Comments:</span>
<strong id="stat-commented">0</strong>
</div>
</div>
<button class="export-btn" onclick="exportDecisions()" id="export-btn">
\u{1F4CB} Copy Decisions to Clipboard
</button>
</div>
</div>
<div class="container">
<div class="header">
<h1>Anki Card Preview</h1>
<div class="subtitle">${n.length} card${n.length!==1?"s":""} ready for review</div>
</div>
<div class="controls">
<button class="btn" onclick="toggleDarkMode()">Toggle Dark Mode</button>
<button class="btn" onclick="revealAllClozes()">Reveal All Clozes</button>
<button class="btn" onclick="hideAllClozes()">Hide All Clozes</button>
<button class="btn" onclick="acceptAllCards()">\u2713 Accept All</button>
<button class="btn" onclick="clearAllDecisions()">\u{1F504} Clear All</button>
</div>
<div id="cards">
${e}
</div>
</div>
<script>
// Card metadata from server
const cardMetadata = ${t};
// Decision state
let decisions = [];
// Initialize decisions
function initDecisions() {
// Try to load from localStorage
const saved = localStorage.getItem('anki-preview-decisions');
if (saved) {
try {
decisions = JSON.parse(saved);
} catch (e) {
decisions = [];
}
}
// Ensure we have an entry for each card
if (decisions.length !== cardMetadata.length) {
decisions = cardMetadata.map((card, index) => ({
index: index,
id: card.id,
action: 'pending',
comment: ''
}));
}
// Restore UI state
restoreUIState();
updateSummary();
}
// Save decisions to localStorage
function saveDecisions() {
localStorage.setItem('anki-preview-decisions', JSON.stringify(decisions));
}
// Restore UI state from decisions
function restoreUIState() {
decisions.forEach((decision, index) => {
const card = document.querySelector(\`.card[data-index="\${index}"]\`);
if (!card) return;
// Update card appearance
card.classList.remove('accepted', 'rejected', 'commented');
if (decision.action === 'accept') {
card.classList.add('accepted');
card.querySelector('.accept-btn').classList.add('active');
} else if (decision.action === 'reject') {
card.classList.add('rejected');
card.querySelector('.reject-btn').classList.add('active');
}
// Restore comment
if (decision.comment) {
card.classList.add('commented');
const commentSection = card.querySelector(\`#comment-\${index}\`);
const commentInput = commentSection.querySelector('textarea');
commentInput.value = decision.comment;
commentSection.style.display = 'block';
card.querySelector('.comment-btn').classList.add('active');
}
});
}
// Accept card
function acceptCard(index) {
const decision = decisions[index];
const card = document.querySelector(\`.card[data-index="\${index}"]\`);
const acceptBtn = card.querySelector('.accept-btn');
const rejectBtn = card.querySelector('.reject-btn');
if (decision.action === 'accept') {
// Toggle off
decision.action = 'pending';
card.classList.remove('accepted');
acceptBtn.classList.remove('active');
} else {
// Accept
decision.action = 'accept';
card.classList.remove('rejected');
card.classList.add('accepted');
acceptBtn.classList.add('active');
rejectBtn.classList.remove('active');
}
saveDecisions();
updateSummary();
}
// Reject card
function rejectCard(index) {
const decision = decisions[index];
const card = document.querySelector(\`.card[data-index="\${index}"]\`);
const acceptBtn = card.querySelector('.accept-btn');
const rejectBtn = card.querySelector('.reject-btn');
const commentSection = card.querySelector(\`#comment-\${index}\`);
if (decision.action === 'reject') {
// Toggle off
decision.action = 'pending';
card.classList.remove('rejected');
rejectBtn.classList.remove('active');
} else {
// Reject
decision.action = 'reject';
card.classList.remove('accepted');
card.classList.add('rejected');
rejectBtn.classList.add('active');
acceptBtn.classList.remove('active');
// Auto-show comment section
commentSection.style.display = 'block';
card.querySelector('.comment-btn').classList.add('active');
commentSection.querySelector('textarea').focus();
}
saveDecisions();
updateSummary();
}
// Toggle comment section
function toggleComment(index) {
const card = document.querySelector(\`.card[data-index="\${index}"]\`);
const commentSection = card.querySelector(\`#comment-\${index}\`);
const commentBtn = card.querySelector('.comment-btn');
if (commentSection.style.display === 'none') {
commentSection.style.display = 'block';
commentBtn.classList.add('active');
commentSection.querySelector('textarea').focus();
} else {
commentSection.style.display = 'none';
commentBtn.classList.remove('active');
}
}
// Update comment
function updateComment(index, value) {
decisions[index].comment = value;
const card = document.querySelector(\`.card[data-index="\${index}"]\`);
if (value.trim()) {
card.classList.add('commented');
} else {
card.classList.remove('commented');
}
saveDecisions();
updateSummary();
}
// Update summary stats
function updateSummary() {
const accepted = decisions.filter(d => d.action === 'accept').length;
const rejected = decisions.filter(d => d.action === 'reject').length;
const commented = decisions.filter(d => d.comment.trim()).length;
document.getElementById('stat-accepted').textContent = accepted;
document.getElementById('stat-rejected').textContent = rejected;
document.getElementById('stat-commented').textContent = commented;
}
// Accept all cards
function acceptAllCards() {
decisions.forEach((decision, index) => {
decision.action = 'accept';
const card = document.querySelector(\`.card[data-index="\${index}"]\`);
card.classList.remove('rejected');
card.classList.add('accepted');
card.querySelector('.accept-btn').classList.add('active');
card.querySelector('.reject-btn').classList.remove('active');
});
saveDecisions();
updateSummary();
}
// Clear all decisions
function clearAllDecisions() {
if (!confirm('Clear all decisions? This cannot be undone.')) return;
decisions.forEach((decision, index) => {
decision.action = 'pending';
decision.comment = '';
const card = document.querySelector(\`.card[data-index="\${index}"]\`);
card.classList.remove('accepted', 'rejected', 'commented');
card.querySelector('.accept-btn').classList.remove('active');
card.querySelector('.reject-btn').classList.remove('active');
card.querySelector('.comment-btn').classList.remove('active');
const commentSection = card.querySelector(\`#comment-\${index}\`);
commentSection.style.display = 'none';
commentSection.querySelector('textarea').value = '';
});
saveDecisions();
updateSummary();
}
// Export decisions to clipboard
async function exportDecisions() {
const exportData = {
version: 1,
totalCards: cardMetadata.length,
decisions: decisions.map((decision, index) => ({
index: decision.index,
id: decision.id,
action: decision.action,
comment: decision.comment,
cardPreview: cardMetadata[index].preview
})),
summary: {
accepted: decisions.filter(d => d.action === 'accept').length,
rejected: decisions.filter(d => d.action === 'reject').length,
pending: decisions.filter(d => d.action === 'pending').length,
commented: decisions.filter(d => d.comment.trim()).length
}
};
const jsonString = JSON.stringify(exportData, null, 2);
try {
await navigator.clipboard.writeText(jsonString);
// Success feedback
const btn = document.getElementById('export-btn');
btn.classList.add('success');
btn.textContent = '\u2713 Copied to Clipboard!';
setTimeout(() => {
btn.classList.remove('success');
btn.textContent = '\u{1F4CB} Copy Decisions to Clipboard';
}, 2000);
} catch (err) {
alert('Failed to copy to clipboard. Please check browser permissions.');
console.error('Copy failed:', err);
}
}
// Dark mode toggle
function toggleDarkMode() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
// Load dark mode preference
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
// Toggle single cloze
function toggleCloze(event) {
event.stopPropagation();
const cloze = event.currentTarget;
const hidden = cloze.querySelector('.cloze-hidden');
const revealed = cloze.querySelector('.cloze-revealed');
if (hidden.style.display !== 'none') {
hidden.style.display = 'none';
revealed.style.display = 'inline';
} else {
hidden.style.display = 'inline';
revealed.style.display = 'none';
}
}
// Add click handlers to clozes
document.querySelectorAll('.cloze').forEach(cloze => {
cloze.addEventListener('click', toggleCloze);
});
// Toggle all clozes in a card
function toggleAllClozes(element) {
const clozes = element.querySelectorAll('.cloze');
const firstHidden = element.querySelector('.cloze-hidden[style="display: inline;"], .cloze-hidden:not([style])');
clozes.forEach(cloze => {
const hidden = cloze.querySelector('.cloze-hidden');
const revealed = cloze.querySelector('.cloze-revealed');
if (firstHidden) {
hidden.style.display = 'none';
revealed.style.display = 'inline';
} else {
hidden.style.display = 'inline';
revealed.style.display = 'none';
}
});
}
// Reveal all clozes on page
function revealAllClozes() {
document.querySelectorAll('.cloze').forEach(cloze => {
cloze.querySelector('.cloze-hidden').style.display = 'none';
cloze.querySelector('.cloze-revealed').style.display = 'inline';
});
}
// Hide all clozes on page
function hideAllClozes() {
document.querySelectorAll('.cloze').forEach(cloze => {
cloze.querySelector('.cloze-hidden').style.display = 'inline';
cloze.querySelector('.cloze-revealed').style.display = 'none';
});
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Don't trigger when typing in textarea
if (e.target.tagName === 'TEXTAREA') return;
if (e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
revealAllClozes();
} else if (e.key === 'h' || e.key === 'H') {
hideAllClozes();
} else if (e.key === 'd' || e.key === 'D') {
toggleDarkMode();
} else if (e.key === 'e' || e.key === 'E') {
exportDecisions();
}
});
// Initialize on load
initDecisions();
console.log('Anki Card Preview loaded');
console.log('Keyboard shortcuts: Space = Reveal all, H = Hide all, D = Dark mode, E = Export');
</script>
</body>
</html>`}import{exec as F}from"node:child_process";import*as T from"node:http";import*as z from"node:net";function A(n){return new Promise(e=>{let t=z.createServer();t.once("error",()=>{e(!1)}),t.once("listening",()=>{t.close(),e(!0)}),t.listen(n)})}async function L(n=3e3,e=100){for(let t=0;t<e;t++){let r=n+t;if(await A(r))return r}throw new Error(`No available ports found in range ${n}-${n+e-1}`)}function B(n){let e=process.platform,t;e==="darwin"?t=`open "${n}"`:e==="win32"?t=`start "" "${n}"`:t=`xdg-open "${n}" || sensible-browser "${n}" || x-www-browser "${n}"`,F(t,r=>{r?(console.error(`[Preview Server] Failed to open browser: ${r.message}`),console.error(`[Preview Server] Please manually open: ${n}`)):console.error(`[Preview Server] Opened browser at ${n}`)})}async function H(n,e={}){let t=e.timeout||3e5,r=e.port;return(!r||!await A(r))&&(r=await L(r||3e3)),new Promise((i,o)=>{let s=T.createServer((a,d)=>{if(d.setHeader("Access-Control-Allow-Origin","*"),d.setHeader("Access-Control-Allow-Methods","GET, OPTIONS"),d.setHeader("Access-Control-Allow-Headers","Content-Type"),a.method==="OPTIONS"){d.writeHead(200),d.end();return}d.writeHead(200,{"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-cache, no-store, must-revalidate"}),d.end(n),console.error(`[Preview Server] Served preview to ${a.socket.remoteAddress}`)});s.on("error",a=>{console.error(`[Preview Server] Server error: ${a.message}`),o(a)}),s.listen(r,"127.0.0.1",()=>{let a=`http://localhost:${r}`;console.error(`[Preview Server] Server started at ${a}`),console.error(`[Preview Server] Server will automatically close after ${t/1e3} seconds of inactivity`);let d=setTimeout(()=>{console.error("[Preview Server] Closing server due to inactivity timeout"),s.close()},t);s.closeTimer=d,B(a),i({url:a,port:r,message:"Preview server running. Browser should open automatically."})}),s.on("close",()=>{console.error("[Preview Server] Server closed");let a=s.closeTimer;a&&clearTimeout(a)})})}async function q(n,e){try{return await H(n,{port:e})}catch(t){throw new Error(`Failed to start preview server: ${t instanceof Error?t.message:String(t)}`)}}var S=class{constructor(){this.ankiClient=new p}async getToolSchema(){return{tools:[{name:"list_decks",description:"List all available Anki decks",inputSchema:{type:"object",properties:{},required:[]}},{name:"create_deck",description:"Create a new Anki deck",inputSchema:{type:"object",properties:{name:{type:"string",description:"Name of the deck to create"}},required:["name"]}},{name:"get_note_type_info",description:"Get detailed structure of a note type",inputSchema:{type:"object",properties:{modelName:{type:"string",description:"Name of the note type/model"},includeCss:{type:"boolean",description:"Whether to include CSS information"}},required:["modelName"]}},{name:"create_note",description:"Create a single note. For multiple notes, use batch_create_notes instead (10-20 notes per batch recommended). Always call get_note_type_info first to understand required fields.",inputSchema:{type:"object",properties:{type:{type:"string",description:"Note type. Common: 'Basic' (has Front/Back), 'Cloze' (has Text with {{c1::deletions}})"},deck:{type:"string",description:"Target deck name"},fields:{type:"object",description:"Note fields. Basic type: {Front: 'question', Back: 'answer'}. Cloze type: {Text: 'text with {{c1::deletion}}'}. Call get_note_type_info for custom types.",additionalProperties:!0},allowDuplicate:{type:"boolean",description:"Whether to allow duplicate notes (default: false)",default:!1},tags:{type:"array",items:{type:"string"},description:"Optional tags for organization"}},required:["type","deck","fields"]}},{name:"batch_create_notes",description:"Create multiple notes at once. IMPORTANT: For optimal performance, limit batch size to 10-20 notes at a time. For larger sets, split into multiple batches. Always call get_note_type_info first to understand the required fields.",inputSchema:{type:"object",properties:{notes:{type:"array",description:"Array of notes to create. RECOMMENDED: 10-20 notes per batch for best performance. Maximum: 50 notes.",maxItems:50,items:{type:"object",properties:{type:{type:"string",description:"Note type. Common types: 'Basic' (Front/Back fields), 'Cloze' (Text field with {{c1::text}} deletions)",enum:["Basic","Cloze"]},deck:{type:"string",description:"Target deck name"},fields:{type:"object",description:"Note fields. For Basic: {Front: '...', Back: '...'}. For Cloze: {Text: '...with {{c1::deletion}}'}",additionalProperties:!0},tags:{type:"array",items:{type:"string"},description:"Optional tags for organization"}},required:["type","deck","fields"]}},allowDuplicate:{type:"boolean",description:"Whether to allow duplicate notes (default: false)",default:!1},stopOnError:{type:"boolean",description:"Whether to stop on first error or continue with remaining notes (default: false)",default:!1}},required:["notes"],examples:[{notes:[{type:"Basic",deck:"Programming",fields:{Front:"What is a closure?",Back:"A function with access to its outer scope"},tags:["javascript","concepts"]},{type:"Cloze",deck:"Programming",fields:{Text:"In JavaScript, {{c1::const}} declares a {{c2::block-scoped}} variable"},tags:["javascript","syntax"]}]}]}},{name:"search_notes",description:"Search for notes using Anki query syntax",inputSchema:{type:"object",properties:{query:{type:"string",description:"Anki search query"}},required:["query"]}},{name:"get_note_info",description:"Get detailed information about a note",inputSchema:{type:"object",properties:{noteId:{type:"number",description:"Note ID"}},required:["noteId"]}},{name:"update_note",description:"Update an existing note",inputSchema:{type:"object",properties:{id:{type:"number",description:"Note ID"},fields:{type:"object",description:"Fields to update"},tags:{type:"array",items:{type:"string"},description:"New tags for the note"}},required:["id","fields"]}},{name:"delete_note",description:"Delete a note",inputSchema:{type:"object",properties:{noteId:{type:"number",description:"Note ID to delete"}},required:["noteId"]}},{name:"list_note_types",description:"List all available note types",inputSchema:{type:"object",properties:{},required:[]}},{name:"create_note_type",description:"Create a new note type",inputSchema:{type:"object",properties:{name:{type:"string",description:"Name of the new note type"},fields:{type:"array",items:{type:"string"},description:"Field names for the note type"},css:{type:"string",description:"CSS styling for the note type"},templates:{type:"array",items:{type:"object",properties:{name:{type:"string"},front:{type:"string"},back:{type:"string"}},required:["name","front","back"]},description:"Card templates"}},required:["name","fields","templates"]}},{name:"gui_selected_notes",description:"Get the selected notes from the Anki GUI browser",inputSchema:{type:"object",properties:{},required:[]}},{name:"gui_current_card",description:"Get the current card being shown in Anki GUI",inputSchema:{type:"object",properties:{},required:[]}},{name:"batch_preview_notes",description:"Preview notes in batch create format in a browser before creating them in Anki. Serves cards on localhost and opens browser automatically.",inputSchema:{type:"object",properties:{notes:{type:"array",items:{type:"object",properties:{type:{type:"string",enum:["Basic","Cloze"]},deck:{type:"string"},fields:{type:"object",additionalProperties:!0},tags:{type:"array",items:{type:"string"}},id:{oneOf:[{type:"string"},{type:"number"}],description:"Optional custom ID for tracking this card across sessions"}},required:["type","deck","fields"]},description:"Array of notes in the same format as batch_create_notes, with optional id field"},port:{type:"number",description:"Optional port number (default: auto-select from 3000+)"}},required:["notes"]}}]}}async executeTool(e,t){await this.ankiClient.checkConnection();try{switch(e){case"list_decks":return this.listDecks();case"create_deck":return this.createDeck(t);case"list_note_types":return this.listNoteTypes();case"create_note_type":return this.createNoteType(t);case"get_note_type_info":return this.getNoteTypeInfo(t);case"create_note":return this.createNote(t);case"batch_create_notes":return this.batchCreateNotes(t);case"search_notes":return this.searchNotes(t);case"get_note_info":return this.getNoteInfo(t);case"update_note":return this.updateNote(t);case"delete_note":return this.deleteNote(t);case"gui_selected_notes":return this.guiSelectedNotes();case"gui_current_card":return this.guiCurrentCard();case"batch_preview_notes":return this.batchPreviewNotes(t);default:{let r=e.match(/^create_(.+)_note$/);if(r){let i=r[1].replace(/_/g," ");return this.createModelSpecificNote(i,t)}throw new c(l.MethodNotFound,`Unknown tool: ${e}`)}}}catch(r){if(r instanceof c)throw r;return{content:[{type:"text",text:`Error: ${r instanceof Error?r.message:String(r)}`}],isError:!0}}}async listDecks(){let e=await this.ankiClient.getDeckNames();return{content:[{type:"text",text:JSON.stringify({decks:e,count:e.length},null,2)}]}}async createDeck(e){if(!e.name)throw new c(l.InvalidParams,"Deck name is required");let t=await this.ankiClient.createDeck(e.name);return{content:[{type:"text",text:JSON.stringify({deckId:t,name:e.name},null,2)}]}}async listNoteTypes(){let e=await this.ankiClient.getModelNames();return{content:[{type:"text",text:JSON.stringify({noteTypes:e,count:e.length},null,2)}]}}async createNoteType(e){if(!e.name)throw new c(l.InvalidParams,"Note type name is required");if(!e.fields||e.fields.length===0)throw new c(l.InvalidParams,"Fields are required");if(!e.templates||e.templates.length===0)throw new c(l.InvalidParams,"Templates are required");if((await this.ankiClient.getModelNames()).includes(e.name))throw new c(l.InvalidParams,`Note type already exists: ${e.name}`);return await this.ankiClient.createModel({modelName:e.name,inOrderFields:e.fields,css:e.css||"",cardTemplates:e.templates}),{content:[{type:"text",text:JSON.stringify({success:!0,modelName:e.name,fields:e.fields,templates:e.templates.length},null,2)}]}}async getNoteTypeInfo(e){if(!e.modelName)throw new c(l.InvalidParams,"Model name is required");if(!(await this.ankiClient.getModelNames()).includes(e.modelName))throw new c(l.InvalidParams,`Note type not found: ${e.modelName}`);let[r,i]=await Promise.all([this.ankiClient.getModelFieldNames(e.modelName),this.ankiClient.getModelTemplates(e.modelName)]),o={modelName:e.modelName,fields:r,templates:i};if(e.includeCss){let s=await this.ankiClient.getModelStyling(e.modelName);o.css=s.css}return{content:[{type:"text",text:JSON.stringify(o,null,2)}]}}async createNote(e){if(!e.type)throw new c(l.InvalidParams,"Note type is required");if(!e.deck)throw new c(l.InvalidParams,"Deck name is required");if(!e.fields||Object.keys(e.fields).length===0)throw new c(l.InvalidParams,"Fields are required");if((await this.ankiClient.getDeckNames()).includes(e.deck)||await this.ankiClient.createDeck(e.deck),!(await this.ankiClient.getModelNames()).includes(e.type))throw new c(l.InvalidParams,`Note type not found: ${e.type}`);let i=await this.ankiClient.getModelFieldNames(e.type),o={};for(let a of i)o[a]=e.fields[a]||e.fields[a.toLowerCase()]||"";let s=await this.ankiClient.addNote({deckName:e.deck,modelName:e.type,fields:o,tags:e.tags||[],options:{allowDuplicate:e.allowDuplicate||!1}});return{content:[{type:"text",text:JSON.stringify({noteId:s,deck:e.deck,modelName:e.type},null,2)}]}}async createModelSpecificNote(e,t){if(!t.deck)throw new c(l.InvalidParams,"Deck name is required");if(!(await this.ankiClient.getModelNames()).includes(e))throw new c(l.InvalidParams,`Note type not found: ${e}`);(await this.ankiClient.getDeckNames()).includes(t.deck)||await this.ankiClient.createDeck(t.deck);let o=await this.ankiClient.getModelFieldNames(e),s={};for(let m of o)s[m]=t[m.toLowerCase()]||t[m]||"";let a=Array.isArray(t.tags)?t.tags:[],d=await this.ankiClient.addNote({deckName:t.deck,modelName:e,fields:s,tags:a});return{content:[{type:"text",text:JSON.stringify({noteId:d,deck:t.deck,modelName:e},null,2)}]}}async batchCreateNotes(e){if(!e.notes||!Array.isArray(e.notes)||e.notes.length===0)throw new c(l.InvalidParams,"Notes array is required");let t=[],r=e.stopOnError!==!1;for(let i=0;i<e.notes.length;i++){let o=e.notes[i];try{if((await this.ankiClient.getDeckNames()).includes(o.deck)||await this.ankiClient.createDeck(o.deck),!(await this.ankiClient.getModelNames()).includes(o.type))throw new Error(`Note type not found: ${o.type}`);let d=await this.ankiClient.getModelFieldNames(o.type),m={};for(let g of d)m[g]=o.fields[g]||o.fields[g.toLowerCase()]||"";let h=await this.ankiClient.addNote({deckName:o.deck,modelName:o.type,fields:m,tags:o.tags||[],options:{allowDuplicate:e.allowDuplicate||!1}});t.push({success:!0,noteId:h,index:i})}catch(s){if(t.push({success:!1,error:s instanceof Error?s.message:String(s),index:i}),r)break}}return{content:[{type:"text",text:JSON.stringify({results:t,total:e.notes.length,successful:t.filter(i=>i.success).length,failed:t.filter(i=>!i.success).length},null,2)}]}}async searchNotes(e){if(!e.query)throw new c(l.InvalidParams,"Search query is required");let t=await this.ankiClient.findNotes(e.query),r=[];if(t.length>0){let i=Math.min(t.length,50);r=await this.ankiClient.notesInfo(t.slice(0,i))}return{content:[{type:"text",text:JSON.stringify({query:e.query,total:t.length,notes:r,limitApplied:t.length>50},null,2)}]}}async getNoteInfo(e){if(!e.noteId)throw new c(l.InvalidParams,"Note ID is required");let t=await this.ankiClient.notesInfo([e.noteId]);if(!t||t.length===0)throw new c(l.InvalidParams,`Note not found: ${e.noteId}`);return{content:[{type:"text",text:JSON.stringify(t[0],null,2)}]}}async updateNote(e){if(!e.id)throw new c(l.InvalidParams,"Note ID is required");if(!e.fields||Object.keys(e.fields).length===0)throw new c(l.InvalidParams,"Fields are required");let t=await this.ankiClient.notesInfo([e.id]);if(!t||t.length===0)throw new c(l.InvalidParams,`Note not found: ${e.id}`);return await this.ankiClient.updateNoteFields({id:e.id,fields:e.fields}),{content:[{type:"text",text:JSON.stringify({success:!0,noteId:e.id},null,2)}]}}async deleteNote(e){if(!e.noteId)throw new c(l.InvalidParams,"Note ID is required");return await this.ankiClient.deleteNotes([e.noteId]),{content:[{type:"text",text:JSON.stringify({success:!0,noteId:e.noteId},null,2)}]}}async guiSelectedNotes(){let e=await this.ankiClient.guiSelect