UNPKG

yanki

Version:

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

11 lines (10 loc) 11.5 kB
#!/usr/bin/env node var z=Object.defineProperty;var d=(e,o)=>z(e,"name",{value:o,configurable:!0});import{globby as Y}from"globby";import W from"node:fs/promises";import H from"node:path";import j from"node:os";import q from"yargs";import{hideBin as G}from"yargs/helpers";import{w as _,x as g,v as V,y as A,n as K,m as Q,l as X,b as Z,c as ee,f as te,s as oe,i as ne}from"../sync-files-Ct6VMyRx.js";import"process";import"rehype-parse";import"node:crypto";const $=j.homedir();function se(e){if(typeof e!="string")throw new TypeError(`Expected a string, got ${typeof e}`);return $?e.replace(/^~(?=$|\/|\\)/,$):e}d(se,"untildify");var ie="1.0.0",y={exports:{}},L;function ae(){if(L)return y.exports;L=1;let e=process||{},o=e.argv||[],s=e.env||{},i=!(s.NO_COLOR||o.includes("--no-color"))&&(!!s.FORCE_COLOR||o.includes("--color")||e.platform==="win32"||(e.stdout||{}).isTTY&&s.TERM!=="dumb"||!!s.CI),m=d((n,t,l=n)=>c=>{let r=""+c,u=r.indexOf(t,n.length);return~u?n+p(r,t,l,u)+t:n+r+t},"formatter"),p=d((n,t,l,c)=>{let r="",u=0;do r+=n.substring(u,c)+l,u=c+t.length,c=n.indexOf(t,u);while(~c);return r+n.substring(u)},"replaceClose"),a=d((n=i)=>{let t=n?m:()=>String;return{isColorSupported:n,reset:t("\x1B[0m","\x1B[0m"),bold:t("\x1B[1m","\x1B[22m","\x1B[22m\x1B[1m"),dim:t("\x1B[2m","\x1B[22m","\x1B[22m\x1B[2m"),italic:t("\x1B[3m","\x1B[23m"),underline:t("\x1B[4m","\x1B[24m"),inverse:t("\x1B[7m","\x1B[27m"),hidden:t("\x1B[8m","\x1B[28m"),strikethrough:t("\x1B[9m","\x1B[29m"),black:t("\x1B[30m","\x1B[39m"),red:t("\x1B[31m","\x1B[39m"),green:t("\x1B[32m","\x1B[39m"),yellow:t("\x1B[33m","\x1B[39m"),blue:t("\x1B[34m","\x1B[39m"),magenta:t("\x1B[35m","\x1B[39m"),cyan:t("\x1B[36m","\x1B[39m"),white:t("\x1B[37m","\x1B[39m"),gray:t("\x1B[90m","\x1B[39m"),bgBlack:t("\x1B[40m","\x1B[49m"),bgRed:t("\x1B[41m","\x1B[49m"),bgGreen:t("\x1B[42m","\x1B[49m"),bgYellow:t("\x1B[43m","\x1B[49m"),bgBlue:t("\x1B[44m","\x1B[49m"),bgMagenta:t("\x1B[45m","\x1B[49m"),bgCyan:t("\x1B[46m","\x1B[49m"),bgWhite:t("\x1B[47m","\x1B[49m"),blackBright:t("\x1B[90m","\x1B[39m"),redBright:t("\x1B[91m","\x1B[39m"),greenBright:t("\x1B[92m","\x1B[39m"),yellowBright:t("\x1B[93m","\x1B[39m"),blueBright:t("\x1B[94m","\x1B[39m"),magentaBright:t("\x1B[95m","\x1B[39m"),cyanBright:t("\x1B[96m","\x1B[39m"),whiteBright:t("\x1B[97m","\x1B[39m"),bgBlackBright:t("\x1B[100m","\x1B[49m"),bgRedBright:t("\x1B[101m","\x1B[49m"),bgGreenBright:t("\x1B[102m","\x1B[49m"),bgYellowBright:t("\x1B[103m","\x1B[49m"),bgBlueBright:t("\x1B[104m","\x1B[49m"),bgMagentaBright:t("\x1B[105m","\x1B[49m"),bgCyanBright:t("\x1B[106m","\x1B[49m"),bgWhiteBright:t("\x1B[107m","\x1B[49m")}},"createColors");return y.exports=a(),y.exports.createColors=a,y.exports}d(ae,"requirePicocolors");var re=ae(),f=_(re);const T=process?.versions?.node!==void 0,h={verbose:!1,log(...e){if(!this.verbose)return;const o=f.gray("[Log]");T?console.warn(o,...e):console.log(o,...e)},logPrefixed(e,...o){this.info(f.blue(`[${e}]`),...o)},info(...e){if(!this.verbose)return;const o=f.green("[Info]");T?console.warn(o,...e):console.info(o,...e)},infoPrefixed(e,...o){this.info(f.blue(`[${e}]`),...o)},warn(...e){console.warn(f.yellow("[Warning]"),...e)},warnPrefixed(e,...o){this.warn(f.blue(`[${e}]`),...o)},error(...e){console.error(f.red("[Error]"),...e)},errorPrefixed(e,...o){this.error(f.blue(`[${e}]`),...o)}},x={"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"}},S={"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"}},B={"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 b(e){return{json:{default:!1,describe:e,type:"boolean"}}}d(b,"jsonOption");const R={"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 P(e){return{namespace:{alias:"n",default:g.namespace,describe:e,type:"string"}}}d(P,"namespaceOption");const le={"strict-line-breaks":{alias:"b",default:g.strictLineBreaks,describe:"Set to false to treat single newlines in Markdown as line breaks.",type:"boolean"}};function w(e){const o=V(e);if(o===void 0)throw new Error(`Invalid AnkiConnect URL: "${e}"`);return o}d(w,"urlToHostAndPortValidated");const k=d(e=>{throw e instanceof Error?(e.cause?.code==="ECONNREFUSED"&&(h.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"),F=q(G(process.argv));await F.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(R).option(P("Advanced option for managing multiple Yanki synchronization groups. Case insensitive. See the readme for more information.")).option(B).option(x).option(S).option("manage-filenames",{alias:"m",choices:["off","prompt","response"],default:g.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(g.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:g.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:g.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(le).option(b("Output the sync report as JSON.")).option(O),async({ankiAutoLaunch:e,ankiConnect:o,ankiWeb:s,directory:i,dryRun:m,json:p,manageFilenames:a,maxFilenameLength:n,namespace:t,recursive:l=!0,strictLineBreaks:c,strictMatching:r,syncMedia:u,verbose:N})=>{h.verbose=N;const v=A(se(i)),I=l?`${v}/**/*.md`:`${v}/*.md`,E=(await Y(I,{absolute:!0})).map(C=>A(C)),U=(await Y(`${v}/**/*`,{absolute:!0})).map(C=>A(C));if(E.length===0){h.error(`No Markdown files found in "${i}".`),process.exitCode=1;return}a==="off"&&n!==void 0&&h.warn("Ignoring `max-filename-length` option because `manage-filenames` is not enabled.");const{host:J,port:D}=w(o),M=await K(E,{allFilePaths:U,ankiConnectOptions:{autoLaunch:e,host:J,port:D},ankiWeb:s,dryRun:m,manageFilenames:a,maxFilenameLength:n,namespace:t,strictLineBreaks:c,strictMatching:r,syncMediaAssets:u}).catch(k);p?(process.stdout.write(JSON.stringify(M,void 0,2)),process.stdout.write(` `)):(process.stderr.write(Q(M,N)),process.stderr.write(` `))}).command("list [options]","Utility command to list Yanki-created notes in the Anki database.",e=>e.option(P("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(B).options(x).option(b("Output the list of notes as JSON to stdout.")),async({ankiAutoLaunch:e,ankiConnect:o,json:s,namespace:i})=>{const{host:m,port:p}=w(o),a=await X({ankiConnectOptions:{autoLaunch:e,host:m,port:p},namespace:i}).catch(k);s?(process.stdout.write(JSON.stringify(a,void 0,2)),process.stdout.write(` `)):(process.stdout.write(Z(a)),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(R).option(P("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(B).options(x).option(S).option(b("Output the list of deleted notes as JSON to stdout.")).option(O),async({ankiAutoLaunch:e,ankiConnect:o,ankiWeb:s,dryRun:i,json:m,namespace:p,verbose:a})=>{const{host:n,port:t}=w(o),l=await ee({ankiConnectOptions:{autoLaunch:e,host:n,port:t},ankiWeb:s,dryRun:i,namespace:p}).catch(k);m?(process.stdout.write(JSON.stringify(l,void 0,2)),process.stdout.write(` `)):(process.stderr.write(te(l,a)),process.stderr.write(` `))}).command("style [options]","Utility command to set the CSS stylesheet for all present and future Yanki-created notes.",e=>e.option(R).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(B).options(x).option(S).option(b("Output the list of updated note types / models as JSON to stdout.")).option(O),async({ankiAutoLaunch:e,ankiConnect:o,ankiWeb:s,css:i,dryRun:m,json:p,verbose:a})=>{const{host:n,port:t}=w(o);let l;if(i!==void 0){if(H.extname(i)!==".css"){h.error("The provided CSS file must have a .css extension."),process.exitCode=1;return}try{l=await W.readFile(i,"utf8")}catch(r){r instanceof Error?h.error(`Error loading CSS file: ${r.message}`):h.error(`Unknown error loading CSS file: ${String(r)}`),process.exitCode=1;return}}const c=await oe({ankiConnectOptions:{autoLaunch:e,host:n,port:t},ankiWeb:s,css:l??void 0,dryRun:m}).catch(k);p?(process.stdout.write(JSON.stringify(c,void 0,2)),process.stdout.write(` `)):(process.stderr.write(ne(c,a)),process.stderr.write(` `))}).demandCommand(1).alias("h","help").version(ie).alias("v","version").help().wrap(process.stdout.isTTY?Math.min(120,F.terminalWidth()):0).parse();