mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-07-29 11:02:28 +08:00
322 lines
9.4 KiB
TypeScript
322 lines
9.4 KiB
TypeScript
"use strict";
|
||
|
||
import crypto from "crypto";
|
||
import { generator } from "rand-token";
|
||
import unescape from "unescape";
|
||
import escape from "escape-html";
|
||
import sanitize from "sanitize-filename";
|
||
import mimeTypes from "mime-types";
|
||
import path from "path";
|
||
import { fileURLToPath } from "url";
|
||
import { dirname, join } from "path";
|
||
import type NoteMeta from "./meta/note_meta.js";
|
||
|
||
const randtoken = generator({ source: "crypto" });
|
||
|
||
export const isMac = process.platform === "darwin";
|
||
|
||
export const isWindows = process.platform === "win32";
|
||
|
||
export const isElectron = !!process.versions["electron"];
|
||
|
||
export const isDev = !!(process.env.TRILIUM_ENV && process.env.TRILIUM_ENV === "dev");
|
||
|
||
export function newEntityId() {
|
||
return randomString(12);
|
||
}
|
||
|
||
export function randomString(length: number): string {
|
||
return randtoken.generate(length);
|
||
}
|
||
|
||
export function randomSecureToken(bytes = 32) {
|
||
return crypto.randomBytes(bytes).toString("base64");
|
||
}
|
||
|
||
export function md5(content: crypto.BinaryLike) {
|
||
return crypto.createHash("md5").update(content).digest("hex");
|
||
}
|
||
|
||
export function hashedBlobId(content: string | Buffer) {
|
||
if (content === null || content === undefined) {
|
||
content = "";
|
||
}
|
||
|
||
// sha512 is faster than sha256
|
||
const base64Hash = crypto.createHash("sha512").update(content).digest("base64");
|
||
|
||
// we don't want such + and / in the IDs
|
||
const kindaBase62Hash = base64Hash.replaceAll("+", "X").replaceAll("/", "Y");
|
||
|
||
// 20 characters of base62 gives us ~120 bit of entropy which is plenty enough
|
||
return kindaBase62Hash.substr(0, 20);
|
||
}
|
||
|
||
export function toBase64(plainText: string | Buffer) {
|
||
return Buffer.from(plainText).toString("base64");
|
||
}
|
||
|
||
export function fromBase64(encodedText: string) {
|
||
return Buffer.from(encodedText, "base64");
|
||
}
|
||
|
||
export function hmac(secret: any, value: any) {
|
||
const hmac = crypto.createHmac("sha256", Buffer.from(secret.toString(), "ascii"));
|
||
hmac.update(value.toString());
|
||
return hmac.digest("base64");
|
||
}
|
||
|
||
export function hash(text: string) {
|
||
text = text.normalize();
|
||
|
||
return crypto.createHash("sha1").update(text).digest("base64");
|
||
}
|
||
|
||
export function isEmptyOrWhitespace(str: string | null | undefined) {
|
||
if (!str) return true;
|
||
return str.match(/^ *$/) !== null;
|
||
}
|
||
|
||
export function sanitizeSqlIdentifier(str: string) {
|
||
return str.replace(/[^A-Za-z0-9_]/g, "");
|
||
}
|
||
|
||
export const escapeHtml = escape;
|
||
|
||
export const unescapeHtml = unescape;
|
||
|
||
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 function escapeRegExp(str: string) {
|
||
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
|
||
}
|
||
|
||
export async function crash() {
|
||
if (isElectron) {
|
||
(await import("electron")).app.exit(1);
|
||
} else {
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
export function getContentDisposition(filename: string) {
|
||
const sanitizedFilename = sanitize(filename).trim() || "file";
|
||
const uriEncodedFilename = encodeURIComponent(sanitizedFilename);
|
||
return `file; filename="${uriEncodedFilename}"; filename*=UTF-8''${uriEncodedFilename}`;
|
||
}
|
||
|
||
// render and book are string note in the sense that they are expected to contain empty string
|
||
const STRING_NOTE_TYPES = new Set(["text", "code", "relationMap", "search", "render", "book", "mermaid", "canvas"]);
|
||
const STRING_MIME_TYPES = new Set(["application/javascript", "application/x-javascript", "application/json", "application/x-sql", "image/svg+xml"]);
|
||
|
||
export function isStringNote(type: string | undefined, mime: string) {
|
||
return (type && STRING_NOTE_TYPES.has(type)) || mime.startsWith("text/") || STRING_MIME_TYPES.has(mime);
|
||
}
|
||
|
||
export function quoteRegex(url: string) {
|
||
return url.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
export function replaceAll(string: string, replaceWhat: string, replaceWith: string) {
|
||
const quotedReplaceWhat = quoteRegex(replaceWhat);
|
||
|
||
return string.replace(new RegExp(quotedReplaceWhat, "g"), replaceWith);
|
||
}
|
||
|
||
export function formatDownloadTitle(fileName: string, type: string | null, mime: string) {
|
||
const fileNameBase = !fileName ? "untitled" : sanitize(fileName);
|
||
|
||
const getExtension = () => {
|
||
if (type === "text") return ".html";
|
||
if (type === "relationMap" || type === "canvas" || type === "search") return ".json";
|
||
if (!mime) return "";
|
||
|
||
const mimeLc = mime.toLowerCase();
|
||
|
||
// better to just return the current name without a fake extension
|
||
// it's possible that the title still preserves the correct extension anyways
|
||
if (mimeLc === "application/octet-stream") return "";
|
||
|
||
// if fileName has an extension matching the mime already - reuse it
|
||
const mimeTypeFromFileName = mimeTypes.lookup(fileName);
|
||
if (mimeTypeFromFileName === mimeLc) return "";
|
||
|
||
// as last resort try to get extension from mimeType
|
||
const extensions = mimeTypes.extension(mime);
|
||
return extensions ? `.${extensions}` : "";
|
||
};
|
||
|
||
return `${fileNameBase}${getExtension()}`;
|
||
}
|
||
|
||
export function removeTextFileExtension(filePath: string) {
|
||
const extension = path.extname(filePath).toLowerCase();
|
||
|
||
switch (extension) {
|
||
case ".md":
|
||
case ".markdown":
|
||
case ".html":
|
||
case ".htm":
|
||
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(removeTextFileExtension(filePath));
|
||
return replaceUnderscoresWithSpaces ? basename.replace(/_/g, " ").trim() : basename;
|
||
}
|
||
|
||
export function timeLimit<T>(promise: Promise<T>, limitMs: number, errorMessage?: string): Promise<T> {
|
||
// TriliumNextTODO: since TS avoids this from ever happening – do we need this check?
|
||
if (!promise || !promise.then) {
|
||
// it's not actually a promise
|
||
return promise;
|
||
}
|
||
|
||
// better stack trace if created outside of promise
|
||
const errorTimeLimit = new Error(errorMessage || `Process exceeded time limit ${limitMs}`);
|
||
|
||
return new Promise((res, rej) => {
|
||
let resolved = false;
|
||
|
||
promise
|
||
.then((result) => {
|
||
resolved = true;
|
||
|
||
res(result);
|
||
})
|
||
.catch((error) => rej(error));
|
||
|
||
setTimeout(() => {
|
||
if (!resolved) {
|
||
rej(errorTimeLimit);
|
||
}
|
||
}, limitMs);
|
||
});
|
||
}
|
||
|
||
interface DeferredPromise<T> extends Promise<T> {
|
||
resolve: (value: T | PromiseLike<T>) => void;
|
||
reject: (reason?: any) => void;
|
||
}
|
||
|
||
export function deferred<T>(): DeferredPromise<T> {
|
||
return (() => {
|
||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||
let reject!: (reason?: any) => void;
|
||
|
||
let promise = new Promise<T>((res, rej) => {
|
||
resolve = res;
|
||
reject = rej;
|
||
}) as DeferredPromise<T>;
|
||
|
||
promise.resolve = resolve;
|
||
promise.reject = reject;
|
||
return promise as DeferredPromise<T>;
|
||
})();
|
||
}
|
||
|
||
export function removeDiacritic(str: string) {
|
||
if (!str) {
|
||
return "";
|
||
}
|
||
str = str.toString();
|
||
return str.normalize("NFD").replace(/\p{Diacritic}/gu, "");
|
||
}
|
||
|
||
export function normalize(str: string) {
|
||
return removeDiacritic(str).toLowerCase();
|
||
}
|
||
|
||
export function toMap<T extends Record<string, any>>(list: T[], key: keyof T) {
|
||
const map = new Map<string, T>();
|
||
for (const el of list) {
|
||
const keyForMap = el[key];
|
||
if (!keyForMap) continue;
|
||
// TriliumNextTODO: do we need to handle the case when the same key is used?
|
||
// currently this will overwrite the existing entry in the map
|
||
map.set(keyForMap, el);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
/**
|
||
* 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 root directory of the application.
|
||
*
|
||
* @returns the resource dir.
|
||
*/
|
||
export function getResourceDir() {
|
||
if (isElectron && !isDev) return process.resourcesPath;
|
||
return join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||
}
|
||
|
||
export default {
|
||
crash,
|
||
deferred,
|
||
envToBoolean,
|
||
escapeHtml,
|
||
escapeRegExp,
|
||
formatDownloadTitle,
|
||
fromBase64,
|
||
getContentDisposition,
|
||
getNoteTitle,
|
||
getResourceDir,
|
||
hash,
|
||
hashedBlobId,
|
||
hmac,
|
||
isDev,
|
||
isElectron,
|
||
isEmptyOrWhitespace,
|
||
isMac,
|
||
isStringNote,
|
||
isWindows,
|
||
md5,
|
||
newEntityId,
|
||
normalize,
|
||
quoteRegex,
|
||
randomSecureToken,
|
||
randomString,
|
||
removeDiacritic,
|
||
removeTextFileExtension,
|
||
replaceAll,
|
||
sanitizeSqlIdentifier,
|
||
stripTags,
|
||
timeLimit,
|
||
toBase64,
|
||
toMap,
|
||
toObject,
|
||
unescapeHtml
|
||
};
|