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)}")
@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}")
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to 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")
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:
"""Read raw markdown content of a file from disk."""
vault_data = get_vault_data(vault_name)
@ -52,8 +58,9 @@ def search(
"""
query = query.strip() if query else ""
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 []
results: List[Dict[str, Any]] = []
@ -63,7 +70,7 @@ def search(
continue
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
score = 0

View File

@ -429,47 +429,70 @@
function addTagFilter(tag) {
if (!selectedTags.includes(tag)) {
selectedTags.push(tag);
renderActiveTags();
performTagSearch();
}
}
function removeTagFilter(tag) {
selectedTags = selectedTags.filter(t => t !== tag);
renderActiveTags();
if (selectedTags.length > 0) {
performTagSearch();
} else {
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();
}
}
}
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() {
const input = document.getElementById("search-input");
const query = input.value.trim();
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) {
@ -679,16 +702,7 @@
const area = document.getElementById("content-area");
area.innerHTML = "";
const header = el("div", { class: "search-results-header" });
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}`;
}
const header = buildSearchResultsHeader(data, query, tagFilter);
area.appendChild(header);
if (data.results.length === 0) {
@ -982,7 +996,7 @@
try {
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();
const response = await fetch(
@ -999,7 +1013,7 @@
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();
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() {
const cancelBtn = document.getElementById("editor-cancel");
const deleteBtn = document.getElementById("editor-delete");
const saveBtn = document.getElementById("editor-save");
const modal = document.getElementById("editor-modal");
cancelBtn.addEventListener("click", closeEditor);
deleteBtn.addEventListener("click", deleteFile);
saveBtn.addEventListener("click", saveFile);
// Close on overlay click

View File

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

View File

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