diff --git a/bin/electron-forge/desktop.ejs b/bin/electron-forge/desktop.ejs index f803f37b2..32430cd2a 100644 --- a/bin/electron-forge/desktop.ejs +++ b/bin/electron-forge/desktop.ejs @@ -1,12 +1,17 @@ [Desktop Entry] -<% if (productName) { %>Name=<%= productName %> -<% } %><% if (description) { %>Comment=<%= description %> -<% } %><% if (genericName) { %>GenericName=<%= genericName %> -<% } %><% if (name) { %>Exec=<%= name %> %U -Icon=<%= name %> -<% } %>Type=Application -StartupNotify=true -<% if (productName) { %>StartupWMClass=<%= productName %> -<% } if (categories && categories.length) { %>Categories=<%= categories.join(';') %>; -<% } %><% if (mimeType && mimeType.length) { %>MimeType=<%= mimeType.join(';') %>; -<% } %> \ No newline at end of file +<%= +Object.entries({ + "Name": productName, + "Comment": description, + "GenericName": genericName, + "Exec": name ? `${name} %U` : undefined, + "Icon": name, + "Type": "Application", + "StartupNotify": "true", + "StartupWMClass": productName, + "Categories": categories?.length ? `${categories.join(";")};` : undefined, + "MimeType": mimeType?.length ? `${mimeType.join(";")};` : undefined +}) +.map(line => line[1] ? line.join("=") : undefined) +.filter(line => !!line) +.join("\n")%> \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3aac1a975..bd31ed854 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,7 +86,6 @@ "sanitize-filename": "1.6.3", "sanitize-html": "2.14.0", "sax": "1.4.1", - "semver": "7.7.0", "serve-favicon": "2.5.0", "session-file-store": "1.5.0", "source-map-support": "0.5.21", @@ -113,7 +112,7 @@ "@electron-forge/maker-zip": "7.6.1", "@electron-forge/plugin-auto-unpack-natives": "7.6.1", "@electron/rebuild": "3.7.1", - "@playwright/test": "1.50.0", + "@playwright/test": "1.50.1", "@types/archiver": "6.0.3", "@types/better-sqlite3": "7.6.12", "@types/bootstrap": "5.2.10", @@ -136,12 +135,11 @@ "@types/leaflet-gpx": "1.3.7", "@types/mime-types": "2.1.4", "@types/multer": "1.4.12", - "@types/node": "22.12.0", + "@types/node": "22.13.1", "@types/react": "18.3.18", "@types/safe-compare": "1.1.2", "@types/sanitize-html": "2.13.0", "@types/sax": "1.2.7", - "@types/semver": "7.5.8", "@types/serve-favicon": "2.5.7", "@types/session-file-store": "1.2.5", "@types/source-map-support": "0.5.10", @@ -151,7 +149,7 @@ "@types/ws": "8.5.14", "@types/xml2js": "0.4.14", "@types/yargs": "17.0.33", - "@vitest/coverage-v8": "3.0.4", + "@vitest/coverage-v8": "3.0.5", "cross-env": "7.0.3", "electron": "34.0.2", "esm": "3.2.25", @@ -166,7 +164,7 @@ "tsx": "4.19.2", "typedoc": "0.27.6", "typescript": "5.7.3", - "vitest": "3.0.4", + "vitest": "3.0.5", "webpack": "5.97.1", "webpack-cli": "6.0.1", "webpack-dev-middleware": "7.4.2" @@ -2880,13 +2878,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.50.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.0.tgz", - "integrity": "sha512-ZGNXbt+d65EGjBORQHuYKj+XhCewlwpnSd/EDuLPZGSiEWmgOJB5RmMCCYGy5aMfTs9wx61RivfDKi8H/hcMvw==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz", + "integrity": "sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.50.0" + "playwright": "1.50.1" }, "bin": { "playwright": "cli.js" @@ -3909,9 +3907,9 @@ } }, "node_modules/@types/node": { - "version": "22.12.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz", - "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", + "version": "22.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", + "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "license": "MIT", "dependencies": { "undici-types": "~6.20.0" @@ -4002,13 +4000,6 @@ "@types/node": "*" } }, - "node_modules/@types/semver": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", - "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", @@ -4163,9 +4154,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.4.tgz", - "integrity": "sha512-f0twgRCHgbs24Dp8cLWagzcObXMcuKtAwgxjJV/nnysPAJJk1JiKu/W0gIehZLmkljhJXU/E0/dmuQzsA/4jhA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.5.tgz", + "integrity": "sha512-zOOWIsj5fHh3jjGwQg+P+J1FW3s4jBu1Zqga0qW60yutsBtqEqNEJKWYh7cYn1yGD+1bdPsPdC/eL4eVK56xMg==", "dev": true, "license": "MIT", "dependencies": { @@ -4186,8 +4177,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.4", - "vitest": "3.0.4" + "@vitest/browser": "3.0.5", + "vitest": "3.0.5" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4196,14 +4187,14 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.4.tgz", - "integrity": "sha512-Nm5kJmYw6P2BxhJPkO3eKKhGYKRsnqJqf+r0yOGRKpEP+bSCBDsjXgiu1/5QFrnPMEgzfC38ZEjvCFgaNBC0Eg==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.5.tgz", + "integrity": "sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.4", - "@vitest/utils": "3.0.4", + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", "chai": "^5.1.2", "tinyrainbow": "^2.0.0" }, @@ -4212,13 +4203,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.4.tgz", - "integrity": "sha512-gEef35vKafJlfQbnyOXZ0Gcr9IBUsMTyTLXsEQwuyYAerpHqvXhzdBnDFuHLpFqth3F7b6BaFr4qV/Cs1ULx5A==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.5.tgz", + "integrity": "sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.4", + "@vitest/spy": "3.0.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -4239,9 +4230,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.4.tgz", - "integrity": "sha512-ts0fba+dEhK2aC9PFuZ9LTpULHpY/nd6jhAQ5IMU7Gaj7crPCTdCFfgvXxruRBLFS+MLraicCuFXxISEq8C93g==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz", + "integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==", "dev": true, "license": "MIT", "dependencies": { @@ -4252,13 +4243,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.4.tgz", - "integrity": "sha512-dKHzTQ7n9sExAcWH/0sh1elVgwc7OJ2lMOBrAm73J7AH6Pf9T12Zh3lNE1TETZaqrWFXtLlx3NVrLRb5hCK+iw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.5.tgz", + "integrity": "sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.4", + "@vitest/utils": "3.0.5", "pathe": "^2.0.2" }, "funding": { @@ -4273,13 +4264,13 @@ "license": "MIT" }, "node_modules/@vitest/snapshot": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.4.tgz", - "integrity": "sha512-+p5knMLwIk7lTQkM3NonZ9zBewzVp9EVkVpvNta0/PlFWpiqLaRcF4+33L1it3uRUCh0BGLOaXPPGEjNKfWb4w==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.5.tgz", + "integrity": "sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.4", + "@vitest/pretty-format": "3.0.5", "magic-string": "^0.30.17", "pathe": "^2.0.2" }, @@ -4295,9 +4286,9 @@ "license": "MIT" }, "node_modules/@vitest/spy": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.4.tgz", - "integrity": "sha512-sXIMF0oauYyUy2hN49VFTYodzEAu744MmGcPR3ZBsPM20G+1/cSW/n1U+3Yu/zHxX2bIDe1oJASOkml+osTU6Q==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.5.tgz", + "integrity": "sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==", "dev": true, "license": "MIT", "dependencies": { @@ -4308,13 +4299,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.4.tgz", - "integrity": "sha512-8BqC1ksYsHtbWH+DfpOAKrFw3jl3Uf9J7yeFh85Pz52IWuh1hBBtyfEbRNNZNjl8H8A5yMLH9/t+k7HIKzQcZQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.5.tgz", + "integrity": "sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.4", + "@vitest/pretty-format": "3.0.5", "loupe": "^3.1.2", "tinyrainbow": "^2.0.0" }, @@ -13320,13 +13311,13 @@ } }, "node_modules/playwright": { - "version": "1.50.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.0.tgz", - "integrity": "sha512-+GinGfGTrd2IfX1TA4N2gNmeIksSb+IAe589ZH+FlmpV3MYTx6+buChGIuDLQwrGNCw2lWibqV50fU510N7S+w==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.1.tgz", + "integrity": "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.50.0" + "playwright-core": "1.50.1" }, "bin": { "playwright": "cli.js" @@ -13339,9 +13330,9 @@ } }, "node_modules/playwright-core": { - "version": "1.50.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.0.tgz", - "integrity": "sha512-CXkSSlr4JaZs2tZHI40DsZUN/NIwgaUPsyLuOAaIZp2CyF2sN5MM5NJsyB188lFSSozFxQ5fPT4qM+f0tH/6wQ==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.1.tgz", + "integrity": "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -14721,9 +14712,9 @@ } }, "node_modules/semver": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.0.tgz", - "integrity": "sha512-DrfFnPzblFmNrIZzg5RzHegbiRWg7KMR7btwi2yjHwx06zsUbO5g613sVwEV7FTwmzJu+Io0lJe2GJ3LxqpvBQ==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -16856,9 +16847,9 @@ } }, "node_modules/vite-node": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.4.tgz", - "integrity": "sha512-7JZKEzcYV2Nx3u6rlvN8qdo3QV7Fxyt6hx+CCKz9fbWxdX5IvUOmTWEAxMrWxaiSf7CKGLJQ5rFu8prb/jBjOA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.5.tgz", + "integrity": "sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==", "dev": true, "license": "MIT", "dependencies": { @@ -17350,19 +17341,19 @@ } }, "node_modules/vitest": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.4.tgz", - "integrity": "sha512-6XG8oTKy2gnJIFTHP6LD7ExFeNLxiTkK3CfMvT7IfR8IN+BYICCf0lXUQmX7i7JoxUP8QmeP4mTnWXgflu4yjw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.5.tgz", + "integrity": "sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.4", - "@vitest/mocker": "3.0.4", - "@vitest/pretty-format": "^3.0.4", - "@vitest/runner": "3.0.4", - "@vitest/snapshot": "3.0.4", - "@vitest/spy": "3.0.4", - "@vitest/utils": "3.0.4", + "@vitest/expect": "3.0.5", + "@vitest/mocker": "3.0.5", + "@vitest/pretty-format": "^3.0.5", + "@vitest/runner": "3.0.5", + "@vitest/snapshot": "3.0.5", + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", "chai": "^5.1.2", "debug": "^4.4.0", "expect-type": "^1.1.0", @@ -17374,7 +17365,7 @@ "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.4", + "vite-node": "3.0.5", "why-is-node-running": "^2.3.0" }, "bin": { @@ -17390,8 +17381,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.4", - "@vitest/ui": "3.0.4", + "@vitest/browser": "3.0.5", + "@vitest/ui": "3.0.5", "happy-dom": "*", "jsdom": "*" }, diff --git a/package.json b/package.json index fa88fd4fd..de2300213 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,6 @@ "sanitize-filename": "1.6.3", "sanitize-html": "2.14.0", "sax": "1.4.1", - "semver": "7.7.0", "serve-favicon": "2.5.0", "session-file-store": "1.5.0", "source-map-support": "0.5.21", @@ -155,7 +154,7 @@ "@electron-forge/maker-zip": "7.6.1", "@electron-forge/plugin-auto-unpack-natives": "7.6.1", "@electron/rebuild": "3.7.1", - "@playwright/test": "1.50.0", + "@playwright/test": "1.50.1", "@types/archiver": "6.0.3", "@types/better-sqlite3": "7.6.12", "@types/bootstrap": "5.2.10", @@ -178,12 +177,11 @@ "@types/leaflet-gpx": "1.3.7", "@types/mime-types": "2.1.4", "@types/multer": "1.4.12", - "@types/node": "22.12.0", + "@types/node": "22.13.1", "@types/react": "18.3.18", "@types/safe-compare": "1.1.2", "@types/sanitize-html": "2.13.0", "@types/sax": "1.2.7", - "@types/semver": "7.5.8", "@types/serve-favicon": "2.5.7", "@types/session-file-store": "1.2.5", "@types/source-map-support": "0.5.10", @@ -193,7 +191,7 @@ "@types/ws": "8.5.14", "@types/xml2js": "0.4.14", "@types/yargs": "17.0.33", - "@vitest/coverage-v8": "3.0.4", + "@vitest/coverage-v8": "3.0.5", "cross-env": "7.0.3", "electron": "34.0.2", "esm": "3.2.25", @@ -208,7 +206,7 @@ "tsx": "4.19.2", "typedoc": "0.27.6", "typescript": "5.7.3", - "vitest": "3.0.4", + "vitest": "3.0.5", "webpack": "5.97.1", "webpack-cli": "6.0.1", "webpack-dev-middleware": "7.4.2" 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 b00c1e488..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,48 +278,87 @@ 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 +/** + * Compares two semantic version strings. + * Returns: + * 1 if v1 is greater than v2 + * 0 if v1 is equal to v2 + * -1 if v1 is less than v2 + * + * @param v1 First version string + * @param v2 Second version string + * @returns + */ +function compareVersions(v1: string, v2: string): number { + // Remove 'v' prefix and everything after dash if present + v1 = v1.replace(/^v/, "").split("-")[0]; + v2 = v2.replace(/^v/, "").split("-")[0]; + + const v1parts = v1.split(".").map(Number); + const v2parts = v2.split(".").map(Number); + + // Pad shorter version with zeros + while (v1parts.length < 3) v1parts.push(0); + while (v2parts.length < 3) v2parts.push(0); + + // Compare major version + if (v1parts[0] !== v2parts[0]) { + return v1parts[0] > v2parts[0] ? 1 : -1; } + + // Compare minor version + if (v1parts[1] !== v2parts[1]) { + return v1parts[1] > v2parts[1] ? 1 : -1; + } + + // Compare patch version + if (v1parts[2] !== v2parts[2]) { + return v1parts[2] > v2parts[2] ? 1 : -1; + } + + return 0; } 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 + toObject, + unescapeHtml }; diff --git a/src/www.ts b/src/www.ts index 258f0f272..d442ba35f 100644 --- a/src/www.ts +++ b/src/www.ts @@ -12,7 +12,8 @@ import ws from "./services/ws.js"; import utils from "./services/utils.js"; import port from "./services/port.js"; import host from "./services/host.js"; -import semver from "semver"; + +const MINIMUM_NODE_VERSION = "22.0.0"; // setup basic error handling even before requiring dependencies, since those can produce errors as well @@ -32,8 +33,8 @@ function exit() { process.on("SIGINT", exit); process.on("SIGTERM", exit); -if (!semver.satisfies(process.version, ">=10.5.0")) { - console.error("Trilium only supports node.js 10.5 and later"); +if (utils.compareVersions(process.version, MINIMUM_NODE_VERSION) < 0) { + console.error(`\nTrilium requires Node.js ${MINIMUM_NODE_VERSION} and later.\n`); process.exit(1); }