yanki
Version:
A CLI tool and TypeScript library to turn Markdown into Anki flashcards.
1,376 lines • 115 kB
JavaScript
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