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,
|
reload_index,
|
||||||
index,
|
index,
|
||||||
get_vault_data,
|
get_vault_data,
|
||||||
|
get_vault_names,
|
||||||
find_file_in_index,
|
find_file_in_index,
|
||||||
parse_markdown_file,
|
parse_markdown_file,
|
||||||
_extract_tags,
|
_extract_tags,
|
||||||
@ -120,10 +121,20 @@ class TagsResponse(BaseModel):
|
|||||||
tags: Dict[str, int]
|
tags: Dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
class ReloadResponse(BaseModel):
|
class TreeSearchResult(BaseModel):
|
||||||
"""Index reload confirmation with per-vault stats."""
|
"""A single tree search result item."""
|
||||||
status: str
|
vault: str
|
||||||
vaults: Dict[str, Any]
|
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):
|
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}
|
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)
|
@app.get("/api/index/reload", response_model=ReloadResponse)
|
||||||
async def api_reload():
|
async def api_reload():
|
||||||
"""Force a full re-index of all configured vaults.
|
"""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;
|
const hasText = input.value.length > 0;
|
||||||
clearBtn.style.display = hasText ? "flex" : "none";
|
clearBtn.style.display = hasText ? "flex" : "none";
|
||||||
|
|
||||||
|
const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||||||
|
|
||||||
if (hasText) {
|
if (hasText) {
|
||||||
// Expand all vaults and load their contents for filtering
|
await performTreeSearch(q);
|
||||||
await expandAllVaultsForFiltering();
|
} else {
|
||||||
|
await restoreSidebarTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
|
||||||
filterSidebarTree(q);
|
|
||||||
filterTagCloud(q);
|
filterTagCloud(q);
|
||||||
});
|
});
|
||||||
|
|
||||||
caseBtn.addEventListener("click", async () => {
|
caseBtn.addEventListener("click", async () => {
|
||||||
sidebarFilterCaseSensitive = !sidebarFilterCaseSensitive;
|
sidebarFilterCaseSensitive = !sidebarFilterCaseSensitive;
|
||||||
caseBtn.classList.toggle("active");
|
caseBtn.classList.toggle("active");
|
||||||
if (input.value.trim()) {
|
|
||||||
await expandAllVaultsForFiltering();
|
|
||||||
}
|
|
||||||
const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
const q = sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
|
||||||
filterSidebarTree(q);
|
if (input.value.trim()) {
|
||||||
|
await performTreeSearch(q);
|
||||||
|
}
|
||||||
filterTagCloud(q);
|
filterTagCloud(q);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -736,7 +736,7 @@
|
|||||||
clearBtn.style.display = "none";
|
clearBtn.style.display = "none";
|
||||||
sidebarFilterCaseSensitive = false;
|
sidebarFilterCaseSensitive = false;
|
||||||
caseBtn.classList.remove("active");
|
caseBtn.classList.remove("active");
|
||||||
filterSidebarTree("");
|
restoreSidebarTree();
|
||||||
filterTagCloud("");
|
filterTagCloud("");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -744,15 +744,101 @@
|
|||||||
clearBtn.style.display = "none";
|
clearBtn.style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expandAllVaultsForFiltering() {
|
async function performTreeSearch(query) {
|
||||||
const vaultItems = document.querySelectorAll(".vault-item");
|
if (!query) {
|
||||||
for (const vaultItem of vaultItems) {
|
await restoreSidebarTree();
|
||||||
const vaultName = vaultItem.getAttribute("data-vault");
|
return;
|
||||||
const childContainer = document.getElementById(`vault-children-${vaultName}`);
|
|
||||||
if (childContainer && childContainer.classList.contains("collapsed")) {
|
|
||||||
await toggleVault(vaultItem, vaultName, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function filterSidebarTree(query) {
|
||||||
@ -1644,6 +1730,40 @@
|
|||||||
return s;
|
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() {
|
function showWelcome() {
|
||||||
const area = document.getElementById("content-area");
|
const area = document.getElementById("content-area");
|
||||||
area.innerHTML = `
|
area.innerHTML = `
|
||||||
|
|||||||
@ -804,6 +804,59 @@ select {
|
|||||||
display: none;
|
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 --- */
|
||||||
.tag-resize-handle {
|
.tag-resize-handle {
|
||||||
height: 5px;
|
height: 5px;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user