From ab65f65a5ff952fcf4f640f8e96118908e77c182 Mon Sep 17 00:00:00 2001 From: s0lray Date: Tue, 24 Mar 2026 17:56:24 -0400 Subject: [PATCH] feat(ui): add client-side search for tools (THE-66) Adds a debounced text search input above the D3 tree that filters all ~1,000+ tool nodes by name and description. Results appear as a flat list with breadcrumb paths; clearing the input hides the panel. - No build step required: vanilla JS, no new dependencies - Sanitizes HTML output (escapeHtml/escapeAttr) and validates URLs (http/https only) - Dark and light mode compatible via existing CSS variables - Results capped at 50 to keep rendering fast Co-Authored-By: Paperclip --- public/css/arf.css | 72 ++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 4 +++ public/js/arf.js | 74 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/public/css/arf.css b/public/css/arf.css index 3238f8b..d3654f5 100644 --- a/public/css/arf.css +++ b/public/css/arf.css @@ -105,3 +105,75 @@ h3 { a { color: var(--color-node-stroke); } + +/* Search */ +#search-container { + text-align: center; + padding: 8px 0 4px; +} + +#search-input { + width: 400px; + max-width: 90vw; + padding: 8px 12px; + font-size: 14px; + font-family: "Helvetica Neue", Helvetica; + background: var(--color-surface); + color: var(--color-text-primary); + border: 1px solid var(--color-link); + border-radius: 4px; + outline: none; +} + +#search-input:focus { + border-color: var(--color-node-stroke); +} + +#search-results { + margin: 8px auto 0; + width: 600px; + max-width: 90vw; + text-align: left; + background: var(--color-surface); + border: 1px solid var(--color-link); + border-radius: 4px; + max-height: 400px; + overflow-y: auto; + display: none; +} + +#search-results.visible { + display: block; +} + +.search-result-item { + padding: 8px 12px; + border-bottom: 1px solid var(--color-link); + font-size: 13px; +} + +.search-result-item:last-child { + border-bottom: none; +} + +.search-result-item a { + font-weight: bold; + color: var(--color-node-stroke); + text-decoration: none; +} + +.search-result-item a:hover { + text-decoration: underline; +} + +.search-result-path { + color: var(--color-text-secondary); + font-size: 11px; + margin-top: 2px; +} + +.search-no-results { + padding: 12px; + color: var(--color-text-secondary); + text-align: center; +} diff --git a/public/index.html b/public/index.html index e349123..f55f4eb 100644 --- a/public/index.html +++ b/public/index.html @@ -28,6 +28,10 @@ OSINT Framework
+
+ +
+
diff --git a/public/js/arf.js b/public/js/arf.js index b3fa05f..10c5dcc 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -3,7 +3,8 @@ var margin = [20, 120, 20, 140], height = 800 - margin[0] - margin[2], i = 0, duration = 1250, - root; + root, + allSearchNodes = []; var tree = d3.tree() .size([height, width]); @@ -29,6 +30,11 @@ d3.json("arf.json").then(function(json) { root.x0 = height / 2; root.y0 = 0; + // Collect all tool nodes (those with URLs) for search + allSearchNodes = root.descendants().filter(function(d) { + return d.data.url; + }); + function collapse(d) { if (d.children) { d._children = d.children; @@ -39,6 +45,7 @@ d3.json("arf.json").then(function(json) { root.children.forEach(collapse); update(root); + initSearch(); }); function update(source) { @@ -160,6 +167,71 @@ function toggle(d) { } } +// Client-side search over all tool nodes. +var searchDebounceTimer = null; + +function initSearch() { + var input = document.getElementById("search-input"); + if (!input) return; + input.addEventListener("input", function() { + clearTimeout(searchDebounceTimer); + var query = input.value.trim(); + searchDebounceTimer = setTimeout(function() { doSearch(query); }, 200); + }); +} + +function doSearch(query) { + var results = document.getElementById("search-results"); + if (!results) return; + + if (!query) { + results.innerHTML = ""; + results.classList.remove("visible"); + return; + } + + var lower = query.toLowerCase(); + var matches = allSearchNodes.filter(function(d) { + var name = (d.data.name || "").toLowerCase(); + var desc = (d.data.description || "").toLowerCase(); + return name.indexOf(lower) !== -1 || desc.indexOf(lower) !== -1; + }).slice(0, 50); + + results.classList.add("visible"); + + if (matches.length === 0) { + results.innerHTML = '
No results found for \u201c' + escapeHtml(query) + '\u201d
'; + return; + } + + var html = matches.map(function(d) { + var path = d.ancestors().reverse().slice(1, -1).map(function(a) { + return escapeHtml(a.data.name); + }).join(" \u203a "); + var name = escapeHtml(d.data.name); + var url = safeUrl(d.data.url); + return '
' + + '' + name + '' + + (path ? '
' + path + '
' : '') + + '
'; + }).join(""); + + results.innerHTML = html; +} + +function escapeHtml(str) { + return (str || "").replace(/&/g, "&").replace(//g, ">"); +} + +function escapeAttr(str) { + return (str || "").replace(/"/g, """).replace(/'/g, "'"); +} + +function safeUrl(url) { + if (!url) return "#"; + return /^https?:\/\//i.test(url) ? url : "#"; +} + // Toggle light/dark mode and persist preference. function goDark() { var body = document.body;