search-phrase.js
Copy to _static/js/search-phrase.js
// Simple phrase search wrapper for Sphinx
(function() {
function initPhraseSearch() {
if (typeof Search === 'undefined' || !Search.query) {
setTimeout(initPhraseSearch, 50);
return;
}
const originalQuery = Search.query;
Search.query = function(query) {
console.log("PHRASE DEBUG: Original search query:", JSON.stringify(query), "Length:", query ? query.length : 'null');
console.log("PHRASE DEBUG: Query type:", typeof query, "Trimmed:", query ? JSON.stringify(query.trim()) : 'null');
// Handle special clear command with empty quotes
if (query === '""' || query === "''") {
console.log("PHRASE DEBUG: Clear command detected - clearing highlights and showing empty results");
// Clear stored phrases to remove highlighting
localStorage.removeItem("sphinx_highlight_phrases");
localStorage.removeItem("sphinx_highlight_terms");
localStorage.removeItem("_sphinx_highlight_terms");
// Clear any existing highlights on current page
clearHighlights();
// Show empty search results to stay on search page but with no results
console.log("PHRASE DEBUG: Clear command handled - highlights cleared, showing empty results");
// Return empty results to display "No results found" message
if (typeof _displayNextItem === 'function') {
_displayNextItem([], 0, new Set(), new Set());
}
return; // Don't proceed with normal search
}
let phrases = [];
// Check if query contains commas - if so, treat as multiple terms
if (query.includes(',')) {
// Split by comma and treat each part as a separate phrase
phrases = query.split(',')
.map(term => term.trim())
.filter(term => term.length > 0)
.map(term => term.replace(/^"|"$/g, '').toLowerCase()); // Remove quotes if present
console.log("PHRASE DEBUG: Comma-separated search - treating as multiple phrases:", phrases);
} else {
// No commas - treat entire query as one phrase (remove outer quotes if present)
const cleanQuery = query.replace(/^"|"$/g, '').trim();
if (cleanQuery.length > 0) {
phrases = [cleanQuery.toLowerCase()];
}
console.log("PHRASE DEBUG: Single phrase search:", phrases);
}
if (phrases.length === 0) {
console.log("PHRASE DEBUG: No phrases after processing, using original search");
localStorage.removeItem("sphinx_highlight_phrases");
return originalQuery.call(this, query);
}
console.log("PHRASE DEBUG: Using phrase search for:", phrases);
// Get initial results using original search
const [searchQuery, searchTerms, excludedTerms, highlightTerms, objectTerms] = Search._parseQuery(query);
const results = Search._performSearch(searchQuery, searchTerms, excludedTerms, highlightTerms, objectTerms);
console.log("PHRASE DEBUG: Original search found", results.length, "results");
console.log("PHRASE DEBUG: Will filter for phrases:", phrases);
// Filter asynchronously by phrase content
(async function() {
const filtered = [];
const contentRoot = document.documentElement.dataset.content_root;
for (const result of results) {
const [docName] = result;
const url = contentRoot + docName + DOCUMENTATION_OPTIONS.FILE_SUFFIX;
try {
const response = await fetch(url);
const html = await response.text();
const text = Search.htmlToText(html).toLowerCase();
const hasAllPhrases = phrases.every(phrase => text.includes(phrase));
console.log(`PHRASE DEBUG: ${docName} contains all phrases:`, hasAllPhrases);
if (hasAllPhrases) {
filtered.push(result);
}
} catch (e) {
console.log(`PHRASE DEBUG: Error fetching ${docName}, including anyway:`, e.message);
filtered.push(result); // Include on error
}
}
console.log("PHRASE DEBUG: Filtered down to", filtered.length, "results");
// Store phrases for custom highlighting on destination pages
console.log("PHRASE DEBUG: Storing phrases for custom highlighting:", phrases);
// Store phrases separately to avoid word-splitting
localStorage.setItem("sphinx_highlight_phrases", JSON.stringify(phrases));
// Clear ALL possible Sphinx highlighting terms to prevent conflicts
localStorage.removeItem("sphinx_highlight_terms");
localStorage.removeItem("_sphinx_highlight_terms"); // Alternative key
console.log("PHRASE DEBUG: Stored phrases in localStorage and cleared Sphinx terms");
// Display results with phrase highlighting in search results
// Use phrase terms for highlighting in search results too
const phraseTerms = new Set(phrases);
_displayNextItem(filtered, filtered.length, phraseTerms, phraseTerms);
})();
};
}
// Custom phrase highlighting for destination pages
function highlightPhrases() {
console.log("PHRASE DEBUG: highlightPhrases function called on:", window.location.href);
console.log("PHRASE DEBUG: Page visit count:", (parseInt(sessionStorage.getItem("phrase_page_count") || "0") + 1));
sessionStorage.setItem("phrase_page_count", parseInt(sessionStorage.getItem("phrase_page_count") || "0") + 1);
// Check if current URL indicates a clear command
const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('q');
if (query === '""' || query === "''") {
console.log("PHRASE DEBUG: Clear command detected in URL - clearing localStorage and highlights");
// Check if this was already handled locally (global handler blocked navigation)
const handledLocally = sessionStorage.getItem('phrase_clear_handled_locally');
if (handledLocally) {
console.log("PHRASE DEBUG: Clear was handled locally by global handler, no redirect needed");
sessionStorage.removeItem('phrase_clear_handled_locally');
return;
}
localStorage.removeItem("sphinx_highlight_phrases");
localStorage.removeItem("sphinx_highlight_terms");
localStorage.removeItem("_sphinx_highlight_terms");
clearHighlights();
// Get the previous page from history or use a stored reference
const previousPage = sessionStorage.getItem('phrase_previous_page');
console.log("PHRASE DEBUG: Stored previous page:", previousPage);
console.log("PHRASE DEBUG: Current sessionStorage contents:", Object.keys(sessionStorage));
if (previousPage && previousPage !== window.location.href) {
console.log("PHRASE DEBUG: Redirecting back to previous page:", previousPage);
// Redirect back to the previous page immediately
setTimeout(function() {
window.location.href = previousPage;
}, 100);
} else {
console.log("PHRASE DEBUG: No valid previous page stored, staying on search results");
// Just clear the highlights and stay on search results page
}
return; // Don't proceed with highlighting
}
// Store current page as previous page for potential redirect back
if (!window.location.href.includes('search.html')) {
const currentPage = window.location.href;
sessionStorage.setItem('phrase_previous_page', currentPage);
console.log("PHRASE DEBUG: Stored current page as previous:", currentPage);
console.log("PHRASE DEBUG: Verification - stored value:", sessionStorage.getItem('phrase_previous_page'));
} else {
console.log("PHRASE DEBUG: On search page, not storing as previous page");
}
const storedPhrases = localStorage.getItem("sphinx_highlight_phrases");
console.log("PHRASE DEBUG: stored phrases from localStorage:", storedPhrases);
// Monitor localStorage changes
const allKeys = Object.keys(localStorage);
const highlightKeys = allKeys.filter(key => key.includes('highlight') || key.includes('sphinx'));
console.log("PHRASE DEBUG: All highlight-related localStorage keys:", highlightKeys);
highlightKeys.forEach(key => {
console.log(`PHRASE DEBUG: ${key} =`, localStorage.getItem(key));
});
// ONLY intervene if we have phrases to highlight
// Otherwise let the normal Sphinx highlighting system work completely uninterrupted
if (!storedPhrases) {
console.log("PHRASE DEBUG: No stored phrases - letting normal Sphinx highlighting work without interference");
return;
}
// We have phrases, so clear existing highlights and apply phrase highlighting
const existingHighlights = document.querySelectorAll('span.highlighted, .highlighted, span[style*="background-color"]');
console.log("PHRASE DEBUG: Found existing highlights to clear:", existingHighlights.length);
existingHighlights.forEach(span => {
console.log("PHRASE DEBUG: Clearing highlight:", span.outerHTML);
const parent = span.parentNode;
parent.replaceChild(document.createTextNode(span.textContent), span);
parent.normalize(); // Merge adjacent text nodes
});
try {
const phrases = JSON.parse(storedPhrases);
console.log("PHRASE DEBUG: Highlighting phrases on page:", phrases);
// Find and highlight each phrase in the page content
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
textNodes.push(node);
}
// Collect all highlighting operations first to avoid DOM modification conflicts
const highlightOperations = [];
phrases.forEach(phrase => {
console.log(`PHRASE DEBUG: Looking for phrase: "${phrase}" on page`);
let foundInAnyNode = false;
textNodes.forEach(textNode => {
const text = textNode.textContent;
const lowerText = text.toLowerCase();
const lowerPhrase = phrase.toLowerCase();
// Check if this text node contains the phrase
if (lowerText.includes(lowerPhrase)) {
console.log(`PHRASE DEBUG: Found phrase "${phrase}" in text node:`, text.substring(0, 100));
foundInAnyNode = true;
}
let searchIndex = 0;
// Find all occurrences of this phrase in the text node
while (true) {
const phraseIndex = lowerText.indexOf(lowerPhrase, searchIndex);
if (phraseIndex === -1) break;
const parent = textNode.parentNode;
if (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE') {
break;
}
highlightOperations.push({
textNode: textNode,
phraseIndex: phraseIndex,
phrase: phrase,
phraseLength: phrase.length
});
searchIndex = phraseIndex + phrase.length;
}
});
if (!foundInAnyNode) {
console.log(`PHRASE DEBUG: Phrase "${phrase}" NOT FOUND on page`);
console.log("PHRASE DEBUG: Sample page text:", document.body.textContent.toLowerCase().substring(0, 500));
}
});
console.log("PHRASE DEBUG: Found", highlightOperations.length, "highlighting operations for phrases:", phrases);
// Sort operations by text node and position to apply them correctly
highlightOperations.sort((a, b) => {
if (a.textNode !== b.textNode) return 0;
return b.phraseIndex - a.phraseIndex; // Apply in reverse order to maintain positions
});
// Apply highlighting operations
const processedNodes = new Set();
highlightOperations.forEach(op => {
if (processedNodes.has(op.textNode)) return; // Skip if node already processed
// Get current text (may have changed due to previous operations)
const text = op.textNode.textContent;
let modifiedText = text;
const replacements = [];
// Find all phrases in this text node
phrases.forEach(phrase => {
const lowerText = modifiedText.toLowerCase();
let searchIndex = 0;
while (true) {
const phraseIndex = lowerText.indexOf(phrase.toLowerCase(), searchIndex);
if (phraseIndex === -1) break;
replacements.push({
start: phraseIndex,
end: phraseIndex + phrase.length,
phrase: phrase
});
searchIndex = phraseIndex + phrase.length;
}
});
if (replacements.length > 0) {
// Sort replacements by position (reverse order for correct replacement)
replacements.sort((a, b) => b.start - a.start);
// Apply all replacements to create highlighted HTML
let finalHTML = text;
replacements.forEach(replacement => {
const beforeText = finalHTML.substring(0, replacement.start);
const phraseText = finalHTML.substring(replacement.start, replacement.end);
const afterText = finalHTML.substring(replacement.end);
finalHTML = beforeText + '<span class="highlighted" style="background-color: yellow;">' + phraseText + '</span>' + afterText;
});
// Replace the text node with HTML
const tempSpan = document.createElement('span');
tempSpan.innerHTML = finalHTML;
const parentElement = op.textNode.parentElement;
// Move all child nodes from temp span to parent
while (tempSpan.firstChild) {
parentElement.insertBefore(tempSpan.firstChild, op.textNode);
}
parentElement.removeChild(op.textNode);
processedNodes.add(op.textNode);
}
});
} catch (e) {
console.error("PHRASE DEBUG: Error parsing stored phrases:", e);
}
}
// Function to clear all existing highlights on the current page
function clearHighlights() {
console.log("PHRASE DEBUG: clearHighlights called");
// Remove all highlighted spans
const highlightedSpans = document.querySelectorAll('.highlighted, span[style*="background-color: yellow"]');
highlightedSpans.forEach(span => {
console.log("PHRASE DEBUG: Removing highlight from:", span.textContent);
const parent = span.parentNode;
if (parent) {
parent.replaceChild(document.createTextNode(span.textContent), span);
parent.normalize(); // Merge adjacent text nodes
}
});
console.log("PHRASE DEBUG: Cleared", highlightedSpans.length, "highlights");
}
// Monitor localStorage changes to catch external clearing
const originalRemoveItem = localStorage.removeItem;
const originalSetItem = localStorage.setItem;
const originalClear = localStorage.clear;
localStorage.removeItem = function(key) {
if (key.includes('highlight') || key.includes('sphinx')) {
console.log("PHRASE DEBUG: External code removing localStorage key:", key);
console.trace("PHRASE DEBUG: Stack trace for localStorage removal");
}
return originalRemoveItem.call(this, key);
};
localStorage.setItem = function(key, value) {
if (key.includes('highlight') || key.includes('sphinx')) {
console.log("PHRASE DEBUG: External code setting localStorage key:", key, "=", value);
}
return originalSetItem.call(this, key, value);
};
localStorage.clear = function() {
console.log("PHRASE DEBUG: External code clearing ALL localStorage");
console.trace("PHRASE DEBUG: Stack trace for localStorage clear");
return originalClear.call(this);
};
// Intercept search form submissions to handle empty searches
function interceptSearchForm() {
// Try multiple selectors to find search forms - be more aggressive
const searchForms = document.querySelectorAll('form[role="search"], #searchbox form, form[action*="search"], form:has(input[name="q"]), .wy-form form, form');
const searchInputs = document.querySelectorAll('input[name="q"], input[type="search"], #searchbox input');
console.log("PHRASE DEBUG: Found", searchForms.length, "search forms and", searchInputs.length, "search inputs");
console.log("PHRASE DEBUG: Search forms:", Array.from(searchForms).map(f => f.outerHTML.substring(0, 100)));
console.log("PHRASE DEBUG: Search inputs:", Array.from(searchInputs).map(i => i.outerHTML));
// Try to find forms that contain search inputs
const formsWithSearchInputs = [];
searchInputs.forEach(input => {
const form = input.closest('form');
if (form && !formsWithSearchInputs.includes(form)) {
formsWithSearchInputs.push(form);
console.log("PHRASE DEBUG: Found form containing search input:", form.outerHTML.substring(0, 100));
}
});
// Intercept form submissions with capture=true to run before Sphinx handlers
// Use ALL forms that contain search inputs
const allSearchForms = [...new Set([...searchForms, ...formsWithSearchInputs])];
console.log("PHRASE DEBUG: Will add event listeners to", allSearchForms.length, "forms");
allSearchForms.forEach((form, index) => {
console.log(`PHRASE DEBUG: Adding submit listener to form ${index}:`, form.outerHTML.substring(0, 100));
form.addEventListener('submit', function(event) {
const formData = new FormData(form);
const query = formData.get('q') || '';
console.log("PHRASE DEBUG: Form submit intercepted with query:", JSON.stringify(query), "on form:", form.outerHTML.substring(0, 50));
// Check specifically for clear command
if (query === '""' || query === "''") {
console.log("PHRASE DEBUG: Clear command form submission - preventing navigation and clearing highlights");
event.preventDefault(); // Prevent form submission
event.stopPropagation(); // Stop event bubbling
event.stopImmediatePropagation(); // Stop other handlers from running
// Clear highlights on current page
localStorage.removeItem("sphinx_highlight_phrases");
localStorage.removeItem("sphinx_highlight_terms");
localStorage.removeItem("_sphinx_highlight_terms");
clearHighlights();
// Clear the search input
const searchInput = form.querySelector('input[name="q"], input[type="search"]');
if (searchInput) {
searchInput.value = '';
searchInput.blur(); // Remove focus
}
console.log("PHRASE DEBUG: Clear command handled at form level - staying on current page");
return false;
}
}, { capture: true }); // Use capture phase to run before other handlers
});
// Also intercept Enter key on search inputs directly
searchInputs.forEach(input => {
// Find the parent form for this input
let parentForm = input.closest('form');
if (parentForm) {
console.log("PHRASE DEBUG: Found parent form for input:", parentForm.outerHTML.substring(0, 100));
// Add form to our list if not already there
if (!Array.from(searchForms).includes(parentForm)) {
parentForm.addEventListener('submit', function(event) {
const formData = new FormData(parentForm);
const query = formData.get('q') || '';
console.log("PHRASE DEBUG: Parent form submit intercepted with query:", JSON.stringify(query));
// Check specifically for clear command
if (query === '""' || query === "''") {
console.log("PHRASE DEBUG: Clear command parent form submission - preventing navigation and clearing highlights");
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
localStorage.removeItem("sphinx_highlight_phrases");
localStorage.removeItem("sphinx_highlight_terms");
localStorage.removeItem("_sphinx_highlight_terms");
clearHighlights();
const searchInput = parentForm.querySelector('input[name="q"], input[type="search"]');
if (searchInput) {
searchInput.value = '';
searchInput.blur();
}
console.log("PHRASE DEBUG: Clear command handled at parent form level - staying on current page");
return false;
}
}, { capture: true });
}
}
input.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
const query = input.value || '';
console.log("PHRASE DEBUG: Enter key pressed with query:", JSON.stringify(query));
// Check specifically for clear command
if (query === '""' || query === "''") {
console.log("PHRASE DEBUG: Clear command Enter key - preventing navigation and clearing highlights");
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
// Clear highlights
localStorage.removeItem("sphinx_highlight_phrases");
localStorage.removeItem("sphinx_highlight_terms");
localStorage.removeItem("_sphinx_highlight_terms");
clearHighlights();
input.value = '';
input.blur();
console.log("PHRASE DEBUG: Clear command handled at input level - staying on current page");
return false;
}
}
}, { capture: true });
});
}
// Try to intercept search forms immediately, even before DOM is ready
function immediateSearchIntercept() {
// Use MutationObserver to catch forms as they're added
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1) { // Element node
// Check if this node is a form or contains forms
const forms = node.tagName === 'FORM' ? [node] : node.querySelectorAll ? Array.from(node.querySelectorAll('form')) : [];
forms.forEach(function(form) {
const searchInput = form.querySelector('input[name="q"]');
if (searchInput) {
console.log("PHRASE DEBUG: MutationObserver found search form, adding immediate handlers");
addClearHandlers(form, searchInput);
}
});
}
});
});
});
observer.observe(document, { childList: true, subtree: true });
// Also try immediate detection if elements already exist
setTimeout(function() {
const existingForms = document.querySelectorAll('form');
existingForms.forEach(function(form) {
const searchInput = form.querySelector('input[name="q"]');
if (searchInput) {
console.log("PHRASE DEBUG: Found existing search form, adding immediate handlers");
addClearHandlers(form, searchInput);
}
});
}, 50);
}
function addClearHandlers(form, input) {
// Prevent duplicate handlers
if (form._clearHandlersAdded || input._clearHandlersAdded) {
console.log("PHRASE DEBUG: Clear handlers already added to this form/input, skipping");
return;
}
console.log("PHRASE DEBUG: Adding IMMEDIATE clear handlers to form and input");
// Mark as having handlers to prevent duplicates
form._clearHandlersAdded = true;
input._clearHandlersAdded = true;
// Add the clear command handlers immediately with maximum priority
input.addEventListener('keydown', function(event) {
console.log("PHRASE DEBUG: IMMEDIATE keydown event, key:", event.key, "value:", JSON.stringify(input.value));
if (event.key === 'Enter' && (input.value === '""' || input.value === "''")) {
console.log("PHRASE DEBUG: IMMEDIATE Clear command Enter key - BLOCKING EVERYTHING");
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
localStorage.removeItem("sphinx_highlight_phrases");
localStorage.removeItem("sphinx_highlight_terms");
localStorage.removeItem("_sphinx_highlight_terms");
clearHighlights();
input.value = '';
input.blur();
console.log("PHRASE DEBUG: IMMEDIATE Clear command handled - staying on current page");
return false;
}
}, { capture: true, passive: false });
form.addEventListener('submit', function(event) {
const query = new FormData(form).get('q') || '';
console.log("PHRASE DEBUG: IMMEDIATE form submit event, query:", JSON.stringify(query));
if (query === '""' || query === "''") {
console.log("PHRASE DEBUG: IMMEDIATE Clear command form submission - BLOCKING EVERYTHING");
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
localStorage.removeItem("sphinx_highlight_phrases");
localStorage.removeItem("sphinx_highlight_terms");
localStorage.removeItem("_sphinx_highlight_terms");
clearHighlights();
input.value = '';
input.blur();
console.log("PHRASE DEBUG: IMMEDIATE Clear command handled - staying on current page");
return false;
}
}, { capture: true, passive: false });
console.log("PHRASE DEBUG: IMMEDIATE clear handlers successfully added");
}
// Start immediate interception
immediateSearchIntercept();
// Also add a global keydown handler as backup
document.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
const target = event.target;
if (target && target.name === 'q' && (target.value === '""' || target.value === "''")) {
console.log("PHRASE DEBUG: GLOBAL keydown handler caught clear command - BLOCKING");
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
localStorage.removeItem("sphinx_highlight_phrases");
localStorage.removeItem("sphinx_highlight_terms");
localStorage.removeItem("_sphinx_highlight_terms");
clearHighlights();
target.value = '';
target.blur();
// Mark that we handled this to prevent redirect-back mechanism
sessionStorage.setItem('phrase_clear_handled_locally', 'true');
console.log("PHRASE DEBUG: GLOBAL clear command handled - staying on current page");
return false;
}
}
}, { capture: true, passive: false });
// Also add a global form submit handler as backup
document.addEventListener('submit', function(event) {
const form = event.target;
if (form && form.tagName === 'FORM') {
const searchInput = form.querySelector('input[name="q"]');
if (searchInput && (searchInput.value === '""' || searchInput.value === "''")) {
console.log("PHRASE DEBUG: GLOBAL submit handler caught clear command - BLOCKING");
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
localStorage.removeItem("sphinx_highlight_phrases");
localStorage.removeItem("sphinx_highlight_terms");
localStorage.removeItem("_sphinx_highlight_terms");
clearHighlights();
searchInput.value = '';
searchInput.blur();
console.log("PHRASE DEBUG: GLOBAL clear command handled - staying on current page");
return false;
}
}
}, { capture: true, passive: false });
document.addEventListener('DOMContentLoaded', initPhraseSearch);
document.addEventListener('DOMContentLoaded', highlightPhrases);
document.addEventListener('DOMContentLoaded', function() {
// Delay form interception to ensure all elements are loaded
setTimeout(interceptSearchForm, 100);
});
})();