import { getCrypto,utils as coreUtils } from "@triliumnext/core"; import chardet from "chardet"; import crypto from "crypto"; import { t } from "i18next"; import { release as osRelease } from "os"; import path from "path"; import stripBom from "strip-bom"; import log from "./log.js"; import type NoteMeta from "./meta/note_meta.js"; const osVersion = osRelease().split('.').map(Number); export const isMac = process.platform === "darwin"; export const isWindows = process.platform === "win32"; export const isWindows11 = isWindows && osVersion[0] === 10 && osVersion[2] >= 22000; export const isElectron = !!process.versions["electron"]; export const isDev = !!(process.env.TRILIUM_ENV && process.env.TRILIUM_ENV === "dev"); /** @deprecated */ export function newEntityId() { return coreUtils.newEntityId(); } /** @deprecated */ export function randomString(length: number): string { return coreUtils.randomString(length); } export function md5(content: crypto.BinaryLike) { return crypto.createHash("md5").update(content).digest("hex"); } /** @deprecated */ export function hashedBlobId(content: string | Buffer) { return coreUtils.hashedBlobId(content); } export function toBase64(plainText: string | Buffer) { const buffer = (Buffer.isBuffer(plainText) ? plainText : Buffer.from(plainText)); return buffer.toString("base64"); } export function fromBase64(encodedText: string) { return Buffer.from(encodedText, "base64"); } export function hmac(secret: string | Uint8Array, value: string | Uint8Array) { return getCrypto().hmac(secret, value); } /** * Constant-time string comparison to prevent timing attacks. * Uses crypto.timingSafeEqual to ensure comparison time is independent * of how many characters match. * * @param a First string to compare * @param b Second string to compare * @returns true if strings are equal, false otherwise * @note Returns false for null/undefined/non-string inputs. Empty strings are considered equal. */ export function constantTimeCompare(a: string | null | undefined, b: string | null | undefined): boolean { // Handle null/undefined/non-string cases safely if (typeof a !== "string" || typeof b !== "string") { return false; } const bufA = Buffer.from(a, "utf-8"); const bufB = Buffer.from(b, "utf-8"); // If lengths differ, we still do a constant-time comparison // to avoid leaking length information through timing if (bufA.length !== bufB.length) { // Compare bufA against itself to maintain constant time behavior crypto.timingSafeEqual(bufA, bufA); return false; } return crypto.timingSafeEqual(bufA, bufB); } export function toObject(array: T[], fn: (item: T) => [K, V]): Record { const obj: Record = {} as Record; // TODO: unsafe? for (const item of array) { const ret = fn(item); obj[ret[0]] = ret[1]; } return obj; } export function stripTags(text: string) { return text.replace(/<(?:.|\n)*?>/gm, ""); } /** @deprecated */ export function getContentDisposition(filename: string) { return coreUtils.getContentDisposition(filename); } /** @deprecated */ export function isStringNote(type: string | undefined, mime: string) { return coreUtils.isStringNote(type, mime); } /** @deprecated */ export function quoteRegex(url: string) { return coreUtils.quoteRegex(url); } /** @deprecated */ export function replaceAll(string: string, replaceWhat: string, replaceWith: string) { return coreUtils.replaceAll(string, replaceWhat, replaceWith); } /** @deprecated */ export function formatDownloadTitle(fileName: string, type: string | null, mime: string) { return coreUtils.formatDownloadTitle(fileName, type, mime); } export function removeFileExtension(filePath: string, mime?: string) { const extension = path.extname(filePath).toLowerCase(); if (mime?.startsWith("video/") || mime?.startsWith("audio/")) { return filePath.substring(0, filePath.length - extension.length); } switch (extension) { case ".md": case ".mdx": case ".markdown": case ".html": case ".htm": case ".excalidraw": case ".mermaid": case ".mmd": case ".pdf": return filePath.substring(0, filePath.length - extension.length); default: return filePath; } } export function getNoteTitle(filePath: string, replaceUnderscoresWithSpaces: boolean, noteMeta?: NoteMeta) { const trimmedNoteMeta = noteMeta?.title?.trim(); if (trimmedNoteMeta) return trimmedNoteMeta; const basename = path.basename(removeFileExtension(filePath, noteMeta?.mime)); return replaceUnderscoresWithSpaces ? basename.replace(/_/g, " ").trim() : basename; } /** @deprecated */ export function removeDiacritic(str: string) { return coreUtils.removeDiacritic(str); } /** @deprecated */ export function normalize(str: string) { return coreUtils.normalize(str); } /** @deprecated */ export function toMap>(list: T[], key: keyof T) { return coreUtils.toMap(list, key); } // try to turn 'true' and 'false' strings from process.env variables into boolean values or undefined export function envToBoolean(val: string | undefined) { if (val === undefined || typeof val !== "string") return undefined; const valLc = val.toLowerCase().trim(); if (valLc === "true") return true; if (valLc === "false") return false; return undefined; } /** * Parses a string value to an integer. If the resulting number is NaN or undefined, the result is also undefined. * * @param val the value to parse. * @returns the parsed value. */ export function stringToInt(val: string | undefined) { if (!val) { return undefined; } const parsed = parseInt(val, 10); if (Number.isNaN(parsed)) { return undefined; } return parsed; } /** * Returns the directory for resources. On Electron builds this corresponds to the `resources` subdirectory inside the distributable package. * On development builds, this simply refers to the src directory of the application. * * @returns the resource dir. */ export function getResourceDir() { if (process.env.TRILIUM_RESOURCE_DIR) { return process.env.TRILIUM_RESOURCE_DIR; } if (isElectron && !isDev) return __dirname; if (!isDev) { return path.dirname(process.argv[1]); } return path.join(__dirname, ".."); } // TODO: Deduplicate with src/public/app/services/utils.ts /** * Compares two semantic version strings. * Returns: * 1 if v1 is greater than v2 * 0 if v1 is equal to v2 * -1 if v1 is less than v2 * * @param v1 First version string * @param v2 Second version string * @returns */ function compareVersions(v1: string, v2: string): number { // Remove 'v' prefix and everything after dash if present v1 = v1.replace(/^v/, "").split("-")[0]; v2 = v2.replace(/^v/, "").split("-")[0]; const v1parts = v1.split(".").map(Number); const v2parts = v2.split(".").map(Number); // Pad shorter version with zeros while (v1parts.length < 3) v1parts.push(0); while (v2parts.length < 3) v2parts.push(0); // Compare major version if (v1parts[0] !== v2parts[0]) { return v1parts[0] > v2parts[0] ? 1 : -1; } // Compare minor version if (v1parts[1] !== v2parts[1]) { return v1parts[1] > v2parts[1] ? 1 : -1; } // Compare patch version if (v1parts[2] !== v2parts[2]) { return v1parts[2] > v2parts[2] ? 1 : -1; } return 0; } /** * For buffers, they are scanned for a supported encoding and decoded (UTF-8, UTF-16). In some cases, the BOM is also stripped. * * For strings, they are returned immediately without any transformation. * * For nullish values, an empty string is returned. * * @param data the string or buffer to process. * @returns the string representation of the buffer, or the same string is it's a string. */ export function processStringOrBuffer(data: string | Buffer | null) { if (!data) { return ""; } if (!Buffer.isBuffer(data)) { return data; } const detectedEncoding = chardet.detect(data); switch (detectedEncoding) { case "UTF-16LE": return stripBom(data.toString("utf-16le")); case "UTF-8": default: return data.toString("utf-8"); } } /** * Normalizes a path pattern for custom request handlers. * Ensures both trailing slash and non-trailing slash versions are handled. * * @param pattern The original pattern from customRequestHandler attribute * @returns An array of patterns to match both with and without trailing slash */ export function normalizeCustomHandlerPattern(pattern: string | null | undefined): (string | null | undefined)[] { if (!pattern || typeof pattern !== 'string') { return [pattern]; } pattern = pattern.trim(); if (!pattern) { return [pattern]; } // If pattern already ends with optional trailing slash, return as-is if (pattern.endsWith('/?$') || pattern.endsWith('/?)')) { return [pattern]; } // If pattern ends with $, handle it specially if (pattern.endsWith('$')) { const basePattern = pattern.slice(0, -1); // If already ends with slash, create both versions if (basePattern.endsWith('/')) { const withoutSlash = `${basePattern.slice(0, -1) }$`; const withSlash = pattern; return [withoutSlash, withSlash]; } // Add optional trailing slash const withSlash = `${basePattern }/?$`; return [withSlash]; } // For patterns without $, add both versions if (pattern.endsWith('/')) { const withoutSlash = pattern.slice(0, -1); return [withoutSlash, pattern]; } const withSlash = `${pattern }/`; return [pattern, withSlash]; } export function formatUtcTime(time: string) { return time.replace("T", " ").substring(0, 19); } // TODO: Deduplicate with client utils export function formatSize(size: number | null | undefined) { if (size === null || size === undefined) { return ""; } size = Math.max(Math.round(size / 1024), 1); if (size < 1024) { return `${size} KiB`; } return `${Math.round(size / 102.4) / 10} MiB`; } function slugify(text: string) { return text .normalize("NFC") // keep composed form, preserves accents .toLowerCase() .replace(/[^\p{Letter}\p{Number}]+/gu, "-") // replace non-letter/number with "-" .replace(/(^-|-$)+/g, ""); // trim dashes } /** @deprecated */ export const escapeHtml = coreUtils.escapeHtml; /** @deprecated */ export const escapeRegExp = coreUtils.escapeRegExp; /** @deprecated */ export const unescapeHtml = coreUtils.unescapeHtml; /** @deprecated */ export const randomSecureToken = coreUtils.randomSecureToken; /** @deprecated */ export const safeExtractMessageAndStackFromError = coreUtils.safeExtractMessageAndStackFromError; /** @deprecated */ export const isEmptyOrWhitespace = coreUtils.isEmptyOrWhitespace; /** @deprecated */ export const normalizeUrl = coreUtils.normalizeUrl; export const timeLimit = coreUtils.timeLimit; export const sanitizeSqlIdentifier = coreUtils.sanitizeSqlIdentifier; export function waitForStreamToFinish(stream: any): Promise { return new Promise((resolve, reject) => { stream.on("finish", () => resolve()); stream.on("error", (err) => reject(err)); }); } export default { compareVersions, constantTimeCompare, envToBoolean, escapeHtml, escapeRegExp, formatDownloadTitle, fromBase64, getContentDisposition, getNoteTitle, getResourceDir, hashedBlobId, hmac, isDev, isElectron, isEmptyOrWhitespace, isMac, isStringNote, isWindows, md5, newEntityId, normalize, normalizeCustomHandlerPattern, quoteRegex, randomSecureToken, randomString, removeDiacritic, removeFileExtension, replaceAll, safeExtractMessageAndStackFromError, stripTags, slugify, toBase64, toMap, toObject, unescapeHtml, waitForStreamToFinish };