UNPKG

@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
#!/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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")}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