mirror of
https://github.com/zadam/trilium.git
synced 2026-06-26 15:10:40 +02:00
444 lines
12 KiB
TypeScript
444 lines
12 KiB
TypeScript
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<T, K extends string | number | symbol, V>(array: T[], fn: (item: T) => [K, V]): Record<K, V> {
|
|
const obj: Record<K, V> = {} as Record<K, V>; // 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, "");
|
|
}
|
|
|
|
export async function crash(message: string) {
|
|
if (isElectron) {
|
|
const electron = await import("electron");
|
|
electron.dialog.showErrorBox(t("modals.error_title"), message);
|
|
electron.app.exit(1);
|
|
} else {
|
|
log.error(message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/** @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<T extends Record<string, any>>(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<void> {
|
|
return new Promise((resolve, reject) => {
|
|
stream.on("finish", () => resolve());
|
|
stream.on("error", (err) => reject(err));
|
|
});
|
|
}
|
|
|
|
export default {
|
|
compareVersions,
|
|
constantTimeCompare,
|
|
crash,
|
|
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
|
|
};
|