chore(deps): swap unmaintained/legacy deps per e18e module-replacements (#2447)

- npm-run-all → npm-run-all2 (dev script uses run-p)
- lint-staged → nano-staged (config block + husky hook)
- lodash.debounce → local src/utils/debounce.ts with cancel/flush/leading/trailing
- dotenv → process.loadEnvFile in integration test bootstrap
- eslint-plugin-react → @eslint-react/eslint-plugin (flat-config migration)
- js-yaml → yaml (mock + jest CJS moduleNameMapper for jsdom)
- crypto-js → pure-JS md5/sha256 in src/utils/hash.ts producing byte-identical
  digests so all on-disk cache keys (PDF, file, project, search index)
  remain valid across the upgrade — no migration or re-parse cost

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-05-14 01:12:52 -07:00 committed by GitHub
parent 8239443814
commit d6255374cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1108 additions and 1088 deletions

View file

@ -1 +1 @@
npx lint-staged
npx nano-staged

View file

@ -1,5 +1,5 @@
// __mocks__/obsidian.js
import yaml from "js-yaml";
import { parse as parseYamlString } from "yaml";
module.exports = {
// Reason: normalizePath is used by projectPaths.ts; identity function is sufficient for tests
@ -32,7 +32,7 @@ module.exports = {
isDesktop: true,
},
parseYaml: jest.fn().mockImplementation((content) => {
return yaml.load(content);
return parseYamlString(content);
}),
Modal: class Modal {
constructor() {

View file

@ -1,5 +1,5 @@
import obsidianmd from "eslint-plugin-obsidianmd";
import reactPlugin from "eslint-plugin-react";
import eslintReact from "@eslint-react/eslint-plugin";
import reactHooks from "eslint-plugin-react-hooks";
import tailwind from "eslint-plugin-tailwindcss";
import globals from "globals";
@ -26,10 +26,10 @@ export default [
// React + tailwind plugins ship flat configs with no `files` filter, so
// they'd cascade onto package.json (which uses the JSON parser) and crash.
// Constrain them to JS/TS sources.
// Constrain them to JSX/TSX sources where React/JSX rules actually apply.
{
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"],
...reactPlugin.configs.flat.recommended,
files: ["**/*.{jsx,tsx}"],
...eslintReact.configs.recommended,
},
{
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"],
@ -53,7 +53,7 @@ export default [
},
},
settings: {
react: { version: "detect" },
"react-x": { version: "detect" },
tailwindcss: {
callees: ["classnames", "clsx", "ctl", "cn", "cva"],
config: "./tailwind.config.js",
@ -63,7 +63,6 @@ export default [
rules: {
// Carry-over from legacy .eslintrc
"no-prototype-builtins": "off",
"react/prop-types": "off",
"tailwindcss/classnames-order": "error",
"tailwindcss/enforces-negative-arbitrary-values": "error",
"tailwindcss/enforces-shorthand": "error",
@ -268,15 +267,7 @@ export default [
"error",
{
presets: ["native", "microutilities", "preferred"],
allowed: [
"crypto-js",
"dotenv",
"js-yaml",
"lodash.debounce",
"eslint-plugin-react",
"lint-staged",
"npm-run-all",
],
allowed: [],
},
],
},

View file

@ -9,6 +9,10 @@ module.exports = {
"\\.(css|less|scss|sass)$": "identity-obj-proxy",
"^@/(.*)$": "<rootDir>/src/$1",
"^obsidian$": "<rootDir>/__mocks__/obsidian.js",
// The yaml package's "exports" field defaults to a browser ESM entry under
// jsdom; Jest can't parse ESM without extra config, so point at the CJS
// build it ships under dist/.
"^yaml$": "<rootDir>/node_modules/yaml/dist/index.js",
},
testRegex: ".*\\.test\\.(jsx?|tsx?)$",
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],

1596
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",
"main": "main.js",
"scripts": {
"dev": "npm-run-all --parallel dev:*",
"dev": "run-p dev:*",
"dev:tailwind": "npx tailwindcss -i src/styles/tailwind.css -o styles.css --watch",
"dev:esbuild": "node esbuild.config.mjs",
"build": "npm run build:tailwind && npm run build:esbuild || exit 1",
@ -22,7 +22,7 @@
},
"keywords": [],
"author": "Logan Yang",
"lint-staged": {
"nano-staged": {
"*.{js,jsx,ts,tsx,json,css,md}": [
"prettier --write"
],
@ -32,18 +32,17 @@
},
"license": "AGPL-3.0",
"devDependencies": {
"@eslint-react/eslint-plugin": "^1.38.4",
"@jest/globals": "^29.7.0",
"@langchain/ollama": "^1.2.2",
"@tailwindcss/container-queries": "^0.1.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/crypto-js": "^4.1.1",
"@types/diff": "^7.0.1",
"@types/events": "^3.0.0",
"@types/jest": "^29.5.11",
"@types/koa": "^2.13.7",
"@types/koa__cors": "^4.0.0",
"@types/lodash.debounce": "^4.0.9",
"@types/luxon": "^3.4.2",
"@types/node": "^16.11.6",
"@types/react": "^18.0.33",
@ -53,27 +52,25 @@
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"dotenv": "^16.4.5",
"electron": "^27.3.2",
"esbuild": "^0.25.0",
"eslint": "^9.18.0",
"eslint-plugin-obsidianmd": "^0.3.0",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-tailwindcss": "^3.18.0",
"husky": "^9.1.5",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.5.0",
"js-yaml": "^4.1.0",
"lint-staged": "^15.2.9",
"npm-run-all": "^4.1.5",
"nano-staged": "^0.8.0",
"npm-run-all2": "^7.0.2",
"obsidian": "^1.2.5",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.15",
"ts-jest": "^29.1.0",
"tslib": "2.4.0",
"typescript": "^5.7.2",
"web-streams-polyfill": "^3.3.2"
"web-streams-polyfill": "^3.3.2",
"yaml": "^2.6.1"
},
"dependencies": {
"@codemirror/state": "^6.5.2",
@ -121,7 +118,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror-companion-extension": "^0.0.11",
"crypto-js": "^4.1.1",
"diff": "^7.0.0",
"esbuild-plugin-svg": "^0.1.0",
"eventsource-parser": "^1.0.0",
@ -131,7 +127,6 @@
"koa-proxies": "^0.12.3",
"langchain": "^1.2.28",
"lexical": "^0.34.0",
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.462.0",
"luxon": "^3.5.0",
"minisearch": "^7.2.0",

View file

@ -1,5 +1,5 @@
import { logError, logInfo } from "@/logger";
import { MD5 } from "crypto-js";
import { md5 } from "@/utils/hash";
import { TFile } from "obsidian";
export interface FileCacheEntry<T> {
@ -33,7 +33,7 @@ export class FileCache<T> {
getCacheKey(file: TFile, additionalContext?: string): string {
// Use file path, size and mtime for a unique but efficient cache key
const metadata = `${file.path}:${file.stat.size}:${file.stat.mtime}${additionalContext ? `:${additionalContext}` : ""}`;
return MD5(metadata).toString();
return md5(metadata);
}
private getCachePath(cacheKey: string): string {

View file

@ -1,6 +1,6 @@
import { Pdf4llmResponse } from "@/LLMProviders/brevilabsClient";
import { logError, logInfo } from "@/logger";
import { MD5 } from "crypto-js";
import { md5 } from "@/utils/hash";
import { TFile } from "obsidian";
export class PDFCache {
@ -26,7 +26,7 @@ export class PDFCache {
private getCacheKey(file: TFile): string {
// Use file path, size and mtime for a unique but efficient cache key
const metadata = `${file.path}:${file.stat.size}:${file.stat.mtime}`;
const key = MD5(metadata).toString();
const key = md5(metadata);
logInfo("Generated cache key for PDF:", { path: file.path, key });
return key;
}

View file

@ -3,9 +3,9 @@ import { FileCache } from "@/cache/fileCache";
import { logError, logInfo, logWarn } from "@/logger";
import { getMatchingPatterns, shouldIndexFile } from "@/search/searchUtils";
import { getCachedProjects } from "@/projects/state";
import { MD5 } from "crypto-js";
import { md5 } from "@/utils/hash";
import { TAbstractFile, TFile, Vault } from "obsidian";
import debounce from "lodash.debounce";
import { debounce } from "@/utils/debounce";
import { Mutex } from "async-mutex";
export interface ContextCache {
@ -173,7 +173,7 @@ export class ProjectContextCache {
private getCacheKey(project: ProjectConfig): string {
// Use project ID as cache key
return MD5(project.id).toString();
return md5(project.id);
}
private getCachePath(cacheKey: string): string {

View file

@ -9,7 +9,7 @@ import {
} from "@/commands/customCommandUtils";
import { Editor, Plugin, TFile, Vault } from "obsidian";
import { CustomCommandChatModal } from "@/commands/CustomCommandChatModal";
import debounce from "lodash.debounce";
import { debounce } from "@/utils/debounce";
import { CustomCommand } from "@/commands/type";
import {
deleteCachedCommand,

View file

@ -12,7 +12,7 @@ import { SettingSwitch } from "@/components/ui/setting-switch";
import { ModelParametersEditor } from "@/components/ui/ModelParametersEditor";
import { CustomModel, getModelKey } from "@/aiParams";
import { getSettings, updateSetting } from "@/settings/model";
import debounce from "lodash.debounce";
import { debounce } from "@/utils/debounce";
import {
getDefaultSystemPromptTitle,
getDisableBuiltinSystemPrompt,
@ -83,8 +83,8 @@ export function ChatSettingsPopover() {
}
}, [showConfirmation]);
// Debounced save function - must be defined before handleOpenChange
// Reference: Command module uses lodash.debounce which has cancel() method
// Debounced save function - must be defined before handleOpenChange.
// Uses debounce.cancel() / debounce.flush() to manage pending writes on unmount.
const debouncedSave = useMemo(
() =>
debounce((updatedModel: CustomModel) => {

View file

@ -1,4 +1,4 @@
import { SHA256 } from "crypto-js";
import { sha256 } from "@/utils/hash";
import { logInfo } from "@/logger";
import {
PROMPT_LAYER_LABELS,
@ -125,11 +125,11 @@ export class PromptContextEngine {
}
/**
* Compute a SHA-256 hash for the supplied value.
* Uses crypto-js for mobile compatibility (no Node.js crypto).
* Compute a SHA-256 hash for the supplied value. Pure-JS so it works on
* Obsidian mobile (no node:crypto, no sync Web Crypto).
*/
private hash(value: string): string {
return SHA256(value || "").toString();
return sha256(value || "");
}
/**

View file

@ -1,5 +1,4 @@
import { DEFAULT_SYSTEM_PROMPT, COMPOSER_OUTPUT_INSTRUCTIONS } from "../constants";
import * as dotenv from "dotenv";
import { jest } from "@jest/globals";
import {
GoogleGenerativeAI,
@ -8,8 +7,13 @@ import {
GenerativeModel,
} from "@google/generative-ai";
// Load environment variables from .env.test
dotenv.config({ path: ".env.test" });
// Load environment variables from .env.test using Node's native env-file loader.
// `loadEnvFile` is available on Node >= 20.12 / 21.7 but @types/node here predates the typing.
try {
(process as unknown as { loadEnvFile: (path: string) => void }).loadEnvFile(".env.test");
} catch {
// .env.test is optional — skip silently when absent
}
// Test data
const atom_note = `Atoms are the basic particles of the chemical elements. An atom consists of a nucleus of protons and generally neutrons, surrounded by an electromagnetically bound swarm of electrons. The chemical elements are distinguished from each other by the number of protons that are in their atoms. For example, any atom that contains 11 protons is sodium, and any atom that contains 29 protons is copper. Atoms with the same number of protons but a different number of neutrons are called isotopes of the same element.

View file

@ -21,7 +21,7 @@ import {
import { loadAllProjects } from "@/projects/projectUtils";
import { PROJECT_CONFIG_FILE_NAME, PROJECTS_UNSUPPORTED_FOLDER_NAME } from "@/projects/constants";
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
import debounce from "lodash.debounce";
import { debounce } from "@/utils/debounce";
import { App, Notice, TAbstractFile, Vault } from "obsidian";
/**

View file

@ -8,7 +8,7 @@ import { areEmbeddingModelsSame } from "@/utils";
import { Embeddings } from "@langchain/core/embeddings";
import { create, insert, Orama, remove, removeMultiple, search } from "@orama/orama";
import { Mutex } from "async-mutex";
import { MD5 } from "crypto-js";
import { md5 } from "@/utils/hash";
import { App, Notice, Platform } from "obsidian";
import { ChunkedStorage } from "./chunkedStorage";
import { getMatchingPatterns, getVectorLength, shouldIndexFile } from "./searchUtils";
@ -260,7 +260,7 @@ export class DBOperations {
private getVaultIdentifier(): string {
const vaultName = this.app.vault.getName();
return MD5(vaultName).toString();
return md5(vaultName);
}
public markUnsavedChanges() {

View file

@ -12,7 +12,7 @@ import { RateLimiter } from "@/rateLimiter";
import { ChunkManager, getSharedChunkManager } from "@/search/v3/chunks";
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
import { formatDateTime } from "@/utils";
import { MD5 } from "crypto-js";
import { md5 } from "@/utils/hash";
import { App, Notice, TFile } from "obsidian";
import type {
SemanticIndexBackend,
@ -379,7 +379,7 @@ export class IndexOperations {
}
private getDocHash(sourceDocument: string): string {
return MD5(sourceDocument).toString();
return md5(sourceDocument);
}
private async getFilesToIndex(overwrite?: boolean): Promise<TFile[]> {

View file

@ -1,6 +1,6 @@
import { atom } from "jotai";
import { TFile, TFolder, TAbstractFile } from "obsidian";
import debounce from "lodash.debounce";
import { debounce } from "@/utils/debounce";
import { settingsStore } from "@/settings/model";
import { getTagsFromNote, isAllowedFileForNoteContext } from "@/utils";
import { logInfo } from "@/logger";
@ -161,7 +161,7 @@ export class VaultDataManager {
};
/**
* Debounced notes refresh - batches rapid file operations using lodash.debounce
* Debounced notes refresh - batches rapid file operations via debounce
*/
private debouncedRefreshNotes = debounce(() => this.refreshNotes(), VAULT_DEBOUNCE_DELAY, {
leading: true,
@ -169,7 +169,7 @@ export class VaultDataManager {
});
/**
* Debounced folders refresh - batches rapid file operations using lodash.debounce
* Debounced folders refresh - batches rapid file operations via debounce
*/
private debouncedRefreshFolders = debounce(() => this.refreshFolders(), VAULT_DEBOUNCE_DELAY, {
leading: true,
@ -177,7 +177,7 @@ export class VaultDataManager {
});
/**
* Debounced frontmatter tags refresh - batches rapid file operations using lodash.debounce
* Debounced frontmatter tags refresh - batches rapid file operations via debounce
*/
private debouncedRefreshTagsFrontmatter = debounce(
() => this.refreshTagsFrontmatter(),
@ -189,7 +189,7 @@ export class VaultDataManager {
);
/**
* Debounced all tags refresh - batches rapid file operations using lodash.debounce
* Debounced all tags refresh - batches rapid file operations via debounce
*/
private debouncedRefreshTagsAll = debounce(() => this.refreshTagsAll(), VAULT_DEBOUNCE_DELAY, {
leading: true,

View file

@ -17,7 +17,7 @@ import {
} from "@/system-prompts/state";
import { getSettings, subscribeToSettingsChange, updateSetting } from "@/settings/model";
import { SystemPromptManager } from "@/system-prompts/systemPromptManager";
import debounce from "lodash.debounce";
import { debounce } from "@/utils/debounce";
import { logError, logInfo } from "@/logger";
/**

View file

@ -42,11 +42,9 @@ jest.mock("@/search/searchUtils", () => ({
}),
}));
// Mock crypto-js
jest.mock("crypto-js", () => ({
MD5: jest.fn().mockImplementation((str) => ({
toString: () => `mocked-hash-${str}`,
})),
// Mock md5 so the cache key in tests is predictable
jest.mock("@/utils/hash", () => ({
md5: jest.fn().mockImplementation((str: string) => `mocked-hash-${str}`),
}));
// Mock plusUtils

View file

@ -1112,16 +1112,7 @@ export async function insertIntoEditor(message: string, replace: boolean = false
finalizeInsertion();
}
export function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: number;
return (...args: Parameters<T>) => {
window.clearTimeout(timeout);
timeout = window.setTimeout(() => func(...args), wait);
};
}
export { debounce } from "@/utils/debounce";
/**
* Compare two semantic version strings.

View file

@ -0,0 +1,86 @@
import { debounce } from "./debounce";
jest.useFakeTimers();
describe("debounce", () => {
it("invokes once after the wait period with the latest args (trailing default)", () => {
const fn = jest.fn();
const d = debounce(fn, 100);
d("a");
d("b");
d("c");
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("c");
});
it("supports leading-only mode", () => {
const fn = jest.fn();
const d = debounce(fn, 100, { leading: true, trailing: false });
d("a");
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("a");
d("b");
d("c");
jest.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
});
it("fires both edges when leading and trailing are true and there are multiple calls", () => {
const fn = jest.fn();
const d = debounce(fn, 100, { leading: true, trailing: true });
d("a");
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenLastCalledWith("a");
d("b");
jest.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenLastCalledWith("b");
});
it("does not fire trailing if leading already covered the single call", () => {
const fn = jest.fn();
const d = debounce(fn, 100, { leading: true, trailing: true });
d("only");
expect(fn).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
});
it("cancel prevents pending trailing invocation", () => {
const fn = jest.fn();
const d = debounce(fn, 100);
d("a");
d.cancel();
jest.advanceTimersByTime(100);
expect(fn).not.toHaveBeenCalled();
});
it("flush invokes the pending call synchronously", () => {
const fn = jest.fn();
const d = debounce(fn, 100);
d("a");
d.flush();
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("a");
jest.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
});
it("flush is a no-op when nothing is pending", () => {
const fn = jest.fn();
const d = debounce(fn, 100);
expect(d.flush()).toBeUndefined();
expect(fn).not.toHaveBeenCalled();
});
});

89
src/utils/debounce.ts Normal file
View file

@ -0,0 +1,89 @@
/**
* Returned by {@link debounce}. Calling it schedules the underlying function,
* with `cancel` and `flush` controls modeled after `lodash.debounce`.
*/
export interface DebouncedFunction<T extends (...args: any[]) => any> {
(...args: Parameters<T>): ReturnType<T> | undefined;
cancel(): void;
flush(): ReturnType<T> | undefined;
}
export interface DebounceOptions {
leading?: boolean;
trailing?: boolean;
}
/**
* Delay-invoking `func` until `wait` ms have elapsed since the last call.
* Supports the `leading` / `trailing` options and `cancel` / `flush` methods
* used by the `lodash.debounce` API the codebase relied on previously.
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
options: DebounceOptions = {}
): DebouncedFunction<T> {
const leading = options.leading ?? false;
const trailing = options.trailing ?? true;
let timeoutId: number | undefined;
let lastArgs: Parameters<T> | undefined;
let lastResult: ReturnType<T> | undefined;
let hasPendingTrailingCall = false;
const invoke = (): ReturnType<T> | undefined => {
const args = lastArgs;
lastArgs = undefined;
hasPendingTrailingCall = false;
if (!args) return lastResult;
lastResult = func(...args) as ReturnType<T>;
return lastResult;
};
const startTimer = () => {
timeoutId = window.setTimeout(() => {
timeoutId = undefined;
if (trailing && hasPendingTrailingCall) {
invoke();
}
}, wait);
};
const debounced = ((...args: Parameters<T>) => {
lastArgs = args;
hasPendingTrailingCall = true;
const isFirstCallInWindow = timeoutId === undefined;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
if (isFirstCallInWindow && leading) {
invoke();
}
startTimer();
return lastResult;
}) as DebouncedFunction<T>;
debounced.cancel = () => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
timeoutId = undefined;
}
lastArgs = undefined;
hasPendingTrailingCall = false;
};
debounced.flush = () => {
if (timeoutId === undefined) return lastResult;
window.clearTimeout(timeoutId);
timeoutId = undefined;
if (hasPendingTrailingCall) {
return invoke();
}
return lastResult;
};
return debounced;
}

80
src/utils/hash.test.ts Normal file
View file

@ -0,0 +1,80 @@
import { md5, sha256 } from "./hash";
// Test vectors below are standard RFC 1321 / FIPS 180-4 reference values and
// match what `crypto-js`'s `MD5(str).toString()` / `SHA256(str).toString()`
// produced for the same UTF-8-encoded inputs. Preserving these byte-for-byte
// keeps existing on-disk cache keys (PDF cache, file cache, project cache,
// search index doc hashes) valid across the crypto-js removal.
describe("md5", () => {
it.each([
["", "d41d8cd98f00b204e9800998ecf8427e"],
["a", "0cc175b9c0f1b6a831c399e269772661"],
["abc", "900150983cd24fb0d6963f7d28e17f72"],
["message digest", "f96b697d7cb7938d525a2f31aaf161d0"],
["abcdefghijklmnopqrstuvwxyz", "c3fcd3d76192e4007dfb496cca67e13b"],
[
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",
"d174ab98d277d9f5a5611c2c9f419d9f",
],
[
"12345678901234567890123456789012345678901234567890123456789012345678901234567890",
"57edf4a22be3c955ac49da2e2107b67a",
],
["The quick brown fox jumps over the lazy dog", "9e107d9d372bb6826bd81d3542a419d6"],
])("md5(%j) = %s", (input, expected) => {
expect(md5(input)).toBe(expected);
});
it("handles UTF-8 multibyte input", () => {
// crypto-js encodes JS strings as UTF-8 before hashing; "café" → 63 61 66 c3 a9.
expect(md5("café")).toBe("07117fe4a1ebd544965dc19573183da2");
});
it("handles inputs across block boundaries (55, 56, 64, 119, 120 bytes)", () => {
expect(md5("a".repeat(55))).toBe("ef1772b6dff9a122358552954ad0df65");
expect(md5("a".repeat(56))).toBe("3b0c8ac703f828b04c6c197006d17218");
expect(md5("a".repeat(64))).toBe("014842d480b571495a4a0363793f7367");
expect(md5("a".repeat(119))).toBe("8a7bd0732ed6a28ce75f6dabc90e1613");
expect(md5("a".repeat(120))).toBe("5f61c0ccad4cac44c75ff505e1f1e537");
});
});
describe("sha256", () => {
it.each([
["", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"],
["abc", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"],
[
"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1",
],
[
"The quick brown fox jumps over the lazy dog",
"d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592",
],
])("sha256(%j) = %s", (input, expected) => {
expect(sha256(input)).toBe(expected);
});
it("handles UTF-8 multibyte input", () => {
expect(sha256("café")).toBe("850f7dc43910ff890f8879c0ed26fe697c93a067ad93a7d50f466a7028a9bf4e");
});
it("handles inputs across block boundaries (55, 56, 64, 119, 120 bytes)", () => {
expect(sha256("a".repeat(55))).toBe(
"9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318"
);
expect(sha256("a".repeat(56))).toBe(
"b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a"
);
expect(sha256("a".repeat(64))).toBe(
"ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb"
);
expect(sha256("a".repeat(119))).toBe(
"31eba51c313a5c08226adf18d4a359cfdfd8d2e816b13f4af952f7ea6584dcfb"
);
expect(sha256("a".repeat(120))).toBe(
"2f3d335432c70b580af0e8e1b3674a7c020d683aa5f73aaaedfdc55af904c21c"
);
});
});

214
src/utils/hash.ts Normal file
View file

@ -0,0 +1,214 @@
/**
* Pure-JS MD5 and SHA-256 producing lowercase-hex digests that match what
* `crypto-js`'s `MD5(str).toString()` / `SHA256(str).toString()` produced.
*
* Used for cache keys and content fingerprints not for security. Pure JS so
* it stays available across Obsidian's desktop (Electron) and mobile (WebView)
* runtimes, where `node:crypto` is unavailable and `crypto.subtle.digest` is
* async-only.
*
* Inputs are interpreted as UTF-8 (matching crypto-js's default `Utf8.parse`).
*/
const HEX = "0123456789abcdef";
function bytesToHex(bytes: Uint8Array): string {
let out = "";
for (let i = 0; i < bytes.length; i++) {
out += HEX[bytes[i] >>> 4] + HEX[bytes[i] & 0xf];
}
return out;
}
// ---------- MD5 (RFC 1321) ----------
const MD5_T = new Int32Array([
-0x28955b88, -0x173848aa, 0x242070db, -0x3e423112, -0x0a83f051, 0x4787c62a, -0x57cfb9ed,
-0x02b96aff, 0x698098d8, -0x74bb0851, -0x0000a44f, -0x76a32842, 0x6b901122, -0x02678e6d,
-0x5986bc72, 0x49b40821, -0x09e1da9e, -0x3fbf4cc0, 0x265e5a51, -0x16493856, -0x29d0efa3,
0x02441453, -0x275e197f, -0x182c0438, 0x21e1cde6, -0x3cc8f82a, -0x0b2af279, 0x455a14ed,
-0x561c16fb, -0x03105c08, 0x676f02d9, -0x72d5b376, -0x0005c6be, -0x788e097f, 0x6d9d6122,
-0x021ac7f4, -0x5b4115bc, 0x4bdecfa9, -0x0944b4a0, -0x41404390, 0x289b7ec6, -0x155ed806,
-0x2b10cf7b, 0x04881d05, -0x262b2fc7, -0x1924661b, 0x1fa27cf8, -0x3b53a99b, -0x0bd6ddbc,
0x432aff97, -0x546bdc59, -0x036c5fc7, 0x655b59c3, -0x70f3336e, -0x00100b83, -0x7a7ba22f,
0x6fa87e4f, -0x01d31920, -0x5cfebcec, 0x4e0811a1, -0x08ac817e, -0x42c50dcb, 0x2ad7d2bb,
-0x14792c6f,
]);
const MD5_S = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14,
20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6,
10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
];
function rotl32(x: number, n: number): number {
return (x << n) | (x >>> (32 - n));
}
export function md5(input: string): string {
const msg = new TextEncoder().encode(input);
const len = msg.length;
// Pad to a multiple of 64 with at least 9 trailing bytes (0x80 + 8-byte length).
const paddedLen = (((len + 8) >>> 6) + 1) << 6;
const padded = new Uint8Array(paddedLen);
padded.set(msg);
padded[len] = 0x80;
const dv = new DataView(padded.buffer);
// Length in bits, little-endian, 64-bit. JS numbers handle len * 8 up to ~2^50.
const bitLenLow = (len * 8) >>> 0;
const bitLenHigh = Math.floor(len / 0x20000000);
dv.setUint32(paddedLen - 8, bitLenLow, true);
dv.setUint32(paddedLen - 4, bitLenHigh, true);
let a0 = 0x67452301 | 0;
let b0 = 0xefcdab89 | 0;
let c0 = 0x98badcfe | 0;
let d0 = 0x10325476 | 0;
const M = new Int32Array(16);
for (let off = 0; off < paddedLen; off += 64) {
for (let j = 0; j < 16; j++) M[j] = dv.getInt32(off + j * 4, true);
let a = a0;
let b = b0;
let c = c0;
let d = d0;
for (let i = 0; i < 64; i++) {
let f: number;
let g: number;
if (i < 16) {
f = (b & c) | (~b & d);
g = i;
} else if (i < 32) {
f = (d & b) | (~d & c);
g = (5 * i + 1) & 0xf;
} else if (i < 48) {
f = b ^ c ^ d;
g = (3 * i + 5) & 0xf;
} else {
f = c ^ (b | ~d);
g = (7 * i) & 0xf;
}
const tmp = d;
d = c;
c = b;
b = (b + rotl32((a + f + MD5_T[i] + M[g]) | 0, MD5_S[i])) | 0;
a = tmp;
}
a0 = (a0 + a) | 0;
b0 = (b0 + b) | 0;
c0 = (c0 + c) | 0;
d0 = (d0 + d) | 0;
}
// MD5 digest: state words in little-endian byte order.
const out = new Uint8Array(16);
const outDv = new DataView(out.buffer);
outDv.setInt32(0, a0, true);
outDv.setInt32(4, b0, true);
outDv.setInt32(8, c0, true);
outDv.setInt32(12, d0, true);
return bytesToHex(out);
}
// ---------- SHA-256 (FIPS 180-4) ----------
const SHA256_K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
function rotr32(x: number, n: number): number {
return (x >>> n) | (x << (32 - n));
}
export function sha256(input: string): string {
const msg = new TextEncoder().encode(input);
const len = msg.length;
const paddedLen = (((len + 8) >>> 6) + 1) << 6;
const padded = new Uint8Array(paddedLen);
padded.set(msg);
padded[len] = 0x80;
const dv = new DataView(padded.buffer);
// Length in bits, big-endian, 64-bit. JS numbers handle len * 8 up to ~2^50.
const bitLenLow = (len * 8) >>> 0;
const bitLenHigh = Math.floor(len / 0x20000000);
dv.setUint32(paddedLen - 8, bitLenHigh, false);
dv.setUint32(paddedLen - 4, bitLenLow, false);
let h0 = 0x6a09e667 | 0;
let h1 = 0xbb67ae85 | 0;
let h2 = 0x3c6ef372 | 0;
let h3 = 0xa54ff53a | 0;
let h4 = 0x510e527f | 0;
let h5 = 0x9b05688c | 0;
let h6 = 0x1f83d9ab | 0;
let h7 = 0x5be0cd19 | 0;
const W = new Int32Array(64);
for (let off = 0; off < paddedLen; off += 64) {
for (let i = 0; i < 16; i++) W[i] = dv.getInt32(off + i * 4, false);
for (let i = 16; i < 64; i++) {
const w15 = W[i - 15];
const w2 = W[i - 2];
const s0 = rotr32(w15, 7) ^ rotr32(w15, 18) ^ (w15 >>> 3);
const s1 = rotr32(w2, 17) ^ rotr32(w2, 19) ^ (w2 >>> 10);
W[i] = (W[i - 16] + s0 + W[i - 7] + s1) | 0;
}
let a = h0;
let b = h1;
let c = h2;
let d = h3;
let e = h4;
let f = h5;
let g = h6;
let h = h7;
for (let i = 0; i < 64; i++) {
const S1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25);
const ch = (e & f) ^ (~e & g);
const t1 = (h + S1 + ch + SHA256_K[i] + W[i]) | 0;
const S0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) | 0;
h = g;
g = f;
f = e;
e = (d + t1) | 0;
d = c;
c = b;
b = a;
a = (t1 + t2) | 0;
}
h0 = (h0 + a) | 0;
h1 = (h1 + b) | 0;
h2 = (h2 + c) | 0;
h3 = (h3 + d) | 0;
h4 = (h4 + e) | 0;
h5 = (h5 + f) | 0;
h6 = (h6 + g) | 0;
h7 = (h7 + h) | 0;
}
const out = new Uint8Array(32);
const outDv = new DataView(out.buffer);
outDv.setInt32(0, h0, false);
outDv.setInt32(4, h1, false);
outDv.setInt32(8, h2, false);
outDv.setInt32(12, h3, false);
outDv.setInt32(16, h4, false);
outDv.setInt32(20, h5, false);
outDv.setInt32(24, h6, false);
outDv.setInt32(28, h7, false);
return bytesToHex(out);
}