Implement backend tree search API with frontend integration for real-time sidebar filtering with highlighted matches and vault-scoped results
This commit is contained in:
parent
29e6e1c052
commit
9cd0090117
@ -17,6 +17,7 @@ from backend.indexer import (
|
||||
reload_index,
|
||||
index,
|
||||
get_vault_data,
|
||||
get_vault_names,
|
||||
find_file_in_index,
|
||||
parse_markdown_file,
|
||||
_extract_tags,
|
||||
@ -120,10 +121,20 @@ class TagsResponse(BaseModel):
|
||||
tags: Dict[str, int]
|
||||
|
||||
|
||||
class ReloadResponse(BaseModel):
|
||||
"""Index reload confirmation with per-vault stats."""
|
||||
status: str
|
||||
vaults: Dict[str, Any]
|
||||
class TreeSearchResult(BaseModel):
|
||||
"""A single tree search result item."""
|
||||
vault: str
|
||||
path: str
|
||||
name: str
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
matched_path: str
|
||||
|
||||
|
||||
class TreeSearchResponse(BaseModel):
|
||||
"""Tree search response with matching paths."""
|
||||
query: str
|
||||
vault_filter: str
|
||||
results: List[TreeSearchResult]
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
@ -588,6 +599,74 @@ async def api_tags(vault: Optional[str] = Query(None, description="Vault filter"
|
||||
return {"vault_filter": vault, "tags": tags}
|
||||
|
||||
|
||||
@app.get("/api/tree-search", response_model=TreeSearchResponse)
|
||||
async def api_tree_search(
|
||||
q: str = Query("", description="Search query"),
|
||||
vault: str = Query("all", description="Vault filter"),
|
||||
):
|
||||
"""Search for files and directories in the tree structure.
|
||||
|
||||
Searches through the file index for matching paths, returning
|
||||
both files and their parent directories that match the query.
|
||||
|
||||
Args:
|
||||
q: Search string to match against file/directory paths.
|
||||
vault: Vault name or "all" to search everywhere.
|
||||
|
||||
Returns:
|
||||
``TreeSearchResponse`` with matching paths and their parent directories.
|
||||
"""
|
||||
if not q:
|
||||
return {"query": q, "vault_filter": vault, "results": []}
|
||||
|
||||
query_lower = q.lower()
|
||||
results = []
|
||||
seen_paths = set() # Avoid duplicates
|
||||
|
||||
vaults_to_search = [vault] if vault != "all" else list(index.keys())
|
||||
|
||||
for vault_name in vaults_to_search:
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
continue
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
if not vault_root.exists():
|
||||
continue
|
||||
|
||||
for fpath in vault_root.rglob("*"):
|
||||
if fpath.name.startswith("."):
|
||||
continue
|
||||
|
||||
try:
|
||||
rel_path = str(fpath.relative_to(vault_root)).replace("\\", "/")
|
||||
path_lower = rel_path.lower()
|
||||
name_lower = fpath.name.lower()
|
||||
|
||||
if query_lower not in name_lower and query_lower not in path_lower:
|
||||
continue
|
||||
|
||||
entry_type = "directory" if fpath.is_dir() else "file"
|
||||
entry_key = f"{vault_name}:{entry_type}:{rel_path}"
|
||||
if entry_key in seen_paths:
|
||||
continue
|
||||
|
||||
seen_paths.add(entry_key)
|
||||
results.append({
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"name": fpath.name,
|
||||
"type": entry_type,
|
||||
"matched_path": rel_path,
|
||||
})
|
||||
except PermissionError:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {"query": q, "vault_filter": vault, "results": results}
|
||||
|
||||
|
||||
@app.get("/api/index/reload", response_model=ReloadResponse)
|
||||
async def api_reload():
|
||||
"""Force a full re-index of all configured vaults.
|
||||
|
||||
154
frontend/app.js
154
frontend/app.js
@ -710,24 +710,24 @@
|
||||
const hasText = input.value.length > 0;
|
||||
clearBtn.style.display = hasText ? "flex" : "none";
|
||||
|
||||
const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||||
|
||||
if (hasText) {
|
||||
// Expand all vaults and load their contents for filtering
|
||||
await expandAllVaultsForFiltering();
|
||||
await performTreeSearch(q);
|
||||
} else {
|
||||
await restoreSidebarTree();
|
||||
}
|
||||
|
||||
const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||||
filterSidebarTree(q);
|
||||
filterTagCloud(q);
|
||||
});
|
||||
|
||||
caseBtn.addEventListener("click", async () => {
|
||||
sidebarFilterCaseSensitive = !sidebarFilterCaseSensitive;
|
||||
caseBtn.classList.toggle("active");
|
||||
if (input.value.trim()) {
|
||||
await expandAllVaultsForFiltering();
|
||||
}
|
||||
const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||||
filterSidebarTree(q);
|
||||
if (input.value.trim()) {
|
||||
await performTreeSearch(q);
|
||||
}
|
||||
filterTagCloud(q);
|
||||
});
|
||||
|
||||
@ -736,7 +736,7 @@
|
||||
clearBtn.style.display = "none";
|
||||
sidebarFilterCaseSensitive = false;
|
||||
caseBtn.classList.remove("active");
|
||||
filterSidebarTree("");
|
||||
restoreSidebarTree();
|
||||
filterTagCloud("");
|
||||
});
|
||||
|
||||
@ -744,15 +744,101 @@
|
||||
clearBtn.style.display = "none";
|
||||
}
|
||||
|
||||
async function expandAllVaultsForFiltering() {
|
||||
const vaultItems = document.querySelectorAll(".vault-item");
|
||||
for (const vaultItem of vaultItems) {
|
||||
const vaultName = vaultItem.getAttribute("data-vault");
|
||||
const childContainer = document.getElementById(`vault-children-${vaultName}`);
|
||||
if (childContainer && childContainer.classList.contains("collapsed")) {
|
||||
await toggleVault(vaultItem, vaultName, true);
|
||||
}
|
||||
async function performTreeSearch(query) {
|
||||
if (!query) {
|
||||
await restoreSidebarTree();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const vaultParam = selectedContextVault === "all" ? "all" : selectedContextVault;
|
||||
const url = `/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vaultParam)}`;
|
||||
const data = await api(url);
|
||||
renderFilteredSidebarResults(query, data.results);
|
||||
} catch (err) {
|
||||
console.error("Tree search error:", err);
|
||||
renderFilteredSidebarResults(query, []);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreSidebarTree() {
|
||||
await refreshSidebarForContext();
|
||||
if (currentVault) {
|
||||
focusPathInSidebar(currentVault, currentPath || "", { alignToTop: false }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function renderFilteredSidebarResults(query, results) {
|
||||
const container = document.getElementById("vault-tree");
|
||||
container.innerHTML = "";
|
||||
|
||||
const grouped = new Map();
|
||||
results.forEach((result) => {
|
||||
if (!grouped.has(result.vault)) {
|
||||
grouped.set(result.vault, []);
|
||||
}
|
||||
grouped.get(result.vault).push(result);
|
||||
});
|
||||
|
||||
if (grouped.size === 0) {
|
||||
container.appendChild(el("div", { class: "sidebar-filter-empty" }, [
|
||||
document.createTextNode("Aucun répertoire ou fichier correspondant."),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
grouped.forEach((entries, vaultName) => {
|
||||
entries.sort((a, b) => a.path.localeCompare(b.path, undefined, { sensitivity: "base" }));
|
||||
|
||||
const vaultHeader = el("div", { class: "tree-item vault-item filter-results-header", "data-vault": vaultName }, [
|
||||
icon("database", 16),
|
||||
document.createTextNode(` ${vaultName} `),
|
||||
smallBadge(entries.length),
|
||||
]);
|
||||
container.appendChild(vaultHeader);
|
||||
|
||||
const resultsWrapper = el("div", { class: "filter-results-group" });
|
||||
entries.forEach((entry) => {
|
||||
const resultItem = el("div", {
|
||||
class: `tree-item filter-result-item filter-result-${entry.type}`,
|
||||
"data-vault": entry.vault,
|
||||
"data-path": entry.path,
|
||||
"data-type": entry.type,
|
||||
}, [
|
||||
icon(entry.type === "directory" ? "folder" : getFileIcon(entry.name), 16),
|
||||
]);
|
||||
|
||||
const textWrap = el("div", { class: "filter-result-text" });
|
||||
const primary = el("div", { class: "filter-result-primary" });
|
||||
appendHighlightedText(primary, entry.name, query, sidebarFilterCaseSensitive);
|
||||
const secondary = el("div", { class: "filter-result-secondary" });
|
||||
appendHighlightedText(secondary, entry.path, query, sidebarFilterCaseSensitive);
|
||||
textWrap.appendChild(primary);
|
||||
textWrap.appendChild(secondary);
|
||||
resultItem.appendChild(textWrap);
|
||||
|
||||
resultItem.addEventListener("click", async () => {
|
||||
const input = document.getElementById("sidebar-filter-input");
|
||||
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
|
||||
if (input) input.value = "";
|
||||
if (clearBtn) clearBtn.style.display = "none";
|
||||
await restoreSidebarTree();
|
||||
if (entry.type === "directory") {
|
||||
await focusPathInSidebar(entry.vault, entry.path, { alignToTop: true });
|
||||
} else {
|
||||
await openFile(entry.vault, entry.path);
|
||||
await focusPathInSidebar(entry.vault, entry.path, { alignToTop: false });
|
||||
}
|
||||
closeMobileSidebar();
|
||||
});
|
||||
|
||||
resultsWrapper.appendChild(resultItem);
|
||||
});
|
||||
|
||||
container.appendChild(resultsWrapper);
|
||||
});
|
||||
|
||||
flushIcons();
|
||||
}
|
||||
|
||||
function filterSidebarTree(query) {
|
||||
@ -1644,6 +1730,40 @@
|
||||
return s;
|
||||
}
|
||||
|
||||
function appendHighlightedText(container, text, query, caseSensitive) {
|
||||
container.textContent = "";
|
||||
if (!query) {
|
||||
container.appendChild(document.createTextNode(text));
|
||||
return;
|
||||
}
|
||||
|
||||
const source = caseSensitive ? text : text.toLowerCase();
|
||||
const needle = caseSensitive ? query : query.toLowerCase();
|
||||
let start = 0;
|
||||
let index = source.indexOf(needle, start);
|
||||
|
||||
if (index === -1) {
|
||||
container.appendChild(document.createTextNode(text));
|
||||
return;
|
||||
}
|
||||
|
||||
while (index !== -1) {
|
||||
if (index > start) {
|
||||
container.appendChild(document.createTextNode(text.slice(start, index)));
|
||||
}
|
||||
const mark = el("mark", { class: "filter-highlight" }, [
|
||||
document.createTextNode(text.slice(index, index + query.length)),
|
||||
]);
|
||||
container.appendChild(mark);
|
||||
start = index + query.length;
|
||||
index = source.indexOf(needle, start);
|
||||
}
|
||||
|
||||
if (start < text.length) {
|
||||
container.appendChild(document.createTextNode(text.slice(start)));
|
||||
}
|
||||
}
|
||||
|
||||
function showWelcome() {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = `
|
||||
|
||||
@ -804,6 +804,59 @@ select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-results-header {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.filter-results-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 4px 0 10px 12px;
|
||||
}
|
||||
|
||||
.filter-result-item {
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-result-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.filter-result-primary,
|
||||
.filter-result-secondary {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.filter-result-primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-result-secondary {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.sidebar-filter-empty {
|
||||
padding: 12px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.filter-highlight {
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
color: var(--accent);
|
||||
padding: 0 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* --- Tag resize handle --- */
|
||||
.tag-resize-handle {
|
||||
height: 5px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user