From 0ff888280a05337045589445ce430526aaca8be4 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 22 Mar 2026 11:29:26 -0400 Subject: [PATCH] Add tag filtering system with configurable pattern-based filters and persistent localStorage settings --- frontend/app.js | 234 +++++++++++++++++++++++++++++++++++++++++--- frontend/index.html | 42 ++++++++ frontend/style.css | 141 ++++++++++++++++++++++++++ 3 files changed, 405 insertions(+), 12 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 54e99f1..f989588 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -427,7 +427,8 @@ async function refreshTagsForContext() { const vaultParam = selectedContextVault === "all" ? "" : `?vault=${encodeURIComponent(selectedContextVault)}`; const data = await api(`/api/tags${vaultParam}`); - renderTagCloud(data.tags); + const filteredTags = TagFilterService.filterTags(data.tags); + renderTagCloud(filteredTags); } // --------------------------------------------------------------------------- @@ -673,12 +674,73 @@ }); } + // --------------------------------------------------------------------------- + // Tag Filter Service + // --------------------------------------------------------------------------- + const TagFilterService = { + defaultFilters: [ + { pattern: "#<% ... %>", regex: "^#<%.*%>$", enabled: true }, + { pattern: "#{{ ... }}", regex: "^#\\{\\{.*\\}\\}$", enabled: true } + ], + + getConfig() { + const stored = localStorage.getItem("obsigate-tag-filters"); + if (stored) { + try { + return JSON.parse(stored); + } catch (e) { + return { tagFilters: this.defaultFilters }; + } + } + return { tagFilters: this.defaultFilters }; + }, + + saveConfig(config) { + localStorage.setItem("obsigate-tag-filters", JSON.stringify(config)); + }, + + patternToRegex(pattern) { + let regex = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); + regex = regex.replace(/\\\.\\\.\\\./g, '.*'); + return regex; + }, + + isTagFiltered(tag) { + const config = this.getConfig(); + const filters = config.tagFilters || this.defaultFilters; + + for (const filter of filters) { + if (!filter.enabled) continue; + try { + const regex = new RegExp(`^${filter.regex}$`); + if (regex.test(`#${tag}`)) { + return true; + } + } catch (e) { + console.warn("Invalid regex:", filter.regex, e); + } + } + return false; + }, + + filterTags(tags) { + const filtered = {}; + Object.entries(tags).forEach(([tag, count]) => { + if (!this.isTagFiltered(tag)) { + filtered[tag] = count; + } + }); + return filtered; + } + }; + // --------------------------------------------------------------------------- // Tags // --------------------------------------------------------------------------- async function loadTags() { const data = await api("/api/tags"); - renderTagCloud(data.tags); + const filteredTags = TagFilterService.filterTags(data.tags); + renderTagCloud(filteredTags); } function renderTagCloud(tags) { @@ -828,9 +890,11 @@ // Tags const tagsDiv = el("div", { class: "file-tags" }); (data.tags || []).forEach((tag) => { - const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); - t.addEventListener("click", () => searchByTag(tag)); - tagsDiv.appendChild(t); + if (!TagFilterService.isTagFiltered(tag)) { + const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); + t.addEventListener("click", () => searchByTag(tag)); + tagsDiv.appendChild(t); + } }); // Action buttons @@ -1026,6 +1090,147 @@ if (modal) modal.classList.remove("active"); } + function initConfigModal() { + const openBtn = document.getElementById("config-open-btn"); + const closeBtn = document.getElementById("config-close"); + const modal = document.getElementById("config-modal"); + const addBtn = document.getElementById("config-add-btn"); + const patternInput = document.getElementById("config-pattern-input"); + + if (!openBtn || !closeBtn || !modal) return; + + openBtn.addEventListener("click", () => { + modal.classList.add("active"); + closeHeaderMenu(); + renderConfigFilters(); + safeCreateIcons(); + }); + + closeBtn.addEventListener("click", closeConfigModal); + modal.addEventListener("click", (e) => { + if (e.target === modal) { + closeConfigModal(); + } + }); + + addBtn.addEventListener("click", addConfigFilter); + patternInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + addConfigFilter(); + } + }); + + patternInput.addEventListener("input", updateRegexPreview); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && modal.classList.contains("active")) { + closeConfigModal(); + } + }); + } + + function closeConfigModal() { + const modal = document.getElementById("config-modal"); + if (modal) modal.classList.remove("active"); + } + + function renderConfigFilters() { + const config = TagFilterService.getConfig(); + const filters = config.tagFilters || TagFilterService.defaultFilters; + const container = document.getElementById("config-filters-list"); + + container.innerHTML = ""; + + filters.forEach((filter, index) => { + const badge = el("div", { class: `config-filter-badge ${!filter.enabled ? "disabled" : ""}` }, [ + el("span", {}, [document.createTextNode(filter.pattern)]), + el("button", { + class: "config-filter-toggle", + title: filter.enabled ? "Désactiver" : "Activer", + type: "button" + }, [document.createTextNode(filter.enabled ? "✓" : "○")]), + el("button", { + class: "config-filter-remove", + title: "Supprimer", + type: "button" + }, [document.createTextNode("×")]), + ]); + + const toggleBtn = badge.querySelector(".config-filter-toggle"); + const removeBtn = badge.querySelector(".config-filter-remove"); + + toggleBtn.addEventListener("click", (e) => { + e.stopPropagation(); + toggleConfigFilter(index); + }); + + removeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + removeConfigFilter(index); + }); + + container.appendChild(badge); + }); + } + + function toggleConfigFilter(index) { + const config = TagFilterService.getConfig(); + const filters = config.tagFilters || TagFilterService.defaultFilters; + if (filters[index]) { + filters[index].enabled = !filters[index].enabled; + config.tagFilters = filters; + TagFilterService.saveConfig(config); + renderConfigFilters(); + refreshTagsForContext().catch(err => console.error("Error refreshing tags:", err)); + } + } + + function removeConfigFilter(index) { + const config = TagFilterService.getConfig(); + let filters = config.tagFilters || TagFilterService.defaultFilters; + filters = filters.filter((_, i) => i !== index); + config.tagFilters = filters; + TagFilterService.saveConfig(config); + renderConfigFilters(); + refreshTagsForContext().catch(err => console.error("Error refreshing tags:", err)); + } + + function addConfigFilter() { + const input = document.getElementById("config-pattern-input"); + const pattern = input.value.trim(); + + if (!pattern) return; + + const regex = TagFilterService.patternToRegex(pattern); + const config = TagFilterService.getConfig(); + const filters = config.tagFilters || TagFilterService.defaultFilters; + + const newFilter = { pattern, regex, enabled: true }; + filters.push(newFilter); + config.tagFilters = filters; + TagFilterService.saveConfig(config); + + input.value = ""; + renderConfigFilters(); + refreshTagsForContext().catch(err => console.error("Error refreshing tags:", err)); + updateRegexPreview(); + } + + function updateRegexPreview() { + const input = document.getElementById("config-pattern-input"); + const preview = document.getElementById("config-regex-preview"); + const code = document.getElementById("config-regex-code"); + const pattern = input.value.trim(); + + if (pattern) { + const regex = TagFilterService.patternToRegex(pattern); + code.textContent = `^${regex}$`; + preview.style.display = "block"; + } else { + preview.style.display = "none"; + } + } + // --------------------------------------------------------------------------- // Search // --------------------------------------------------------------------------- @@ -1081,14 +1286,18 @@ if (r.tags && r.tags.length > 0) { const tagsDiv = el("div", { class: "search-result-tags" }); r.tags.forEach((tag) => { - const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); - tagEl.addEventListener("click", (e) => { - e.stopPropagation(); - addTagFilter(tag); - }); - tagsDiv.appendChild(tagEl); + if (!TagFilterService.isTagFiltered(tag)) { + const tagEl = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); + tagEl.addEventListener("click", (e) => { + e.stopPropagation(); + addTagFilter(tag); + }); + tagsDiv.appendChild(tagEl); + } }); - item.appendChild(tagsDiv); + if (tagsDiv.children.length > 0) { + item.appendChild(tagsDiv); + } } item.addEventListener("click", () => openFile(r.vault, r.path)); @@ -1473,6 +1682,7 @@ initVaultContext(); initCollapsiblePanels(); initHelpModal(); + initConfigModal(); initSidebarFilter(); initSidebarResize(); initTagResize(); diff --git a/frontend/index.html b/frontend/index.html index 8b11d8d..335bb8f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -116,6 +116,15 @@ Clair + + + +
+
+
+

Filtrage de tags

+

Définissez les patterns de tags à masquer dans la sidebar. Vous pouvez utiliser des wildcards pour cibler les tags de template.

+ +
+ +
+ + +
+ + +
+
+
+ + +
diff --git a/frontend/style.css b/frontend/style.css index db5c08b..5680646 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -1810,3 +1810,144 @@ body.resizing-v { flex-wrap: wrap; } } + +/* --- Configuration Modal --- */ +.config-content { + max-width: 720px; + margin: 0 auto; + padding: 28px 24px 40px; +} + +.config-section { + margin-bottom: 28px; +} + +.config-section h2 { + font-family: 'JetBrains Mono', monospace; + font-size: 1.05rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.config-description { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 16px; + line-height: 1.5; +} + +.config-filters-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; + min-height: 32px; +} + +.config-filter-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--tag-bg); + color: var(--tag-text); + border-radius: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; + border: 1px solid color-mix(in srgb, var(--tag-text) 30%, transparent); +} + +.config-filter-badge.disabled { + opacity: 0.5; +} + +.config-filter-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + padding: 0; + font-size: 1.1rem; + line-height: 1; +} + +.config-filter-remove:hover { + opacity: 0.7; +} + +.config-filter-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + padding: 0; + font-size: 0.9rem; +} + +.config-add-pattern { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.config-input { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-secondary); + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; + outline: none; + transition: border-color 200ms ease; +} + +.config-input:focus { + border-color: var(--accent); +} + +.config-btn-add { + padding: 8px 16px; + border: 1px solid var(--accent); + border-radius: 6px; + background: var(--accent); + color: #ffffff; + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: opacity 150ms ease; +} + +.config-btn-add:hover { + opacity: 0.9; +} + +.config-regex-preview { + padding: 8px 12px; + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.config-regex-preview code { + font-family: 'JetBrains Mono', monospace; + color: var(--accent); + background: transparent; + border: none; + padding: 0; +}