diff --git a/backend/utils.py b/backend/utils.py index 4d97d8b..4e8e2b4 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -273,9 +273,10 @@ def search_notes(notes_dir: str, query: str) -> List[Dict]: """ Full-text search through note contents only. Does NOT search in file names, folder names, or paths - only note content. + Uses character-based context extraction with highlighted matches. """ + from html import escape results = [] - query_lower = query.lower() notes_path = Path(notes_dir) for md_file in notes_path.rglob("*.md"): @@ -283,29 +284,51 @@ def search_notes(notes_dir: str, query: str) -> List[Dict]: with open(md_file, 'r', encoding='utf-8') as f: content = f.read() - # Only search in note content (not file name, folder name, or path) - if query_lower in content.lower(): - # Find context around match - lines = content.split('\n') + # Find all matches using regex (case-insensitive) + matches = list(re.finditer(re.escape(query), content, re.IGNORECASE)) + + if matches: matched_lines = [] - for i, line in enumerate(lines): - if query_lower in line.lower(): - # Get surrounding context - start = max(0, i - 1) - end = min(len(lines), i + 2) - context = '\n'.join(lines[start:end]) - matched_lines.append({ - "line_number": i + 1, - "context": context[:200] # Limit context length - }) + for match in matches[:3]: # Limit to 3 matches per file + start_index = match.start() + end_index = match.end() + matched_text = match.group() # Preserve original case + + # Create slice window: ±15 characters around match + context_start = max(0, start_index - 15) + context_end = min(len(content), end_index + 15) + + # Extract and clean parts (newlines → spaces) + before = escape(content[context_start:start_index].replace('\n', ' ')) + after = escape(content[end_index:context_end].replace('\n', ' ')) + matched_clean = escape(matched_text.replace('\n', ' ')) + + # Build snippet with highlight (styled via CSS) + snippet = f'{before}{matched_clean}{after}' + + # Add ellipsis if truncated at start + if context_start > 0: + snippet = '...' + snippet + + # Add ellipsis if truncated at end + if context_end < len(content): + snippet = snippet + '...' + + # Calculate line number by counting newlines up to match start + line_number = content.count('\n', 0, start_index) + 1 + + matched_lines.append({ + "line_number": line_number, + "context": snippet + }) relative_path = md_file.relative_to(notes_path) results.append({ "name": md_file.stem, "path": str(relative_path.as_posix()), "folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "", - "matches": matched_lines[:3] # Limit to 3 matches per file + "matches": matched_lines }) except Exception: continue diff --git a/frontend/app.js b/frontend/app.js index 867eb74..99a4cb8 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1915,18 +1915,10 @@ function noteApp() { mark.className = 'search-highlight'; mark.setAttribute('data-match-index', matchIndex); mark.textContent = text.substring(index, index + searchTerm.length); - mark.style.padding = '2px 4px'; - mark.style.borderRadius = '3px'; - mark.style.transition = 'all 0.2s'; - // Style first match as active, others as inactive + // First match is active (styled via CSS) if (matchIndex === 0) { - mark.style.backgroundColor = 'var(--accent-primary)'; - mark.style.color = 'white'; mark.classList.add('active-match'); - } else { - mark.style.backgroundColor = 'rgba(255, 193, 7, 0.4)'; - mark.style.color = 'var(--text-primary)'; } fragment.appendChild(mark); @@ -1981,17 +1973,9 @@ function noteApp() { const allMatches = preview.querySelectorAll('mark.search-highlight'); if (index < 0 || index >= allMatches.length) return; - // Update styling - make current match prominent + // Update styling - make current match prominent (via CSS class) allMatches.forEach((mark, i) => { - if (i === index) { - mark.style.backgroundColor = 'var(--accent-primary)'; - mark.style.color = 'white'; - mark.classList.add('active-match'); - } else { - mark.style.backgroundColor = 'rgba(255, 193, 7, 0.4)'; - mark.style.color = 'var(--text-primary)'; - mark.classList.remove('active-match'); - } + mark.classList.toggle('active-match', i === index); }); // Scroll to the match diff --git a/frontend/index.html b/frontend/index.html index e5c2df7..38092e8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -292,6 +292,19 @@ .markdown-preview a[data-wikilink].wikilink-broken:hover { opacity: 1; } + + /* Search highlight base style (used in note preview and search results) */ + .search-highlight { + padding: 2px 4px; + border-radius: 3px; + background-color: rgba(255, 193, 7, 0.4); + color: var(--text-primary); + transition: all 0.2s; + } + .search-highlight.active-match { + background-color: var(--accent-primary); + color: white; + } /* Note Properties Panel */ .note-properties { @@ -992,9 +1005,10 @@ :style="currentNote === note.path ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'" onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'" onmouseout="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='transparent'" + :title="note.path" >
-
+