yanki
Version:
A CLI tool and TypeScript library for syncing Markdown to Anki flashcards.
11 lines (10 loc) • 12 kB
JavaScript
var q=Object.defineProperty;var m=(e,o)=>q(e,"name",{value:o,configurable:!0});import{globby as $}from"globby";import G from"node:fs/promises";import W from"node:path";import L from"node:os";import _ from"yargs";import{hideBin as V}from"yargs/helpers";import{w as K,x as h,v as Q,y as O,n as X,m as Z,l as ee,b as te,c as oe,f as ne,s as se,i as ae}from"../sync-files-DyHJPksV.js";import"process";import"rehype-parse";import"node:crypto";let y,x;function ie(e){if(typeof e!="string")throw new TypeError(`Expected a string, got ${typeof e}`);if(y===void 0&&(y=L.homedir()),y&&/^~(?=$|\/|\\)/.test(e))return e.replace(/^~/,y);const o=e.match(/^~([^/\\]+)(.*)/);if(o&&(x===void 0&&(x=L.userInfo().username),x)){const s=o[1],a=o[2];if(s===x)return y+a}return e}m(ie,"untildify");var re="1.2.9",B={exports:{}},F;function le(){if(F)return B.exports;F=1;let e=process||{},o=e.argv||[],s=e.env||{},a=!(s.NO_COLOR||o.includes("--no-color"))&&(!!s.FORCE_COLOR||o.includes("--color")||e.platform==="win32"||(e.stdout||{}).isTTY&&s.TERM!=="dumb"||!!s.CI),d=m((n,t,l=n)=>c=>{let i=""+c,u=i.indexOf(t,n.length);return~u?n+p(i,t,l,u)+t:n+i+t},"formatter"),p=m((n,t,l,c)=>{let i="",u=0;do i+=n.substring(u,c)+l,u=c+t.length,c=n.indexOf(t,u);while(~c);return i+n.substring(u)},"replaceClose"),r=m((n=a)=>{let t=n?d:()=>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 B.exports=r(),B.exports.createColors=r,B.exports}m(le,"requirePicocolors");var ce=le(),f=K(ce);const T=process?.versions?.node!==void 0,g={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)}},b={"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"}},R={"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"}},P={verbose:{default:!1,describe:"Enable verbose logging.",type:"boolean"}};function k(e){return{json:{default:!1,describe:e,type:"boolean"}}}m(k,"jsonOption");const N={"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 E(e){return{namespace:{alias:"n",default:h.namespace,describe:e,type:"string"}}}m(E,"namespaceOption");const de={"strict-line-breaks":{alias:"b",default:h.strictLineBreaks,describe:"Set to false to treat single newlines in Markdown as line breaks.",type:"boolean"}};function v(e){const o=Q(e);if(o===void 0)throw new Error(`Invalid AnkiConnect URL: "${e}"`);return o}m(v,"urlToHostAndPortValidated");const C=m(e=>{throw e instanceof Error?(e.cause?.code==="ECONNREFUSED"&&(g.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"),U=_(V(process.argv));await U.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(N).option(E("Advanced option for managing multiple Yanki synchronization groups. Case insensitive. See the readme for more information.")).option(w).option(b).option(R).option("manage-filenames",{alias:"m",choices:["off","prompt","response"],default:h.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(h.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:h.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:h.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.',type:"boolean"}).option("check-database",{default:h.checkDatabase,describe:'Automatically run Anki\'s "Check Database" command after note model updates that might produce database corruption. In Yanki 1.0.2 and earlier, `--check-database false` was the default behavior. Starting with version 1.1.0, it is enabled by default.',type:"boolean"}).option(de).option(k("Output the sync report as JSON.")).option(P),async({ankiAutoLaunch:e,ankiConnect:o,ankiWeb:s,checkDatabase:a,directory:d,dryRun:p,json:r,manageFilenames:n,maxFilenameLength:t,namespace:l,recursive:c=!0,strictLineBreaks:i,strictMatching:u,syncMedia:D,verbose:M})=>{g.verbose=M;const A=O(ie(d)),J=c?`${A}/**/*.md`:`${A}/*.md`,Y=(await $(J,{absolute:!0})).map(S=>O(S)),z=(await $(`${A}/**/*`,{absolute:!0})).map(S=>O(S));if(Y.length===0){g.error(`No Markdown files found in "${d}".`),process.exitCode=1;return}n==="off"&&t!==void 0&&g.warn("Ignoring `max-filename-length` option because `manage-filenames` is not enabled.");const{host:H,port:j}=v(o),I=await X(Y,{allFilePaths:z,ankiConnectOptions:{autoLaunch:e,host:H,port:j},ankiWeb:s,checkDatabase:a,dryRun:p,manageFilenames:n,maxFilenameLength:t,namespace:l,strictLineBreaks:i,strictMatching:u,syncMediaAssets:D}).catch(C);r?(process.stdout.write(JSON.stringify(I,void 0,2)),process.stdout.write(`
`)):(process.stderr.write(Z(I,M)),process.stderr.write(`
`))}).command("list [options]","Utility command to list Yanki-created notes in the Anki database.",e=>e.option(E("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(b).option(k("Output the list of notes as JSON to stdout.")),async({ankiAutoLaunch:e,ankiConnect:o,json:s,namespace:a})=>{const{host:d,port:p}=v(o),r=await ee({ankiConnectOptions:{autoLaunch:e,host:d,port:p},namespace:a}).catch(C);s?(process.stdout.write(JSON.stringify(r,void 0,2)),process.stdout.write(`
`)):(process.stdout.write(te(r)),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(N).option(E("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(b).option(R).option(k("Output the list of deleted notes as JSON to stdout.")).option(P),async({ankiAutoLaunch:e,ankiConnect:o,ankiWeb:s,dryRun:a,json:d,namespace:p,verbose:r})=>{const{host:n,port:t}=v(o),l=await oe({ankiConnectOptions:{autoLaunch:e,host:n,port:t},ankiWeb:s,dryRun:a,namespace:p}).catch(C);d?(process.stdout.write(JSON.stringify(l,void 0,2)),process.stdout.write(`
`)):(process.stderr.write(ne(l,r)),process.stderr.write(`
`))}).command("style [options]","Utility command to set the CSS stylesheet for all present and future Yanki-created notes.",e=>e.option(N).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(b).option(R).option(k("Output the list of updated note types / models as JSON to stdout.")).option(P),async({ankiAutoLaunch:e,ankiConnect:o,ankiWeb:s,css:a,dryRun:d,json:p,verbose:r})=>{const{host:n,port:t}=v(o);let l;if(a!==void 0){if(W.extname(a)!==".css"){g.error("The provided CSS file must have a .css extension."),process.exitCode=1;return}try{l=await G.readFile(a,"utf8")}catch(i){i instanceof Error?g.error(`Error loading CSS file: ${i.message}`):g.error(`Unknown error loading CSS file: ${String(i)}`),process.exitCode=1;return}}const c=await se({ankiConnectOptions:{autoLaunch:e,host:n,port:t},ankiWeb:s,css:l??void 0,dryRun:d}).catch(C);p?(process.stdout.write(JSON.stringify(c,void 0,2)),process.stdout.write(`
`)):(process.stderr.write(ae(c,r)),process.stderr.write(`
`))}).demandCommand(1).alias("h","help").version(re).alias("v","version").help().wrap(process.stdout.isTTY?Math.min(120,U.terminalWidth()):0).parse();