diff --git a/.eslintrc.js b/.eslintrc.js index a4975e8f..c120f3c1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -59,7 +59,6 @@ module.exports = { "react/function-component-definition": "off", "jsx-a11y/control-has-associated-label": "off", "react/button-has-type": "off", - "no-labels": ["error", { "allowLoop": true }], "max-classes-per-file": "off", }, parserOptions: { diff --git a/assets/tests/standard.zip b/assets/tests/standard.zip deleted file mode 100644 index 96b81a8e..00000000 Binary files a/assets/tests/standard.zip and /dev/null differ diff --git a/src/__tests__/assets/zip/corrupted.zip b/src/__tests__/assets/zip/corrupted.zip new file mode 100644 index 00000000..4db9efcc Binary files /dev/null and b/src/__tests__/assets/zip/corrupted.zip differ diff --git a/src/__tests__/assets/zip/empty.zip b/src/__tests__/assets/zip/empty.zip new file mode 100644 index 00000000..15cb0ecb Binary files /dev/null and b/src/__tests__/assets/zip/empty.zip differ diff --git a/assets/tests/manifest.zip b/src/__tests__/assets/zip/manifest.zip similarity index 100% rename from assets/tests/manifest.zip rename to src/__tests__/assets/zip/manifest.zip diff --git a/src/__tests__/assets/zip/standard.zip b/src/__tests__/assets/zip/standard.zip new file mode 100644 index 00000000..106adc5c Binary files /dev/null and b/src/__tests__/assets/zip/standard.zip differ diff --git a/assets/tests/subfolders.zip b/src/__tests__/assets/zip/subfolders.zip similarity index 100% rename from assets/tests/subfolders.zip rename to src/__tests__/assets/zip/subfolders.zip diff --git a/src/__tests__/assets/zip/unicode.zip b/src/__tests__/assets/zip/unicode.zip new file mode 100644 index 00000000..a0a1854f Binary files /dev/null and b/src/__tests__/assets/zip/unicode.zip differ diff --git a/assets/tests/windows_legacy.zip b/src/__tests__/assets/zip/windows_legacy.zip similarity index 99% rename from assets/tests/windows_legacy.zip rename to src/__tests__/assets/zip/windows_legacy.zip index 3ac92eae..aa9da43c 100644 Binary files a/assets/tests/windows_legacy.zip and b/src/__tests__/assets/zip/windows_legacy.zip differ diff --git a/assets/tests/windows_legacy_subfolders.zip b/src/__tests__/assets/zip/windows_legacy_subfolders.zip similarity index 100% rename from assets/tests/windows_legacy_subfolders.zip rename to src/__tests__/assets/zip/windows_legacy_subfolders.zip diff --git a/src/__tests__/unit/zip.test.ts b/src/__tests__/unit/zip.test.ts index 4c228765..459c4242 100644 --- a/src/__tests__/unit/zip.test.ts +++ b/src/__tests__/unit/zip.test.ts @@ -1,16 +1,18 @@ import path from "path"; import { mkdir, pathExistsSync, readFile, rm } from "fs-extra"; -import { YauzlZip } from "main/models/yauzl-zip.class"; +import { BsmZipExtractor } from "main/models/bsm-zip-extractor.class"; -const TEST_FOLDER = path.resolve(__dirname, "../../..", "assets", "tests"); +const TEST_FOLDER = path.resolve(__dirname, "..", "assets", "zip"); const STANDARD_ZIP = path.join(TEST_FOLDER, "standard.zip"); const WINDOWS_LEGACY_MAP_ZIP = path.join(TEST_FOLDER, "windows_legacy.zip"); const SUBFOLDERS_ZIP = path.join(TEST_FOLDER, "subfolders.zip"); const MANIFEST_ZIP = path.join(TEST_FOLDER, "manifest.zip"); +const EMPTY_ZIP = path.join(TEST_FOLDER, "empty.zip"); +const UNICODE_ZIP = path.join(TEST_FOLDER, "unicode.zip"); const DESTINATION_FOLDER = path.join(TEST_FOLDER, "out"); -describe("Test YauzlZip class", () => { - let zip: YauzlZip; +describe("Test BsmZipExtractor class", () => { + let zip: BsmZipExtractor; beforeAll(async () => { if (pathExistsSync(DESTINATION_FOLDER)) { @@ -19,9 +21,25 @@ describe("Test YauzlZip class", () => { await mkdir(DESTINATION_FOLDER); }); + afterEach(async () => { + if (zip) { + zip.close(); + } + if (pathExistsSync(DESTINATION_FOLDER)) { + await rm(DESTINATION_FOLDER, { recursive: true, force: true }); + } + await mkdir(DESTINATION_FOLDER); + }); + + afterAll(async () => { + if (pathExistsSync(DESTINATION_FOLDER)) { + await rm(DESTINATION_FOLDER, { recursive: true, force: true }); + } + }); + it("Extract standard zip", async () => { - zip = await YauzlZip.fromPath(STANDARD_ZIP); + zip = await BsmZipExtractor.fromPath(STANDARD_ZIP); const res = await zip.extract(DESTINATION_FOLDER); expect(res.sort()).toEqual([ @@ -53,7 +71,7 @@ describe("Test YauzlZip class", () => { it("Extract map zips using back slashes", async () => { - zip = await YauzlZip.fromPath(WINDOWS_LEGACY_MAP_ZIP); + zip = await BsmZipExtractor.fromPath(WINDOWS_LEGACY_MAP_ZIP); const res = await zip.extract(DESTINATION_FOLDER); expect(res.sort()).toEqual([ @@ -100,7 +118,7 @@ describe("Test YauzlZip class", () => { it("Read Zip with multiple subfolders", async () => { - const zip = await YauzlZip.fromPath(SUBFOLDERS_ZIP); + const zip = await BsmZipExtractor.fromPath(SUBFOLDERS_ZIP); const res = await zip.extract(DESTINATION_FOLDER); expect(res.sort()).toEqual([ @@ -129,7 +147,7 @@ describe("Test YauzlZip class", () => { it("Read manifest.json from zip file", async () => { const zipBuffer = await readFile(MANIFEST_ZIP); - zip = await YauzlZip.fromBuffer(zipBuffer); + zip = await BsmZipExtractor.fromBuffer(zipBuffer); const buffer = await (await zip.findEntry(entry => entry.fileName === "manifest.json")).read(); const manifest = JSON.parse(buffer.toString()); expect(manifest).toBeTruthy(); @@ -140,16 +158,172 @@ describe("Test YauzlZip class", () => { expect(manifest.isCore).toBe(true); }); - afterEach(() => { - if (zip) { - zip.close(); - } - }) - afterAll(async () => { - if (pathExistsSync(DESTINATION_FOLDER)) { - await rm(DESTINATION_FOLDER, { recursive: true, force: true }); - } + it("Should find an entry using findEntry", async () => { + zip = await BsmZipExtractor.fromPath(STANDARD_ZIP); + const entry = await zip.findEntry(entry => entry.fileName === "file_1.2.txt"); + + expect(entry).toBeTruthy(); + expect(entry.fileName).toBe("file_1.2.txt"); + + const contentBuffer = await entry.read(); + const content = contentBuffer.toString(); + + expect(content).toContain("This is file 1.2"); // Assurez-vous que le contenu correspond }); -}) + it("Should filter entries using filterEntries", async () => { + zip = await BsmZipExtractor.fromPath(STANDARD_ZIP); + const entries = await zip.filterEntries(entry => !entry.isDirectory && entry.fileName.startsWith("folder_1.1/")); + + expect(entries.length).toBe(2); + const fileNames = entries.map(entry => entry.fileName).sort(); + + expect(fileNames).toEqual([ + "folder_1.1/file_2.1.txt", + "folder_1.1/file_2.2.txt", + ]); + }); + + it("Should get an entry by file name using getEntry", async () => { + zip = await BsmZipExtractor.fromPath(STANDARD_ZIP); + const entry = await zip.getEntry("file_1.3.txt"); + + expect(entry).toBeTruthy(); + expect(entry.fileName).toBe("file_1.3.txt"); + + const contentBuffer = await entry.read(); + const content = contentBuffer.toString(); + + expect(content).toContain("This is file 1.3"); // Assurez-vous que le contenu correspond + }); + + it("Should extract only specified entries using entriesNames option", async () => { + zip = await BsmZipExtractor.fromPath(STANDARD_ZIP); + const res = await zip.extract(DESTINATION_FOLDER, { entriesNames: ["file_1.2.txt", "folder_1.1/file_2.1.txt"] }); + + expect(res.sort()).toEqual([ + "file_1.2.txt", + "folder_1.1/", + "folder_1.1/file_2.1.txt", + ].sort()); + + for (const file of [ + "file_1.2.txt", + "folder_1.1/file_2.1.txt", + ]) { + expect(pathExistsSync(path.join(DESTINATION_FOLDER, file))) + .toBe(true); + } + + // Vérifiez que les autres fichiers ne sont pas extraits + expect(pathExistsSync(path.join(DESTINATION_FOLDER, "file_1.3.txt"))) + .toBe(false); + expect(pathExistsSync(path.join(DESTINATION_FOLDER, "folder_1.1/file_2.2.txt"))) + .toBe(false); + }); + + it("Should not extract any files if abortToken is already aborted", async () => { + zip = await BsmZipExtractor.fromPath(STANDARD_ZIP); + const abortController = new AbortController(); + + // Abortez avant d'appeler extract + abortController.abort(); + + const res = await zip.extract(DESTINATION_FOLDER, { abortToken: abortController }); + + expect(res.length).toBe(0); + + // Vérifiez qu'aucun fichier n'a été extrait + expect(pathExistsSync(path.join(DESTINATION_FOLDER, "file_1.2.txt"))) + .toBe(false); + }); + + + it("Should throw an error when opening a non-existent zip file", async () => { + await expect(BsmZipExtractor.fromPath("non_existent.zip")) + .rejects + .toThrow(); + }); + + it("Should handle empty zip file correctly", async () => { + zip = await BsmZipExtractor.fromPath(EMPTY_ZIP); + const entries = []; + + for await (const entry of zip.entries()) { + entries.push(entry); + } + + expect(entries.length).toBe(0); + + const res = await zip.extract(DESTINATION_FOLDER); + expect(res.length).toBe(0); + }); + + it("Should return the same entries on multiple calls to entries()", async () => { + zip = await BsmZipExtractor.fromPath(STANDARD_ZIP); + + const entries1 = []; + for await (const entry of zip.entries()) { + entries1.push(entry); + } + + expect(entries1.length).toBe(5); // Basé sur le contenu de STANDARD_ZIP + + const entries2 = []; + for await (const entry of zip.entries()) { + entries2.push(entry); + } + + expect(entries2.length).toBe(5); + expect(entries2.map(e => e.fileName).sort()).toEqual(entries1.map(e => e.fileName).sort()); + }); + + it("Should not be able to read entries after zip is closed", async () => { + zip = await BsmZipExtractor.fromPath(STANDARD_ZIP); + + zip.close(); + + await expect(zip.entries().next()).rejects.toThrow(); + }); + + it("Should handle zip file from buffer", async () => { + const zipBuffer = await readFile(STANDARD_ZIP); + zip = await BsmZipExtractor.fromBuffer(zipBuffer); + + const entries = []; + for await (const entry of zip.entries()) { + entries.push(entry.fileName); + } + + expect(entries.sort()).toEqual([ + "file_1.2.txt", + "file_1.3.txt", + "folder_1.1/", + "folder_1.1/file_2.1.txt", + "folder_1.1/file_2.2.txt", + ].sort()); + }); + + it("Should read and extract files with Unicode filenames", async () => { + zip = await BsmZipExtractor.fromPath(UNICODE_ZIP); + + const entries = []; + for await (const entry of zip.entries()) { + entries.push(entry); + } + + expect(entries.map(e => e.fileName)).toContain("こんにちは.txt"); // "Bonjour" en japonais + + const res = await zip.extract(DESTINATION_FOLDER); + expect(res).toContain("こんにちは.txt"); + expect(pathExistsSync(path.join(DESTINATION_FOLDER, "こんにちは.txt"))) + .toBe(true); + + }); + + it("Should throw an error when reading a corrupted zip file", async () => { + const CORRUPTED_ZIP = path.join(TEST_FOLDER, "corrupted.zip"); + await expect(BsmZipExtractor.fromPath(CORRUPTED_ZIP)).rejects.toThrow(/.*[Error: Invalid comment length].*/) + }); +}); diff --git a/src/main/models/yauzl-zip.class.ts b/src/main/models/bsm-zip-extractor.class.ts similarity index 79% rename from src/main/models/yauzl-zip.class.ts rename to src/main/models/bsm-zip-extractor.class.ts index 83774e10..c05939b8 100644 --- a/src/main/models/yauzl-zip.class.ts +++ b/src/main/models/bsm-zip-extractor.class.ts @@ -3,7 +3,7 @@ import path from "path"; import { Readable } from "stream"; import yauzl, { ZipFile, Options, Entry } from "yauzl" -export class YauzlZip { +export class BsmZipExtractor { private static readonly YAUZL_OPEN_OPTIONS: Options = { lazyEntries: true, @@ -11,36 +11,36 @@ export class YauzlZip { autoClose: false, }; - public static fromPath(path: string): Promise { + public static fromPath(path: string): Promise { return new Promise((resolve, reject) => { - yauzl.open(path, YauzlZip.YAUZL_OPEN_OPTIONS, (error: Error, zip: yauzl.ZipFile) => { + yauzl.open(path, BsmZipExtractor.YAUZL_OPEN_OPTIONS, (error: Error, zip: yauzl.ZipFile) => { if (error) return reject(error); - resolve(new YauzlZip(zip)); + resolve(new BsmZipExtractor(zip)); }); }); } - public static fromBuffer(buffer: Buffer): Promise { + public static fromBuffer(buffer: Buffer): Promise { return new Promise((resolve, reject) => { - yauzl.fromBuffer(buffer, YauzlZip.YAUZL_OPEN_OPTIONS, (error: Error, zip: yauzl.ZipFile) => { + yauzl.fromBuffer(buffer, BsmZipExtractor.YAUZL_OPEN_OPTIONS, (error: Error, zip: yauzl.ZipFile) => { if (error) return reject(error); - resolve(new YauzlZip(zip)); + resolve(new BsmZipExtractor(zip)); }); }); } private readonly zip: ZipFile; - private readonly entriesMap = new Map(); + private readonly entriesMap = new Map(); private constructor(zip: ZipFile) { this.zip = zip; } - private async readEntry(): Promise { + private async readEntry(): Promise { return new Promise((resolve, reject) => { const onEntry = (entry: Entry) => { cleanup(); - resolve(new YauzlZipEntry({ entry, zip: this.zip })); + resolve(new BsmZipExtractorEntry({ entry, zip: this.zip })); }; const onEnd = () => { @@ -68,13 +68,13 @@ export class YauzlZip { // The first time this method is called, it will read all the entries and store them in a map // The next times it will return the entries from the map to avoid reopening the zip file again - public async *entries(): AsyncGenerator { + public async *entries(): AsyncGenerator { for (const entry of Array.from(this.entriesMap.values())) { yield entry; } - let entry: YauzlZipEntry = await this.readEntry(); + let entry: BsmZipExtractorEntry = await this.readEntry(); while (entry) { this.entriesMap.set(entry.fileName, entry); yield entry; @@ -82,7 +82,7 @@ export class YauzlZip { } } - public async findEntry(func: (entry: YauzlZipEntry) => boolean): Promise { + public async findEntry(func: (entry: BsmZipExtractorEntry) => boolean): Promise { for await (const entry of this.entries()) { if (func(entry)) { return entry; @@ -91,6 +91,20 @@ export class YauzlZip { return null; } + public async filterEntries(func: (entry: BsmZipExtractorEntry) => boolean): Promise { + const filtered: BsmZipExtractorEntry[] = []; + for await (const entry of this.entries()) { + if (func(entry)) { + filtered.push(entry); + } + } + return filtered; + } + + public async getEntry(fileName: string): Promise { + return this.entriesMap.get(fileName) ?? this.findEntry(entry => entry.fileName === fileName); + } + /** * Extracts all entries from the zip file to the destination folder * @param destination @@ -149,7 +163,7 @@ export class YauzlZip { } -class YauzlZipEntry { +class BsmZipExtractorEntry { private readonly entry: Entry; private readonly zip: ZipFile; diff --git a/src/main/models/oculus-downloader.class.ts b/src/main/models/oculus-downloader.class.ts index 0b48e6fd..47587e73 100644 --- a/src/main/models/oculus-downloader.class.ts +++ b/src/main/models/oculus-downloader.class.ts @@ -6,7 +6,8 @@ import { inflate } from "pako" import { EMPTY, Observable, ReplaySubject, Subscriber, catchError, filter, from, lastValueFrom, mergeMap, scan, share, tap } from "rxjs"; import { Progression, hashFile } from "../helpers/fs.helpers"; import { OculusDownloaderErrorCodes } from "../../shared/models/bs-version-download/oculus-download.model"; -import { YauzlZip } from "./yauzl-zip.class"; +import { BsmZipExtractor } from "./bsm-zip-extractor.class"; +import { tryit } from "shared/helpers/error.helpers"; export class OculusDownloader { @@ -34,19 +35,23 @@ export class OculusDownloader { const buffer = await this.downloadManifestZip(downloadUrl) .catch(err => CustomError.throw(err, "DOWNLOAD_MANIFEST_FAILED")); - const manifestName = "manifest.json"; - const zip = await YauzlZip.fromBuffer(buffer); - const entry = await zip.findEntry((entry) => entry.fileName === manifestName); + const zip = await BsmZipExtractor.fromBuffer(buffer); + const entry = await zip.getEntry("manifest.json"); + if(!entry) { throw new CustomError("Manifest file not found", "MANIFEST_FILE_NOT_FOUND"); } const manifest = await entry.read(); - return JSON.parse(manifest.toString()) - .catch((err: Error) => CustomError.throw(err, "PARSE_MANIFEST_FILE_FAILED")) - .finally(() => { - zip.close(); - }); + zip.close(); + + const { result, error } = tryit(() => JSON.parse(manifest.toString()) as OculusManifest); + + if(error){ + throw CustomError.throw(error, "PARSE_MANIFEST_FILE_FAILED") + } + + return result; } private downloadManifestFile(file: OculusManifestFile, destination: string): Observable> { diff --git a/src/main/services/additional-content/maps/local-maps-manager.service.ts b/src/main/services/additional-content/maps/local-maps-manager.service.ts index ced486b7..d4a6558d 100644 --- a/src/main/services/additional-content/maps/local-maps-manager.service.ts +++ b/src/main/services/additional-content/maps/local-maps-manager.service.ts @@ -29,7 +29,8 @@ import { MapInfo } from "shared/models/maps/info/map-info.model"; import { parseMapInfoDat } from "shared/parsers/maps/map-info.parser"; import { CustomError } from "shared/models/exceptions/custom-error.class"; import { tryit } from "shared/helpers/error.helpers"; -import { YauzlZip } from "main/models/yauzl-zip.class"; +import { BsmZipExtractor } from "main/models/bsm-zip-extractor.class"; +import { escapeRegExp } from "../../../../shared/helpers/string.helpers"; export class LocalMapsManagerService { private static instance: LocalMapsManagerService; @@ -327,7 +328,7 @@ export class LocalMapsManagerService { let progress: Progression = { total: 0, current: 0 }; let nbImportedMaps = 0; const abortController = new AbortController(); - let zip: YauzlZip; + let zip: BsmZipExtractor; (async () => { const mapsPath = await this.getMapsFolderPath(version); @@ -342,14 +343,9 @@ export class LocalMapsManagerService { if(!pathExistsSync(zipPath)) { continue; } - const mapsFolders: string[] = []; - - zip = await YauzlZip.fromPath(zipPath); - for await (const entry of zip.entries()) { - if (/(^|\/)[Ii]nfo\.dat$/.test(entry.fileName)) { - mapsFolders.push(path.dirname(entry.fileName)); - } - } + zip = await BsmZipExtractor.fromPath(zipPath); + const mapsFolders = (await zip.filterEntries(entry => /(^|\/)[Ii]nfo\.dat$/.test(entry.fileName))) + .map(entry => path.dirname(entry.fileName)); if (mapsFolders.length === 0) { log.warn("No maps \"info.dat\" found in zip", zipPath); @@ -370,20 +366,19 @@ export class LocalMapsManagerService { log.info("Extracting", `"${zipPath}"`, "into", `"${mapsPath}"`); for (const folder of mapsFolders) { log.info(">", folder); - const regex = new RegExp(`^${folder - .replaceAll(".", "\\.") - .replaceAll("+", "\\+") - .replaceAll("(", "\\(") - .replaceAll(")", "\\)") - .replaceAll("[", "\\[") - .replaceAll("]", "\\]") - }\\/`); - await zip.extract(destination, { + const regex = new RegExp(`^${escapeRegExp(folder)}\\/`); + + const exported = await zip.extract(destination, { entriesNames: [regex], abortToken: abortController }); + if(exported.length === 0) { + log.warn("No files extracted from", folder); + continue; + } + if (abortController.signal?.aborted) { break; } @@ -449,7 +444,7 @@ export class LocalMapsManagerService { } const zipPath = await this.downloadMapZip(zipUrl); - const zip = await YauzlZip.fromPath(zipPath); + const zip = await BsmZipExtractor.fromPath(zipPath); await zip.extract(mapPath); zip.close(); await unlink(zipPath); diff --git a/src/main/services/mods/bs-mods-manager.service.ts b/src/main/services/mods/bs-mods-manager.service.ts index 64f1682e..78e969bb 100644 --- a/src/main/services/mods/bs-mods-manager.service.ts +++ b/src/main/services/mods/bs-mods-manager.service.ts @@ -8,18 +8,18 @@ import { RequestService } from "../request.service"; import { spawn } from "child_process"; import { BS_EXECUTABLE } from "../../constants"; import log from "electron-log"; -import { deleteFolder, ensureFolderExist, pathExist, Progression, unlinkPath } from "../../helpers/fs.helpers"; +import { deleteFolder, pathExist, Progression, unlinkPath } from "../../helpers/fs.helpers"; import { lastValueFrom, Observable } from "rxjs"; import recursiveReadDir from "recursive-readdir"; import { sToMs } from "../../../shared/helpers/time.helpers"; -import { pathExistsSync, unlinkSync } from "fs-extra"; +import { pathExistsSync } from "fs-extra"; import { CustomError } from "shared/models/exceptions/custom-error.class"; import { popElement } from "shared/helpers/array.helpers"; import { LinuxService } from "../linux.service"; import { tryit } from "shared/helpers/error.helpers"; import { UtilsService } from "../utils.service"; import crypto from "crypto"; -import { YauzlZip } from "main/models/yauzl-zip.class"; +import { BsmZipExtractor } from "main/models/bsm-zip-extractor.class"; export class BsModsManagerService { private static instance: BsModsManagerService; @@ -113,22 +113,23 @@ export class BsModsManagerService { return this.beatModsApi.getModByHash(injectorMd5); } - private async downloadZip(zipUrl: string): Promise { + private async downloadZip(zipUrl: string): Promise { zipUrl = path.join(this.beatModsApi.BEAT_MODS_URL, zipUrl); log.info("Download mod zip", zipUrl); - const fileName = `${path.basename(zipUrl, ".zip")}-${crypto.randomUUID()}.zip`; - const tempPath = this.utilsService.getTempPath(); - const destination = path.join(tempPath, fileName); + const buffer = await lastValueFrom(this.requestService.downloadBuffer(zipUrl)) + .then(progress => progress.data) + .catch(e => { + log.error("ZIP", "Error while downloading zip", e); + return undefined; + }); - try { - await ensureFolderExist(tempPath); - return (await lastValueFrom(this.requestService.downloadFile(zipUrl, destination))).data; - } catch (error) { - log.error("Could not download zip file at", zipUrl); + if (!buffer) { return null; } + + return BsmZipExtractor.fromBuffer(buffer); } private async executeBSIPA(version: BSVersion, args: string[]): Promise { @@ -199,13 +200,13 @@ export class BsModsManagerService { } log.info("Start download mod zip", mod.name, download.url); - const zipPath = await this.downloadZip(download.url); - log.info("Mod zip download end", mod.name, download.url, !!zipPath); - if (!zipPath) { + const zip = await this.downloadZip(download.url); + log.info("Mod zip download end", mod.name, download.url); + + if (!zip) { return false; } - const zip = await YauzlZip.fromPath(zipPath); let hashCount = 0; for await (const entry of zip.entries()) { const buffer = await entry.read(); @@ -232,9 +233,6 @@ export class BsModsManagerService { }) .finally(() => { zip.close(); - if (pathExistsSync(zipPath)) { - unlinkSync(zipPath); - } }); log.info("Mod zip extraction end", mod.name, "to", destDir, "success:", extracted); diff --git a/src/shared/helpers/string.helpers.ts b/src/shared/helpers/string.helpers.ts index 56649c4c..584168db 100644 --- a/src/shared/helpers/string.helpers.ts +++ b/src/shared/helpers/string.helpers.ts @@ -11,3 +11,8 @@ export function findHashInString(str: string, algorithm: keyof typeof HashAlgori const match = regex.exec(str); return match ? match[0] : undefined; } + +export function escapeRegExp(str: string): string { + // Regex taken from lodash escapeRegExp function + return str.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); +}