UNPKG

yanki

Version:

A CLI tool and TypeScript library for syncing Markdown to Anki flashcards.

11 lines (10 loc) 9.42 kB
#!/usr/bin/env node var z=Object.defineProperty;var p=(e,t)=>z(e,"name",{value:t,configurable:!0});import{globby as E}from"globby";import D from"node:fs/promises";import B from"node:path";import H from"node:os";import W from"yargs";import{hideBin as j}from"yargs/helpers";import{w as f,v as q,x as S,n as G,m as V,l as K,b as Q,c as X,f as Z,s as _,i as ee}from"../sync-files-YR9VgFTQ.js";import s from"chalk";import"process";import"rehype-parse";import"entities/lib/decode.js";import"entities/lib/escape.js";import"node:crypto";const M=H.homedir();function te(e){if(typeof e!="string")throw new TypeError(`Expected a string, got ${typeof e}`);return M?e.replace(/^~(?=$|\/|\\)/,M):e}p(te,"untildify");var oe="0.18.10";const I=process?.versions?.node!==void 0,c={verbose:!1,log(...e){if(!this.verbose)return;const t=s.gray("[Log]");I?console.warn(t,...e):console.log(t,...e)},logPrefixed(e,...t){this.info(s.blue(`[${e}]`),...t)},info(...e){if(!this.verbose)return;const t=s.green("[Info]");I?console.warn(t,...e):console.info(t,...e)},infoPrefixed(e,...t){this.info(s.blue(`[${e}]`),...t)},warn(...e){console.warn(s.yellow("[Warning]"),...e)},warnPrefixed(e,...t){this.warn(s.blue(`[${e}]`),...t)},error(...e){console.error(s.red("[Error]"),...e)},errorPrefixed(e,...t){this.error(s.blue(`[${e}]`),...t)}},y={"anki-auto-launch":{alias:"l",default:!1,describe:"Attempt to open the Anki desktop app if it's not already running. (Experimental, macOS only.)",type:"boolean"}},C={"anki-web":{alias:"w",default:!0,describe:'Automatically sync any changes to AnkiWeb after Yanki has finished syncing locally. If false, only local Anki data is updated and you must manually invoke a sync to AnkiWeb. This is the equivalent of pushing the "sync" button in the Anki app.',type:"boolean"}},w={"anki-connect":{default:"http://127.0.0.1:8765",describe:"Host and port of the Anki-Connect server. The default is usually fine. See the Anki-Connect documentation for more information.",type:"string"}},O={verbose:{default:!1,describe:"Enable verbose logging.",type:"boolean"}};function k(e){return{json:{default:!1,describe:e,type:"boolean"}}}p(k,"jsonOption");const x={"dry-run":{alias:"d",default:!1,describe:"Run without making any changes to the Anki database. See a report of what would have been done.",type:"boolean"}};function R(e){return{namespace:{alias:"n",default:f.namespace,describe:e,type:"string"}}}p(R,"namespaceOption");const ne={"strict-line-breaks":{alias:"b",default:f.strictLineBreaks,describe:"Set to false to treat single newlines in Markdown as line breaks.",type:"boolean"}};function b(e){const t=q(e);if(t===void 0)throw new Error(`Invalid AnkiConnect URL: "${e}"`);return t}p(b,"urlToHostAndPortValidated");const g=p(e=>{throw e instanceof Error?(e.cause?.code==="ECONNREFUSED"&&(c.error("Failed to connect to Anki. Make sure Anki is running and AnkiConnect is installed."),process.exitCode=1,process.exit()),e):new Error("Unknown error")},"ankiNotRunningErrorHandler"),L=W(j(process.argv));await L.scriptName("yanki").usage("$0 [command]","Run a Yanki command. Defaults to `sync` if a command is not provided.").command(["$0 <directory> [options]","sync <directory> [options]"],"Perform a one-way synchronization from a local directory of Markdown files to the Anki database. Any Markdown files in subdirectories are included as well.",e=>e.positional("directory",{demandOption:!0,describe:"The path to the local directory of Markdown files to sync.",type:"string"}).option(x).option(R("Advanced option for managing multiple Yanki synchronization groups. Case insensitive. See the readme for more information.")).option(w).option(y).option(C).option("manage-filenames",{alias:"m",choices:["off","prompt","response"],default:f.manageFilenames,describe:'Rename local note files to match their content. Useful if you want to feel have semantically reasonable note file names without managing them by hand. The `"prompt"` option will attempt to create the filename based on the "front" of the card, while `"response"` will prioritize the "back", "Cloze", or "type in the answer" portions of the card. Truncation, sanitization, and deduplication are taken care of.',type:"string"}).option("max-filename-length",{default:void 0,defaultDescription:String(f.maxFilenameLength),describe:"If `manage-filenames` is enabled, this option specifies the maximum length of the filename in characters.",type:"number"}).option("sync-media",{alias:"s",choices:["off","all","local","remote"],default:f.syncMediaAssets,describe:"Sync image, video, and audio assets to Anki's media storage system. Clean up is managed automatically. The `all` argument will save both local and remote assets to Anki, while `local` will only save local assets, `remote` will only save remote assets, and `off` will not save any assets.",type:"string"}).option("strict-matching",{default:f.strictMatching,describe:'Consider notes to be a "match" only if the local Markdown frontmatter `noteId` matches the remote Anki database `noteId` exactly. When disabled, Yanki will attempt to reuse existing Anki notes whose content matches a local Markdown note, even if the local and remote `noteId` differs. This helps preserve study progress in Anki if the local Markdown frontmatter is lost or corrupted. In Yanki 0.17.0 and earlier, `--strict-matching` was the default behavior. Starting with version 0.18.0, it is disabled by default and may be enabled via this flag.',type:"boolean"}).option(ne).option(k("Output the sync report as JSON.")).option(O),async({ankiAutoLaunch:e,ankiConnect:t,ankiWeb:a,directory:o,dryRun:i,json:r,manageFilenames:n,maxFilenameLength:d,namespace:u,recursive:l=!0,strictLineBreaks:h,strictMatching:m,syncMedia:Y,verbose:P})=>{c.verbose=P;const v=S(te(o)),F=l?`${v}/**/*.md`:`${v}/*.md`,N=(await E(F,{absolute:!0})).map(A=>S(A)),T=(await E(`${v}/**/*`,{absolute:!0})).map(A=>S(A));if(N.length===0){c.error(`No Markdown files found in "${o}".`),process.exitCode=1;return}n==="off"&&d!==void 0&&c.warn("Ignoring `max-filename-length` option because `manage-filenames` is not enabled.");const{host:U,port:J}=b(t),$=await G(N,{allFilePaths:T,ankiConnectOptions:{autoLaunch:e,host:U,port:J},ankiWeb:a,dryRun:i,manageFilenames:n,maxFilenameLength:d,namespace:u,strictLineBreaks:h,strictMatching:m,syncMediaAssets:Y}).catch(g);r?(process.stdout.write(JSON.stringify($,void 0,2)),process.stdout.write(` `)):(process.stderr.write(V($,P)),process.stderr.write(` `))}).command("list [options]","Utility command to list Yanki-created notes in the Anki database.",e=>e.option(R("Advanced option to list notes in a specific namespace. Case insensitive. Notes from the default internal namespace are listed by default. Pass `'*'` to list all Yanki-created notes in the Anki database.")).options(w).options(y).option(k("Output the list of notes as JSON to stdout.")),async({ankiAutoLaunch:e,ankiConnect:t,json:a,namespace:o})=>{const{host:i,port:r}=b(t),n=await K({ankiConnectOptions:{autoLaunch:e,host:i,port:r},namespace:o}).catch(g);a?(process.stdout.write(JSON.stringify(n,void 0,2)),process.stdout.write(` `)):(process.stdout.write(Q(n)),process.stdout.write(` `))}).command("delete [options]","Utility command to manually delete Yanki-created notes in the Anki database. This is for advanced use cases, usually the `sync` command takes care of deleting files from Anki Database once they're removed from the local file system.",e=>e.option(x).option(R("Advanced option to list notes in a specific namespace. Case insensitive. Notes from the default internal namespace are listed by default. If you've synced notes to multiple namespaces, Pass `'*'` to delete all Yanki-created notes in the Anki database.")).options(w).options(y).option(C).option(k("Output the list of deleted notes as JSON to stdout.")).option(O),async({ankiAutoLaunch:e,ankiConnect:t,ankiWeb:a,dryRun:o,json:i,namespace:r,verbose:n})=>{const{host:d,port:u}=b(t),l=await X({ankiConnectOptions:{autoLaunch:e,host:d,port:u},ankiWeb:a,dryRun:o,namespace:r}).catch(g);i?(process.stdout.write(JSON.stringify(l,void 0,2)),process.stdout.write(` `)):(process.stderr.write(Z(l,n)),process.stderr.write(` `))}).command("style [options]","Utility command to set the CSS stylesheet for all present and future Yanki-created notes.",e=>e.option(x).option("css",{alias:"c",default:void 0,describe:"Path to the CSS stylesheet to set for all Yanki-created notes. If not provided, the default Anki stylesheet is used.",type:"string"}).options(w).options(y).option(C).option(k("Output the list of updated note types / models as JSON to stdout.")).option(O),async({ankiAutoLaunch:e,ankiConnect:t,ankiWeb:a,css:o,dryRun:i,json:r,verbose:n})=>{const{host:d,port:u}=b(t);let l;if(o!==void 0){if(B.extname(o)!==".css"){c.error("The provided CSS file must have a .css extension."),process.exitCode=1;return}try{l=await D.readFile(o,"utf8")}catch(m){m instanceof Error?c.error(`Error loading CSS file: ${m.message}`):c.error(`Unknown error loading CSS file: ${String(m)}`),process.exitCode=1;return}}const h=await _({ankiConnectOptions:{autoLaunch:e,host:d,port:u},ankiWeb:a,css:l??void 0,dryRun:i}).catch(g);r?(process.stdout.write(JSON.stringify(h,void 0,2)),process.stdout.write(` `)):(process.stderr.write(ee(h,n)),process.stderr.write(` `))}).demandCommand(1).alias("h","help").version(oe).alias("v","version").help().wrap(process.stdout.isTTY?Math.min(120,L.terminalWidth()):0).parse();