search added

This commit is contained in:
nahakubuilde
2025-08-25 18:02:41 +01:00
parent 240b71e321
commit da71deb7e2
3 changed files with 238 additions and 1 deletions

View File

@@ -547,3 +547,104 @@ func (h *Handlers) TreeAPIHandler(c *gin.Context) {
c.JSON(http.StatusOK, notesTree)
}
// SearchHandler performs a simple full-text search across markdown and allowed text files
// within the notes directory, honoring skipped directories.
// GET /api/search?q=term
func (h *Handlers) SearchHandler(c *gin.Context) {
query := strings.TrimSpace(c.Query("q"))
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing query"})
return
}
// Case-insensitive search
qLower := strings.ToLower(query)
type Snippet struct {
Line int `json:"line"`
Preview string `json:"preview"`
}
type Result struct {
Path string `json:"path"`
Type string `json:"type"` // "md" or "text"
Snippets []Snippet `json:"snippets"`
}
results := make([]Result, 0, 32)
// Walk the notes directory
err := filepath.Walk(h.config.NotesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip on error
}
if info.IsDir() {
// Compute relative dir path to check skip list
rel, _ := filepath.Rel(h.config.NotesDir, path)
rel = filepath.ToSlash(rel)
if rel == "." {
rel = ""
}
if utils.IsPathInSkippedDirs(rel, h.config.NotesDirSkip) {
return filepath.SkipDir
}
return nil
}
// Compute relative file path
relPath, _ := filepath.Rel(h.config.NotesDir, path)
relPath = filepath.ToSlash(relPath)
// Skip disallowed files
ext := strings.ToLower(filepath.Ext(relPath))
isMD := ext == ".md"
if !isMD && !models.IsAllowedFile(relPath, h.config.AllowedFileExtensions) {
return nil
}
// Read file content (limit size to prevent huge memory usage)
data, readErr := os.ReadFile(path)
if readErr != nil {
return nil
}
content := string(data)
contentLower := strings.ToLower(content)
if !strings.Contains(contentLower, qLower) {
return nil
}
// Build snippets: show up to 3 matches with 2 lines of context
lines := strings.Split(content, "\n")
linesLower := strings.Split(contentLower, "\n")
snippets := make([]Snippet, 0, 3)
for i := 0; i < len(linesLower) && len(snippets) < 3; i++ {
if strings.Contains(linesLower[i], qLower) {
start := i - 2
if start < 0 {
start = 0
}
end := i + 2
if end >= len(lines) {
end = len(lines) - 1
}
// Join preview lines
preview := strings.Join(lines[start:end+1], "\n")
snippets = append(snippets, Snippet{Line: i + 1, Preview: preview})
}
}
rtype := "text"
if isMD {
rtype = "md"
}
results = append(results, Result{Path: relPath, Type: rtype, Snippets: snippets})
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"query": query, "results": results})
}

View File

@@ -96,6 +96,7 @@ func (s *Server) setupRoutes() {
// API routes
s.router.GET("/api/tree", h.TreeAPIHandler)
s.router.GET("/api/search", h.SearchHandler)
}
func (s *Server) setupStaticFiles() {

View File

@@ -265,7 +265,10 @@
<div class="flex items-center justify-between">
<h1 class="sidebar-title text-xl font-bold text-white">{{.app_name}}</h1>
<div class="flex items-center space-x-2 items-center">
<div class="sidebar-actions flex items-center space-x-2">
<div class="sidebar-actions flex items-center space-x-3">
<button id="open-search" class="text-gray-400 hover:text-white transition-colors" title="Search" aria-label="Search">
<i class="fas fa-magnifying-glass"></i>
</button>
<a href="/settings" class="text-gray-400 hover:text-white transition-colors" title="Settings">
<i class="fas fa-cog"></i>
</a>
@@ -334,6 +337,26 @@
</div>
</div>
<!-- Search Modal -->
<div id="search-modal" class="modal-overlay hidden">
<div class="modal-content w-full max-w-3xl">
<div class="flex items-center mb-3">
<div class="relative flex-1">
<i class="fas fa-magnifying-glass absolute left-3 top-3 text-gray-400"></i>
<input id="search-input" type="text" placeholder="Search notes..." class="form-input pl-9 w-full" />
</div>
<button id="clear-search" class="ml-2 px-3 py-2 rounded bg-red-600 hover:bg-red-700 text-white" title="Clear">
Clear
</button>
<button id="close-search" class="btn-secondary ml-2" title="Close">
<i class="fas fa-xmark"></i>
</button>
</div>
<div id="search-status" class="text-sm text-gray-400 mb-2"></div>
<div id="search-results" class="space-y-3"></div>
</div>
</div>
<!-- Scripts -->
<script>
// Initialize syntax highlighting
@@ -441,6 +464,118 @@
// Expand active path in tree
expandActivePath();
// Search modal wiring
const searchModal = document.getElementById('search-modal');
const openSearchBtn = document.getElementById('open-search');
const closeSearchBtn = document.getElementById('close-search');
const clearSearchBtn = document.getElementById('clear-search');
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
const searchStatus = document.getElementById('search-status');
const LS_KEY_QUERY = 'globalSearch:query';
function openSearch() {
if (!searchModal) return;
searchModal.classList.remove('hidden');
setTimeout(() => searchInput && searchInput.focus(), 0);
const lastQuery = localStorage.getItem(LS_KEY_QUERY) || '';
if (searchInput) {
searchInput.value = lastQuery;
if (lastQuery) doSearch(lastQuery);
}
}
function closeSearch() {
if (!searchModal) return;
searchModal.classList.add('hidden');
}
function clearSearch() {
if (!searchInput) return;
searchInput.value = '';
localStorage.removeItem(LS_KEY_QUERY);
renderResults({ query: '', results: [] });
}
let debounceTimer = null;
function debounce(fn, delay) {
return function(...args) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => fn.apply(this, args), delay);
}
}
function escapeHTML(str) {
return str.replace(/[&<>"']/g, s => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[s]));
}
function renderResults(data) {
if (!searchResults || !searchStatus) return;
searchResults.innerHTML = '';
if (!data || !data.results) {
searchStatus.textContent = '';
return;
}
searchStatus.textContent = `${data.results.length} result(s)`;
data.results.forEach(r => {
const item = document.createElement('div');
item.className = 'p-3 bg-slate-700 rounded border border-slate-600';
const title = document.createElement('div');
title.className = 'flex items-center justify-between text-sm text-blue-300 hover:text-blue-200 cursor-pointer';
title.innerHTML = `<span><i class="fas ${r.type === 'md' ? 'fa-file-lines' : 'fa-file'} mr-2"></i>${escapeHTML(r.path)}</span>`;
title.addEventListener('click', () => {
const url = r.path.endsWith('.md') ? `/note/${r.path}` : `/view_text/${r.path}`;
// remember query
if (searchInput) localStorage.setItem(LS_KEY_QUERY, searchInput.value.trim());
window.location.href = url;
});
item.appendChild(title);
(r.snippets || []).forEach(snip => {
const pre = document.createElement('pre');
pre.className = 'mt-2 bg-slate-800 p-2 rounded text-xs whitespace-pre-wrap border border-slate-600';
pre.textContent = `Line ${snip.line}:\n` + snip.preview;
item.appendChild(pre);
});
searchResults.appendChild(item);
});
}
const doSearch = debounce(async function(q) {
const query = (q || '').trim();
if (!query) {
renderResults({ query: '', results: [] });
return;
}
try {
searchStatus.textContent = 'Searching...';
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
if (res.ok) {
localStorage.setItem(LS_KEY_QUERY, query);
renderResults(data);
} else {
searchStatus.textContent = data.error || 'Search failed';
}
} catch (e) {
searchStatus.textContent = 'Search failed';
}
}, 250);
if (openSearchBtn) openSearchBtn.addEventListener('click', openSearch);
if (closeSearchBtn) closeSearchBtn.addEventListener('click', closeSearch);
if (clearSearchBtn) clearSearchBtn.addEventListener('click', clearSearch);
if (searchInput) searchInput.addEventListener('input', (e) => doSearch(e.target.value));
// Close modal on overlay click (but not when clicking inside content)
if (searchModal) {
searchModal.addEventListener('click', (e) => {
if (e.target === searchModal) closeSearch();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) closeSearch();
});
}
});
</script>