UNPKG

yanki

Version:

A CLI tool and TypeScript library to turn Markdown into Anki flashcards.

1,376 lines 115 kB
import { deepmerge } from "deepmerge-ts"; import plur from "plur"; import prettyMilliseconds from "pretty-ms"; import { YankiConnect, defaultYankiConnectOptions } from "yanki-connect"; import bash from "@shikijs/langs/bash"; import c from "@shikijs/langs/c"; import cpp from "@shikijs/langs/cpp"; import css from "@shikijs/langs/css"; import diff from "@shikijs/langs/diff"; import dockerfile from "@shikijs/langs/dockerfile"; import go from "@shikijs/langs/go"; import html from "@shikijs/langs/html"; import java from "@shikijs/langs/java"; import javascript from "@shikijs/langs/javascript"; import json from "@shikijs/langs/json"; import jsx from "@shikijs/langs/jsx"; import kotlin from "@shikijs/langs/kotlin"; import markdown from "@shikijs/langs/markdown"; import php from "@shikijs/langs/php"; import python from "@shikijs/langs/python"; import regex from "@shikijs/langs/regex"; import ruby from "@shikijs/langs/ruby"; import rust from "@shikijs/langs/rust"; import scss from "@shikijs/langs/scss"; import shellscript from "@shikijs/langs/shellscript"; import sql from "@shikijs/langs/sql"; import swift from "@shikijs/langs/swift"; import toml from "@shikijs/langs/toml"; import tsx from "@shikijs/langs/tsx"; import typescript from "@shikijs/langs/typescript"; import xml from "@shikijs/langs/xml"; import yaml from "@shikijs/langs/yaml"; import rehypeShikiFromHighlighter from "@shikijs/rehype/core"; import githubDark from "@shikijs/themes/github-dark"; import githubLight from "@shikijs/themes/github-light"; import { toText } from "hast-util-to-text"; import rehypeFormat from "rehype-format"; import rehypeParse from "rehype-parse"; import rehypeRaw from "rehype-raw"; import rehypeStringify from "rehype-stringify"; import remarkRehype from "remark-rehype"; import { createHighlighterCoreSync } from "shiki/core"; import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; import { unified } from "unified"; import { u } from "unist-builder"; import { CONTINUE, EXIT, SKIP, visit } from "unist-util-visit"; import fnv1a from "@sindresorhus/fnv1a"; import path from "path-browserify-esm"; import slugify from "@sindresorhus/slugify"; import { sha256 } from "crypto-hash"; import isAbsolutePath from "@stdlib/assert-is-absolute-path"; import slash from "slash"; import remarkBreaks from "remark-breaks"; import { uint8ArrayToBase64 } from "uint8array-extras"; import filenamify from "filenamify"; import { nanoid } from "nanoid"; import remarkRuby from "remark-denden-ruby"; import remarkFlexibleMarkers from "remark-flexible-markers"; import remarkFrontmatter from "remark-frontmatter"; import remarkGfm from "remark-gfm"; import remarkGithubBetaBlockquoteAdmonitions from "remark-github-beta-blockquote-admonitions"; import remarkMath from "remark-math"; import remarkParse from "remark-parse"; import { parse, stringify } from "yaml"; import { sanitizeUri } from "micromark-util-sanitize-uri"; //#region src/lib/utilities/string.ts function getHash(text, length) { return fnv1a(text, { size: length === 8 ? 32 : 64 }).toString(16).padStart(length, "0"); } function capitalize(text) { return text.charAt(0).toUpperCase() + text.slice(1); } /** * Truncates on word boundary and adds ellipsis. Does not give special treatment * to file extensions. If there are no spaces in the text, it will truncate at * `maxLength` without respect for word boundaries. * * @param text Text to truncate * @param maxLength Maximum length excluding ellipsis * @param truncationString String to append to truncated text. Defaults to '...' * @param wordBoundary Character to consider a word boundary. Defaults to a * space. * * @returns Truncated string */ function truncateOnWordBoundary(text, maxLength, truncationString = "...", wordBoundary = " ") { if (text.length <= maxLength) return text; const maxLengthSafe = maxLength - truncationString.length; const words = text.split(wordBoundary); while (words.length > 1 && words.join(wordBoundary).length > maxLengthSafe) words.pop(); return `${words.join(wordBoundary).slice(0, maxLengthSafe)}${truncationString}`; } function cleanClassName(className) { return className.toLowerCase().replaceAll(/[^\da-z]/gi, " ").trim().replaceAll(/ +/g, "-"); } function emptyIsUndefined(text) { if (text === void 0) return; return text.trim() === "" ? void 0 : text; } /** * Mainly for nice formatting with prettier. But the line wrapping means we have * to strip surplus whitespace. * * @public */ function css$1(strings, ...values) { return trimLeadingIndentation(strings, ...values); } const NEWLINE_REGEX$1 = /\r?\n/; const LEADING_WHITESPACE_REGEX = /^(\s+)/; function trimLeadingIndentation(strings, ...values) { const lines = strings.reduce((result, text, i) => `${result}${text}${String(values[i] ?? "")}`, "").split(NEWLINE_REGEX$1).filter((line) => line.trim() !== ""); const leadingSpace = LEADING_WHITESPACE_REGEX.exec(lines[0])?.[0] ?? ""; const leadingSpaceRegex = new RegExp(`^${leadingSpace}`); return lines.map((line) => line.replace(leadingSpaceRegex, "").trimEnd()).join("\n"); } function splitAtFirstMatch(text, regex) { const match = text.match(regex); if (match?.index === void 0) return [text, void 0]; const { index } = match; return [text.slice(0, index), text.slice(index)]; } //#endregion //#region src/lib/shared/constants.ts /** * The default CSS to use for cards. This matches Anki's default. Stored in the * Yanki card models and shared across all Yanki-managed notes regardless of * namespace. It does change occasionally, see * https://github.com/ankitects/anki/blob/main/rslib/src/notetype/styling.css */ const CSS_DEFAULT_STYLE = css$1` .card { font-family: arial; font-size: 20px; line-height: 1.5; text-align: center; color: black; background-color: white; } `; /** * CSS class to always include in a top-level div wrapper in the card template * to allow for custom styling. */ const CSS_DEFAULT_CLASS_NAME = "yanki"; /** * The default deck to put a card in if the deck deck can not be inferred from * the file path, e.g. when the `sync` command is used directly instead of * `syncFiles`. */ const NOTE_DEFAULT_DECK_NAME = "Yanki"; /** * Text to show if a note 'Front' field is empty, and content is required for a * semantically valid card. */ const NOTE_DEFAULT_EMPTY_TEXT = "(Empty)"; /** * HTML element to use to present `NOTE_DEFAULT_EMPTY_TEXT`. TODO consider * hidden span? */ const NOTE_DEFAULT_EMPTY_HAST = u("element", { properties: {}, tagName: "p" }, [u("element", { properties: {}, tagName: "em" }, [u("text", NOTE_DEFAULT_EMPTY_TEXT)])]); const MEDIA_DEFAULT_HASH_MODE_FILE = "metadata"; const MEDIA_DEFAULT_HASH_MODE_URL = "metadata"; /** * How to first attempt to infer the asset type behind a URL. * * - `metadata`: Fetch the head and hope for a `Content-Type` header. * - `name`: Infer the extension from the URL alone, won't work if there's nothing * extension-like in the `pathname`. */ const MEDIA_URL_CONTENT_TYPE_MODE = "metadata"; /** * Filename to use when a media asset has no name. Will be appended with counter * parenthetical as needed. */ const MEDIA_DEFAULT_EMPTY_FILENAME = "Untitled"; /** * Supported image extensions for Anki media assets. * * Note that while officially "supported", some of these are not universally * compatible across Anki platforms. * * Via * https://github.com/ankitects/anki/blob/e41c4573d789afe8b020fab5d9d1eede50c3fa3d/qt/aqt/editor.py#L62 */ const MEDIA_SUPPORTED_IMAGE_EXTENSIONS = [ "avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "tif", "tiff", "webp" ]; /** * Supported audio / video extensions for Anki media assets. * * Note that while officially "supported", some of these are not universally * compatible across Anki platforms. * * Via * https://github.com/ankitects/anki/blob/e41c4573d789afe8b020fab5d9d1eede50c3fa3d/qt/aqt/editor.py#L63-L85 */ const MEDIA_SUPPORTED_AUDIO_VIDEO_EXTENSIONS = [ "3gp", "aac", "avi", "flac", "flv", "m4a", "mkv", "mov", "mp3", "mp4", "mpeg", "mpg", "oga", "ogg", "ogv", "ogx", "opus", "spx", "swf", "wav", "webm" ]; /** * Anki seems happy to open PDF files and download markdown files that have been * added to the assets folder... * https://help.obsidian.md/Files+and+folders/Accepted+file+formats */ const MEDIA_SUPPORTED_FILE_EXTENSIONS = ["md", "pdf"]; const MEDIA_SUPPORTED_EXTENSIONS = [ ...MEDIA_SUPPORTED_AUDIO_VIDEO_EXTENSIONS, ...MEDIA_SUPPORTED_IMAGE_EXTENSIONS, ...MEDIA_SUPPORTED_FILE_EXTENSIONS ]; //#endregion //#region src/lib/utilities/platform.ts const ENVIRONMENT = typeof window === "undefined" ? typeof process === "undefined" ? "other" : "node" : "browser"; ENVIRONMENT === "browser" ? /windows/i.test(navigator.userAgent) || /mac/i.test(navigator.userAgent) || /linux/i.test(navigator.userAgent) || /ubuntu/i.test(navigator.userAgent) : ENVIRONMENT === "node" && (process.platform === "win32" || process.platform === "darwin" || process.platform); //#endregion //#region src/lib/shared/types.ts const defaultGlobalOptions = { allFilePaths: [], ankiConnectOptions: defaultYankiConnectOptions, ankiWeb: false, basePath: void 0, checkDatabase: true, cwd: path.process_cwd, dryRun: false, fetchAdapter: void 0, fileAdapter: void 0, manageFilenames: "off", maxFilenameLength: 60, namespace: "Yanki", obsidianVault: void 0, resolveUrls: true, strictLineBreaks: true, strictMatching: false, syncMediaAssets: "local" }; async function getDefaultFileAdapter() { if (ENVIRONMENT === "node") { const nodeFs = await import("node:fs/promises"); if (nodeFs === void 0) throw new Error("Error loading file functions in Node environment"); return { async readFile(filePath) { return nodeFs.readFile(filePath, "utf8"); }, async readFileBuffer(filePath) { return new Uint8Array(await nodeFs.readFile(filePath)); }, async rename(oldPath, newPath) { await nodeFs.rename(oldPath, newPath); }, async stat(filePath) { return nodeFs.stat(filePath); }, async writeFile(filePath, data) { await nodeFs.writeFile(filePath, data, "utf8"); } }; } throw new Error("The \"readFile\", \"readFileBuffer\", \"rename\" , \"stat\", and \"writeFile\" function implementations must be provided to the function when running in the browser"); } function getDefaultFetchAdapter() { return fetch.bind(globalThis); } //#endregion //#region src/lib/utilities/file.ts async function fileExists(absoluteFilePath, fileAdapter) { try { await fileAdapter.stat(absoluteFilePath); return true; } catch { return false; } } async function getFileContentHash(absoluteFilePath, fileAdapter, mode = MEDIA_DEFAULT_HASH_MODE_FILE) { switch (mode) { case "content": return (await sha256(await fileAdapter.readFileBuffer(absoluteFilePath))).slice(0, 16); case "metadata": { const { mtimeMs, size } = await fileAdapter.stat(absoluteFilePath); const stringToHash = `${mtimeMs ?? ""}${size ?? ""}`; if (stringToHash === "") return getFileContentHash(absoluteFilePath, fileAdapter, "name"); return getHash(stringToHash, 16); } case "name": return getHash(absoluteFilePath, 16); } } //#endregion //#region src/lib/utilities/namespace.ts const CONSECUTIVE_HYPHENS_REGEX = /-+/g; const ASTERISK_REGEX = /\*/; const FORBIDDEN_CHARACTERS = [ [/:/, "Colon"], [/\u0000/, "Null"], [/\u0001/, "Start of Heading"], [/\u0002/, "Start of Text"], [/\u0003/, "End of Text"], [/\u0004/, "End of Transmission"], [/\u0005/, "Enquiry"], [/\u0006/, "Acknowledge"], [/\u0007/, "Bell"], [/\u0008/, "Backspace"], [/\u0009/, "Horizontal Tab"], [/\u000A/, "Line Feed"], [/\u000B/, "Vertical Tab"], [/\u000C/, "Form Feed"], [/\u000D/, "Carriage Return"], [/\u000E/, "Shift Out"], [/\u000F/, "Shift In"], [/\u0010/, "Data Link Escape"], [/\u0011/, "Device Control 1"], [/\u0012/, "Device Control 2"], [/\u0013/, "Device Control 3"], [/\u0014/, "Device Control 4"], [/\u0015/, "Negative Acknowledge"], [/\u0016/, "Synchronous Idle"], [/\u0017/, "End of Transmission Block"], [/\u0018/, "Cancel"], [/\u0019/, "End of Medium"], [/\u001A/, "Substitute"], [/\u001B/, "Escape"], [/\u001C/, "File Separator"], [/\u001D/, "Group Separator"], [/\u001E/, "Record Separator"], [/\u001F/, "Unit Separator"], [/\u007F/, "Delete"], [/\u0080/, "Padding Character"], [/\u0081/, "High Octet Preset"], [/\u0082/, "Break Permitted Here"], [/\u0083/, "No Break Here"], [/\u0084/, "Index"], [/\u0085/, "Next Line"], [/\u0086/, "Start of Selected Area"], [/\u0087/, "End of Selected Area"], [/\u0088/, "Character Tabulation Set"], [/\u0089/, "Character Tabulation with Justification"], [/\u008A/, "Line Tabulation Set"], [/\u008B/, "Partial Line Forward"], [/\u008C/, "Partial Line Backward"], [/\u008D/, "Reverse Line Feed"], [/\u008E/, "Single Shift Two"], [/\u008F/, "Single Shift Three"], [/\u0090/, "Device Control String"], [/\u0091/, "Private Use One"], [/\u0092/, "Private Use Two"], [/\u0093/, "Set Transmit State"], [/\u0094/, "Cancel Character"], [/\u0095/, "Message Waiting"], [/\u0096/, "Start of Protected Area"], [/\u0097/, "End of Protected Area"], [/\u0098/, "Start of String"], [/\u0099/, "Single Graphic Character Introducer"], [/\u009A/, "Single Character Introducer"], [/\u009B/, "Control Sequence Introducer"], [/\u009C/, "String Terminator"], [/\u009D/, "Operating System Command"], [/\u009E/, "Privacy Message"], [/\u009F/, "Application Program Command"], [/\u00A0/, "Non-breaking Space"], [/\u00AD/, "Soft Hyphen"], [/\u200B/, "Zero-width Space"], [/\u200C/, "Zero-width Non-joiner"], [/\u200D/, "Zero-width Joiner"], [/\u200E/, "Left-to-right Mark"], [/\u200F/, "Right-to-left Mark"], [/\u202A/, "Left-to-right Embedding"], [/\u202B/, "Right-to-left Embedding"], [/\u202C/, "Pop Directional Formatting"], [/\u202D/, "Left-to-right Override"], [/\u202E/, "Right-to-left Override"], [/\uFEFF/, "Byte Order Mark (BOM)"] ]; /** * Convenience * * @returns Sanitized valid namespace * @throws {Error} If namespace is invalid */ function validateAndSanitizeNamespace(namespace, allowAsterisk = false) { validateNamespace(namespace, allowAsterisk); return sanitizeNamespace(namespace); } /** * Used internally before storing and searching * * @returns Sanitized namespace */ function sanitizeNamespace(namespace) { return namespace.normalize("NFC").trim(); } /** * Used whenever a users is creating data with a namespace they've provided. * * Note that namespaces are case insensitive! * * Namespace validation is tricky, because the user has to agree with the system * about the letter of the namespace, otherwise there's a risk of data loss. For * this reason, validation is strict and throws errors, so that the user can * understand and correct their input so that they know the proper form for * subsequent uses of the namespace string — especially if they're using the * CLI. * * Silently correcting the namespace would be a bad idea, because the user might * not realize that the namespace has been changed, and then they might not be * able to find their notes. * * @throws {Error} */ function validateNamespace(namespace, allowAsterisk = false) { const errorMessages = []; if (namespace.trim().length === 0) errorMessages.push("Cannot be empty"); if (namespace.trim().length > 60) errorMessages.push(`Cannot be longer than 60 characters`); const forbiddenCharacters = allowAsterisk ? FORBIDDEN_CHARACTERS : [...FORBIDDEN_CHARACTERS, [ASTERISK_REGEX, "Asterisk"]]; for (const [regex, description] of forbiddenCharacters) { const match = namespace.match(regex); if (match) { const character = JSON.stringify(match[0]).slice(1, -1); errorMessages.push(`Forbidden character: ${description}: "${character}"`); } } if (errorMessages.length > 0) throw new Error(`Invalid namespace "${namespace}":\n\t- ${errorMessages.join("\n - ")}`); } /** * Get sanitized namespace with yanki-media- prefix (for ease of searching) */ function getSlugifiedNamespace(namespace) { return `yanki-media-${slugify(sanitizeNamespace(namespace)).replaceAll(CONSECUTIVE_HYPHENS_REGEX, "-")}`; } //#endregion //#region src/lib/utilities/mime.ts /** * Only supports MIMEs for valid Anki media types. */ function getFileExtensionForMimeType(mimeType) { const mimeToExtension = { "application/octet-stream": "mp4", "application/ogg": "ogx", "application/pdf": "pdf", "application/x-shockwave-flash": "swf", "audio/aac": "aac", "audio/flac": "flac", "audio/mp4": "m4a", "audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/opus": "opus", "audio/wav": "wav", "audio/webm": "webm", "audio/x-speex": "spx", "image/avif": "avif", "image/gif": "gif", "image/jpeg": "jpg", "image/png": "png", "image/svg+xml": "svg", "image/tiff": "tif", "image/vnd.microsoft.icon": "ico", "image/webp": "webp", "image/x-icon": "ico", "text/markdown": "md", "video/3gpp": "3gp", "video/flv": "flv", "video/matroska": "mkv", "video/mp4": "mp4", "video/mpeg": "mpg", "video/msvideo": "avi", "video/ogg": "ogv", "video/quicktime": "mov", "video/webm": "webm", "video/x-flv": "flv", "video/x-matroska": "mkv", "video/x-msvideo": "avi" }; const normalizedMimeType = mimeType.split(";")[0].trim().toLowerCase(); if (normalizedMimeType === "") return; return mimeToExtension[normalizedMimeType]; } //#endregion //#region src/lib/utilities/path.ts const WINDOWS_DRIVE_LETTER_REGEX = /^[A-Z]:/i; const QUERY_FRAGMENT_START_REGEX = /[#?^]/; /** * The browserify polyfill doesn't implement win32 absolute path detection... * * @param filePath Normalized path * * @returns Whether the path is absolute */ function isAbsolute(filePath) { return isAbsolutePath.posix(filePath) || isAbsolutePath.win32(filePath); } const RE_WINDOWS_EXTENDED_LENGTH_PATH = /^\\\\\?\\.+/; /** * Converts all paths to cross-platform 'mixed' style with forward slashes. * Warns on unsupported Windows extended length paths. * * @param filePath Path to normalize * * @returns Normalized path */ function normalize(filePath) { if (RE_WINDOWS_EXTENDED_LENGTH_PATH.test(filePath)) { console.warn(`Unsupported extended length path detected: ${filePath}`); return filePath; } const basicPath = slash(filePath); const normalizedPath = path.normalize(basicPath); if (basicPath.startsWith("./")) return `./${normalizedPath}`; return normalizedPath; } /** * Special handling for `/absolute-path.md` style links in Obsidian and static * site generators, where absolute paths are relative to a base path instead of * the volume root. * * Paths starting with Windows drive letters, while technically absolute, are * _not_ prepended with the base: * * - If no base path is provided, paths are resolved relative to the the provided * CWD. * - If paths are relative, the base paths are ignored and the CWD is used. * * All path values are normalized and in 'mixed' platform style. */ function resolveWithBasePath(filePath, options) { const { basePath, compoundBase = false, cwd } = options; if (basePath !== void 0) { if (!isAbsolute(basePath)) console.warn(`Base path "${basePath}" is not absolute`); if (!cwd.startsWith(basePath)) console.warn(`CWD "${cwd}" does not start with base path "${basePath}"`); } if (!isAbsolute(cwd)) console.warn(`CWD "${cwd}" is not absolute`); if (isAbsolute(filePath)) { if (basePath === void 0 || WINDOWS_DRIVE_LETTER_REGEX.test(filePath) || !compoundBase && filePath.startsWith(basePath)) return filePath; return path.join(basePath, filePath); } return path.join(cwd, filePath); } function stripBasePath(filePath, basePath) { if (filePath.toLowerCase().startsWith(basePath.toLowerCase())) return filePath.slice(basePath.length); return filePath; } function getBaseAndQueryParts(filePath) { const directoryPath = path.dirname(filePath); const [base, query] = splitAtFirstMatch(path.basename(filePath), QUERY_FRAGMENT_START_REGEX); return [path.join(directoryPath, base), query]; } function getBase(filePath) { return getBaseAndQueryParts(filePath)[0]; } function getQuery(filePath) { return getBaseAndQueryParts(filePath).at(1) ?? ""; } function hasExtension(filePath) { return getExtension(filePath) !== ""; } function getExtension(filePath) { return path.extname(getBase(filePath)); } function addExtensionIfMissing(filePath, extension) { if (hasExtension(filePath)) return filePath; return addExtension(filePath, extension); } function addExtension(filePath, extension) { const [base, query] = getBaseAndQueryParts(filePath); return `${base}.${extension}${query ?? ""}`; } //#endregion //#region src/lib/utilities/url.ts const DRIVE_LETTER_REGEX = /^[a-z]:/i; const FILE_PREFIX_REGEX = /^file:/i; function safeDecodeURI(text) { try { return decodeURI(text); } catch (error) { console.warn(`Error decoding URI text: "${text}"`, error); return; } } /** * Parse a string into a URL object if parsable, and return undefined otherwise * (e.g. if it's a file path) _instead_ of throwing an error like the native URL * constructor does. */ function safeParseUrl(text) { try { const url = new URL(text); if ((FILE_PREFIX_REGEX.test(url.protocol) || DRIVE_LETTER_REGEX.test(url.protocol)) && !FILE_PREFIX_REGEX.test(text)) return; return url; } catch { return; } } function isUrl(text) { return safeParseUrl(text) !== void 0; } /** * Helper to "filter" file URLs into path strings so they're treated correctly * in mdastToHtml * * @todo Need stuff from node's implementation, fileURLToPath? */ function fileUrlToPath(url) { const parsedUrl = safeParseUrl(url); if (parsedUrl?.protocol === "file:") return parsedUrl.pathname; return url; } function getSrcType(filePathOrUrl) { const url = safeParseUrl(filePathOrUrl); if (url === void 0) { const normalizedPath = normalize(filePathOrUrl); if (isAbsolute(normalizedPath) || normalizedPath.startsWith("./") || normalizedPath.startsWith("../")) return "localFilePath"; return "localFileName"; } if (url.protocol === "file:") return "localFileUrl"; if (url.protocol === "obsidian:") return "obsidianVaultUrl"; if (url.protocol === "http:" || url.protocol === "https:") return "remoteHttpUrl"; return "unsupportedProtocolUrl"; } /** * Supports both Header type and Record<string, string> type * * @param headers Headers object or record from a fetch response * @param headerKeys Headers to include in the string * * @returns A concatenated string of the header contents, suitable for hashing, * or undefined if no matching headers are present */ function getHeadersString(headers, headerKeys) { if (headers === void 0) return; if (!(headers instanceof Headers)) headers = convertKeysToLowercase(headers); const headerString = (headers instanceof Headers ? headerKeys.map((key) => headers.get(key)) : headerKeys.map((key) => headers[key])).filter((value) => value !== null && value !== void 0).join(""); if (headerString === "") return; return headerString; } function convertKeysToLowercase(object) { const result = {}; for (const [key, value] of Object.entries(object)) result[key.toLowerCase()] = value; return result; } async function urlExists(url, fetchAdapter) { try { return (await fetchAdapter(url, { method: "HEAD" }))?.status === 200; } catch { return false; } } async function getFileExtensionFromUrl(url, fetchAdapter, mode = MEDIA_URL_CONTENT_TYPE_MODE) { switch (mode) { case "metadata": if (fetchAdapter === void 0) return getFileExtensionFromUrl(url, fetchAdapter, "name"); try { const contentTypeHeaderValue = getHeadersString((await fetchAdapter(url, { method: "HEAD" }))?.headers, ["content-type"]); if (contentTypeHeaderValue === void 0) throw new Error("No content-type header found"); const extension = getFileExtensionForMimeType(contentTypeHeaderValue); if (extension !== void 0) return extension; return await getFileExtensionFromUrl(url, fetchAdapter, "name"); } catch { return getFileExtensionFromUrl(url, fetchAdapter, "name"); } case "name": { let extensionInUrl; const parsedUrl = safeParseUrl(url); if (parsedUrl === void 0) { console.warn(`Could not parse URL: ${url}`); return; } const pathnameParts = parsedUrl.pathname.split("."); if (pathnameParts.length > 1) extensionInUrl = pathnameParts.at(-1); else extensionInUrl = parsedUrl.search.split(".").at(-1); if (MEDIA_SUPPORTED_EXTENSIONS.includes(extensionInUrl ?? "")) return extensionInUrl; return; } } } /** * Tradeoffs between content change sensitivity and sync speed / efficiency, * especially for remote assets. * * - `filename`: Use the filename of the media asset, no network required. * - `metadata`: Use the metadata of the media asset, either fstat stuff for * files, or reading the headers for URLs... requires a network request for * remote urls. Falls through to `filename` if not available. * - `content`: Actually read the content of the media asset, requires reading the * file or fetching the URL. Not yet implemented. Falls through to `metadata` * if not available. */ async function getUrlContentHash(url, fetchAdapter, mode = MEDIA_DEFAULT_HASH_MODE_URL) { switch (mode) { case "content": console.warn("`content` hash mode is not yet implemented for URLs"); return getUrlContentHash(url, fetchAdapter, "metadata"); case "metadata": try { const stringToHash = getHeadersString((await fetchAdapter(url, { method: "HEAD" }))?.headers, [ "etag", "last-modified", "content-length" ]); if (stringToHash === void 0) throw new Error("No headers found"); return getHash(stringToHash, 16); } catch { return getUrlContentHash(url, fetchAdapter, "name"); } case "name": return getHash(url, 16); } } function urlToHostAndPort(url) { const parsedUrl = safeParseUrl(url); return parsedUrl === void 0 ? void 0 : { host: `${parsedUrl.protocol}//${parsedUrl.hostname}`, port: Number.parseInt(parsedUrl.port, 10) }; } function hostAndPortToUrl(host, port) { return `${host}:${port}`; } //#endregion //#region src/lib/utilities/media.ts /** * Get the extension of a media file, if it's supported * * @returns Extension without the `.`, possibly an extra string if no extension * is found * @todo Check for how it handles query strings * * @todo Clean up type casting */ async function getAnkiMediaFilenameExtension(pathOrUrl, fetchAdapter) { const extensionCandidate = isUrl(pathOrUrl) ? await getFileExtensionFromUrl(pathOrUrl, fetchAdapter) : path.extname(pathOrUrl).slice(1); if (extensionCandidate === void 0 || !MEDIA_SUPPORTED_EXTENSIONS.includes(extensionCandidate)) return; return extensionCandidate; } /** * Check if a media asset exists */ async function mediaAssetExists(absolutePathOrUrl, fileAdapter, fetchAdapter) { if (isUrl(absolutePathOrUrl)) return urlExists(absolutePathOrUrl, fetchAdapter); return fileExists(absolutePathOrUrl, fileAdapter); } /** * Get a safe filename for an Anki media asset Anki truncates long file names... * so we crush the complete path down to a hash */ async function getSafeAnkiMediaFilename(absolutePathOrUrl, namespace, fileExtension, fileAdapter, fetchAdapter) { if (!await mediaAssetExists(absolutePathOrUrl, fileAdapter, fetchAdapter)) return; const safeNamespace = getSlugifiedNamespace(namespace); const assetHash = await getContentHash(absolutePathOrUrl, fileAdapter, fetchAdapter); const resolvedFileExtension = fileExtension === void 0 ? "" : `.${fileExtension}`; let safeFilename; safeFilename = `${safeNamespace}-${assetHash}${resolvedFileExtension}`; if (safeFilename.length > 120) throw new Error(`Filename too long: ${safeFilename}`); return safeFilename; } async function getContentHash(absolutePathOrUrl, fileAdapter, fetchAdapter) { return isUrl(absolutePathOrUrl) ? getUrlContentHash(absolutePathOrUrl, fetchAdapter) : getFileContentHash(absolutePathOrUrl, fileAdapter); } //#endregion //#region src/lib/parse/rehype-mathjax-anki.ts const CONSECUTIVE_BRACES_REGEX = /([{}])(?=[{}])/g; function sanitizeBraces(value) { return value.replaceAll(CONSECUTIVE_BRACES_REGEX, "$1 "); } /** * Non-rendering replacement for the `rehype-mathjax` plugin, which takes output * from `remark-math` and wraps it in Anki-specific syntax. * * See https://docs.ankiweb.net/math.html?#mathjax */ const plugin$3 = function() { return function(tree) { let fenced = false; visit(tree, (node, index, parent) => { if (parent === void 0 || index === void 0 || node.type !== "element") return CONTINUE; if (node.tagName === "pre" && node.children.length === 1 && node.children[0].type === "element" && node.children[0].tagName === "code" && Array.isArray(node.children[0].properties.className) && node.children[0].properties.className.includes("language-math")) { fenced = true; parent.children.splice(index, 1, node.children[0]); } if (node.tagName === "code" && Array.isArray(node.properties.className) && node.properties.className.includes("language-math")) { const isBlock = node.properties.className.includes("math-display") || fenced; fenced = false; for (const child of node.children) if (child.type === "text") child.value = sanitizeBraces(child.value); node.tagName = isBlock ? "div" : "span"; node.children = [ { type: "text", value: isBlock ? String.raw`\[` : String.raw`\(` }, ...node.children, { type: "text", value: isBlock ? String.raw`\]` : String.raw`\)` } ]; } }); }; }; //#endregion //#region src/lib/parse/remark-conditional-breaks.ts const plugin$2 = function() { return function(tree, file) { if (file.data.strictLineBreaks === false) { remarkBreaks()(tree); return; } return tree; }; }; //#endregion //#region src/lib/parse/rehype-utilities.ts const DIMENSION_CHARS_REGEX = /^[\dx]+$/; const highlighter = createHighlighterCoreSync({ engine: createJavaScriptRegexEngine(), langs: [ bash, c, cpp, css, diff, dockerfile, go, html, java, javascript, json, jsx, kotlin, markdown, php, python, regex, ruby, rust, scss, shellscript, sql, swift, toml, tsx, typescript, xml, yaml ], themes: [githubDark, githubLight] }); const processor = unified().use(plugin$2).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(plugin$3).use(rehypeShikiFromHighlighter, highlighter, { defaultLanguage: "plaintext", fallbackLanguage: "plaintext", themes: { dark: "github-dark", light: "github-light" } }).use(rehypeFormat).use(rehypeStringify); const defaultMdastToHtmlOptions = { ...defaultGlobalOptions }; async function mdastToHtml(mdast, options) { if (mdast === void 0) return ""; const { cssClassNames, fetchAdapter = getDefaultFetchAdapter(), fileAdapter = await getDefaultFileAdapter(), namespace, strictLineBreaks, syncMediaAssets, useEmptyPlaceholder } = deepmerge(defaultMdastToHtmlOptions, options ?? {}); const hast = await processor.run(mdast, { data: { strictLineBreaks } }); const hastWithClass = u("root", [u("element", { properties: { className: cssClassNames?.map((name) => cleanClassName(name)) }, tagName: "div" }, hast.children)]); const treeMutationPromises = []; visit(hastWithClass, "element", (node, index, parent) => { if (parent === void 0 || index === void 0 || node.tagName !== "img") return CONTINUE; if (typeof node.properties.src !== "string" || node.properties?.src?.trim().length === 0) { console.warn("Image has no src"); return CONTINUE; } let absolutePathOrUrl; const srcType = getSrcType(node.properties.src); switch (srcType) { case "localFilePath": absolutePathOrUrl = safeDecodeURI(node.properties.src); if (absolutePathOrUrl === void 0) return CONTINUE; absolutePathOrUrl = getBase(absolutePathOrUrl); break; case "unsupportedProtocolUrl": case "localFileName": console.warn(`Unsupported URL for media asset, treating as link: "${node.properties.src}"`); absolutePathOrUrl = node.properties.src; break; case "obsidianVaultUrl": case "localFileUrl": absolutePathOrUrl = node.properties.src; break; case "remoteHttpUrl": absolutePathOrUrl = node.properties.src; break; } treeMutationPromises.push(async () => { const extension = await getAnkiMediaFilenameExtension(absolutePathOrUrl, srcType === "obsidianVaultUrl" ? void 0 : fetchAdapter); const supportedMedia = extension !== void 0 && srcType !== "unsupportedProtocolUrl" && srcType !== "localFileName" && srcType !== "obsidianVaultUrl" && srcType !== "localFileUrl"; const yankiSyncMedia = (srcType === "localFilePath" && syncMediaAssets === "local" || srcType === "remoteHttpUrl" && syncMediaAssets === "remote" || syncMediaAssets === "all") && supportedMedia; const ankiMediaFilename = yankiSyncMedia ? await getSafeAnkiMediaFilename(absolutePathOrUrl, namespace, extension, fileAdapter, fetchAdapter) : void 0; const finalSrc = ankiMediaFilename ?? absolutePathOrUrl; const syncEnabled = yankiSyncMedia && ankiMediaFilename !== void 0 ? "true" : "false"; if (!supportedMedia || MEDIA_SUPPORTED_FILE_EXTENSIONS.includes(extension)) { const finalSourceWithQuery = isUrl(finalSrc) ? finalSrc : `${finalSrc}${getQuery(String(node.properties.dataYankiSrcOriginal))}`; parent.children.splice(index, 1, u("element", { properties: { className: ["yanki-media", `yanki-media-${supportedMedia ? "file" : "unsupported"}`], "data-yanki-alt-text": node.properties.alt, "data-yanki-media-src": absolutePathOrUrl, "data-yanki-media-sync": syncEnabled, "data-yanki-src": finalSrc, "data-yanki-src-original": node.properties.dataYankiSrcOriginal }, tagName: "span" }, [u("element", { properties: { href: finalSourceWithQuery }, tagName: "a" }, [u("text", decodeURI(String(node.properties.dataYankiSrcOriginal)))])])); } else if (MEDIA_SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) { node.properties.src = finalSrc; node.properties.className = ["yanki-media", "yanki-media-image"]; node.properties.dataYankiMediaSrc = absolutePathOrUrl; node.properties.dataYankiMediaSync = syncEnabled; } else if (MEDIA_SUPPORTED_AUDIO_VIDEO_EXTENSIONS.includes(extension)) parent.children.splice(index, 1, u("element", { properties: { className: ["yanki-media", "yanki-media-audio-video"], "data-yanki-alt-text": node.properties.alt, "data-yanki-media-src": absolutePathOrUrl, "data-yanki-media-sync": syncEnabled, "data-yanki-src": finalSrc, "data-yanki-src-original": node.properties.dataYankiSrcOriginal }, tagName: "span" }, [u("text", `[sound:${finalSrc}]`)])); }); }); for (const mutationPromise of treeMutationPromises) await mutationPromise(); visit(hastWithClass, "element", (node, index, parent) => { if (parent === void 0 || index === void 0 || node.tagName !== "img" || emptyIsUndefined(String(node.properties.alt)) === void 0) return CONTINUE; const { alt, height, width } = parseDimensionsFromAltText(String(node.properties.alt ?? "")); if (alt === void 0) delete node.properties.alt; else node.properties.alt = alt; if (height !== void 0) node.properties.height = height; if (width !== void 0) node.properties.width = width; }); const isEmpty = isVisuallyEmpty(hastWithClass); if (isEmpty && !useEmptyPlaceholder) return ""; const nonEmptyHast = isEmpty ? addFirstChildToFirstDiv(hastWithClass, NOTE_DEFAULT_EMPTY_HAST) : hastWithClass; return addBoilerplateComment(processor.stringify(nonEmptyHast)).trim(); } const htmlProcessor = unified().use(rehypeParse, { fragment: true }); function addBoilerplateComment(html) { return `<!-- This HTML was generated by Yanki, a Markdown to Anki converter. Do not edit directly. -->\n${html}`; } function hastToPlainText(hast) { return toText(hast); } function htmlToPlainText(html) { return hastToPlainText(htmlProcessor.parse(html)); } function getAllLinesOfHtmlAsPlainText(html) { return htmlToPlainText(html).split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join(" "); } function getFirstLineOfHtmlAsPlainText(html) { return htmlToPlainText(html).split("\n").map((line) => line.trim()).find((line) => line.length > 0) ?? ""; } function extractMediaFromHtml(html) { const hast = htmlProcessor.parse(html); const media = []; visit(hast, "element", (node) => { if ((node.tagName === "img" || node.tagName === "span") && node.properties?.dataYankiMediaSync === "true") { const filename = node.properties?.src ?? node.properties?.dataYankiSrc; const originalSrc = node.properties?.dataYankiMediaSrc; if (filename !== void 0 && originalSrc !== void 0 && typeof filename === "string" && typeof originalSrc === "string") media.push({ filename, originalSrc }); } }); return media; } function parseDimensionsFromAltText(alt) { const altParts = alt.split("|"); const lastAltPart = emptyIsUndefined(altParts.pop()); const firstAltPart = emptyIsUndefined(altParts.join("|")); if (lastAltPart !== void 0) { const { width, height } = parseDimensions(lastAltPart); if (width !== void 0 || height !== void 0) return { alt: firstAltPart, height, width }; } return { alt, height: void 0, width: void 0 }; } function parseDimensions(dimensions) { if (!DIMENSION_CHARS_REGEX.test(dimensions)) return { width: void 0, height: void 0 }; if (!dimensions.includes("x")) { const widthOnly = Number.parseInt(dimensions, 10); if (!Number.isNaN(widthOnly)) return { width: widthOnly, height: void 0 }; } const [width, height] = dimensions.split("x").map((dim) => Number.parseInt(dim, 10)); return { width: Number.isNaN(width) || width === void 0 ? void 0 : width, height: Number.isNaN(height) || height === void 0 ? void 0 : height }; } /** * Determine if a HAST tree is visually empty. * * @param tree - The HAST tree to check. * * @returns - True if the tree is visually empty, otherwise false. */ function isVisuallyEmpty(tree) { let hasVisualContent = false; visit(tree, (node) => { if (hasVisualContent) return; if (node.type === "element") { const element = node; const { tagName } = element; if ([ "img", "video", "audio", "iframe", "object", "embed", "canvas", "svg", "picture" ].includes(tagName)) { hasVisualContent = true; return EXIT; } if (toText(element).trim()) { hasVisualContent = true; return EXIT; } } if (node.type === "text" && node.value !== void 0 && node.value.trim() !== "") { hasVisualContent = true; return EXIT; } }); return !hasVisualContent; } /** * Add a first child to the first div element in a HAST tree. Intended for use * with the "div-wrapped" HAST tree generated early in `mdastToHtml`. * * @param tree - The HAST tree to modify in place. * @param newChild - The new child node to add. * * @returns - The modified-in-place HAST tree. */ function addFirstChildToFirstDiv(tree, newChild) { visit(tree, "element", (node) => { if (node.tagName === "div") { node.children.unshift(newChild); return EXIT; } }); return tree; } //#endregion //#region src/lib/model/model.ts const yankiModels = [ { cardTemplates: [{ Back: "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}", Front: "{{Front}}", YankiNamespace: "{{YankiNamespace}}" }], inOrderFields: [ "Front", "Back", "YankiNamespace" ], modelName: "Yanki - Basic" }, { cardTemplates: [{ Back: "{{cloze:Front}}<br>\n{{Back}}", Front: "{{cloze:Front}}", YankiNamespace: "{{YankiNamespace}}" }], inOrderFields: [ "Front", "Back", "YankiNamespace" ], isCloze: true, modelName: "Yanki - Cloze" }, { cardTemplates: [{ Back: "{{Front}}\n\n<hr id=answer>\n\n{{type:Back}}", Front: "{{Front}}\n\n{{type:Back}}", YankiNamespace: "{{YankiNamespace}}" }], inOrderFields: [ "Front", "Back", "YankiNamespace" ], modelName: "Yanki - Basic (type in the answer)" }, { cardTemplates: [{ Back: "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}{{#Extra}}\n\n<hr>\n\n{{Extra}}{{/Extra}}", Front: "{{Front}}", YankiNamespace: "{{YankiNamespace}}" }, { Back: "{{FrontSide}}\n\n<hr id=answer>\n\n{{Front}}{{#Extra}}\n\n<hr>\n\n{{Extra}}{{/Extra}}", Front: "{{Back}}", YankiNamespace: "{{YankiNamespace}}" }], inOrderFields: [ "Front", "Back", "Extra", "YankiNamespace" ], modelName: "Yanki - Basic (and reversed card with extra)" } ]; const yankiModelNames = yankiModels.map((model) => model.modelName); const legacyYankiModelNames = ["Yanki - Basic (and reversed card)"]; //#endregion //#region src/lib/utilities/anki-connect.ts async function ensureModelExists(client, model) { try { await client.model.createModel(model); } catch (error) { if (error instanceof Error && error.message === "Model name already exists") return; throw error; } } async function deleteNotes(client, notes, dryRun = false) { if (dryRun) return; const noteIds = notes.map((note) => note.noteId).filter((noteId) => noteId !== void 0); await client.note.deleteNotes({ notes: noteIds }); } /** * Add a note to Anki. * * Does "just in time" creation of requisite models and decks. * * Duplicates will be created if present in the source. It's up to the user to * manage their Markdown files as they like. * * @param client An instance of YankiConnect * @param note The note to add * @param dryRun If true, the note will not be created and an ID of 0 will be * returned * * @returns The ID of the newly created note in Anki * @throws {Error} */ async function addNote(client, note, dryRun, fileAdapter) { if (note.noteId !== void 0) throw new Error("Note already has an ID"); if (dryRun) return 0; const newNote = await client.note.addNote({ note: { ...note, options: { allowDuplicate: true } } }).catch(async (error) => { if (error instanceof Error) { if (error.message === `model was not found: ${note.modelName}`) { const model = yankiModels.find((model) => model.modelName === note.modelName); if (model === void 0) throw new Error(`Model not found: ${note.modelName}`); await ensureModelExists(client, model); return addNote(client, note, dryRun, fileAdapter); } if (error.message === `deck was not found: ${note.deckName}`) { if (note.deckName === "") throw new Error("Deck name is empty"); await client.deck.createDeck({ deck: note.deckName }); return addNote(client, note, dryRun, fileAdapter); } throw error; } else throw new TypeError("Unknown error"); }); if (newNote === null) throw new Error("Note creation failed"); await uploadMediaForNote(client, note, dryRun, fileAdapter); return newNote; } /** * Updates a note in Anki. * * @param client An instance of YankiConnect * @param localNote A note read from a markdown file * @param remoteNote A note loaded from Anki * * @returns True if the note was updated, false otherwise. * @throws {Error} If the local note ID or remote note cards are undefined, or * if model/deck errors occur. */ async function updateNote(client, localNote, remoteNote, dryRun, fileAdapter) { if (localNote.noteId === void 0) throw new Error("Local note ID is undefined"); if (remoteNote.cards === void 0) throw new Error("Remote note cards are undefined"); let updated = false; if (localNote.deckName !== remoteNote.deckName) { if (localNote.deckName === "") throw new Error("Local deck name is empty"); if (!dryRun) await client.deck.changeDeck({ cards: remoteNote.cards, deck: localNote.deckName }); updated = true; } if (!areTagsEqual(localNote.tags ?? [], remoteNote.tags ?? []) || !areFieldsEqual(localNote.fields, remoteNote.fields) || localNote.modelName !== remoteNote.modelName) { if (!dryRun) { await client.note.updateNoteModel({ note: { ...localNote, id: localNote.noteId, tags: localNote.tags ?? [] } }).catch(async (error) => { if (error instanceof Error) { if (error.message === `Model '${localNote.modelName}' not found`) { const model = yankiModels.find((model) => model.modelName === localNote.modelName); if (model === void 0) throw new Error(`Model not found: ${localNote.modelName}`); await ensureModelExists(client, model); return updateNote(client, localNote, remoteNote, dryRun, fileAdapter); } throw error; } else throw new TypeError("Unknown error"); }); await uploadMediaForNote(client, localNote, dryRun, fileAdapter); } updated = true; } return updated; } /** * Helper to compare local and remote field contents. * * @returns True if the fields are equal, false otherwise. */ function areFieldsEqual(localFields, remoteFields) { for (const key of [ "Front", "Back", "Extra" ]) if (key in localFields && key in remoteFields) { if (localFields[key].normalize("NFC") !== remoteFields[key].normalize("NFC")) return false; } else if (key in localFields || key in remoteFields) return false; return true; } function areNotesEqual(noteA, noteB, includeId = true) { if (includeId && noteA.noteId !== noteB.noteId) return false; if (noteA.deckName !== noteB.deckName) return false; if (noteA.modelName !== noteB.modelName) return false; if (!areFieldsEqual(noteA.fields, noteB.fields)) return false; if (!areTagsEqual(noteA.tags ?? [], noteB.tags ?? [])) return false; return true; } /** * Helper function to compare two arrays of tags. Note some nuances around case * insensitivity as discussed here: * https://github.com/kitschpatrol/yanki-obsidian/issues/44 Anki will * alphabetically sort tags, so we sort as well. Duplicate tags are ignored in * Anki, so we ignore them here: ['yes', 'yes'] is considered equal to ['yes']. * Tags in different orders are considered equal: ['yes', 'no'] is considered * equal to ['no', 'yes']. * * @returns True if the tags are equal, false otherwise. */ function areTagsEqual(localTags, remoteTags) { const localTagsSet = new Set(localTags.map((tag) => tag.normalize("NFC").toLowerCase())); const remoteTagsSet = new Set(remoteTags.map((tag) => tag.normalize("NFC").toLowerCase())); return new Set([...localTagsSet, ...remoteTagsSet]).size === remoteTagsSet.size; } /** * Get all notes from Anki that match the model prefix. * * @param client An instance of YankiConnect * @param namespace The value of the YankiNamespace field, or search with '*' to * get all notes. Defaults to the global default namespace. * * @returns An array of YankiNote objects * @throws {Error} */ async function getRemoteNotes(client, namespace = defaultGlobalOptions.namespace) { return (await getRemoteNotesById(client, await client.note.findNotes({ query: `"YankiNamespace:${namespace}"` }))).filter((note) => note !== void 0); } /** * Get all data from Anki required to populate the YankiNote type. * * Handles some extra footwork to identify the deck name and validate the model * name. There's no way to get everything we need in one shot from AnkiConnect. * * @param client An instance of YankiConnect * @param noteIds An array of note IDs (from `findNotes`) to fetch * * @returns Array positionally aligned with `noteIds`. Entries are `undefined` * when `notesInfo` does not resolve the corresponding ID — `findNotes` can * return phantom IDs (e.g. left behind by `addNote` retries) that no longer * exist as notes. * @throws {Error} If an unknown model name or multiple decks are found for a * note, or if no deck is found. */ async function getRemoteNotesById(client, noteIds) { const ankiNotes = await client.note.notesInfo({ notes: noteIds }); const yankiNotes = []; if (ankiNotes.every((ankiNote) => ankiNote.noteId === void 0)) return Array.from({ length: ankiNotes.length }).fill(void 0); const allCardIds = ankiNotes.flatMap((note) => note.cards ?? []); const deckToCardMap = await client.deck.getDecks({ cards: allCardIds }); const cardIdToDeckMap = /* @__PURE__ */ new Map(); for (const [deck, cards] of Object.entries(deckToCardMap)) for (const card of cards) cardIdToDeckMap.set(card, deck); const deckFilteredStatusMap = /* @__PURE__ */ new Map(); const unfilteredDeckNoteIdMap = /* @__PURE__ */ new Map(); for (const ankiNote of ankiNotes) { if (ankiNote.noteId === void 0) { yankiNotes.push(void 0); continue; } if (![...legacyYankiModelNames, ...yankiModelNames].includes(ankiNote.modelName)) throw new Error(`Unknown model name ${ankiNote.modelName} for note ${ankiNote.noteId}`); const deckNamesForNote = [...new Set(ankiNote.cards.map((card) => cardIdToDeckMap.get(card)))]; if (deckNamesForNote.length > 1) {} let deckName = deckNamesForNote.at(0); if (deckName === void 0) throw new Error(`No deck found for cards in note ${ankiNote.noteId}`); if (!deckFilteredStatusMap.has(deckName)) { const deckConfig = await client.deck.getDeckConfig({ deck: deckName }); const isFiltered = Boolean(deckConfig.dyn); deckFilteredStatusMap.set(deckName, isFiltered); if (isFiltered) { const allDeckNames = await client.deck.deckNames(); for (const localName of allDeckNames) if (!deckFilteredStatusMap.has(localName)) { const deckConfig = await client.deck.getDeckConfig({ deck: localName }); deckFilteredStatusMap.set(localName, Boolean(deckConfig.dyn)); } const sortedUnfilteredDeckNames = [...deckFilteredStatusMap.entries()].filter(([deck, isFiltered]) => !isFiltered && deck !== "Default").map(([deck]) => deck).sort((a, b) => b.split("::").length - a.split("::").length); sortedUnfilteredDeckNames.push("Default"); for (const localDeckName of sortedUnfilteredDeckNames) unfilteredDeckNoteIdMap.set(localDeckName, void 0); } } if (deckFilteredStatusMap.get(deckName)) { const namesToCheck = unfilteredDeckNoteIdMap.keys(); let found = false; for (const nameToCheck of namesToCheck) { if (unfilteredDeckNoteIdMap.get(nameToCheck) === void 0) unfilteredDeckNoteIdMap.set(nameToCheck, await client.note.findNotes({ query: `"deck:${nameToCheck}"` })); if (unfilteredDeckNoteIdMap.get(nameToCheck)?.includes(ankiNote.noteId