Add file deletion endpoint with multi-tag filtering support and redesigned editor UI with icon-only buttons

This commit is contained in:
Bruno Charest 2026-03-21 22:42:55 -04:00
parent eba5af2fe0
commit b73aa19c51
5 changed files with 196 additions and 113 deletions

View File

@ -231,6 +231,35 @@ async def api_file_save(vault_name: str, path: str = Query(..., description="Rel
raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}") raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}")
@app.delete("/api/file/{vault_name}")
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file")):
"""Delete a file."""
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = vault_root / path
try:
file_path.resolve().relative_to(vault_root.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
try:
file_path.unlink()
logger.info(f"File deleted: {vault_name}/{path}")
return {"status": "ok", "vault": vault_name, "path": path}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
except Exception as e:
logger.error(f"Error deleting file {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}")
@app.get("/api/file/{vault_name}") @app.get("/api/file/{vault_name}")
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file")): async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file")):
"""Return rendered HTML + metadata for a file.""" """Return rendered HTML + metadata for a file."""

View File

@ -8,6 +8,12 @@ from backend.indexer import index, get_vault_data
logger = logging.getLogger("obsigate.search") logger = logging.getLogger("obsigate.search")
def _normalize_tag_filter(tag_filter: Optional[str]) -> List[str]:
if not tag_filter:
return []
return [tag.strip().lstrip("#") for tag in tag_filter.split(",") if tag.strip()]
def _read_file_content(vault_name: str, file_path: str) -> str: def _read_file_content(vault_name: str, file_path: str) -> str:
"""Read raw markdown content of a file from disk.""" """Read raw markdown content of a file from disk."""
vault_data = get_vault_data(vault_name) vault_data = get_vault_data(vault_name)
@ -52,8 +58,9 @@ def search(
""" """
query = query.strip() if query else "" query = query.strip() if query else ""
has_query = len(query) > 0 has_query = len(query) > 0
selected_tags = _normalize_tag_filter(tag_filter)
if not has_query and not tag_filter: if not has_query and not selected_tags:
return [] return []
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
@ -63,7 +70,7 @@ def search(
continue continue
for file_info in vault_data["files"]: for file_info in vault_data["files"]:
if tag_filter and tag_filter not in file_info["tags"]: if selected_tags and not all(tag in file_info["tags"] for tag in selected_tags):
continue continue
score = 0 score = 0

View File

@ -429,47 +429,70 @@
function addTagFilter(tag) { function addTagFilter(tag) {
if (!selectedTags.includes(tag)) { if (!selectedTags.includes(tag)) {
selectedTags.push(tag); selectedTags.push(tag);
renderActiveTags();
performTagSearch(); performTagSearch();
} }
} }
function removeTagFilter(tag) { function removeTagFilter(tag) {
selectedTags = selectedTags.filter(t => t !== tag); selectedTags = selectedTags.filter(t => t !== tag);
renderActiveTags();
if (selectedTags.length > 0) { if (selectedTags.length > 0) {
performTagSearch(); performTagSearch();
} else { } else {
const input = document.getElementById("search-input"); const input = document.getElementById("search-input");
if (!input.value.trim()) { if (input.value.trim()) {
performSearch(input.value.trim(), document.getElementById("vault-filter").value, null);
} else {
showWelcome(); showWelcome();
} }
} }
} }
function renderActiveTags() {
const container = document.getElementById("active-tags");
if (!container) return;
container.innerHTML = "";
selectedTags.forEach(tag => {
const tagEl = el("div", { class: "active-tag" }, [
document.createTextNode(`#${tag}`),
el("span", { class: "remove-icon" }, [icon("x", 14)])
]);
tagEl.addEventListener("click", () => removeTagFilter(tag));
container.appendChild(tagEl);
});
safeCreateIcons();
}
function performTagSearch() { function performTagSearch() {
const input = document.getElementById("search-input"); const input = document.getElementById("search-input");
const query = input.value.trim(); const query = input.value.trim();
const vault = document.getElementById("vault-filter").value; const vault = document.getElementById("vault-filter").value;
performSearch(query, vault, selectedTags.join(",")); performSearch(query, vault, selectedTags.length > 0 ? selectedTags.join(",") : null);
}
function buildSearchResultsHeader(data, query, tagFilter) {
const header = el("div", { class: "search-results-header" });
const summaryText = el("span", { class: "search-results-summary-text" });
if (query && tagFilter) {
summaryText.textContent = `${data.count} résultat(s) pour "${query}" avec les tags`;
} else if (query) {
summaryText.textContent = `${data.count} résultat(s) pour "${query}"`;
} else if (tagFilter) {
summaryText.textContent = `${data.count} fichier(s) avec les tags`;
} else {
summaryText.textContent = `${data.count} résultat(s)`;
}
header.appendChild(summaryText);
if (selectedTags.length > 0) {
const activeTags = el("div", { class: "search-results-active-tags" });
selectedTags.forEach((tag) => {
const removeBtn = el("button", {
class: "search-results-active-tag-remove",
title: `Retirer ${tag} du filtre`,
"aria-label": `Retirer ${tag} du filtre`
}, [document.createTextNode("×")]);
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
removeTagFilter(tag);
});
const chip = el("span", { class: "search-results-active-tag" }, [
document.createTextNode(`#${tag}`),
removeBtn,
]);
activeTags.appendChild(chip);
});
header.appendChild(activeTags);
}
return header;
} }
function searchByTag(tag) { function searchByTag(tag) {
@ -679,16 +702,7 @@
const area = document.getElementById("content-area"); const area = document.getElementById("content-area");
area.innerHTML = ""; area.innerHTML = "";
const header = el("div", { class: "search-results-header" }); const header = buildSearchResultsHeader(data, query, tagFilter);
if (query && tagFilter) {
const tags = tagFilter.split(',').map(t => `#${t}`).join(', ');
header.textContent = `${data.count} résultat(s) pour "${query}" avec les tags ${tags}`;
} else if (query) {
header.textContent = `${data.count} résultat(s) pour "${query}"`;
} else if (tagFilter) {
const tags = tagFilter.split(',').map(t => `#${t}`).join(', ');
header.textContent = `${data.count} fichier(s) avec les tags ${tags}`;
}
area.appendChild(header); area.appendChild(header);
if (data.results.length === 0) { if (data.results.length === 0) {
@ -982,7 +996,7 @@
try { try {
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.innerHTML = '<i data-lucide="loader" style="width:14px;height:14px"></i><span class="editor-btn-text">Sauvegarde...</span>'; saveBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
safeCreateIcons(); safeCreateIcons();
const response = await fetch( const response = await fetch(
@ -999,7 +1013,7 @@
throw new Error(error.detail || "Erreur de sauvegarde"); throw new Error(error.detail || "Erreur de sauvegarde");
} }
saveBtn.innerHTML = '<i data-lucide="check" style="width:14px;height:14px"></i><span class="editor-btn-text">Sauvegardé !</span>'; saveBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px"></i>';
safeCreateIcons(); safeCreateIcons();
setTimeout(() => { setTimeout(() => {
@ -1017,12 +1031,48 @@
} }
} }
async function deleteFile() {
if (!editorVault || !editorPath) return;
const deleteBtn = document.getElementById("editor-delete");
const originalHTML = deleteBtn.innerHTML;
try {
deleteBtn.disabled = true;
deleteBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
safeCreateIcons();
const response = await fetch(
`/api/file/${encodeURIComponent(editorVault)}?path=${encodeURIComponent(editorPath)}`,
{ method: "DELETE" }
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Erreur de suppression");
}
closeEditor();
showWelcome();
await refreshSidebarForContext();
await refreshTagsForContext();
} catch (err) {
console.error("Delete error:", err);
alert(`Erreur: ${err.message}`);
deleteBtn.innerHTML = originalHTML;
deleteBtn.disabled = false;
safeCreateIcons();
}
}
function initEditor() { function initEditor() {
const cancelBtn = document.getElementById("editor-cancel"); const cancelBtn = document.getElementById("editor-cancel");
const deleteBtn = document.getElementById("editor-delete");
const saveBtn = document.getElementById("editor-save"); const saveBtn = document.getElementById("editor-save");
const modal = document.getElementById("editor-modal"); const modal = document.getElementById("editor-modal");
cancelBtn.addEventListener("click", closeEditor); cancelBtn.addEventListener("click", closeEditor);
deleteBtn.addEventListener("click", deleteFile);
saveBtn.addEventListener("click", saveFile); saveBtn.addEventListener("click", saveFile);
// Close on overlay click // Close on overlay click

View File

@ -80,7 +80,6 @@
<i data-lucide="search" class="search-icon" style="width:16px;height:16px"></i> <i data-lucide="search" class="search-icon" style="width:16px;height:16px"></i>
<input type="text" id="search-input" placeholder="Recherche..." autocomplete="off"> <input type="text" id="search-input" placeholder="Recherche..." autocomplete="off">
</div> </div>
<div class="active-tags" id="active-tags"></div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -157,13 +156,14 @@
<div class="editor-header"> <div class="editor-header">
<div class="editor-title" id="editor-title">Édition</div> <div class="editor-title" id="editor-title">Édition</div>
<div class="editor-actions"> <div class="editor-actions">
<button class="editor-btn" id="editor-cancel"> <button class="editor-btn" id="editor-cancel" title="Annuler" aria-label="Annuler">
<i data-lucide="x" style="width:14px;height:14px"></i> <i data-lucide="rotate-ccw" style="width:16px;height:16px"></i>
<span class="editor-btn-text">Annuler</span>
</button> </button>
<button class="editor-btn primary" id="editor-save"> <button class="editor-btn danger" id="editor-delete" title="Supprimer" aria-label="Supprimer">
<i data-lucide="save" style="width:14px;height:14px"></i> <i data-lucide="trash-2" style="width:16px;height:16px"></i>
<span class="editor-btn-text">Sauvegarder</span> </button>
<button class="editor-btn primary" id="editor-save" title="Sauvegarder" aria-label="Sauvegarder">
<i data-lucide="check" style="width:16px;height:16px"></i>
</button> </button>
</div> </div>
</div> </div>

View File

@ -177,38 +177,6 @@ a:hover {
pointer-events: none; pointer-events: none;
} }
.active-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
min-height: 0;
}
.active-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: var(--tag-bg);
color: var(--tag-text);
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
cursor: pointer;
transition: opacity 150ms ease;
}
.active-tag:hover {
opacity: 0.7;
}
.active-tag .remove-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
}
.header-menu { .header-menu {
position: relative; position: relative;
} }
@ -832,8 +800,44 @@ a:hover {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 16px; margin-bottom: 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.search-results-summary-text {
display: inline;
}
.search-results-active-tags {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.search-results-active-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: var(--tag-bg);
color: var(--tag-text);
border-radius: 999px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
line-height: 1.2;
}
.search-results-active-tag-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0;
} }
.search-result-item { .search-result-item {
padding: 14px 16px; padding: 14px 16px;
border: 1px solid var(--border); border: 1px solid var(--border);
@ -846,7 +850,6 @@ a:hover {
background: var(--bg-hover); background: var(--bg-hover);
border-color: var(--accent); border-color: var(--accent);
} }
.search-result-title { .search-result-title {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 0.92rem; font-size: 0.92rem;
@ -854,20 +857,17 @@ a:hover {
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 4px; margin-bottom: 4px;
} }
.search-result-vault { .search-result-vault {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 0.72rem; font-size: 0.72rem;
color: var(--accent-green); color: var(--accent-green);
margin-bottom: 4px; margin-bottom: 4px;
} }
.search-result-snippet { .search-result-snippet {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.5; line-height: 1.5;
} }
.search-result-tags { .search-result-tags {
margin-top: 6px; margin-top: 6px;
display: flex; display: flex;
@ -918,7 +918,6 @@ a:hover {
.editor-modal.active { .editor-modal.active {
display: flex; display: flex;
} }
.editor-container { .editor-container {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border); border: 1px solid var(--border);
@ -931,7 +930,6 @@ a:hover {
overflow: hidden; overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.4); box-shadow: 0 8px 32px rgba(0,0,0,0.4);
} }
.editor-header { .editor-header {
display: flex; display: flex;
align-items: center; align-items: center;
@ -940,24 +938,24 @@ a:hover {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg-primary); background: var(--bg-primary);
flex-shrink: 0; flex-shrink: 0;
position: sticky;
top: 0;
z-index: 10;
} }
.editor-title { .editor-title {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-primary); color: var(--text-primary);
font-weight: 600; font-weight: 600;
} }
.editor-actions { .editor-actions {
display: flex; display: flex;
gap: 8px; gap: 6px;
} }
.editor-btn { .editor-btn {
font-family: 'JetBrains Mono', monospace; width: 36px;
font-size: 0.8rem; height: 36px;
padding: 6px 12px; padding: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
@ -966,7 +964,8 @@ a:hover {
transition: all 150ms ease; transition: all 150ms ease;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; justify-content: center;
flex: 0 0 auto;
} }
.editor-btn:hover { .editor-btn:hover {
color: var(--accent); color: var(--accent);
@ -980,7 +979,10 @@ a:hover {
.editor-btn.primary:hover { .editor-btn.primary:hover {
opacity: 0.9; opacity: 0.9;
} }
.editor-btn.danger:hover {
color: #ff7b72;
border-color: #ff7b72;
}
.editor-body { .editor-body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@ -988,13 +990,11 @@ a:hover {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }
.cm-editor { .cm-editor {
height: auto; height: auto;
min-height: 100%; min-height: 100%;
font-size: 0.9rem; font-size: 0.9rem;
} }
.cm-scroller { .cm-scroller {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
overflow-y: auto !important; overflow-y: auto !important;
@ -1003,7 +1003,6 @@ a:hover {
min-height: 100%; min-height: 100%;
max-width: 100%; max-width: 100%;
} }
.fallback-editor { .fallback-editor {
width: 100%; width: 100%;
min-height: 100%; min-height: 100%;
@ -1028,60 +1027,58 @@ a:hover {
@media (max-width: 768px) { @media (max-width: 768px) {
.editor-modal { .editor-modal {
padding: 0; padding: 0;
align-items: stretch;
} }
.editor-container { .editor-container {
max-width: 100%; max-width: 100%;
max-height: 100vh; max-height: 100vh;
border-radius: 0; border-radius: 0;
height: 100vh; height: 100vh;
overflow: hidden;
} }
.editor-header { .editor-header {
padding: 10px 12px; padding: 10px 12px;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 20;
background: var(--bg-primary);
} }
.editor-title { .editor-title {
font-size: 0.85rem; font-size: 0.85rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.editor-actions { .editor-actions {
gap: 6px; gap: 4px;
} }
.editor-btn { .editor-btn {
font-size: 0.75rem; width: 36px;
padding: 8px; height: 36px;
min-width: 36px; min-width: 36px;
} }
.editor-btn-text {
display: none;
}
.editor-body { .editor-body {
height: calc(100vh - 50px); flex: 1;
min-height: 0;
overflow: auto; overflow: auto;
} }
.cm-editor { .cm-editor {
height: auto; height: auto;
min-height: 100%; min-height: 100%;
} }
.cm-scroller { .cm-scroller {
overflow: auto !important; overflow: auto !important;
height: auto; height: auto;
min-height: 100%; min-height: 100%;
} }
.fallback-editor { .fallback-editor {
min-height: calc(100vh - 50px); min-height: 100%;
height: auto; height: auto;
} }
.search-results-header {
align-items: flex-start;
}
} }
/* --- No-select during resize --- */ /* --- No-select during resize --- */