From 1f49db69c915c599048c67bf50af25d4cf5433e0 Mon Sep 17 00:00:00 2001 From: Zaine Arch Date: Wed, 31 Dec 2025 17:57:57 +0000 Subject: [PATCH] Fixed and finished the search function --- Makefile | 27 +- assets/scripts/competency-status-board.js | 38 +- assets/scripts/search.js | 471 ++++++++++++++-------- assets/styles/misc.css | 18 + blogs/blogs-list.org | 4 +- posts/posts-list.org | 2 +- requirements.txt | 2 + search-index-json.py | 4 - sitemap.org | 8 +- 9 files changed, 360 insertions(+), 214 deletions(-) create mode 100644 requirements.txt diff --git a/Makefile b/Makefile index 0f2ae09..06d9f2e 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,28 @@ .PHONY: all clean norm help -# Default target: full build with search index -all: - @echo "Building project (full rebuild with search index)..." - emacs -Q --script build-site.el - python search-index-json.py +VENV := .venv +PY := $(VENV)/bin/python +PIP := $(VENV)/bin/pip + +all: build search + +build: + @echo "Building project..." + emacs -Q --script build-site.el + +search: $(VENV) + @echo "Generating search index" + $(PY) search-index-json.py + +$(VENV): + @echo "Generating virtual environment" + python3 -m venv $(VENV) + $(PIP) install -r requirements.txt -# Clean output directory clean: @echo "Cleaning output directory..." rm -rf output/ + rm -rf $(VENV) norm: @echo "sorting out the backups..." @@ -19,6 +32,8 @@ norm: help: @echo "Available targets:" @echo " make - Full rebuild" + @echo " make build - Rebuild the output directory" @echo " make clean - Remove output directory" @echo " make norm - Move all files that end with ~ to the backup folder" + @echo " make search - creates the search index" @echo " make help - Show this help message" diff --git a/assets/scripts/competency-status-board.js b/assets/scripts/competency-status-board.js index 0d54656..25cb779 100644 --- a/assets/scripts/competency-status-board.js +++ b/assets/scripts/competency-status-board.js @@ -1,9 +1,9 @@ const STATES = [ - { key: "completed", label: "Completed" }, - { key: "manager_review", label: "Manager Review" }, - { key: "in_progress", label: "In Progress" }, - { key: "not_started", label: "Not Started" }, - { key: "comments", label: "Comments" } + { key: "completed", label: "Completed" }, + { key: "manager_review", label: "Manager Review" }, + { key: "in_progress", label: "In Progress" }, + { key: "not_started", label: "Not Started" }, + { key: "comments", label: "Comments" }, ]; function isMobile() { @@ -20,9 +20,9 @@ function populateMobileControls(items) { itemSelect.innerHTML = ""; fromSelect.innerHTML = ""; - toSelect.innerHTML = ""; + toSelect.innerHTML = ""; - items.forEach(item => { + items.forEach((item) => { const opt = document.createElement("option"); opt.value = item.id; opt.textContent = item.title; @@ -30,7 +30,7 @@ function populateMobileControls(items) { itemSelect.appendChild(opt); }); - STATES.forEach(s => { + STATES.forEach((s) => { fromSelect.appendChild(new Option(s.label, s.key)); toSelect.appendChild(new Option(s.label, s.key)); }); @@ -46,8 +46,8 @@ function populateMobileControls(items) { document.getElementById("move-confirm").addEventListener("click", async () => { const itemId = document.getElementById("move-item").value; - const from = document.getElementById("move-from").value; - const to = document.getElementById("move-to").value; + const from = document.getElementById("move-from").value; + const to = document.getElementById("move-to").value; if (!itemId || !to) { alert("Select an item and target column"); @@ -62,7 +62,7 @@ document.getElementById("move-confirm").addEventListener("click", async () => { const res = await fetch(`/api/competencies/items/${itemId}/state`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ state: to }) + body: JSON.stringify({ state: to }), }); if (res.ok) { @@ -72,8 +72,6 @@ document.getElementById("move-confirm").addEventListener("click", async () => { } }); - - let draggedItemId = null; function countByState(items) { @@ -113,11 +111,11 @@ function createColumn(state, items, counts) { ${state.label} ${counts[state.key] || 0} `; - + const list = document.createElement("div"); list.className = "kanban-list"; - list.addEventListener("dragover", e => e.preventDefault()); + list.addEventListener("dragover", (e) => e.preventDefault()); list.addEventListener("drop", async () => { if (!draggedItemId) return; @@ -125,7 +123,7 @@ function createColumn(state, items, counts) { const res = await fetch(`/api/competencies/items/${draggedItemId}/state`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ state: state.key }) + body: JSON.stringify({ state: state.key }), }); if (res.ok) { @@ -136,8 +134,8 @@ function createColumn(state, items, counts) { }); items - .filter(i => i.state === state.key) - .forEach(i => list.appendChild(createCard(i))); + .filter((i) => i.state === state.key) + .forEach((i) => list.appendChild(createCard(i))); col.appendChild(header); col.appendChild(list); @@ -152,12 +150,11 @@ function renderBoard(items) { const counts = countByState(items); - STATES.forEach(state => { + STATES.forEach((state) => { board.appendChild(createColumn(state, items, counts)); }); } - async function loadBoard() { const res = await fetch("/api/competencies/items"); const items = await res.json(); @@ -165,5 +162,4 @@ async function loadBoard() { populateMobileControls(items); } - loadBoard(); diff --git a/assets/scripts/search.js b/assets/scripts/search.js index 5eeb96a..cad145d 100644 --- a/assets/scripts/search.js +++ b/assets/scripts/search.js @@ -1,138 +1,16 @@ let lunrIndex; let documents = []; - -/* ------------------------------ - JSON → documents --------------------------------- */ -function extractDocuments(node, currentPath = "") { - if (node.type === "folder" && node.children) { - const nextPath = currentPath - ? `${currentPath}/${node.name}` - : node.name; - - node.children.forEach(child => { - extractDocuments(child, nextPath); - }); - } - - if (node.type === "file") { - const filePath = currentPath - ? `${currentPath}/${node.name}` - : node.name; - - documents.push({ - id: documents.length.toString(), - title: node.name, - content: node.content || "", - path: "/" + filePath.replace(/^output\//, "") - }); - } -} - -/* ------------------------------ - Lunr index --------------------------------- */ -function buildIndex() { - lunrIndex = lunr(function () { - this.ref("id"); - this.field("title", { boost: 10 }); - this.field("content"); - - documents.forEach(doc => this.add(doc)); - }); - - console.log("Lunr index built with", documents.length, "documents"); -} - -async function initSearch() { - const response = await fetch("test.json"); - const json = await response.json(); - - extractDocuments(json); - buildIndex(); -} - -initSearch(); - -/* ------------------------------ - Fuzzy search --------------------------------- */ -function buildFuzzyQuery(query) { - return query - .split(/\s+/) - .map(term => `${term}~1`) - .join(" "); -} - -function search(query) { - if (!lunrIndex) return []; - - const results = lunrIndex.search(buildFuzzyQuery(query)); - - return results.map(r => { - const doc = documents.find(d => d.id === r.ref); - return { - title: doc.title, - path: doc.path, - score: r.score, - preview: doc.content.slice(0, 200) + "…" - }; - }); -} - -/* ------------------------------ - DOM elements --------------------------------- */ +let activeIndex = -1; const searchInput = document.getElementById("search-input"); -const searchBtn = document.getElementById("search-btn"); - -/* ------------------------------ - Live dropdown results --------------------------------- */ +const searchBtn = document.getElementById("search-btn"); const resultsBox = document.createElement("div"); +const searchForm = searchInput.closest("form"); +const modal = document.createElement("div"); +const modalResults = modal.querySelector("#search-modal-results"); + resultsBox.id = "search-results"; document.body.appendChild(resultsBox); -function showResults(results) { - resultsBox.innerHTML = ""; - - if (results.length === 0) { - resultsBox.style.display = "none"; - return; - } - - const rect = searchInput.getBoundingClientRect(); - resultsBox.style.top = `${rect.bottom + window.scrollY}px`; - resultsBox.style.left = `${rect.left + window.scrollX}px`; - resultsBox.style.width = `${rect.width}px`; - - results.slice(0, 5).forEach(result => { - const item = document.createElement("div"); - item.className = "search-result"; - item.innerHTML = ` -
${result.title}
-
${result.preview}
- `; - item.onclick = () => window.location.href = result.path; - resultsBox.appendChild(item); - }); - - resultsBox.style.display = "block"; -} - -searchInput.addEventListener("input", () => { - const q = searchInput.value.trim(); - if (!q) { - resultsBox.style.display = "none"; - return; - } - showResults(search(q)); -}); - -/* ------------------------------ - Modal popup --------------------------------- */ -const modal = document.createElement("div"); modal.id = "search-modal"; modal.innerHTML = `
@@ -141,70 +19,311 @@ modal.innerHTML = `
`; + document.body.appendChild(modal); -const modalResults = modal.querySelector("#search-modal-results"); +function extractDocuments(node, currentPath = "") { + if (node.type === "folder" && node.children) { + const nextPath = currentPath ? `${currentPath}/${node.name}` : node.name; + + node.children.forEach((child) => { + extractDocuments(child, nextPath); + }); + } + + if (node.type === "file") { + documents.push({ + id: documents.length.toString(), + title: node.name, + content: node.content || "", + path: node.url, + }); + } +} + +function buildIndex() { + lunrIndex = lunr(function () { + this.ref("id"); + this.field("title", { boost: 10 }); + this.field("url"); + this.field("content"); + + documents.forEach((doc) => this.add(doc)); + }); +} + +async function initSearch() { + const response = await fetch("/test.json"); + const json = await response.json(); + + extractDocuments(json); + buildIndex(); +} + +initSearch(); + +function buildExactQuery(query) { + return query + .toLowerCase() + .split(/\s+/) + .filter((term) => term.length > 1) + .map((term) => `${term}*`) + .join(" "); +} + +function buildSnippet(content, terms, radius = 80) { + if (!content || terms.length === 0) return ""; + + const lower = content.toLowerCase(); + + let index = -1; + let matchedTerm = ""; + + for (const term of terms) { + const i = lower.indexOf(term); + if (i !== -1 && (index === -1 || i < index)) { + index = i; + matchedTerm = term; + } + } + + if (index === -1) { + return content.slice(0, radius * 2) + "…"; + } + + const start = Math.max(0, index - radius); + const end = Math.min(content.length, index + matchedTerm.length + radius); + + const prefix = start > 0 ? "…" : ""; + const suffix = end < content.length ? "…" : ""; + + return prefix + content.slice(start, end) + suffix; +} + +function getSearchTerms(query) { + return query + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 2); +} + +function highlight(text, terms) { + if (!text || terms.length === 0) return text; + + const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + + const regex = new RegExp(`(${escaped.join("|")})`, "gi"); + + return text.replace(regex, "$1"); +} + +function search(query) { + if (!lunrIndex) return []; + + const q = buildExactQuery(query); + if (!q) return []; + + const terms = getSearchTerms(query); + const results = lunrIndex.search(q); + + return results + .map((r) => { + const doc = documents.find((d) => d.id === r.ref); + if (!doc) return null; + + const snippet = buildSnippet(doc.content, terms); + + return { + title: highlight(doc.title, terms), + path: doc.path, + score: r.score, + preview: highlight(snippet, terms), + }; + }) + .filter(Boolean); +} + +function showResults(results) { + resultsBox.innerHTML = ""; + activeIndex = -1; + + if (results.length === 0) { + resultsBox.style.display = "none"; + return; + } + + const rect = searchInput.getBoundingClientRect(); + resultsBox.style.top = `${rect.bottom + window.scrollY}px`; + resultsBox.style.left = `${rect.left + window.scrollX}px`; + resultsBox.style.width = `${rect.width}px`; + + results.forEach((result, i) => { + const item = document.createElement("div"); + item.className = "search-result"; + item.dataset.index = i; + + item.innerHTML = ` +
${result.title}
+
${result.preview}
+ `; + + item.onclick = () => { + window.location.href = result.path; + }; + + resultsBox.appendChild(item); + }); + + resultsBox.style.display = "block"; +} + +function updateActiveResult() { + const items = resultsBox.querySelectorAll(".search-result"); + + items.forEach((item) => item.classList.remove("active")); + + if (activeIndex >= 0 && activeIndex < items.length) { + const activeItem = items[activeIndex]; + + activeItem.classList.add("active"); + + activeItem.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } +} function openSearchModal(results) { - modalResults.innerHTML = ""; + const modal = document.getElementById("search-modal"); + const modalResults = modal?.querySelector("#search-modal-results"); + modal.tabIndex = -1; - if (results.length === 0) { - modalResults.innerHTML = "

No results found.

"; - } + if (!modal || !modalResults) { + console.warn("Search modal not available"); + return; + } - results.forEach(result => { - const item = document.createElement("div"); - item.className = "search-modal-result"; - item.innerHTML = ` -
${result.title}
-
${result.preview}
- `; - item.onclick = () => window.location.href = result.path; - modalResults.appendChild(item); - }); + modalResults.innerHTML = ""; - modal.style.display = "block"; + if (results.length === 0) { + modalResults.innerHTML = "

No results found.

"; + } + + results.forEach((result) => { + const item = document.createElement("div"); + item.className = "search-modal-result"; + item.innerHTML = ` +
${result.title}
+
${result.preview}
+ `; + item.onclick = () => (window.location.href = result.path); + modalResults.appendChild(item); + }); + + modal.style.display = "block"; + modal.focus(); } -/* ------------------------------ - Submit search (Enter / 🔍) --------------------------------- */ function submitSearch() { - const q = searchInput.value.trim(); - if (!q) return; - openSearchModal(search(q)); + const q = searchInput.value.trim(); + if (!q) return; + openSearchModal(search(q)); } -searchInput.addEventListener("keydown", e => { - if (e.key === "Enter") { - e.preventDefault(); +/* EVENTS */ +searchInput.addEventListener("keydown", (e) => { + const items = resultsBox.querySelectorAll(".search-result"); + if (!items.length) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + if (activeIndex < items.length - 1) { + activeIndex++; + updateActiveResult(); + } + break; + + case "ArrowUp": + e.preventDefault(); + if (activeIndex > 0) { + activeIndex--; + updateActiveResult(); + } + break; + + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < items.length) { + items[activeIndex].click(); + } else { submitSearch(); - } + } + break; + + case "Escape": + resultsBox.style.display = "none"; + activeIndex = -1; + break; + } }); +document.addEventListener("click", (e) => { + if ( + !resultsBox.contains(e.target) && + e.target !== searchInput && + e.target !== searchBtn + ) { + resultsBox.style.display = "none"; + } +}); + +modal.addEventListener("click", (e) => { + if (e.target.classList.contains("search-modal-backdrop")) { + modal.style.display = "none"; + } +}); +modal.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + modal.style.display = "none"; + searchInput.focus(); + } +}); + +window.addEventListener("pageshow", () => { + searchInput.value = ""; + resultsBox.style.display = "none"; +}); +window.addEventListener("beforeunload", () => { + searchInput.value = ""; +}); + +if (searchForm) { + searchForm.addEventListener("submit", (e) => { + e.preventDefault(); + submitSearch(); + }); +} + searchBtn.addEventListener("click", submitSearch); -/* ------------------------------ - Close behaviours --------------------------------- */ -document.addEventListener("click", e => { - if ( - !resultsBox.contains(e.target) && - e.target !== searchInput && - e.target !== searchBtn - ) { - resultsBox.style.display = "none"; - } +searchInput.addEventListener("input", () => { + const q = searchInput.value.trim(); + + if (q.length < 3) { + resultsBox.style.display = "none"; + return; + } + showResults(search(q)); }); -modal.addEventListener("click", e => { - if (e.target.classList.contains("search-modal-backdrop")) { - modal.style.display = "none"; - } -}); - -document.addEventListener("keydown", e => { - if (e.key === "Escape") { - modal.style.display = "none"; - } -}); +/* OLD FUNCTIONS */ +function buildFuzzyQuery(query) { + return query + .toLowerCase() + .split(/\s+/) + .filter((term) => term.length > 2) + .map((term) => `${term}~1`) + .join(" "); +} diff --git a/assets/styles/misc.css b/assets/styles/misc.css index 89356fe..24af4cc 100644 --- a/assets/styles/misc.css +++ b/assets/styles/misc.css @@ -345,3 +345,21 @@ nav { font-size: 0.85rem; color: #666; } +mark { + background: rgba(255, 230, 150, 0.8); + color: inherit; + padding: 0 0.15em; + border-radius: 3px; +} +.search-result { + cursor: pointer; + padding: 0.5em 0.75em; +} + +.search-result.active { + background: rgba(0, 0, 0, 0.08); +} + +.search-result.active mark { + background: rgba(255, 230, 150, 0.9); +} diff --git a/blogs/blogs-list.org b/blogs/blogs-list.org index 52b3206..31573ee 100644 --- a/blogs/blogs-list.org +++ b/blogs/blogs-list.org @@ -5,8 +5,8 @@ See the categories: @@html:Categories@@ * Blogs: - [[file:2026/01-january/04-01-week-review.org][[04-01-2026] - Weekly Review]] @@html:04-01-2026 12:12@@ @@html: @@ -- [[file:2026/2026-list.org][2026 List]] @@html:30-12-2025 22:33@@ -- [[file:2025/2025-list.org][2025 List]] @@html:30-12-2025 22:33@@ +- [[file:2026/2026-list.org][2026 List]] @@html:31-12-2025 17:49@@ +- [[file:2025/2025-list.org][2025 List]] @@html:31-12-2025 17:49@@ - [[file:2025/12-december/28-12-week-review.org][[28-12-2025] - Weekly Review]] @@html:28-12-2025 12:12@@ @@html: @@ - [[file:2025/12-december/21-12-week-review.org][[21-12-2025] - Weekly Review]] @@html:21-12-2025 12:12@@ @@html: @@ - [[file:2025/12-december/14-12-week-review.org][[14-12-2025] - Weekly Review]] @@html:09-12-2025 12:12@@ @@html: @@ diff --git a/posts/posts-list.org b/posts/posts-list.org index e4b924a..94c747e 100644 --- a/posts/posts-list.org +++ b/posts/posts-list.org @@ -4,7 +4,7 @@ See the categories: @@html:Categories@@ * Posts: -- [[file:career/career-list.org][Career List]] @@html:30-12-2025 22:33@@ +- [[file:career/career-list.org][Career List]] @@html:31-12-2025 17:49@@ - [[file:career/probation-objectives.org][Probation Objectives:]] @@html:08-12-2025 17:55@@ @@html: @@ @@html: @@ - [[file:career/airflow.org][Datamarts, Airflow and DAG's]] @@html:15-11-2025 18:37@@ @@html: @@ @@html: @@ - [[file:career/normalisation.org][Benefits of Normalisation]] @@html:10-11-2025 18:04@@ @@html: @@ @@html: @@ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6f83e94 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +beautifulsoup4 +lxml diff --git a/search-index-json.py b/search-index-json.py index 84c5276..8359e1b 100644 --- a/search-index-json.py +++ b/search-index-json.py @@ -4,15 +4,11 @@ from bs4 import BeautifulSoup def strip_html_tags(html): soup = BeautifulSoup(html, "html.parser") - - # Remove script and style elements completely for tag in soup(["script", "style"]): tag.decompose() - # Extract plain text text = soup.get_text(separator="\n") - # Clean up whitespace lines = [line.strip() for line in text.splitlines() if line.strip()] return "\n".join(lines) diff --git a/sitemap.org b/sitemap.org index a250e45..6449560 100644 --- a/sitemap.org +++ b/sitemap.org @@ -71,11 +71,11 @@ - tags - [[file:tags/learning.org][Tag: learning]] - [[file:tags/introduction.org][Tag: introduction]] + - [[file:tags/education.org][Tag: education]] + - [[file:tags/website.org][Tag: website]] - [[file:tags/notes.org][Tag: notes]] - [[file:tags/review.org][Tag: review]] - [[file:tags/emacs.org][Tag: emacs]] - - [[file:tags/education.org][Tag: education]] - - [[file:tags/website.org][Tag: website]] - [[file:tags/reading.org][Tag: reading]] - - [[file:tags/insights.org][Tag: insights]] - - [[file:tags/maths.org][Tag: maths]] \ No newline at end of file + - [[file:tags/maths.org][Tag: maths]] + - [[file:tags/insights.org][Tag: insights]] \ No newline at end of file