diff --git a/boneset-api/server.js b/boneset-api/server.js
index e060ce8..d0505ad 100644
--- a/boneset-api/server.js
+++ b/boneset-api/server.js
@@ -1,266 +1,247 @@
-//const express = require("express");
-//const axios = require("axios");
-//const cors = require("cors");
-//const path = require('path'); // Added for consistency, though not strictly needed for this version
-//
-//const app = express();
-//const PORT = process.env.PORT || 8000;
-//
-//app.use(cors());
-//
-//// --- Original GitHub URLs ---
-//const GITHUB_REPO = "https://raw.githubusercontent.com/oss-slu/DigitalBonesBox/data/DataPelvis/";
-//const BONESET_JSON_URL = `${GITHUB_REPO}boneset/bony_pelvis.json`;
-//const BONES_DIR_URL = `${GITHUB_REPO}bones/`;
-//
-//// Helper function to fetch JSON from GitHub
-//async function fetchJSON(url) {
-// try {
-// const response = await axios.get(url);
-// return response.data;
-// } catch (error) {
-// console.error(`Failed to fetch ${url}:`, error.message);
-// return null;
-// }
-//}
-//
-//// Home route (fixes "Cannot GET /" issue)
-//app.get("/", (req, res) => {
-// res.json({ message: "Welcome to the Boneset API (GitHub-Integrated)" });
-//});
-//
-//// --- Original Combined Data Endpoint ---
-//// This endpoint still provides the main data for the dropdowns
-//app.get("/combined-data", async (req, res) => {
-// try {
-// const bonesetData = await fetchJSON(BONESET_JSON_URL);
-// if (!bonesetData) return res.status(500).json({ error: "Failed to load boneset data" });
-//
-// const bonesets = [{ id: bonesetData.id, name: bonesetData.name }];
-// const bones = [];
-// const subbones = [];
-//
-// for (const boneId of bonesetData.bones) {
-// const boneJsonUrl = `${BONES_DIR_URL}${boneId}.json`;
-// const boneData = await fetchJSON(boneJsonUrl);
-//
-// if (boneData) {
-// bones.push({ id: boneData.id, name: boneData.name, boneset: bonesetData.id });
-// boneData.subBones.forEach(subBoneId => {
-// subbones.push({ id: subBoneId, name: subBoneId.replace(/_/g, " "), bone: boneData.id });
-// });
-// }
-// }
-//
-// res.json({ bonesets, bones, subbones });
-//
-// } catch (error) {
-// console.error("Error fetching combined data:", error.message);
-// res.status(500).json({ error: "Internal Server Error" });
-// }
-//});
-//
-//// --- NEW HTMX ENDPOINT ---
-//// This endpoint fetches a description and returns it as an HTML fragment
-//app.get("/api/description/", async (req, res) => { // Path changed here
-// const { boneId } = req.query; // Changed from req.params to req.query
-// if (!boneId) {
-// return res.send(''); // Send empty response if no boneId is provided
-// }
-// const GITHUB_DESC_URL = `https://raw.githubusercontent.com/oss-slu/DigitalBonesBox/data/DataPelvis/descriptions/${boneId}_description.json`;
-//
-// try {
-// const response = await axios.get(GITHUB_DESC_URL);
-// const descriptionData = response.data;
-//
-// let html = `
${descriptionData.name}`;
-// descriptionData.description.forEach(point => {
-// html += `${point}`;
-// });
-// res.send(html);
-//
-// } catch (error) {
-// res.send('Description not available.');
-// }
-//});
-
-
-// Start server
-//app.listen(PORT, () => {
-// console.log(`🚀 Server running on http://127.0.0.1:${PORT}`);
-//});
-
// boneset-api/server.js
const express = require("express");
const axios = require("axios");
const cors = require("cors");
-const path = require("path");
-const fs = require("fs/promises");
const rateLimit = require("express-rate-limit");
const app = express();
const PORT = process.env.PORT || 8000;
app.use(cors());
+app.use(express.json());
-// ---- Existing GitHub sources used only by /combined-data (unchanged) ----
const GITHUB_REPO = "https://raw.githubusercontent.com/oss-slu/DigitalBonesBox/data/DataPelvis/";
const BONESET_JSON_URL = `${GITHUB_REPO}boneset/bony_pelvis.json`;
const BONES_DIR_URL = `${GITHUB_REPO}bones/`;
-// ---- Local data directory for merged files ----
-const DATA_DIR = path.join(__dirname, "data");
-
-// ---- Simple rate limiter for FS-backed endpoints ----
-const bonesetLimiter = rateLimit({
- windowMs: 60 * 1000, // 1 minute
- max: 60, // 60 requests / min / IP
- standardHeaders: true,
- legacyHeaders: false,
+// Rate limiter for search endpoint
+const searchLimiter = rateLimit({
+ windowMs: 60 * 1000,
+ max: 100,
+ standardHeaders: true,
+ legacyHeaders: false,
});
-// ---- Only allow bonesets we ship locally right now ----
-const ALLOWED_BONESETS = new Set(["bony_pelvis"]);
+// Cache for search data
+let searchCache = null;
-// ---- Helpers ----
-async function fetchJSON(url) {
- try {
- const response = await axios.get(url, { timeout: 10_000 });
- return response.data;
- } catch (error) {
- console.error(`Failed to fetch ${url}:`, error.message);
- return null;
- }
+// HTML escaping helper
+function escapeHtml(str = "") {
+ return String(str).replace(/[&<>"']/g, (c) => ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ "\"": """,
+ "'": "'",
+ })[c]);
}
-// Ensure any resolved path stays inside DATA_DIR
-function safeDataPath(fileName) {
- const base = path.resolve(DATA_DIR);
- const candidate = path.resolve(DATA_DIR, fileName);
- if (!candidate.startsWith(base + path.sep)) {
- const err = new Error("Invalid path");
- err.code = "EINVAL";
- throw err;
- }
- return candidate;
+// GitHub JSON fetcher
+async function fetchJSON(url) {
+ try {
+ const response = await axios.get(url, { timeout: 10_000 });
+ return response.data;
+ } catch (error) {
+ console.error(`Failed to fetch ${url}:`, error.message);
+ return null;
+ }
}
-// Tiny HTML escape (double-quotes everywhere for ESLint)
-function escapeHtml(str = "") {
- return String(str).replace(/[&<>"']/g, (c) => ({
- "&": "&",
- "<": "<",
- ">": ">",
- "\"": """,
- "'": "'",
- })[c]);
-}
+// Initialize search cache at startup
+async function initializeSearchCache() {
+ try {
+ console.log("Initializing search cache...");
+ const bonesetData = await fetchJSON(BONESET_JSON_URL);
+ if (!bonesetData) {
+ console.error("Failed to load boneset data for search cache");
+ return;
+ }
+
+ const searchData = [];
+
+ // Add boneset to search data
+ searchData.push({
+ id: bonesetData.id,
+ name: bonesetData.name,
+ type: "boneset",
+ boneset: bonesetData.id,
+ bone: null,
+ subbone: null
+ });
-// Cache the merged boneset for fast description lookups
-let cachedBoneset = null;
-async function loadBoneset() {
- if (cachedBoneset) return cachedBoneset;
- const file = safeDataPath("final_bony_pelvis.json");
- const raw = await fs.readFile(file, "utf8");
- cachedBoneset = JSON.parse(raw);
- return cachedBoneset;
+ // Load all bones and sub-bones
+ for (const boneId of bonesetData.bones || []) {
+ const boneData = await fetchJSON(`${BONES_DIR_URL}${boneId}.json`);
+ if (boneData) {
+ // Add bone to search data
+ searchData.push({
+ id: boneData.id,
+ name: boneData.name,
+ type: "bone",
+ boneset: bonesetData.id,
+ bone: boneData.id,
+ subbone: null
+ });
+
+ // Add sub-bones to search data
+ for (const subBoneId of boneData.subBones || []) {
+ const subBoneName = subBoneId.replace(/_/g, " ");
+ searchData.push({
+ id: subBoneId,
+ name: subBoneName,
+ type: "subbone",
+ boneset: bonesetData.id,
+ bone: boneData.id,
+ subbone: subBoneId
+ });
+ }
+ }
+ }
+
+ searchCache = searchData;
+ console.log(`Search cache initialized with ${searchData.length} items`);
+ } catch (error) {
+ console.error("Error initializing search cache:", error);
+ }
}
-function findNodeById(boneset, id) {
- if (!boneset) return null;
- for (const bone of boneset.bones || []) {
- if (bone.id === id) return bone;
- for (const sub of bone.subbones || []) {
- if (sub.id === id) return sub;
+// Search function with ranking
+function searchItems(query, limit = 20) {
+ if (!searchCache) return [];
+
+ const q = query.toLowerCase().trim();
+ const results = [];
+
+ // First pass: prefix matches (higher priority)
+ for (const item of searchCache) {
+ if (item.name.toLowerCase().startsWith(q)) {
+ results.push({ ...item, priority: 1 });
+ }
+ }
+
+ // Second pass: substring matches (lower priority)
+ for (const item of searchCache) {
+ if (!item.name.toLowerCase().startsWith(q) && item.name.toLowerCase().includes(q)) {
+ results.push({ ...item, priority: 2 });
+ }
}
- }
- return null;
+
+ // Sort by priority, then by name
+ results.sort((a, b) => {
+ if (a.priority !== b.priority) return a.priority - b.priority;
+ return a.name.localeCompare(b.name);
+ });
+
+ return results.slice(0, limit);
}
-// ---- Routes ----
-
+// Routes
app.get("/", (_req, res) => {
- res.json({ message: "Welcome to the Boneset API (GitHub-Integrated)" });
+ res.json({ message: "Welcome to the Boneset API" });
});
-// Unchanged: used by the dropdowns in the current UI
app.get("/combined-data", async (_req, res) => {
- try {
- const bonesetData = await fetchJSON(BONESET_JSON_URL);
- if (!bonesetData) return res.status(500).json({ error: "Failed to load boneset data" });
-
- const bonesets = [{ id: bonesetData.id, name: bonesetData.name }];
- const bones = [];
- const subbones = [];
-
- for (const boneId of bonesetData.bones) {
- const boneJsonUrl = `${BONES_DIR_URL}${boneId}.json`;
- const boneData = await fetchJSON(boneJsonUrl);
- if (boneData) {
- bones.push({ id: boneData.id, name: boneData.name, boneset: bonesetData.id });
- (boneData.subBones || []).forEach((subBoneId) => {
- subbones.push({ id: subBoneId, name: subBoneId.replace(/_/g, " "), bone: boneData.id });
- });
- }
+ try {
+ const bonesetData = await fetchJSON(BONESET_JSON_URL);
+ if (!bonesetData) {
+ return res.status(500).json({ error: "Failed to load boneset data" });
+ }
+
+ const bonesets = [{ id: bonesetData.id, name: bonesetData.name }];
+ const bones = [];
+ const subbones = [];
+
+ for (const boneId of bonesetData.bones || []) {
+ const boneData = await fetchJSON(`${BONES_DIR_URL}${boneId}.json`);
+ if (boneData) {
+ bones.push({ id: boneData.id, name: boneData.name, boneset: bonesetData.id });
+ (boneData.subBones || []).forEach((subBoneId) => {
+ subbones.push({ id: subBoneId, name: subBoneId.replace(/_/g, " "), bone: boneData.id });
+ });
+ }
+ }
+
+ res.json({ bonesets, bones, subbones });
+ } catch (error) {
+ console.error("Error fetching combined data:", error.message);
+ res.status(500).json({ error: "Internal Server Error" });
}
-
- res.json({ bonesets, bones, subbones });
- } catch (error) {
- console.error("Error fetching combined data:", error.message);
- res.status(500).json({ error: "Internal Server Error" });
- }
});
-// Serve description from the local merged JSON (no SSRF)
-app.get("/api/description", bonesetLimiter, async (req, res) => {
- const boneId = String(req.query.boneId || "");
-
- // Basic allowlist-style validation
- if (!/^[a-z0-9_]+$/.test(boneId)) {
- return res.type("text/html").send("");
- }
-
- try {
- const set = await loadBoneset();
- const node = findNodeById(set, boneId);
- if (!node) return res.type("text/html").send("");
+app.get("/api/description/", async (req, res) => {
+ const { boneId } = req.query;
+ if (!boneId) {
+ return res.send(" ");
+ }
+
+ const GITHUB_DESC_URL = `https://raw.githubusercontent.com/oss-slu/DigitalBonesBox/data/DataPelvis/descriptions/${boneId}_description.json`;
- const name = node.name || boneId.replace(/_/g, " ");
- const lines = Array.isArray(node.description) ? node.description : [];
+ try {
+ const response = await axios.get(GITHUB_DESC_URL);
+ const descriptionData = response.data;
- // HTMX expects an list fragment
- let html = `${escapeHtml(name)}`;
- for (const line of lines) {
- html += `${escapeHtml(line)}`;
+ let html = `${escapeHtml(descriptionData.name)}`;
+ descriptionData.description.forEach(point => {
+ html += `${escapeHtml(point)}`;
+ });
+ res.send(html);
+ } catch (error) {
+ res.send("Description not available.");
}
- res.type("text/html").send(html);
- } catch (err) {
- console.error("description error:", err);
- res.type("text/html").send("Description not available.");
- }
});
-// Safe path + allowlist + rate limit
-app.get("/api/boneset/:bonesetId", bonesetLimiter, async (req, res) => {
- const { bonesetId } = req.params;
+// Search endpoint
+app.get("/api/search", searchLimiter, (req, res) => {
+ const query = req.query.q;
+
+ console.log("Search request received for:", query);
- if (!ALLOWED_BONESETS.has(bonesetId)) {
- return res.status(404).json({ error: `Boneset '${bonesetId}' not found` });
- }
+ // Handle empty or too short queries
+ if (!query || query.trim().length < 2) {
+ return res.send("Enter at least 2 characters to search");
+ }
- try {
- const filePath = safeDataPath(`final_${bonesetId}.json`);
- const raw = await fs.readFile(filePath, "utf8");
- res.type("application/json").send(raw);
- } catch (err) {
- if (err.code === "ENOENT") {
- return res.status(404).json({ error: `Boneset '${bonesetId}' not found` });
+ const searchTerm = query.trim();
+
+ try {
+ if (!searchCache) {
+ return res.send("Search not available - cache not initialized");
+ }
+
+ const results = searchItems(searchTerm, 20);
+ console.log(`Found ${results.length} results for "${searchTerm}"`);
+
+ if (results.length === 0) {
+ return res.send("No results found");
+ }
+
+ let html = "";
+ for (const result of results) {
+ const escapedName = escapeHtml(result.name);
+ const escapedType = escapeHtml(result.type);
+
+ html += `
+ ${escapedName} (${escapedType})
+ `;
+ }
+
+ res.send(html);
+ } catch (error) {
+ console.error("Search error:", error);
+ res.status(500).send("Search error occurred");
}
- console.error("Error reading boneset file:", err);
- res.status(500).json({ error: "Internal Server Error" });
- }
});
+// Initialize search cache on startup
+initializeSearchCache();
+
app.listen(PORT, () => {
- console.log(`🚀 Server running on http://127.0.0.1:${PORT}`);
-});
+ console.log(`🚀 Server running on http://127.0.0.1:${PORT}`);
+});
\ No newline at end of file
diff --git a/templates/boneset.html b/templates/boneset.html
index f02169e..ec0c408 100644
--- a/templates/boneset.html
+++ b/templates/boneset.html
@@ -7,7 +7,7 @@
Bone Set Viewer
-
+
@@ -28,8 +28,18 @@
Bone Set Viewer
-
-
+
+
+
+
+
+
@@ -61,8 +71,7 @@
Bone Set Viewer
-
-
+
-
-
-
+
+
+