diff --git a/README.md b/README.md index 0671430e..2e6fa89e 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ Type a package name directly to search, or prefix with a filter name followed by - `ext` — file extension present in the package (e.g. `.js`, `.ts`). - `builtin` — Node.js core module used by the package (e.g. `fs`, `path`). - `size` — size range (see [size-satisfies](https://github.com/NodeSecure/size-satisfies#usage-example), e.g. `>50kb`, `10kb..200kb`). +- `highlighted` — all highlighted packages by default. ## FAQ diff --git a/bin/index.js b/bin/index.js index 6aa098d2..b082adb6 100755 --- a/bin/index.js +++ b/bin/index.js @@ -144,6 +144,7 @@ function defaultScannerCommand(name, options = {}) { .option("-d, --depth", i18n.getTokenSync("cli.commands.option_depth"), Infinity) .option("--silent", i18n.getTokenSync("cli.commands.option_silent"), false) .option("-c, --contacts", i18n.getTokenSync("cli.commands.option_contacts"), []) + .option("-p, --packages", i18n.getTokenSync("cli.commands.option_packages"), []) .option("--verbose", i18n.getTokenSync("cli.commands.option_verbose"), false); if (includeOutput) { diff --git a/docs/cli/auto.md b/docs/cli/auto.md index b1cc61b7..824509a2 100644 --- a/docs/cli/auto.md +++ b/docs/cli/auto.md @@ -28,4 +28,6 @@ $ nsecure auto --keep | `--vulnerabilityStrategy` | `-s` | github-advisory | Strategy used to fetch package vulnerabilities (see Vulnera [available strategy](https://github.com/NodeSecure/vulnera?tab=readme-ov-file#available-strategy)). | | `--keep` | `-k` | `false` | Preserve JSON payload after execution. | | `--developer` | | `false` | Launch the server in developer mode, enabling automatic refresh on HTML/CSS/JS changes. | -| `--contacts` | `-c` | `[]` | List of contacts to highlight. | `--verbose` | | `false` | Sets cli log level to verbose, causing the CLI to output more detailed logs. | +| `--contacts` | `-c` | `[]` | List of contacts to highlight. | +| `--packages` | `-p` | `[]` | List of packages to highlight. | +`--verbose` | | `false` | Sets cli log level to verbose, causing the CLI to output more detailed logs. | diff --git a/docs/cli/cwd.md b/docs/cli/cwd.md index 5d4888de..9def6099 100644 --- a/docs/cli/cwd.md +++ b/docs/cli/cwd.md @@ -18,4 +18,6 @@ $ nsecure cwd [options] | `--silent` | | `false` | Suppress console output, making execution silent. | | `--output` | `-o` | `nsecure-result` | Specify the output file for the results. | | `--vulnerabilityStrategy` | `-s` | github-advisory | Strategy used to fetch package vulnerabilities (see Vulnera [available strategy](https://github.com/NodeSecure/vulnera?tab=readme-ov-file#available-strategy)). | -| `--contacts` | `-c` | `[]` | List of contacts to highlight. | `--verbose` | | `false` | Sets cli log level to verbose, causing the CLI to output more detailed logs. | +| `--contacts` | `-c` | `[]` | List of contacts to highlight. | +| `--packages` | `-p` | `[]` | List of packages to highlight. | +`--verbose` | | `false` | Sets cli log level to verbose, causing the CLI to output more detailed logs. | diff --git a/docs/cli/from.md b/docs/cli/from.md index 7613cd14..b2676d74 100644 --- a/docs/cli/from.md +++ b/docs/cli/from.md @@ -24,4 +24,6 @@ $ nsecure from express@3.0.0 -o express-report | `--silent` | | `false` | Suppress console output, making execution silent. | | `--output` | `-o` | `nsecure-result` | Specify the output file for the results. | | `--vulnerabilityStrategy` | `-s` | github-advisory | Strategy used to fetch package vulnerabilities (see Vulnera [available strategy](https://github.com/NodeSecure/vulnera?tab=readme-ov-file#available-strategy)). | -| `--contacts` | `-c` | `[]` | List of contacts to highlight. | `--verbose` | | `false` | Sets cli log level to verbose, causing the CLI to output more detailed logs. | +| `--contacts` | `-c` | `[]` | List of contacts to highlight. | +| `--packages` | `-p` | `[]` | List of packages to highlight. | +`--verbose` | | `false` | Sets cli log level to verbose, causing the CLI to output more detailed logs. | diff --git a/i18n/arabic.js b/i18n/arabic.js index 18ff9c0d..11619d88 100644 --- a/i18n/arabic.js +++ b/i18n/arabic.js @@ -22,6 +22,7 @@ const cli = { option_output: "اسم ملف JSON الناتج", option_silent: "تفعيل الوضع الصامت الذي يعطل مؤشرات CLI", option_contacts: "قائمة جهات الاتصال للتمييز", + option_packages: "قائمة الحزم للتمييز", option_verbose: "ضبط مستوى الـ log الخاص بالـ CLI على verbose، مما يجعل الـ CLI يولّد logs أكثر تفصيلاً.", strategy: "مصدر الثغرات للاستخدام", cwd: { @@ -232,7 +233,8 @@ const ui = { legend: { default: "الحزمة بخير.", warn: "الحزمة بها تحذيرات.", - friendly: "الحزمة تتم صيانتها بواسطة نفس مؤلفي الحزمة الجذرية." + friendly: "الحزمة تتم صيانتها بواسطة نفس مؤلفي الحزمة الجذرية.", + highlighted: "الحزمة جزء من الحزم المميزة" }, lockedNavigation: { next: "التالي", diff --git a/i18n/english.js b/i18n/english.js index 85a4f833..105af881 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -24,6 +24,7 @@ const cli = { option_output: "Json file output name", option_silent: "enable silent mode which disable CLI spinners", option_contacts: "List of contacts to hightlight", + option_packages: "List of packages to highlight", option_verbose: "Sets cli log level to verbose, causing the CLI to output more detailed logs.", strategy: "Vulnerabilities source to use", cwd: { @@ -258,13 +259,15 @@ const ui = { author: "name or email", ext: "file extension", builtin: "node.js module", - size: "e.g. >50kb" + size: "e.g. >50kb", + highlighted: "all" } }, legend: { default: "The package is fine.", warn: "The package has warnings.", - friendly: "The package is maintained by the same authors as the root package." + friendly: "The package is maintained by the same authors as the root package.", + highlighted: "The package is part of highlighted packages" }, lockedNavigation: { next: "Next", diff --git a/i18n/french.js b/i18n/french.js index 419ca2f3..182968cf 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -24,6 +24,7 @@ const cli = { option_output: "Nom de sortie du fichier json", option_silent: "Activer le mode silencieux qui désactive les spinners du CLI", option_contacts: "Liste des contacts à mettre en évidence", + option_packages: "Liste des packages à mettre en évidence", option_verbose: "Définir le niveau de log CLI à verbeux, ce qui amènera la CLI à générer des logs plus détaillés.", strategy: "Source de vulnérabilités à utiliser", cwd: { @@ -258,13 +259,15 @@ const ui = { author: "nom ou email", ext: "extension de fichier", builtin: "module node.js", - size: "ex. >50kb" + size: "ex. >50kb", + highlighted: "all" } }, legend: { default: "Rien à signaler.", warn: "La dépendance contient des menaces.", - friendly: "La dépendance est maintenu par des auteurs du package principal." + friendly: "La dépendance est maintenu par des auteurs du package principal.", + highlighted: "Le package fait partie des packages mis en évidence" }, lockedNavigation: { next: "Suivant", diff --git a/i18n/turkish.js b/i18n/turkish.js index e1c26b80..55dd8c17 100644 --- a/i18n/turkish.js +++ b/i18n/turkish.js @@ -24,6 +24,7 @@ const cli = { option_output: "JSON dosyası çıktı adı", option_silent: "CLI döndürücülerini devre dışı bırakan sessiz modu etkinleştir", option_contacts: "Vurgulanacak kişilerin listesi", + option_packages: "Vurgulanacak paketlerin listesi", option_verbose: "CLI'nin log seviyesini verbose olarak ayarlar, bu da CLI'nin daha ayrıntılı loglar üretmesine neden olur.", strategy: "Kullanılacak güvenlik açığı kaynağı", cwd: { @@ -234,7 +235,8 @@ const ui = { legend: { default: "Paket sorunsuz.", warn: "Pakette uyarılar var.", - friendly: "Paket, kök paketin yazarlarıyla aynı kişiler tarafından bakılmaktadır." + friendly: "Paket, kök paketin yazarlarıyla aynı kişiler tarafından bakılmaktadır.", + highlighted: "Paket, vurgulanan paketlerin bir parçasıdır" }, lockedNavigation: { next: "Sonraki", diff --git a/public/components/legend/legend.js b/public/components/legend/legend.js index 46cb9b1d..0a4d28f9 100644 --- a/public/components/legend/legend.js +++ b/public/components/legend/legend.js @@ -90,6 +90,7 @@ class Legend extends LitElement { ${this.#createLegendBoxElement(colors.WARN, legend.warn)} ${this.#createLegendBoxElement(colors.FRIENDLY, legend.friendly)} ${this.#createLegendBoxElement(colors.DEFAULT, legend.default)} + ${this.#createLegendBoxElement(colors.HIGHLIGHTED, legend.highlighted)} `; } @@ -98,7 +99,7 @@ class Legend extends LitElement { theme, text ) { - const style = `background-color: ${theme.color}; color: ${theme.font.color};`; + const style = `background-color: ${theme.color}; color: ${(theme.font ?? COLORS.LIGHT.DEFAULT.font).color};`; return html`
diff --git a/public/components/search-command/filters.js b/public/components/search-command/filters.js index 6e05e27f..1080ced4 100644 --- a/public/components/search-command/filters.js +++ b/public/components/search-command/filters.js @@ -20,11 +20,13 @@ export const VERSION_PRESETS = [ { label: "≥ 1.0", value: ">=1.0.0" }, { label: "< 1.0", value: "<1.0.0" } ]; -export const FILTERS_NAME = new Set(["package", "version", "flag", "license", "author", "ext", "builtin", "size"]); +export const FILTERS_NAME = new Set(["package", "version", "flag", "license", "author", "ext", "builtin", "size", "highlighted"]); // Filters that use a searchable text-based list (not a rich visual panel) export const FILTER_HAS_HELPERS = new Set(["license", "ext", "builtin", "author"]); // Filters where the mode persists after selection (multi-select) export const FILTER_MULTI_SELECT = new Set(["flag"]); +// Filters that auto-confirm immediately on selection (no text input needed) +export const FILTER_INSTANT_CONFIRM = new Set(["highlighted"]); /** * Returns per-flag package counts across the full linker. @@ -236,6 +238,8 @@ function matchesFilter(opt, filterName, inputValue) { } case "flag": return opt.flags.includes(inputValue); + case "highlighted": + return inputValue === "true" ? opt.isHighlighted === true : opt.isHighlighted !== true; default: return false; } diff --git a/public/components/search-command/search-command.js b/public/components/search-command/search-command.js index 3ca97266..201ff79f 100644 --- a/public/components/search-command/search-command.js +++ b/public/components/search-command/search-command.js @@ -9,6 +9,7 @@ import { FILTERS_NAME, FILTER_HAS_HELPERS, FILTER_MULTI_SELECT, + FILTER_INSTANT_CONFIRM, computeMatches, getHelperValues } from "./filters.js"; @@ -59,12 +60,13 @@ class SearchCommand extends LitElement { #init = ({ detail: { linker, packages, network } }) => { this.#linker = linker; this.#network = network; - this.#packages = packages.map(({ id, name, version, flags }) => { + this.#packages = packages.map(({ id, name, version, flags, isHighlighted }) => { return { id: String(id), name, version, - flags + flags, + isHighlighted }; }); }; @@ -280,10 +282,15 @@ class SearchCommand extends LitElement { #selectHelper(helper) { if (helper.type === "filter") { - this.inputValue = `${helper.value}:`; - this.activeFilter = helper.value; - this.selectedIndex = -1; - this.results = []; + if (FILTER_INSTANT_CONFIRM.has(helper.value)) { + this.#addQuery(helper.value, "true"); + } + else { + this.inputValue = `${helper.value}:`; + this.activeFilter = helper.value; + this.selectedIndex = -1; + this.results = []; + } } else { this.#addQuery(this.activeFilter, helper.value); diff --git a/public/main.js b/public/main.js index 64cf442b..8b6b7bef 100644 --- a/public/main.js +++ b/public/main.js @@ -247,12 +247,7 @@ function onSettingsSaved(defaultConfig = null) { window.settings.config.theme = theme; window.settings.config.disableExternalRequests = config.disableExternalRequests; - if (theme === "dark") { - document.body.classList.add("dark"); - } - else { - document.body.classList.remove("dark"); - } + document.body.classList.toggle("dark", theme === "dark"); await secureDataSet.init( secureDataSet.data, diff --git a/src/commands/parsers/packages.js b/src/commands/parsers/packages.js new file mode 100644 index 00000000..2c04157c --- /dev/null +++ b/src/commands/parsers/packages.js @@ -0,0 +1,70 @@ +/** + * Parse a list of CLI package strings into the expected HighlightPackages format expected + * by @nodesecure/scanner: `string[] | Record`. + * + * Each input string can be: + * - "lodash" → plain name, no version constraint + * - "lodash@^4.0.0" → name with a semver range + * - "lodash@1.0.0,2.0.0" → name with a list of specific versions + * - "@scope/pkg" → scoped package, no version constraint + * - "@scope/pkg@^1.0.0" → scoped package with a semver range + * + * When none of the entries carry a version constraint the function returns a plain `string[]`. + * If at least one entry has a version constraint the function returns a `Record`; + * Entries without a constraint are mapped to '*' + * + * @param {string | string[]} input + * @returns {string[] | Record} + */ +export function parsePackages(input) { + const items = Array.isArray(input) ? input : [input]; + const parsed = items.map(parsePackage); + + const hasVersionConstraints = parsed.some(({ version }) => version !== null); + + if (!hasVersionConstraints) { + return parsed.map(({ name }) => name); + } + + return Object.fromEntries( + parsed.map(({ name, version }) => [name, version ?? "*"]) + ); +} + +/** + * @param {string} str + * @returns {{ name: string, version: string | string[] | null }} + */ +function parsePackage(str) { + // Scoped packages start with "@", so search for a second "@" after index 1. + const versionSeparator = str.startsWith("@") ? str.indexOf("@", 1) : str.indexOf("@"); + + if (versionSeparator === -1) { + return { name: str.trim(), version: null }; + } + + const name = str.slice(0, versionSeparator).trim(); + const versionStr = str.slice(versionSeparator + 1).trim(); + + if (versionStr === "") { + return { name, version: null }; + } + + if (versionStr.includes(",")) { + const versions = versionStr.split(",").map((v) => v.trim()).filter(Boolean); + let version; + if (versions.length === 0) { + version = null; + } + else if (versions.length === 1) { + version = versions[0]; + } + else { + version = versions; + } + + return { name, version }; + } + + return { name, version: versionStr }; +} diff --git a/src/commands/scanner.js b/src/commands/scanner.js index ba48a2b5..3b83f7c8 100644 --- a/src/commands/scanner.js +++ b/src/commands/scanner.js @@ -15,6 +15,7 @@ import * as scanner from "@nodesecure/scanner"; import kleur from "../utils/styleText.js"; import * as http from "./http.js"; import { parseContacts } from "./parsers/contacts.js"; +import { parsePackages } from "./parsers/packages.js"; export async function auto(spec, options) { const { keep, ...commandOptions } = options; @@ -22,7 +23,8 @@ export async function auto(spec, options) { const optionsWithContacts = { ...commandOptions, highlight: { - contacts: parseContacts(options.contacts) + contacts: parseContacts(options.contacts), + packages: parsePackages(options.packages ?? []) } }; @@ -67,6 +69,7 @@ export async function cwd(options) { vulnerabilityStrategy, silent, contacts, + packages: highlightPackages = [], verbose } = options; @@ -74,7 +77,7 @@ export async function cwd(options) { process.cwd(), { maxDepth, usePackageLock: !nolock, fullLockMode: full, vulnerabilityStrategy, highlight: - { contacts: parseContacts(contacts) }, isVerbose: verbose + { contacts: parseContacts(contacts), packages: parsePackages(highlightPackages) }, isVerbose: verbose }, initLogger(void 0, !silent) ); @@ -83,7 +86,15 @@ export async function cwd(options) { } export async function from(spec, options) { - const { depth: maxDepth = Infinity, output, silent, contacts, vulnerabilityStrategy, verbose } = options; + const { + depth: maxDepth = Infinity, + output, + silent, + contacts, + packages: highlightPackages = [], + vulnerabilityStrategy, + verbose + } = options; const payload = await scanner.from( spec, @@ -91,7 +102,8 @@ export async function from(spec, options) { maxDepth, vulnerabilityStrategy, highlight: { - contacts: parseContacts(contacts) + contacts: parseContacts(contacts), + packages: parsePackages(highlightPackages) }, isVerbose: verbose }, diff --git a/test/commands/parsers/packages.test.js b/test/commands/parsers/packages.test.js new file mode 100644 index 00000000..213f1b82 --- /dev/null +++ b/test/commands/parsers/packages.test.js @@ -0,0 +1,108 @@ +// Import Node.js Dependencies +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { parsePackages } from "../../../src/commands/parsers/packages.js"; + +describe("packages parser", () => { + describe("returns string[] when no version constraints", () => { + it("should parse a single plain package name", () => { + assert.deepEqual(parsePackages("lodash"), ["lodash"]); + }); + + it("should parse a single scoped package with no version", () => { + assert.deepEqual(parsePackages("@scope/pkg"), ["@scope/pkg"]); + }); + + it("should parse multiple plain packages with no versions", () => { + assert.deepEqual(parsePackages(["lodash", "express"]), ["lodash", "express"]); + }); + + it("should trim package names", () => { + assert.deepEqual(parsePackages(" lodash "), ["lodash"]); + }); + }); + + describe("returns Record when at least one entry has a version constraint", () => { + it("should parse a package with a semver range", () => { + assert.deepEqual(parsePackages("lodash@^4.0.0"), { lodash: "^4.0.0" }); + }); + + it("should parse a package with a list of specific versions", () => { + assert.deepEqual(parsePackages("lodash@1.0.0,2.0.0"), { lodash: ["1.0.0", "2.0.0"] }); + }); + + it("should parse a scoped package with a semver range", () => { + assert.deepEqual(parsePackages("@scope/pkg@^1.0.0"), { "@scope/pkg": "^1.0.0" }); + }); + + it("should map entries without a version to '*' when mixed with versioned entries", () => { + assert.deepEqual( + parsePackages(["lodash", "express@^4.0.0"]), + { lodash: "*", express: "^4.0.0" } + ); + }); + + it("should parse multiple packages all with version constraints", () => { + assert.deepEqual( + parsePackages(["lodash@^4.0.0", "express@^4.18.0"]), + { lodash: "^4.0.0", express: "^4.18.0" } + ); + }); + + it("should trim version strings", () => { + assert.deepEqual(parsePackages("lodash@ ^4.0.0 "), { lodash: "^4.0.0" }); + }); + + it("should trim individual versions in a comma-separated list", () => { + assert.deepEqual(parsePackages("lodash@1.0.0 , 2.0.0"), { lodash: ["1.0.0", "2.0.0"] }); + }); + }); + + describe("JSON array string input", () => { + it("should parse a JSON array string of plain packages", () => { + assert.deepEqual( + parsePackages('["@hapi/formula@3.0.2", "@hapi/tlds@1.1.6"]'), + { "@hapi/formula": "3.0.2", "@hapi/tlds": "1.1.6" } + ); + }); + + it("should parse a JSON array string with no version constraints", () => { + assert.deepEqual( + parsePackages('["lodash", "express"]'), + ["lodash", "express"] + ); + }); + + it("should fall through to plain string parsing for invalid JSON starting with '['", () => { + assert.deepEqual(parsePackages("[notjson"), ["[notjson"]); + }); + }); + + describe("edge cases", () => { + it("should return an empty array for an empty array input", () => { + assert.deepEqual(parsePackages([]), []); + }); + + it("should treat a scoped name with no slash and no version as a plain package name", () => { + assert.deepEqual(parsePackages("@scope"), ["@scope"]); + }); + + it("should treat a trailing '@' with no version as no version constraint", () => { + assert.deepEqual(parsePackages("lodash@"), ["lodash"]); + }); + + it("should treat a scoped package with trailing '@' as no version constraint", () => { + assert.deepEqual(parsePackages("@scope/pkg@"), ["@scope/pkg"]); + }); + + it("should ignore a trailing comma in a version list", () => { + assert.deepEqual(parsePackages("lodash@1.0.0,"), { lodash: "1.0.0" }); + }); + + it("should treat a lone comma as version as no version constraint", () => { + assert.deepEqual(parsePackages("lodash@,"), ["lodash"]); + }); + }); +}); diff --git a/workspaces/vis-network/src/constants.js b/workspaces/vis-network/src/constants.js index 4a954927..8aaf56e2 100644 --- a/workspaces/vis-network/src/constants.js +++ b/workspaces/vis-network/src/constants.js @@ -10,9 +10,9 @@ export const COLORS = Object.freeze({ LIGHT: { SELECTED: { - color: "#4527A0", + color: "#BFC5E0", font: { - color: "#FFF" + color: "#443730" } }, SELECTED_GROUP: { @@ -28,23 +28,26 @@ export const COLORS = Object.freeze({ } }, DEFAULT: { - color: "#E3F2FD", + color: "#BEE7E8", font: { color: "#121533" } }, WARN: { - color: "#EF5350", + color: "#FFBFA0", font: { - color: "#FFF" + color: "#6B2737" } }, FRIENDLY: { - color: "#e3fde3", + color: "#EDEEC0", font: { color: "#0e4522" } }, + HIGHLIGHTED: { + color: "#EA9010" + }, CONNECTED_IN: { color: "#C8E6C9", font: { @@ -101,6 +104,9 @@ export const COLORS = Object.freeze({ color: "#FFF" } }, + HIGHLIGHTED: { + color: "#dec42c" + }, CONNECTED_IN: { color: "rgb(89, 44, 109)", font: { diff --git a/workspaces/vis-network/src/dataset.js b/workspaces/vis-network/src/dataset.js index 0ecab670..f980ff9d 100644 --- a/workspaces/vis-network/src/dataset.js +++ b/workspaces/vis-network/src/dataset.js @@ -16,6 +16,7 @@ export default class NodeSecureDataSet extends EventTarget { */ #highligthedContacts; + #highlightedPackages; constructor(options = {}) { super(); @@ -94,6 +95,8 @@ export default class NodeSecureDataSet extends EventTarget { return acc; }, { names: new Set(), emails: new Set() }); + this.#highlightedPackages = new Set(data.highlighted.packages); + const dependencies = Object.entries(data.dependencies); this.dependenciesCount = dependencies.length; @@ -124,6 +127,7 @@ export default class NodeSecureDataSet extends EventTarget { opt.version = currVersion; opt.hidden = false; opt.hasWarnings = hasWarnings; + opt.isHighlighted = this.#isHighlightedPackage(packageName, currVersion); this.computeAuthor(author, `${packageName}@${currVersion}`, contributors); @@ -160,7 +164,8 @@ export default class NodeSecureDataSet extends EventTarget { hasWarnings, flags: flagStr.replace(/\s/g, ""), links, - isFriendly + isFriendly, + isHighlighted: opt.isHighlighted }); const label = `${packageName}@${currVersion}${flagStr}\n[${prettyBytes(size)}]`; @@ -168,6 +173,7 @@ export default class NodeSecureDataSet extends EventTarget { id, hasWarnings, isFriendly, + isHighlighted: opt.isHighlighted, theme: this.theme.toUpperCase() }); color.font.multi = "html"; @@ -229,6 +235,10 @@ export default class NodeSecureDataSet extends EventTarget { return this.#highligthedContacts.names.has(contact.name) || this.#highligthedContacts.emails.has(contact.email); } + #isHighlightedPackage(name, version) { + return this.#highlightedPackages.has(name) || this.#highlightedPackages.has(`${name}@${version}`); + } + findPackagesByName(name) { return this.packages.filter((pkg) => pkg.name === name); } diff --git a/workspaces/vis-network/src/network.js b/workspaces/vis-network/src/network.js index 78155ffa..b172a5b0 100644 --- a/workspaces/vis-network/src/network.js +++ b/workspaces/vis-network/src/network.js @@ -220,6 +220,12 @@ export default class NodeSecureNetwork { this.colors.HARDTOREAD; Object.assign(node, color); + + const { isHighlighted } = this.linker.get(Number(node.id)); + if (isHighlighted) { + node.shadow = { enabled: false }; + node.borderWidth = 1; + } } for (const nodeId of nodeIdsToHighlight) { @@ -267,9 +273,9 @@ export default class NodeSecureNetwork { this.highlightEnabled = false; for (const node of allNodes) { - const { id, hasWarnings, isFriendly } = this.linker.get(Number(node.id)); + const { id, hasWarnings, isFriendly, isHighlighted } = this.linker.get(Number(node.id)); - Object.assign(node, utils.getNodeColor({ id, hasWarnings, theme: this.theme, isFriendly })); + Object.assign(node, utils.getNodeColor({ id, hasWarnings, theme: this.theme, isFriendly, isHighlighted })); } this.lastHighlightedIds = null; @@ -317,6 +323,38 @@ export default class NodeSecureNetwork { } } + /** + * Returns the selected color for a node, preserving highlighted border+shadow if applicable. + * @param {number} nodeId + * @param {"SELECTED" | "SELECTED_LOCK"} colorKey + */ + #selectedColor(nodeId, colorKey) { + const { isHighlighted } = this.linker.get(Number(nodeId)); + const base = this.colors[colorKey]; + + if (!isHighlighted) { + return base; + } + + const borderColor = CONSTANTS.COLORS[this.theme].HIGHLIGHTED.color; + + return { + color: { + background: base.color, + border: borderColor + }, + font: base.font, + borderWidth: 2, + shadow: { + enabled: true, + color: borderColor, + size: 12, + x: 0, + y: 0 + } + }; + } + lockedNeighbourHighlight(params) { if (this.lastHighlightedIds === null) { return false; @@ -338,7 +376,7 @@ export default class NodeSecureNetwork { } const color = node.id === selectedNode ? - this.colors.SELECTED_LOCK : + this.#selectedColor(node.id, "SELECTED_LOCK") : this.colors.SELECTED_GROUP; Object.assign(node, color); @@ -397,6 +435,12 @@ export default class NodeSecureNetwork { // mark all nodes as hard to read. for (const node of Object.values(allNodes)) { Object.assign(node, this.colors.HARDTOREAD); + + const { isHighlighted } = this.linker.get(Number(node.id)); + if (isHighlighted) { + node.shadow = { enabled: false }; + node.borderWidth = 1; + } } // get the second degree nodes @@ -420,7 +464,7 @@ export default class NodeSecureNetwork { } // the main node gets its own color and its label back. - Object.assign(allNodes[selectedNode], this.colors.SELECTED); + Object.assign(allNodes[selectedNode], this.#selectedColor(selectedNode, "SELECTED")); // select and label edges connected to the selected node const connectedEdges = this.network.getConnectedEdges(selectedNode); @@ -445,9 +489,9 @@ export default class NodeSecureNetwork { else if (this.highlightEnabled) { this.highlightEnabled = false; for (const node of Object.values(allNodes)) { - const { id, hasWarnings, isFriendly } = this.linker.get(Number(node.id)); + const { id, hasWarnings, isFriendly, isHighlighted } = this.linker.get(Number(node.id)); - Object.assign(node, utils.getNodeColor({ id, hasWarnings, theme: this.theme, isFriendly })); + Object.assign(node, utils.getNodeColor({ id, hasWarnings, theme: this.theme, isFriendly, isHighlighted })); } } diff --git a/workspaces/vis-network/src/utils.js b/workspaces/vis-network/src/utils.js index b385f1a8..d65ab1f1 100644 --- a/workspaces/vis-network/src/utils.js +++ b/workspaces/vis-network/src/utils.js @@ -39,27 +39,54 @@ export async function getJSON(path, customHeaders = Object.create(null)) { * @param {string} options.hasWarnings * @param {string} options.theme * @param {string} options.isFriendly + * @param {boolean} [options.isHighlighted] */ export function getNodeColor(options) { const { id, hasWarnings = false, theme = "LIGHT", - isFriendly = false + isFriendly = false, + isHighlighted = false } = options; // id 0 is the root package (so by default he is highlighted as selected). if (id === 0) { return CONSTANTS.COLORS[theme].SELECTED; } - else if (hasWarnings) { - return CONSTANTS.COLORS[theme].WARN; + + let nodeColor; + if (hasWarnings) { + nodeColor = CONSTANTS.COLORS[theme].WARN; } else if (isFriendly) { - return CONSTANTS.COLORS[theme].FRIENDLY; + nodeColor = CONSTANTS.COLORS[theme].FRIENDLY; + } + else { + nodeColor = CONSTANTS.COLORS[theme].DEFAULT; + } + + if (isHighlighted) { + const borderColor = CONSTANTS.COLORS[theme].HIGHLIGHTED_BORDER; + + return { + color: { + background: nodeColor.color, + border: borderColor + }, + font: nodeColor.font, + borderWidth: 2, + shadow: { + enabled: true, + color: borderColor, + size: 12, + x: 0, + y: 0 + } + }; } - return CONSTANTS.COLORS[theme].DEFAULT; + return nodeColor; } export function getFlagsEmojisInlined( diff --git a/workspaces/vis-network/test/dataset-payload.json b/workspaces/vis-network/test/dataset-payload.json index ccd1add6..2601137f 100644 --- a/workspaces/vis-network/test/dataset-payload.json +++ b/workspaces/vis-network/test/dataset-payload.json @@ -34,6 +34,10 @@ "string-width" ] } + ], + "packages": [ + "pkg3", + "pkg2@1.0.4" ] }, "dependencies": { diff --git a/workspaces/vis-network/test/dataset.test.js b/workspaces/vis-network/test/dataset.test.js index 30d63902..ae55accc 100644 --- a/workspaces/vis-network/test/dataset.test.js +++ b/workspaces/vis-network/test/dataset.test.js @@ -70,6 +70,37 @@ test("NodeSecureDataSet.isHighlighted", async() => { "email: gentilhomme.thomas@gmail.com should be hightlighted"); }); +test("NodeSecureDataSet.init should mark highlighted packages by name", async() => { + const nsDataSet = new NodeSecureDataSet(); + await nsDataSet.init(dataSetPayload); + + const pkg3Packages = nsDataSet.findPackagesByName("pkg3"); + assert.ok(pkg3Packages.length > 0, "should have pkg3 packages"); + assert.ok(pkg3Packages.every((pkg) => pkg.isHighlighted), "all pkg3 versions should be highlighted (matched by name)"); +}); + +test("NodeSecureDataSet.init should mark highlighted packages by name@version", async() => { + const nsDataSet = new NodeSecureDataSet(); + await nsDataSet.init(dataSetPayload); + + const pkg2Packages = nsDataSet.findPackagesByName("pkg2"); + const highlighted = pkg2Packages.find((pkg) => pkg.version === "1.0.4"); + const notHighlighted = pkg2Packages.find((pkg) => pkg.version === "1.0.3"); + + assert.ok(highlighted, "should find pkg2@1.0.4"); + assert.equal(highlighted.isHighlighted, true, "pkg2@1.0.4 should be highlighted (matched by name@version)"); + assert.equal(notHighlighted.isHighlighted, false, "pkg2@1.0.3 should not be highlighted"); +}); + +test("NodeSecureDataSet.init should not highlight packages absent from highlighted list", async() => { + const nsDataSet = new NodeSecureDataSet(); + await nsDataSet.init(dataSetPayload); + + const pkg1Packages = nsDataSet.findPackagesByName("pkg1"); + assert.ok(pkg1Packages.length > 0, "should have pkg1 packages"); + assert.ok(pkg1Packages.every((pkg) => !pkg.isHighlighted), "pkg1 should not be highlighted"); +}); + test("NodeSecureDataSet.computeAuthors", () => { const nsDataSet = new NodeSecureDataSet(); nsDataSet.computeAuthor({ name: "John Doe" }, "pkg@1.1"); @@ -118,7 +149,8 @@ test("NodeSecureDataSet.findPackagesByName should have packages when name matche hasWarnings: false, flags: "", links: undefined, - isFriendly: 0 + isFriendly: 0, + isHighlighted: false }, { id: undefined, @@ -127,7 +159,9 @@ test("NodeSecureDataSet.findPackagesByName should have packages when name matche hasWarnings: false, flags: "", links: undefined, - isFriendly: 0 + isFriendly: 0, + isHighlighted: true + } ];