search added
This commit is contained in:
@@ -547,3 +547,104 @@ func (h *Handlers) TreeAPIHandler(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, notesTree)
|
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
|
// API routes
|
||||||
s.router.GET("/api/tree", h.TreeAPIHandler)
|
s.router.GET("/api/tree", h.TreeAPIHandler)
|
||||||
|
s.router.GET("/api/search", h.SearchHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setupStaticFiles() {
|
func (s *Server) setupStaticFiles() {
|
||||||
|
|||||||
@@ -265,7 +265,10 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="sidebar-title text-xl font-bold text-white">{{.app_name}}</h1>
|
<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="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">
|
<a href="/settings" class="text-gray-400 hover:text-white transition-colors" title="Settings">
|
||||||
<i class="fas fa-cog"></i>
|
<i class="fas fa-cog"></i>
|
||||||
</a>
|
</a>
|
||||||
@@ -334,6 +337,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Scripts -->
|
||||||
<script>
|
<script>
|
||||||
// Initialize syntax highlighting
|
// Initialize syntax highlighting
|
||||||
@@ -441,6 +464,118 @@
|
|||||||
|
|
||||||
// Expand active path in tree
|
// Expand active path in tree
|
||||||
expandActivePath();
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user