search added
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user