diff --git a/src/routes/api/branches.ts b/src/routes/api/branches.ts index 746ebb92a..b9c5f751d 100644 --- a/src/routes/api/branches.ts +++ b/src/routes/api/branches.ts @@ -216,6 +216,7 @@ function deleteBranch(req: Request) { function setPrefix(req: Request) { const branchId = req.params.branchId; + //TriliumNextTODO: req.body arrives as string, so req.body.prefix will be undefined – did the code below ever even work? const prefix = utils.isEmptyOrWhitespace(req.body.prefix) ? null : req.body.prefix; const branch = becca.getBranchOrThrow(branchId); diff --git a/src/services/notes.ts b/src/services/notes.ts index d0a699984..26e51675e 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -6,7 +6,7 @@ import eventService from "./events.js"; import cls from "../services/cls.js"; import protectedSessionService from "../services/protected_session.js"; import log from "../services/log.js"; -import { newEntityId, isString, unescapeHtml, quoteRegex, toMap } from "../services/utils.js"; +import { newEntityId, unescapeHtml, quoteRegex, toMap } from "../services/utils.js"; import revisionService from "./revisions.js"; import request from "./request.js"; import path from "path"; @@ -731,13 +731,13 @@ function updateNoteData(noteId: string, content: string, attachments: Attachment note.setContent(newContent, { forceFrontendReload }); if (attachments?.length > 0) { - const existingAttachmentsByTitle = toMap(note.getAttachments({ includeContentLength: false }), "title"); + const existingAttachmentsByTitle = toMap(note.getAttachments({ includeContentLength: false }), "title"); for (const { attachmentId, role, mime, title, position, content } of attachments) { - if (attachmentId || !(title in existingAttachmentsByTitle)) { + const existingAttachment = existingAttachmentsByTitle.get(title); + if (attachmentId || !existingAttachment) { note.saveAttachment({ attachmentId, role, mime, title, content, position }); } else { - const existingAttachment = existingAttachmentsByTitle[title]; existingAttachment.role = role; existingAttachment.mime = mime; existingAttachment.position = position; @@ -884,7 +884,7 @@ async function asyncPostProcessContent(note: BNote, content: string | Buffer) { return; } - if (note.hasStringContent() && !isString(content)) { + if (note.hasStringContent() && typeof content !== "string") { content = content.toString(); } diff --git a/src/services/utils.formatDownloadTitle.spec.ts b/src/services/utils.formatDownloadTitle.spec.ts deleted file mode 100644 index 0cc259e16..000000000 --- a/src/services/utils.formatDownloadTitle.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { expect, describe, it } from "vitest"; -import { formatDownloadTitle } from "./utils.js"; - -const testCases: [fnValue: Parameters, expectedValue: ReturnType][] = [ - // empty fileName tests - [["", "text", ""], "untitled.html"], - - [["", "canvas", ""], "untitled.json"], - - [["", null, ""], "untitled"], - - // json extension from type tests - [["test_file", "canvas", ""], "test_file.json"], - - [["test_file", "relationMap", ""], "test_file.json"], - - [["test_file", "search", ""], "test_file.json"], - - // extension based on mime type - [["test_file", null, "text/csv"], "test_file.csv"], - - [["test_file_wo_ext", "image", "image/svg+xml"], "test_file_wo_ext.svg"], - - [["test_file_wo_ext", "file", "application/json"], "test_file_wo_ext.json"], - - [["test_file_w_fake_ext.ext", "image", "image/svg+xml"], "test_file_w_fake_ext.ext.svg"], - - [["test_file_w_correct_ext.svg", "image", "image/svg+xml"], "test_file_w_correct_ext.svg"], - - [["test_file_w_correct_ext.svgz", "image", "image/svg+xml"], "test_file_w_correct_ext.svgz"], - - [["test_file.zip", "file", "application/zip"], "test_file.zip"], - - [["test_file", "file", "application/zip"], "test_file.zip"], - - // application/octet-stream tests - [["test_file", "file", "application/octet-stream"], "test_file"], - - [["test_file.zip", "file", "application/octet-stream"], "test_file.zip"], - - [["test_file.unknown", null, "application/octet-stream"], "test_file.unknown"], - - // sanitized filename tests - [["test/file", null, "application/octet-stream"], "testfile"], - - [["test:file.zip", "file", "application/zip"], "testfile.zip"], - - [[":::", "file", "application/zip"], ".zip"], - - [[":::a", "file", "application/zip"], "a.zip"] -]; - -describe("utils/formatDownloadTitle unit tests", () => { - testCases.forEach((testCase) => { - return it(`With args '${JSON.stringify(testCase[0])}' it should return '${testCase[1]}'`, () => { - const [value, expected] = testCase; - const actual = formatDownloadTitle(...value); - expect(actual).toStrictEqual(expected); - }); - }); -}); \ No newline at end of file diff --git a/src/services/utils.spec.ts b/src/services/utils.spec.ts new file mode 100644 index 000000000..3451a881d --- /dev/null +++ b/src/services/utils.spec.ts @@ -0,0 +1,630 @@ +import { describe, it, expect } from "vitest"; +import utils from "./utils.js"; + +type TestCase any> = [desc: string, fnParams: Parameters, expected: ReturnType]; + +describe("#newEntityId", () => { + + it("should return a string with a length of 12", () => { + const result = utils.newEntityId(); + expect(result).toBeTypeOf("string"); + expect(result).toHaveLength(12); + }); + +}); + +describe("#randomString", () => { + + it("should return a string with a length as per argument", () => { + const stringLength = 5; + const result = utils.randomString(stringLength); + expect(result).toBeTypeOf("string"); + expect(result).toHaveLength(stringLength); + }); + +}); + +// TriliumNextTODO: should use mocks and assert that functions get called +describe("#randomSecureToken", () => { + // base64 -> 4 * (bytes/3) length -> if padding and rounding up is ignored for simplicity + // https://stackoverflow.com/a/13378842 + const byteToBase64Length = (bytes: number) => 4 * (bytes / 3); + + it("should return a string and use 32 bytes by default", () => { + const result = utils.randomSecureToken(); + expect(result).toBeTypeOf("string"); + expect(result.length).toBeGreaterThanOrEqual(byteToBase64Length(32)); + }); + + it("should return a string and use passed byte length", () => { + const bytes = 16; + const result = utils.randomSecureToken(bytes); + expect(result).toBeTypeOf("string"); + expect(result.length).toBeGreaterThanOrEqual(byteToBase64Length(bytes)); + expect(result.length).toBeLessThan(44); // default argument uses 32 bytes -> which translates to 44 base64 legal chars + }); +}); + +// TriliumNextTODO: should use mocks and assert that functions get called +describe.todo("#md5", () => {}); + +// TriliumNextTODO: should use mocks and assert that functions get called +describe.todo("#hashedBlobId", () => {}); + +// TriliumNextTODO: should use mocks and assert that functions get called +describe.todo("#toBase64", () => {}); + +// TriliumNextTODO: should use mocks and assert that functions get called +describe.todo("#fromBase64", () => {}); + +// TriliumNextTODO: should use mocks and assert that functions get called +describe.todo("#hmac", () => {}); + +// TriliumNextTODO: should use mocks and assert that functions get called +describe.todo("#hash", () => {}); + +describe("#isEmptyOrWhitespace", () => { + + const testCases: TestCase[] = [ + ["w/ 'null' it should return true", [null], true], + ["w/ 'null' it should return true", [null], true], + ["w/ undefined it should return true", [undefined], true], + ["w/ empty string '' it should return true", [""], true], + ["w/ single whitespace string ' ' it should return true", [" "], true], + ["w/ multiple whitespace string ' ' it should return true", [" "], true], + ["w/ non-empty string ' t ' it should return false", [" t "], false], + ]; + + testCases.forEach(testCase => { + const [desc, fnParams, expected] = testCase; + it(desc, () => { + const result = utils.isEmptyOrWhitespace(...fnParams); + expect(result).toStrictEqual(expected); + }) + }) + +}); + +describe("#sanitizeSqlIdentifier", () => { + + const testCases: TestCase[] = [ + ["w/ 'test' it should not strip anything", ["test"], "test"], + ["w/ 'test123' it should not strip anything", ["test123"], "test123"], + ["w/ 'tEst_TeSt' it should not strip anything", ["tEst_TeSt"], "tEst_TeSt"], + ["w/ 'test_test' it should not strip '_'", ["test_test"], "test_test"], + ["w/ 'test-' it should strip the '-'", ["test-"], "test"], + ["w/ 'test-test' it should strip the '-'", ["test-test"], "testtest"], + ["w/ 'test; --test' it should strip the '; --'", ["test; --test"], "testtest"], + ["w/ 'test test' it should strip the ' '", ["test test"], "testtest"], + ]; + + testCases.forEach(testCase => { + const [desc, fnParams, expected] = testCase; + it(desc, () => { + const result = utils.sanitizeSqlIdentifier(...fnParams); + expect(result).toStrictEqual(expected); + }) + }); + +}); + +describe("#escapeHtml", () => { + it("should re-export 'escape-html' npm module as escapeHtml", () => { + expect(utils.escapeHtml).toBeTypeOf("function"); + }); +}); + +describe("#unescapeHtml", () => { + it("should re-export 'unescape' npm module as unescapeHtml", () => { + expect(utils.unescapeHtml).toBeTypeOf("function"); + }); +}); + +describe("#toObject", () => { + it("should return an object with keys and value being set from the supplied Function", () => { + type TestListEntry = { testPropA: string, testPropB: string }; + type TestListFn = (testListEntry: TestListEntry) => [string, string]; + const testList: [TestListEntry, TestListEntry] = [{ testPropA: "keyA", testPropB: "valueA" }, { testPropA: "keyB", testPropB: "valueB" }]; + const fn: TestListFn = (testListEntry: TestListEntry) => [testListEntry.testPropA + "_fn", testListEntry.testPropB + "_fn"]; + + const result = utils.toObject(testList, fn); + expect(result).toStrictEqual({ + "keyA_fn": "valueA_fn", + "keyB_fn": "valueB_fn" + }); + }); +}); + +describe("#stripTags", () => { + + //prettier-ignore + const htmlWithNewlines = +`

abc +def

+

ghi

`; + + const testCases: TestCase[] = [ + ["should strip all tags and only return the content, leaving new lines and spaces in tact", [htmlWithNewlines], "abc\ndef\nghi"], + //TriliumNextTODO: should this actually insert a space between content to prevent concatenated text? + ["should strip all tags and only return the content", ["

abc

def

"], "abcdef"], + ]; + + testCases.forEach(testCase => { + const [desc, fnParams, expected] = testCase; + it(desc, () => { + const result = utils.stripTags(...fnParams); + expect(result).toStrictEqual(expected); + }) + }); +}); + +describe.todo("#escapeRegExp", () => {}); + +describe.todo("#crash", () => {}); + +describe("#getContentDisposition", () => { + + const defaultFallBackDisposition = `file; filename="file"; filename*=UTF-8''file`; + const testCases: TestCase[] = [ + [ + "when passed filename is empty, it should fallback to default value 'file'", + [" "], + defaultFallBackDisposition + ], + [ + "when passed filename '..' would cause sanitized filename to be empty, it should fallback to default value 'file'", + [".."], + defaultFallBackDisposition + ], + // COM1 is a Windows specific "illegal filename" that sanitize filename strips away + [ + "when passed filename 'COM1' would cause sanitized filename to be empty, it should fallback to default value 'file'", + ["COM1"], + defaultFallBackDisposition + ], + [ + "sanitized passed filename should be returned URIEncoded", + ["test file.csv"], + `file; filename="test%20file.csv"; filename*=UTF-8''test%20file.csv` + ] + ] + + testCases.forEach(testCase => { + const [desc, fnParams, expected] = testCase; + it(desc, () => { + const result = utils.getContentDisposition(...fnParams); + expect(result).toStrictEqual(expected); + }) + }); +}); + +describe("#isStringNote", () => { + + const testCases: TestCase[] = [ + [ + "w/ 'undefined' note type, but a string mime type, it should return true", + [undefined, "application/javascript"], + true + ], + [ + "w/ non-string note type, it should return false", + ["image", "image/jpeg"], + false + ], + [ + "w/ string note type (text), it should return true", + ["text", "text/html"], + true + ], + [ + "w/ string note type (code), it should return true", + ["code", "application/json"], + true + ], + [ + "w/ non-string note type (file), but string mime type, it should return true", + ["file", "application/json"], + true + ], + [ + "w/ non-string note type (file), but mime type starting with 'text/', it should return true", + ["file", "text/html"], + true + ], + ]; + + testCases.forEach(testCase => { + const [desc, fnParams, expected] = testCase; + it(desc, () => { + const result = utils.isStringNote(...fnParams); + expect(result).toStrictEqual(expected); + }); + }); +}); + +describe.todo("#quoteRegex", () => {}); + +describe.todo("#replaceAll", () => {}); + +describe("#removeTextFileExtension", () => { + const testCases: TestCase[] = [ + ["w/ 'test.md' it should strip '.md'", ["test.md"], "test"], + ["w/ 'test.markdown' it should strip '.markdown'", ["test.markdown"], "test"], + ["w/ 'test.html' it should strip '.html'", ["test.html"], "test"], + ["w/ 'test.htm' it should strip '.htm'", ["test.htm"], "test"], + ["w/ 'test.zip' it should NOT strip '.zip'", ["test.zip"], "test.zip"], + ]; + + testCases.forEach(testCase => { + const [desc, fnParams, expected] = testCase; + it(desc, () => { + const result = utils.removeTextFileExtension(...fnParams); + expect(result).toStrictEqual(expected); + }); + }); + +}); + +describe("#getNoteTitle", () => { + const testCases: TestCase[] = [ + [ + "when file has no spaces, and no special file extension, it should return the filename unaltered", + ["test.json", true, undefined], + "test.json" + ], + [ + "when replaceUnderscoresWithSpaces is false, it should keep the underscores in the title", + ["test_file.json", false, undefined], + "test_file.json" + ], + [ + "when replaceUnderscoresWithSpaces is true, it should replace the underscores in the title", + ["test_file.json", true, undefined], + "test file.json" + ], + [ + "when filePath ends with one of the extra handled endings (.md), it should strip the file extension from the title", + ["test_file.md", false, undefined], + "test_file" + ], + [ + "when filePath ends with one of the extra handled endings (.md) and replaceUnderscoresWithSpaces is true, it should strip the file extension from the title and replace underscores", + ["test_file.md", true, undefined], + "test file" + ], + [ + "when filepath contains a full path, it should only return the basename of the file", + ["Trilium Demo/Scripting examples/Statistics/Most cloned notes/template.zip", true, undefined], + "template.zip" + ], + [ + "when filepath contains a full path and has extra handled ending (.html), it should only return the basename of the file and strip the file extension", + ["Trilium Demo/Scripting examples/Statistics/Most cloned notes/template.html", true, undefined], + "template" + ], + [ + "when a noteMeta object is passed, it should use the title from the noteMeta, if present", + //@ts-expect-error - passing in incomplete noteMeta - but we only care about the title prop here + ["test_file.md", true, { title: "some other title"}], + "some other title" + ], + [ + "when a noteMeta object is passed, but the title prop is empty, it should try to handle the filename as if no noteMeta was passed", + //@ts-expect-error - passing in incomplete noteMeta - but we only care about the title prop here + ["test_file.md", true, { title: ""}], + "test file" + ], + [ + "when a noteMeta object is passed, but the title prop is empty, it should try to handle the filename as if no noteMeta was passed", + //@ts-expect-error - passing in incomplete noteMeta - but we only care about the title prop here + ["test_file.json", false, { title: " "}], + "test_file.json" + ] + ]; + + testCases.forEach(testCase => { + const [desc, fnParams, expected] = testCase; + it(desc, () => { + const result = utils.getNoteTitle(...fnParams); + expect(result).toStrictEqual(expected); + }); + }); + +}); + +describe("#timeLimit", () => { + + it("when promise execution does NOT exceed timeout, it should resolve with promises' value", async () => { + const resolvedValue = `resolved: ${new Date().toISOString()}`; + const testPromise = new Promise((res, rej) => { + setTimeout(() => { + return res(resolvedValue); + }, 200); + //rej("rejected!"); + }); + await expect(utils.timeLimit(testPromise, 1_000)).resolves.toBe(resolvedValue); + }); + + it("when promise execution rejects within timeout, it should return the original promises' rejected value, not the custom set one", async () => { + const rejectedValue = `rejected: ${new Date().toISOString()}`; + const testPromise = new Promise((res, rej) => { + setTimeout(() => { + //return res("resolved"); + rej(rejectedValue); + }, 100); + }); + await expect(utils.timeLimit(testPromise, 200, "Custom Error")).rejects.toThrow(rejectedValue) + }); + + it("when promise execution exceeds the set timeout, and 'errorMessage' is NOT set, it should reject the promise and display default error message", async () => { + const testPromise = new Promise((res, rej) => { + setTimeout(() => { + return res("resolved"); + }, 500); + //rej("rejected!"); + }); + await expect(utils.timeLimit(testPromise, 200)).rejects.toThrow(`Process exceeded time limit 200`) + }); + + it("when promise execution exceeds the set timeout, and 'errorMessage' is set, it should reject the promise and display set error message", async () => { + const customErrorMsg = "Custom Error"; + const testPromise = new Promise((res, rej) => { + setTimeout(() => { + return res("resolved"); + }, 500); + //rej("rejected!"); + }); + await expect(utils.timeLimit(testPromise, 200, customErrorMsg)).rejects.toThrow(customErrorMsg) + }); + + // TriliumNextTODO: since TS avoids this from ever happening – do we need this check? + it("when the passed promise is not a promise but 'undefined', it should return 'undefined'", async () => { + //@ts-expect-error - passing in illegal type 'undefined' + expect(utils.timeLimit(undefined, 200)).toBe(undefined) + }); + + // TriliumNextTODO: since TS avoids this from ever happening – do we need this check? + it("when the passed promise is not a promise, it should return the passed value", async () => { + //@ts-expect-error - passing in illegal type 'object' + expect(utils.timeLimit({test: 1}, 200)).toStrictEqual({test: 1}) + }); + +}); + +describe("#deferred", () => { + it("should return a promise", () => { + const result = utils.deferred(); + expect(result).toBeInstanceOf(Promise) + }) + // TriliumNextTODO: Add further tests! +}); + +describe("#removeDiacritic", () => { + + const testCases: TestCase[] = [ + ["w/ 'Äpfel' it should replace the 'Ä'", ["Äpfel"], "Apfel"], + ["w/ 'Été' it should replace the 'É' and 'é'", ["Été"], "Ete"], + ["w/ 'Fête' it should replace the 'ê'", ["Fête"], "Fete"], + ["w/ 'Αλφαβήτα' it should replace the 'ή'", ["Αλφαβήτα"], "Αλφαβητα"], + ["w/ '' (empty string) it should return empty string", [""], ""], + ]; + + testCases.forEach(testCase => { + const [desc, fnParams, expected] = testCase; + it(desc, () => { + const result = utils.removeDiacritic(...fnParams); + expect(result).toStrictEqual(expected); + }); + }); +}); + + +describe("#normalize", () => { + + const testCases: TestCase[] = [ + ["w/ 'Äpfel' it should replace the 'Ä' and return lowercased", ["Äpfel"], "apfel"], + ["w/ 'Été' it should replace the 'É' and 'é' and return lowercased", ["Été"], "ete"], + ["w/ 'FêTe' it should replace the 'ê' and return lowercased", ["FêTe"], "fete"], + ["w/ 'ΑλΦαβήΤα' it should replace the 'ή' and return lowercased", ["ΑλΦαβήΤα"], "αλφαβητα"], + ["w/ '' (empty string) it should return empty string", [""], ""], + ]; + + testCases.forEach(testCase => { + const [desc, fnParams, expected] = testCase; + it(desc, () => { + const result = utils.normalize(...fnParams); + expect(result).toStrictEqual(expected); + }); + }); + +}); + +describe("#toMap", () => { + it("should return an instace of Map, with the correct size and keys, when supplied with a list and existing keys", () => { + const testList = [{title: "test", propA: "text", propB: 123 }, {title: "test2", propA: "prop2", propB: 456 }]; + const result = utils.toMap(testList, "title"); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(2); + expect(Array.from(result.keys())).toStrictEqual(["test", "test2"]); + }); + it("should return an instace of Map, with an empty size, when the supplied list does not contain the supplied key", () => { + const testList = [{title: "test", propA: "text", propB: 123 }, {title: "test2", propA: "prop2", propB: 456 }]; + //@ts-expect-error - key is non-existing on supplied list type + const result = utils.toMap(testList, "nonExistingKey"); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + it.fails("should correctly handle duplicate keys? (currently it will overwrite the entry, so returned size will be 1 instead of 2)", () => { + const testList = [{title: "testDupeTitle", propA: "text", propB: 123 }, {title: "testDupeTitle", propA: "prop2", propB: 456 }]; + const result = utils.toMap(testList, "title"); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(2); + }); +}); + +describe("#envToBoolean", () => { + const testCases: TestCase[] = [ + ["w/ 'true' it should return boolean 'true'", ["true"], true], + ["w/ 'True' it should return boolean 'true'", ["True"], true], + ["w/ 'TRUE' it should return boolean 'true'", ["TRUE"], true], + ["w/ 'true ' it should return boolean 'true'", ["true "], true], + ["w/ 'false' it should return boolean 'false'", ["false"], false], + ["w/ 'False' it should return boolean 'false'", ["False"], false], + ["w/ 'FALSE' it should return boolean 'false'", ["FALSE"], false], + ["w/ 'false ' it should return boolean 'false'", ["false "], false], + ["w/ 'whatever' (non-boolean string) it should return undefined", ["whatever"], undefined], + ["w/ '-' (non-boolean string) it should return undefined", ["-"], undefined], + ["w/ '' (empty string) it should return undefined", [""], undefined], + ["w/ ' ' (white space string) it should return undefined", [" "], undefined], + ["w/ undefined it should return undefined", [undefined], undefined], + //@ts-expect-error - pass wrong type as param + ["w/ number 1 it should return undefined", [1], undefined], + ]; + + testCases.forEach(testCase => { + const [desc, fnParams, expected] = testCase; + it(desc, () => { + const result = utils.envToBoolean(...fnParams); + expect(result).toStrictEqual(expected); + }); + }); +}); + +describe.todo("#getResourceDir", () => {}); + +describe("#isElectron", () => { + it("should export a boolean", () => { + expect(utils.isElectron).toBeTypeOf("boolean"); + }); +}); + +describe("#isMac", () => { + it("should export a boolean", () => { + expect(utils.isMac).toBeTypeOf("boolean"); + }); +}); + +describe("#isWindows", () => { + it("should export a boolean", () => { + expect(utils.isWindows).toBeTypeOf("boolean"); + }); +}); + +describe("#isDev", () => { + it("should export a boolean", () => { + expect(utils.isDev).toBeTypeOf("boolean"); + }); +}); + +describe("#formatDownloadTitle", () => { + + //prettier-ignore + const testCases: [fnValue: Parameters, expectedValue: ReturnType][] = [ + + // empty fileName tests + [ + ["", "text", ""], + "untitled.html" + ], + [ + ["", "canvas", ""], + "untitled.json" + ], + [ + ["", null, ""], + "untitled" + ], + + + // json extension from type tests + [ + ["test_file", "canvas", ""], + "test_file.json" + ], + [ + ["test_file", "relationMap", ""], + "test_file.json" + ], + [ + ["test_file", "search", ""], + "test_file.json" + ], + + + // extension based on mime type + [ + ["test_file", null, "text/csv"], + "test_file.csv" + ], + [ + ["test_file_wo_ext", "image", "image/svg+xml"], + "test_file_wo_ext.svg" + ], + [ + ["test_file_wo_ext", "file", "application/json"], + "test_file_wo_ext.json" + ], + [ + ["test_file_w_fake_ext.ext", "image", "image/svg+xml"], + "test_file_w_fake_ext.ext.svg" + ], + [ + ["test_file_w_correct_ext.svg", "image", "image/svg+xml"], + "test_file_w_correct_ext.svg" + ], + [ + ["test_file_w_correct_ext.svgz", "image", "image/svg+xml"], + "test_file_w_correct_ext.svgz" + ], + [ + ["test_file.zip", "file", "application/zip"], + "test_file.zip" + ], + [ + ["test_file", "file", "application/zip"], + "test_file.zip" + ], + + + // application/octet-stream tests + [ + ["test_file", "file", "application/octet-stream"], + "test_file" + ], + [ + ["test_file.zip", "file", "application/octet-stream"], + "test_file.zip" + ], + [ + ["test_file.unknown", null, "application/octet-stream"], + "test_file.unknown" + ], + + + // sanitized filename tests + [ + ["test/file", null, "application/octet-stream"], + "testfile" + ], + [ + ["test:file.zip", "file", "application/zip"], + "testfile.zip" + ], + [ + [":::", "file", "application/zip"], + ".zip" + ], + [ + [":::a", "file", "application/zip"], + "a.zip" + ] + ]; + + testCases.forEach((testCase) => { + const [fnParams, expected] = testCase; + return it(`With args '${JSON.stringify(fnParams)}', it should return '${expected}'`, () => { + const actual = utils.formatDownloadTitle(...fnParams); + expect(actual).toStrictEqual(expected); + }); + }); +}); \ No newline at end of file diff --git a/src/services/utils.ts b/src/services/utils.ts index b4d7ad67c..289e1af6b 100644 --- a/src/services/utils.ts +++ b/src/services/utils.ts @@ -9,6 +9,7 @@ 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" }); @@ -71,21 +72,18 @@ export function hash(text: string) { return crypto.createHash("sha1").update(text).digest("base64"); } -export function isEmptyOrWhitespace(str: string) { - return str === null || str.match(/^ *$/) !== null; +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 function escapeHtml(str: string) { - return escape(str); -} +export const escapeHtml = escape; -export function unescapeHtml(str: string) { - return unescape(str); -} +export const unescapeHtml = unescape; export function toObject(array: T[], fn: (item: T) => [K, V]): Record { const obj: Record = {} as Record; // TODO: unsafe? @@ -103,29 +101,6 @@ export function stripTags(text: string) { return text.replace(/<(?:.|\n)*?>/gm, ""); } -export function union(a: T[], b: T[]): T[] { - const obj: Record = {} as Record; // TODO: unsafe? - - for (let i = a.length - 1; i >= 0; i--) { - obj[a[i]] = a[i]; - } - - for (let i = b.length - 1; i >= 0; i--) { - obj[b[i]] = b[i]; - } - - const res: T[] = []; - - for (const k in obj) { - if (obj.hasOwnProperty(k)) { - // <-- optional - res.push(obj[k]); - } - } - - return res; -} - export function escapeRegExp(str: string) { return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); } @@ -138,27 +113,18 @@ export async function crash() { } } -export function sanitizeFilenameForHeader(filename: string) { - let sanitizedFilename = sanitize(filename); - - if (sanitizedFilename.trim().length === 0) { - sanitizedFilename = "file"; - } - - return encodeURIComponent(sanitizedFilename); -} - export function getContentDisposition(filename: string) { - const sanitizedFilename = sanitizeFilenameForHeader(filename); - - return `file; filename="${sanitizedFilename}"; filename*=UTF-8''${sanitizedFilename}`; + 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) { - // render and book are string note in the sense that they are expected to contain empty string - return (type && ["text", "code", "relationMap", "search", "render", "book", "mermaid", "canvas"].includes(type)) || mime.startsWith("text/") || STRING_MIME_TYPES.has(mime); + return (type && STRING_NOTE_TYPES.has(type)) || mime.startsWith("text/") || STRING_MIME_TYPES.has(mime); } export function quoteRegex(url: string) { @@ -211,26 +177,23 @@ export function removeTextFileExtension(filePath: string) { } } -export function getNoteTitle(filePath: string, replaceUnderscoresWithSpaces: boolean, noteMeta?: { title?: string }) { - if (noteMeta?.title) { - return noteMeta.title; - } else { - const basename = path.basename(removeTextFileExtension(filePath)); - if (replaceUnderscoresWithSpaces) { - return basename.replace(/_/g, " ").trim(); - } - return basename; - } +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(promise: Promise, limitMs: number, errorMessage?: string): Promise { + // 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 error = new Error(errorMessage || `Process exceeded time limit ${limitMs}`); + const errorTimeLimit = new Error(errorMessage || `Process exceeded time limit ${limitMs}`); return new Promise((res, rej) => { let resolved = false; @@ -245,7 +208,7 @@ export function timeLimit(promise: Promise, limitMs: number, errorMessage? setTimeout(() => { if (!resolved) { - rej(error); + rej(errorTimeLimit); } }, limitMs); }); @@ -284,20 +247,18 @@ export function normalize(str: string) { return removeDiacritic(str).toLowerCase(); } -export function toMap>(list: T[], key: keyof T): Record { - const map: Record = {}; - +export function toMap>(list: T[], key: keyof T) { + const map = new Map(); for (const el of list) { - map[el[key]] = el; + 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; } -export function isString(x: any) { - return Object.prototype.toString.call(x) === "[object String]"; -} - // 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; @@ -317,11 +278,8 @@ export function envToBoolean(val: string | undefined) { * @returns the resource dir. */ export function getResourceDir() { - if (isElectron && !isDev) { - return process.resourcesPath; - } else { - return join(dirname(fileURLToPath(import.meta.url)), "..", ".."); - } + if (isElectron && !isDev) return process.resourcesPath; + return join(dirname(fileURLToPath(import.meta.url)), "..", ".."); } // TODO: Deduplicate with src/public/app/services/utils.ts @@ -367,41 +325,40 @@ function compareVersions(v1: string, v2: string): number { } export default { - randomSecureToken, - randomString, + compareVersions, + crash, + deferred, + envToBoolean, + escapeHtml, + escapeRegExp, + formatDownloadTitle, + fromBase64, + getContentDisposition, + getNoteTitle, + getResourceDir, + hash, + hashedBlobId, + hmac, + isDev, + isElectron, + isEmptyOrWhitespace, + isMac, + isStringNote, + isWindows, md5, newEntityId, - toBase64, - fromBase64, - hmac, - isElectron, - hash, - isEmptyOrWhitespace, - sanitizeSqlIdentifier, - escapeHtml, - unescapeHtml, - toObject, - stripTags, - union, - escapeRegExp, - crash, - getContentDisposition, - isStringNote, - quoteRegex, - replaceAll, - getNoteTitle, - removeTextFileExtension, - formatDownloadTitle, - timeLimit, - deferred, - removeDiacritic, normalize, - hashedBlobId, + quoteRegex, + randomSecureToken, + randomString, + removeDiacritic, + removeTextFileExtension, + replaceAll, + sanitizeSqlIdentifier, + stripTags, + timeLimit, + toBase64, toMap, - isString, - getResourceDir, - isMac, - isWindows, - envToBoolean, - compareVersions + toObject, + unescapeHtml };