From 370420aa00d6619ae5952f169bf9cb0e660abb73 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 25 May 2026 20:21:42 -0400 Subject: [PATCH] =?UTF-8?q?ajout=20de=20fonctionnalit=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 10 + backend/indexer.py | 29 +- backend/main.py | 261 ++++++++++++- frontend/app.js | 901 ++++++++++++++++++++++++++++++++++++++++++- frontend/index.html | 47 +++ frontend/popout.html | 66 +++- frontend/style.css | 104 +++++ 7 files changed, 1412 insertions(+), 6 deletions(-) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0c39606 --- /dev/null +++ b/TODO.md @@ -0,0 +1,10 @@ +# Nouvelles fonctionnalités + - [ ] la détection et l'ajout de nouveaux fichiers dans l'interface se fait bien. Cependant, il faut que l'indexation se fasse automatiquement aussi pour les nouveaux fichiers. Optimiser l'indexation pour ne pas réindexer tout le contenu déjà indexé mais seulement les nouveaux fichiers. + - [ ] Dans le menu contextuel de l'arborescence, ajouter une option pour copier le path du répertoire courant ou du fichier courant dans le presse-papiers. + - [] Ajouter dans le menu de configuration une option pour visialiser un panneau a propos (About) permettant d'afficher des informations sur l'application ainsi que les versions des composants utilisés et la version de l'application. + - [ ] Dans le menu contextuel de l'arborescence sur n'importe quel voutes et dossiers, ajouter un bouton pour afficher un "graph view" en lien avec le path courant. Ce graph view doit afficher les relations entre les fichiers et les dossiers dans l'arborescence. Ce graph view doit être interactif et permettre de zoomer et de déplacer les nœuds comme dans celui de l'outils Obsidian. + - [ ] Ajouter le mode tab permettant de visualiser plusieurs fichiers à la fois dans l'interface. ajouter les fonctionnalités courantes de la gestion des tabs (fermer, déplacer, ajouter, etc.) incluant l'utilisation de raccourcis clavier pour les opérations de tab (simple clic vs double clic). + +# Corrections + - [ ] quand l'on ouvre un fichier via l'url popout, https://xxx/popout/path/du/fichier/fichier.md et qu'il y a un requis d'authentification (quand le message indique "Erreur lors du chargement (Essayez de vous reconnecter sur le site principal)") proposer un login et une fois login rediriger vers https://xxx/popout/path/du/fichier/fichier.md. + - [ ] Quand il y a une table des matières dans un fichier markdown, en clicant sur un titre de section, le scroll doit se déplacer automatiquement vers la section correspondante. Ca fonctionne avec certains liens mais pas sur d'autres. J'ai l'impression que le problème provient de lien avec des titre qui contient des lettres accentié. diff --git a/backend/indexer.py b/backend/indexer.py index b6814ff..89a8595 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -582,6 +582,30 @@ def _remove_file_from_structures(vault_name: str, rel_path: str) -> Optional[Dic return removed +def _ensure_parent_dirs_in_path_index(vault_name: str, rel_path: str, existing: set): + """Ensure all parent directories of a file path exist in path_index. + + For a path like ``a/b/c/file.md``, ensures ``a``, ``a/b``, and ``a/b/c`` + directory entries exist in path_index. + + Args: + vault_name: Name of the vault. + rel_path: Relative path of the file (e.g. ``a/b/c/file.md``). + existing: Set of paths already in path_index for this vault. + """ + parts = rel_path.split("/") + # Build parent directory paths + for i in range(1, len(parts)): + dir_path = "/".join(parts[:i]) + if dir_path and dir_path not in existing: + existing.add(dir_path) + path_index[vault_name].append({ + "path": dir_path, + "name": parts[i - 1], + "type": "directory", + }) + + def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]): """Add a file entry to all index structures. @@ -608,9 +632,8 @@ def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]): _file_lookup[key] = [] _file_lookup[key].append(entry) - # Add to path_index + # Add to path_index — also ensures parent directories are present if vault_name in path_index: - # Check if already present (avoid duplicates) existing = {p["path"] for p in path_index[vault_name]} if rel_path not in existing: path_index[vault_name].append({ @@ -618,6 +641,8 @@ def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]): "name": rel_path.rsplit("/", 1)[-1], "type": "file", }) + # Ensure all parent directories are in path_index + _ensure_parent_dirs_in_path_index(vault_name, rel_path, existing) _index_generation += 1 diff --git a/backend/main.py b/backend/main.py index cd7ac6c..c98a2c1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -221,6 +221,30 @@ class TagSuggestResponse(BaseModel): suggestions: List[TagSuggestion] +class GraphNode(BaseModel): + """A single node in the graph view.""" + id: str = Field(description="Unique node identifier") + name: str = Field(description="Display name") + type: str = Field(description="'vault', 'directory', or 'file'") + path: str = Field(description="Relative path within vault") + size: int = Field(default=0, description="File size in bytes") + + +class GraphEdge(BaseModel): + """An edge between two nodes in the graph view.""" + source: str = Field(description="Source node ID") + target: str = Field(description="Target node ID") + relation: str = Field(description="'parent' or 'wikilink'") + + +class GraphResponse(BaseModel): + """Graph data for a vault or directory.""" + vault: str + path: str + nodes: List[GraphNode] + edges: List[GraphEdge] + + class ReloadResponse(BaseModel): """Index reload confirmation with per-vault stats.""" status: str @@ -595,6 +619,71 @@ def _check_vault_writable(vault_root: Path) -> bool: # Markdown rendering helpers (singleton renderer) # --------------------------------------------------------------------------- +import unicodedata + + +def _heading_slugify(text: str) -> str: + """Generate a URL-safe slug from heading text. + + Matches the JavaScript slugify algorithm exactly: + 1. Lowercase + 2. NFD normalize + strip combining marks + 3. Keep only Unicode letters, numbers, spaces, hyphens + 4. Replace spaces with hyphens, collapse multiple hyphens + + Args: + text: The heading text content. + + Returns: + A URL-safe slug string. + """ + text = text.lower() + text = unicodedata.normalize("NFD", text) + text = "".join(ch for ch in text if not unicodedata.combining(ch)) + # Keep only Unicode letters, numbers, spaces, and hyphens + cleaned = [] + for ch in text: + if ch.isalpha() or ch.isdigit() or ch in (" ", "-"): + cleaned.append(ch) + text = "".join(cleaned) + text = re.sub(r"\s+", "-", text) + text = re.sub(r"-+", "-", text) + return text.strip("-") or "heading" + + +def _add_heading_ids(html: str) -> str: + """Post-process rendered HTML to add IDs to heading tags. + + Adds an ``id`` attribute to every ``

`` through ``

`` tag + using a slug generated from the heading's text content. + Duplicate slugs get a ``-2``, ``-3``, etc. suffix. + + Args: + html: Rendered HTML string. + + Returns: + HTML with heading IDs injected. + """ + used_ids: Dict[str, int] = {} + + def _replace_heading(match): + tag = match.group(1) + content = match.group(2) + slug = _heading_slugify(content) + count = used_ids.get(slug, 0) + used_ids[slug] = count + 1 + if count > 0: + slug = f"{slug}-{count + 1}" + return f'<{tag} id="{slug}">{content}' + + # Match h1-h6 tags with text content (no existing id attribute) + return re.sub( + r'<(h[1-6])>([^<]*(?:<(?!/?h[1-6])[^<]*)*)', + _replace_heading, + html, + ) + + # Cached mistune renderer — avoids re-creating on every request _markdown_renderer = mistune.create_markdown( escape=False, @@ -656,7 +745,12 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Optional[P # Convert wikilinks converted = _convert_wikilinks(raw_md, vault_name) - return _markdown_renderer(converted) + rendered = _markdown_renderer(converted) + + # Add heading IDs for TOC navigation + rendered = _add_heading_ids(rendered) + + return rendered # --------------------------------------------------------------------------- @@ -1165,6 +1259,26 @@ async def api_directory_create( dir_path.mkdir(parents=True, exist_ok=False) logger.info(f"Directory created: {vault_name}/{body.path}") + # Update path_index with the new directory + from backend.indexer import path_index as _path_idx, _index_generation + import threading + from backend.indexer import _index_lock + with _index_lock: + if vault_name not in _path_idx: + _path_idx[vault_name] = [] + existing = {p["path"] for p in _path_idx[vault_name]} + # Build all parent segments + parts = body.path.split("/") + for i in range(1, len(parts) + 1): + seg_path = "/".join(parts[:i]) + if seg_path and seg_path not in existing: + existing.add(seg_path) + _path_idx[vault_name].append({ + "path": seg_path, + "name": parts[i - 1], + "type": "directory", + }) + # Broadcast SSE event await sse_manager.broadcast("directory_created", { "vault": vault_name, @@ -1744,6 +1858,151 @@ async def api_reload(current_user=Depends(require_admin)): return {"status": "ok", "vaults": stats} +@app.get("/api/graph/{vault_name}", response_model=GraphResponse) +async def api_graph( + vault_name: str, + path: str = Query("", description="Relative path to focus on"), + depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"), + current_user=Depends(require_auth), +): + """Return graph data (nodes and edges) for a vault or directory. + + Nodes represent files and directories. Edges represent parent-child + relationships and wikilinks between markdown files. + + Args: + vault_name: Name of the vault. + path: Relative directory path to focus on (empty = root). + depth: Expansion depth (0 = only direct children, 1-3 = deeper). + + Returns: + ``GraphResponse`` with nodes and edges. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + 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"]) + target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve() + + if not target.exists(): + raise HTTPException(status_code=404, detail=f"Path not found: {path}") + + nodes: List[dict] = [] + edges: List[dict] = [] + node_ids: set = set() + + def _add_node(name: str, ntype: str, npath: str, size: int = 0) -> str: + nid = f"{vault_name}:{npath}" + if nid not in node_ids: + node_ids.add(nid) + nodes.append({"id": nid, "name": name, "type": ntype, "path": npath, "size": size}) + return nid + + def _add_edge(source: str, target: str, relation: str): + edges.append({"source": source, "target": target, "relation": relation}) + + # Get vault settings for hidden files + from backend.vault_settings import get_vault_setting + settings = get_vault_setting(vault_name) or {} + hide_hidden = settings.get("hideHiddenFiles", False) + + # Add the focus node + focus_name = path.split("/")[-1] if path else vault_name + focus_type = "directory" if path else "vault" + focus_id = _add_node(focus_name, focus_type, path) + + # Walk directory tree up to depth levels + def _walk_dir(dir_path: Path, parent_id: str, current_depth: int): + if current_depth > depth: + return + try: + for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): + if hide_hidden and entry.name.startswith("."): + continue + rel = str(entry.relative_to(vault_root)).replace("\\", "/") + if entry.is_dir(): + did = _add_node(entry.name, "directory", rel) + _add_edge(parent_id, did, "parent") + if current_depth < depth: + _walk_dir(entry, did, current_depth + 1) + elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"): + fid = _add_node(entry.name, "file", rel, entry.stat().st_size) + _add_edge(parent_id, fid, "parent") + except PermissionError: + pass + + if target.is_dir(): + _walk_dir(target, focus_id, 0) + elif target.is_file(): + # For a single file, show siblings in same directory + _walk_dir(target.parent, focus_id, 0) + + # Add wikilink edges between markdown files in the current scope + _add_wikilink_edges(nodes, edges, node_ids, vault_name) + + return {"vault": vault_name, "path": path, "nodes": nodes, "edges": edges} + + +def _add_wikilink_edges(nodes: list, edges: list, node_ids: set, vault_name: str): + """Add edges for wikilinks between markdown files in the current graph scope.""" + from backend.indexer import find_file_in_index + + # Only consider files nodes + file_nodes = [n for n in nodes if n["type"] == "file" and n["path"].endswith(".md")] + if len(file_nodes) < 2: + return + + # Build lookup: relative_path → node_id + path_to_id = {n["path"]: n["id"] for n in file_nodes} + + wikilink_pattern = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]+)?\]\]") + + for node in file_nodes: + # Get the file content from index + vault_data = index.get(vault_name) + if not vault_data: + continue + file_entry = None + for f in vault_data.get("files", []): + if f["path"] == node["path"]: + file_entry = f + break + if not file_entry: + continue + + content = file_entry.get("content", "") + if not content: + continue + + # Find all wikilinks in content + for match in wikilink_pattern.finditer(content): + target = match.group(1).strip() + # Try to find the target in our graph scope first + target_lower = target.lower() + if not target_lower.endswith(".md"): + target_lower += ".md" + + for target_path, target_id in path_to_id.items(): + if target_id == node["id"]: + continue + target_name = target_path.rsplit("/", 1)[-1].lower() + if target_name == target_lower or target_path.lower() == target_lower: + # Avoid duplicate edges + edge_key = tuple(sorted([node["id"], target_id])) + if edge_key not in {(e["source"], e["target"]) for e in edges} and \ + edge_key not in {(e["target"], e["source"]) for e in edges}: + edges.append({ + "source": node["id"], + "target": target_id, + "relation": "wikilink" + }) + break + + @app.get("/api/index/reload/{vault_name}") async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)): """Force a re-index of a single vault. diff --git a/frontend/app.js b/frontend/app.js index 5835c1c..c17adf4 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -769,7 +769,7 @@ .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") - .replace(/[^\w\s-]/g, "") + .replace(/[^\p{L}\p{N}\s-]/gu, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .trim() || "heading" @@ -3723,6 +3723,7 @@ renderConfigFilters(); loadConfigFields(); loadDiagnostics(); + loadAbout(); await loadHiddenFilesSettings(); safeCreateIcons(); }); @@ -4017,6 +4018,70 @@ }); } + // --- About Section --- + + function loadAbout() { + const container = document.getElementById("config-about"); + if (!container) return; + + // Fetch health info for version + api("/api/health").then((health) => { + container.innerHTML = ""; + + const sections = [ + { + title: "Application", + rows: [ + ["Nom", "ObsiGate"], + ["Version", APP_VERSION], + ["Version API", health.version || "—"], + ["Statut", health.status || "—"], + ], + }, + { + title: "Environnement", + rows: [ + ["Vaults configurés", health.vaults || "—"], + ["Fichiers indexés", health.total_files || "—"], + ["Navigateur", navigator.userAgent.split(" ").pop()], + ["Plateforme", navigator.platform || "—"], + ["Langue", navigator.language || "—"], + ], + }, + { + title: "Composants", + rows: [ + ["Backend", "FastAPI (Python)"], + ["Rendu Markdown", "mistune"], + ["Surveillance fichiers", "watchdog"], + ["Frontend", "Vanilla JavaScript"], + ["Icônes", "Lucide Icons"], + ["Coloration syntaxe", "highlight.js"], + ["Éditeur", "CodeMirror 6"], + ], + }, + ]; + + sections.forEach((section) => { + const div = document.createElement("div"); + div.className = "config-diag-section"; + const title = document.createElement("div"); + title.className = "config-diag-section-title"; + title.textContent = section.title; + div.appendChild(title); + section.rows.forEach(([label, value]) => { + const row = document.createElement("div"); + row.className = "config-diag-row"; + row.innerHTML = `${label}${value}`; + div.appendChild(row); + }); + container.appendChild(div); + }); + }).catch(() => { + container.innerHTML = '
Erreur de chargement
'; + }); + } + // --- Hidden Files Configuration --- async function loadHiddenFilesSettings() { @@ -5863,6 +5928,16 @@ this._menu.innerHTML = ''; + // Copy path — available for all types + const pathToCopy = type === 'vault' ? vault : `${vault}/${path}`; + this._addItem('clipboard-copy', 'Copier le chemin', () => this._copyPath(pathToCopy), false); + + // Graph view — available for all types + const graphPath = type === 'vault' ? '' : path; + this._addItem('git-graph', 'Vue Graphique', () => GraphViewManager.open(vault, graphPath, type), false); + + this._addSeparator(); + if (type === 'vault') { this._addItem('folder-plus', 'Nouveau dossier', () => this._createDirectory(), isReadonly); this._addItem('file-plus', 'Nouveau fichier', () => this._createFile(), isReadonly); @@ -5951,6 +6026,14 @@ _deleteFile() { FileOperations.confirmDeleteFile(this._targetVault, this._targetPath); + }, + + _copyPath(path) { + navigator.clipboard.writeText(path).then(() => { + showToast(`Chemin copié : ${path}`, 'success'); + }).catch(() => { + showToast('Erreur lors de la copie', 'error'); + }); } }; @@ -6989,4 +7072,820 @@ init(); registerServiceWorker(); }); + + // --------------------------------------------------------------------------- + // Tab Manager — Multi-file tab support + // --------------------------------------------------------------------------- + + const TabManager = { + _tabs: [], + _activeTabId: null, + _tabCache: {}, // { tabId: { vault, path, title, data, rawSource, sourceView, scrollTop, icon } } + _tabBar: null, + _tabList: null, + _dirtyTabs: new Set(), + + init() { + this._tabBar = document.getElementById("tab-bar"); + this._tabList = document.getElementById("tab-list"); + }, + + /** Open a file in a tab (or focus existing) */ + async open(vault, path, options = {}) { + const tabId = `${vault}::${path}`; + + // If already open, just focus it + const existing = this._tabs.find(t => t.id === tabId); + if (existing) { + this.activate(tabId); + return; + } + + // Create new tab + const name = path.split("/").pop().replace(/\.md$/i, ""); + const icon = getFileIcon(name + ".md"); + + this._tabs.push({ id: tabId, vault, path, name, icon }); + this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon }; + + this._renderTabs(); + this.activate(tabId); + }, + + /** Activate a specific tab */ + async activate(tabId) { + if (this._activeTabId === tabId && this._tabs.length > 0) return; + + // Save current tab state + if (this._activeTabId && this._tabCache[this._activeTabId]) { + this._saveCurrentTabState(); + } + + this._activeTabId = tabId; + this._renderTabs(); + + // Load tab content + const cache = this._tabCache[tabId]; + if (!cache) return; + + // Update global state + currentVault = cache.vault; + currentPath = cache.path; + syncActiveFileTreeItem(cache.vault, cache.path); + + const area = document.getElementById("content-area"); + + if (cache.data) { + // Use cached data + this._restoreTabContent(cache, area); + } else { + // Fetch file content + area.innerHTML = '
Chargement...
'; + try { + const data = await api(`/api/file/${encodeURIComponent(cache.vault)}?path=${encodeURIComponent(cache.path)}`); + cache.data = data; + cache.title = data.title; + renderFile(cache.data); + + // Restore source view if needed + if (cache.sourceView) { + await this._toggleSourceView(cache, area); + } + if (cache.scrollTop) { + area.scrollTop = cache.scrollTop; + } + } catch (err) { + area.innerHTML = `
Erreur: ${escapeHtml(err.message)}
`; + } + } + + // Update URL hash + if (history.pushState) { + history.pushState(null, "", `#/file/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`); + } + + // Hide dashboard + const dashboard = document.getElementById("dashboard-home"); + if (dashboard) dashboard.style.display = "none"; + }, + + /** Close a tab */ + close(tabId) { + const idx = this._tabs.findIndex(t => t.id === tabId); + if (idx === -1) return; + + this._tabs.splice(idx, 1); + delete this._tabCache[tabId]; + this._dirtyTabs.delete(tabId); + + if (this._tabs.length === 0) { + this._activeTabId = null; + this._showDashboard(); + this._tabBar.hidden = true; + } else if (this._activeTabId === tabId) { + // Activate adjacent tab + const newIdx = Math.min(idx, this._tabs.length - 1); + this.activate(this._tabs[newIdx].id); + } + + this._renderTabs(); + }, + + /** Close all tabs */ + closeAll() { + this._tabs = []; + this._tabCache = {}; + this._dirtyTabs.clear(); + this._activeTabId = null; + this._showDashboard(); + this._tabBar.hidden = true; + }, + + /** Close tabs to the right */ + closeRight(tabId) { + const idx = this._tabs.findIndex(t => t.id === tabId); + if (idx === -1) return; + const toClose = this._tabs.slice(idx + 1); + for (const tab of toClose) { + delete this._tabCache[tab.id]; + this._dirtyTabs.delete(tab.id); + } + this._tabs = this._tabs.slice(0, idx + 1); + if (!this._tabs.find(t => t.id === this._activeTabId)) { + this.activate(tabId); + } + this._renderTabs(); + }, + + /** Close other tabs */ + closeOthers(tabId) { + const tab = this._tabs.find(t => t.id === tabId); + if (!tab) return; + for (const t of this._tabs) { + if (t.id !== tabId) { + delete this._tabCache[t.id]; + this._dirtyTabs.delete(t.id); + } + } + this._tabs = [tab]; + this.activate(tabId); + this._renderTabs(); + }, + + /** Reorder tabs by drag and drop */ + moveTab(fromIdx, toIdx) { + if (fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return; + const tab = this._tabs.splice(fromIdx, 1)[0]; + this._tabs.splice(toIdx, 0, tab); + this._renderTabs(); + }, + + /** Save current tab state before switching */ + _saveCurrentTabState() { + const cache = this._tabCache[this._activeTabId]; + if (!cache) return; + + const area = document.getElementById("content-area"); + const rendered = document.getElementById("file-rendered-content"); + + cache.scrollTop = area.scrollTop; + cache.sourceView = rendered ? rendered.style.display === "none" : false; + }, + + /** Restore tab content from cache */ + _restoreTabContent(cache, area) { + renderFile(cache.data); + if (cache.sourceView) { + this._restoreSourceView(cache, area); + } + if (cache.scrollTop) { + area.scrollTop = cache.scrollTop; + } + }, + + async _toggleSourceView(cache, area) { + const rendered = document.getElementById("file-rendered-content"); + const raw = document.getElementById("file-raw-content"); + if (!rendered || !raw) return; + + if (!cache.rawSource) { + const rawData = await api(`/api/file/${encodeURIComponent(cache.vault)}/raw?path=${encodeURIComponent(cache.path)}`); + cache.rawSource = rawData.raw; + } + raw.textContent = cache.rawSource; + rendered.style.display = "none"; + raw.style.display = "block"; + }, + + _restoreSourceView(cache, area) { + requestAnimationFrame(() => { + const rendered = document.getElementById("file-rendered-content"); + const raw = document.getElementById("file-raw-content"); + if (rendered && raw && cache.rawSource) { + raw.textContent = cache.rawSource; + rendered.style.display = "none"; + raw.style.display = "block"; + } + }); + }, + + _showDashboard() { + const area = document.getElementById("content-area"); + area.innerHTML = ""; + const dashboard = document.getElementById("dashboard-home"); + if (dashboard) { + dashboard.style.display = ""; + area.appendChild(dashboard); + } + if (history.pushState) { + history.pushState(null, "", "#"); + } + }, + + /** Render the tab bar */ + _renderTabs() { + if (!this._tabList) return; + + this._tabList.innerHTML = ""; + + if (this._tabs.length === 0) { + this._tabBar.hidden = true; + return; + } + + this._tabBar.hidden = false; + + this._tabs.forEach((tab, idx) => { + const el = document.createElement("div"); + el.className = "tab-item" + (tab.id === this._activeTabId ? " active" : ""); + el.draggable = true; + el.dataset.tabId = tab.id; + el.dataset.index = idx; + + // Icon + const iconEl = document.createElement("i"); + iconEl.setAttribute("data-lucide", tab.icon); + iconEl.className = "tab-icon"; + iconEl.style.width = "14px"; + iconEl.style.height = "14px"; + el.appendChild(iconEl); + + // Name + const nameEl = document.createElement("span"); + nameEl.className = "tab-name"; + nameEl.textContent = tab.name; + nameEl.title = `${tab.vault}/${tab.path}`; + el.appendChild(nameEl); + + // Close button + const closeEl = document.createElement("span"); + closeEl.className = "tab-close"; + closeEl.innerHTML = ''; + closeEl.addEventListener("click", (e) => { + e.stopPropagation(); + this.close(tab.id); + }); + el.appendChild(closeEl); + + // Click to activate + el.addEventListener("click", () => this.activate(tab.id)); + + // Double-click to close + el.addEventListener("dblclick", (e) => { + e.preventDefault(); + this.close(tab.id); + }); + + // Middle-click to close + el.addEventListener("mousedown", (e) => { + if (e.button === 1) { + e.preventDefault(); + this.close(tab.id); + } + }); + + // Context menu on tab + el.addEventListener("contextmenu", (e) => { + e.preventDefault(); + this._showTabContextMenu(e.clientX, e.clientY, tab.id); + }); + + // Drag and drop + el.addEventListener("dragstart", (e) => { + e.dataTransfer.setData("text/plain", String(idx)); + el.classList.add("dragging"); + }); + el.addEventListener("dragend", () => { + el.classList.remove("dragging"); + document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); + }); + el.addEventListener("dragover", (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + const rect = el.getBoundingClientRect(); + const mid = rect.left + rect.width / 2; + document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); + const indicator = document.createElement("div"); + indicator.className = "tab-drop-indicator"; + if (e.clientX < mid) { + el.before(indicator); + } else { + el.after(indicator); + } + }); + el.addEventListener("drop", (e) => { + e.preventDefault(); + document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove()); + const fromIdx = parseInt(e.dataTransfer.getData("text/plain")); + const rect = el.getBoundingClientRect(); + const mid = rect.left + rect.width / 2; + const toIdx = e.clientX < mid ? idx : idx + 1; + if (fromIdx !== toIdx && fromIdx !== toIdx - 1) { + this.moveTab(fromIdx, toIdx); + } + }); + + this._tabList.appendChild(el); + }); + + safeCreateIcons(); + }, + + _showTabContextMenu(x, y, tabId) { + const existing = document.getElementById("tab-context-menu"); + if (existing) existing.remove(); + + const menu = document.createElement("div"); + menu.id = "tab-context-menu"; + menu.className = "context-menu active"; + menu.style.left = x + "px"; + menu.style.top = y + "px"; + menu.innerHTML = ` +
Fermer
+
Fermer les autres
+
Fermer à droite
+
+
Fermer tout
+ `; + document.body.appendChild(menu); + safeCreateIcons(); + + menu.addEventListener("click", (e) => { + const action = e.target.closest(".context-menu-item")?.dataset.action; + if (action === "close") this.close(tabId); + else if (action === "closeOthers") this.closeOthers(tabId); + else if (action === "closeRight") this.closeRight(tabId); + else if (action === "closeAll") this.closeAll(); + menu.remove(); + }); + + const closeMenu = () => menu.remove(); + document.addEventListener("click", closeMenu, { once: true }); + document.addEventListener("keydown", (e) => { if (e.key === "Escape") { menu.remove(); } }, { once: true }); + }, + }; + + // ---- Modify openFile to use TabManager ---- + const _originalOpenFile = openFile; + openFile = function(vault, path) { + TabManager.open(vault, path); + }; + + // ---- Keyboard shortcuts for tabs ---- + document.addEventListener("keydown", (e) => { + if (e.ctrlKey || e.metaKey) { + if (e.key === "w" || e.key === "W") { + e.preventDefault(); + if (TabManager._activeTabId) { + TabManager.close(TabManager._activeTabId); + } + } else if (e.key === "Tab" && !e.shiftKey) { + e.preventDefault(); + const tabs = TabManager._tabs; + const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId); + if (currentIdx >= 0 && tabs.length > 1) { + const nextIdx = (currentIdx + 1) % tabs.length; + TabManager.activate(tabs[nextIdx].id); + } + } else if (e.key === "Tab" && e.shiftKey) { + e.preventDefault(); + const tabs = TabManager._tabs; + const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId); + if (currentIdx >= 0 && tabs.length > 1) { + const prevIdx = (currentIdx - 1 + tabs.length) % tabs.length; + TabManager.activate(tabs[prevIdx].id); + } + } + } + }); + + // ---- Modify init to include TabManager ---- + const _origInit2 = init; + init = function() { + _origInit2(); + TabManager.init(); + }; + + // --------------------------------------------------------------------------- + // Graph View Manager — Interactive file/folder relationship visualization + // --------------------------------------------------------------------------- + + const GraphViewManager = { + _canvas: null, + _ctx: null, + _nodes: [], + _edges: [], + _offsetX: 0, + _offsetY: 0, + _zoom: 1, + _dragging: false, + _dragNode: null, + _panning: false, + _panStartX: 0, + _panStartY: 0, + _animFrame: null, + _vault: null, + _path: null, + _nodePositions: {}, + _width: 0, + _height: 0, + + async open(vault, path, type) { + this._vault = vault; + this._path = path; + + const modal = document.getElementById("graph-modal"); + const title = document.getElementById("graph-title"); + const info = document.getElementById("graph-info"); + const canvas = document.getElementById("graph-canvas"); + + if (!modal || !canvas) return; + + title.textContent = `Vue Graphique — ${vault}${path ? "/" + path : ""}`; + info.textContent = "Chargement..."; + modal.classList.add("active"); + + this._canvas = canvas; + this._ctx = canvas.getContext("2d"); + this._resetView(); + + // Fetch graph data + try { + const data = await api(`/api/graph/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}&depth=1`); + this._nodes = data.nodes || []; + this._edges = data.edges || []; + info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens`; + this._initLayout(); + this._startRender(); + } catch (err) { + info.textContent = "Erreur de chargement"; + console.error("Graph error:", err); + } + + safeCreateIcons(); + }, + + close() { + const modal = document.getElementById("graph-modal"); + if (modal) modal.classList.remove("active"); + if (this._animFrame) { + cancelAnimationFrame(this._animFrame); + this._animFrame = null; + } + }, + + _resetView() { + this._offsetX = 0; + this._offsetY = 0; + this._zoom = 1; + this._nodePositions = {}; + }, + + _initLayout() { + const w = this._canvas.parentElement.clientWidth; + const h = this._canvas.parentElement.clientHeight; + this._canvas.width = w * devicePixelRatio; + this._canvas.height = h * devicePixelRatio; + this._canvas.style.width = w + "px"; + this._canvas.style.height = h + "px"; + this._width = w; + this._height = h; + this._ctx.scale(devicePixelRatio, devicePixelRatio); + + // Position nodes in a circle initially + const cx = w / 2; + const cy = h / 2; + const radius = Math.min(w, h) * 0.35; + + this._nodes.forEach((node, i) => { + const angle = (2 * Math.PI * i) / this._nodes.length; + this._nodePositions[node.id] = { + x: cx + radius * Math.cos(angle), + y: cy + radius * Math.sin(angle), + vx: 0, + vy: 0, + }; + }); + }, + + _startRender() { + const self = this; + let lastTime = 0; + + const loop = (time) => { + const dt = Math.min((time - lastTime) / 1000, 0.1); + lastTime = time; + self._simulate(dt); + self._draw(); + self._animFrame = requestAnimationFrame(loop); + }; + + this._animFrame = requestAnimationFrame(loop); + }, + + _simulate(dt) { + if (this._dragging || this._dragNode) return; + + const positions = this._nodePositions; + const cx = this._width / 2; + const cy = this._height / 2; + + // Spring forces (edges) + for (const edge of this._edges) { + const a = positions[edge.source]; + const b = positions[edge.target]; + if (!a || !b) continue; + + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const targetLen = 80; + const force = (dist - targetLen) * 0.01; + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + + a.vx += fx; + a.vy += fy; + b.vx -= fx; + b.vy -= fy; + } + + // Repulsion between all nodes + for (const n1 of this._nodes) { + for (const n2 of this._nodes) { + if (n1.id === n2.id) continue; + const a = positions[n1.id]; + const b = positions[n2.id]; + if (!a || !b) continue; + + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const force = 2000 / (dist * dist); + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + + a.vx -= fx; + a.vy -= fy; + } + } + + // Center gravity + for (const node of this._nodes) { + const p = positions[node.id]; + if (!p) continue; + p.vx += (cx - p.x) * 0.001; + p.vy += (cy - p.y) * 0.001; + } + + // Apply velocities with damping + for (const node of this._nodes) { + const p = positions[node.id]; + if (!p) continue; + p.vx *= 0.9; + p.vy *= 0.9; + p.x += p.vx * dt * 60; + p.y += p.vy * dt * 60; + } + }, + + _draw() { + const ctx = this._ctx; + const w = this._width; + const h = this._height; + + ctx.save(); + ctx.clearRect(0, 0, w, h); + + // Apply transform (pan + zoom) + ctx.translate(this._offsetX, this._offsetY); + ctx.scale(this._zoom, this._zoom); + + // Draw edges + for (const edge of this._edges) { + const a = this._nodePositions[edge.source]; + const b = this._nodePositions[edge.target]; + if (!a || !b) continue; + + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.strokeStyle = edge.relation === "wikilink" ? "var(--accent-color, #2563eb)" : "var(--text-muted, #888)"; + ctx.lineWidth = edge.relation === "wikilink" ? 2 : 1; + ctx.setLineDash(edge.relation === "wikilink" ? [4, 4] : []); + ctx.stroke(); + } + ctx.setLineDash([]); + + // Draw nodes + for (const node of this._nodes) { + const p = this._nodePositions[node.id]; + if (!p) continue; + + const r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 100)); + + // Node circle + ctx.beginPath(); + ctx.arc(p.x, p.y, r, 0, Math.PI * 2); + + switch (node.type) { + case "directory": + ctx.fillStyle = "#5b9bd5"; + break; + case "file": + ctx.fillStyle = (node.path || "").endsWith(".md") ? "#70ad47" : "#c0c0c0"; + break; + default: + ctx.fillStyle = "#ffc000"; + break; + } + ctx.fill(); + ctx.strokeStyle = "var(--bg-primary, #1e1e1e)"; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Node label + const label = node.name.length > 20 ? node.name.slice(0, 18) + "..." : node.name; + ctx.font = `${11 / this._zoom}px -apple-system, sans-serif`; + ctx.fillStyle = "var(--text-primary, #ddd)"; + ctx.textAlign = "center"; + ctx.fillText(label, p.x, p.y + r + 12 / this._zoom); + } + + ctx.restore(); + }, + + _getNodeAt(screenX, screenY) { + const x = (screenX - this._offsetX) / this._zoom; + const y = (screenY - this._offsetY) / this._zoom; + + for (const node of this._nodes) { + const p = this._nodePositions[node.id]; + if (!p) continue; + const r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 100)); + const dx = x - p.x; + const dy = y - p.y; + if (dx * dx + dy * dy <= r * r + 100) { + return { node, pos: p }; + } + } + return null; + }, + + _onMouseDown(e) { + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + const hit = this._getNodeAt(mx, my); + if (hit) { + this._dragging = true; + this._dragNode = hit; + this._canvas.style.cursor = "grabbing"; + } else { + this._panning = true; + this._panStartX = e.clientX - this._offsetX; + this._panStartY = e.clientY - this._offsetY; + this._canvas.style.cursor = "grabbing"; + } + }, + + _onMouseMove(e) { + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + if (this._dragging && this._dragNode) { + this._dragNode.pos.x = (mx - this._offsetX) / this._zoom; + this._dragNode.pos.y = (my - this._offsetY) / this._zoom; + this._dragNode.pos.vx = 0; + this._dragNode.pos.vy = 0; + } else if (this._panning) { + this._offsetX = e.clientX - this._panStartX; + this._offsetY = e.clientY - this._panStartY; + } else { + const hit = this._getNodeAt(mx, my); + this._canvas.style.cursor = hit ? "pointer" : "grab"; + this._canvas.title = hit ? `Ouvrir: ${hit.node.path || hit.node.name}` : ""; + } + }, + + _onMouseUp(e) { + if (this._dragging && this._dragNode) { + // Check if it was a click (not a drag) + const rect = this._canvas.getBoundingClientRect(); + const node = this._dragNode.node; + this._dragging = false; + this._dragNode = null; + this._canvas.style.cursor = "grab"; + + // If it's a file, open it on click + if (node.type === "file") { + this.close(); + openFile(this._vault, node.path); + } else if (node.type === "directory" || node.type === "vault") { + // Expand into this directory + this.close(); + this.open(this._vault, node.path, node.type); + } + } + this._panning = false; + }, + + _onWheel(e) { + e.preventDefault(); + const rect = this._canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; + const newZoom = Math.max(0.2, Math.min(3, this._zoom * zoomFactor)); + + this._offsetX = mx - (mx - this._offsetX) * (newZoom / this._zoom); + this._offsetY = my - (my - this._offsetY) * (newZoom / this._zoom); + this._zoom = newZoom; + }, + + _onResize() { + if (!this._canvas || !this._nodes.length) return; + const w = this._canvas.parentElement.clientWidth; + const h = this._canvas.parentElement.clientHeight; + this._canvas.width = w * devicePixelRatio; + this._canvas.height = h * devicePixelRatio; + this._canvas.style.width = w + "px"; + this._canvas.style.height = h + "px"; + this._width = w; + this._height = h; + this._ctx.setTransform(1, 0, 0, 1, 0, 0); + this._ctx.scale(devicePixelRatio, devicePixelRatio); + }, + }; + + // Init graph view event listeners after DOM ready + function initGraphView() { + const closeBtn = document.getElementById("graph-close"); + const zoomIn = document.getElementById("graph-zoom-in"); + const zoomOut = document.getElementById("graph-zoom-out"); + const reset = document.getElementById("graph-reset"); + const modal = document.getElementById("graph-modal"); + const canvas = document.getElementById("graph-canvas"); + + if (closeBtn) closeBtn.addEventListener("click", () => GraphViewManager.close()); + if (modal) modal.addEventListener("click", (e) => { if (e.target === modal) GraphViewManager.close(); }); + if (zoomIn) zoomIn.addEventListener("click", () => { GraphViewManager._zoom = Math.min(3, GraphViewManager._zoom * 1.2); }); + if (zoomOut) zoomOut.addEventListener("click", () => { GraphViewManager._zoom = Math.max(0.2, GraphViewManager._zoom * 0.8); }); + if (reset) reset.addEventListener("click", () => { + GraphViewManager._offsetX = 0; + GraphViewManager._offsetY = 0; + GraphViewManager._zoom = 1; + }); + + if (canvas) { + canvas.addEventListener("mousedown", (e) => GraphViewManager._onMouseDown(e)); + canvas.addEventListener("mousemove", (e) => GraphViewManager._onMouseMove(e)); + canvas.addEventListener("mouseup", (e) => GraphViewManager._onMouseUp(e)); + canvas.addEventListener("mouseleave", () => { + GraphViewManager._dragging = false; + GraphViewManager._dragNode = null; + GraphViewManager._panning = false; + canvas.style.cursor = "grab"; + }); + canvas.addEventListener("wheel", (e) => GraphViewManager._onWheel(e), { passive: false }); + window.addEventListener("resize", () => GraphViewManager._onResize()); + } + + document.addEventListener("keydown", (e) => { + if (modal && modal.classList.contains("active") && e.key === "Escape") { + GraphViewManager.close(); + } + }); + } + + // Add to init + const _origInit = init; + init = function() { + _origInit(); + initGraphView(); + }; + })(); diff --git a/frontend/index.html b/frontend/index.html index 0cf55f0..6216f99 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -348,6 +348,11 @@ + + +
@@ -620,6 +625,48 @@ + +
+

📦 À propos

+
+
Chargement...
+
+
+ +
+ + + + + +
+
+
+
Vue Graphique
+
+ + + + + +
+
+
+ +
+ 🟦 Dossier + 🟩 Fichier .md + ⬜ Autre fichier + ── Parent + ┅┅ Wikilink
diff --git a/frontend/popout.html b/frontend/popout.html index aa4c427..8cdbf45 100644 --- a/frontend/popout.html +++ b/frontend/popout.html @@ -174,6 +174,57 @@ function safeCreateIcons() { if (window.lucide) window.lucide.createIcons(); } + // ---- Login form for popout auth redirect ---- + + function showLoginForm(vault, path) { + const loading = document.getElementById('loading'); + loading.innerHTML = ` + + `; + + document.getElementById('popout-login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const username = document.getElementById('popout-username').value; + const password = document.getElementById('popout-password').value; + const errorEl = document.getElementById('popout-login-error'); + + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + credentials: 'include', + }); + if (!res.ok) { + const err = await res.json(); + errorEl.textContent = err.detail || 'Identifiants invalides'; + errorEl.style.display = 'block'; + return; + } + // Login successful — reload the page to try loading the file again + window.location.reload(); + } catch (err) { + errorEl.textContent = 'Erreur réseau'; + errorEl.style.display = 'block'; + } + }); + } + + // ---- Outline / TOC Manager ---- + const OutlineManager = { /** * Slugify text to create valid IDs @@ -183,7 +234,7 @@ .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') - .replace(/[^\w\s-]/g, '') + .replace(/[^\p{L}\p{N}\s-]/gu, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .trim() || 'heading'; @@ -598,6 +649,12 @@ const RightSidebarManager = { const response = await fetch(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, { credentials: 'include' }); + + if (response.status === 401) { + showLoginForm(vault, path); + return; + } + if (!response.ok) throw new Error("Erreur lors du chargement (Essayez de vous reconnecter sur le site principal)"); const data = await response.json(); @@ -772,7 +829,12 @@ const RightSidebarManager = { }, 300); } catch (err) { - document.getElementById('loading').textContent = err.message; + // Check if this was an auth error from a later fetch + if (err.message && err.message.includes('401')) { + showLoginForm(vault, path); + } else { + document.getElementById('loading').textContent = err.message || 'Erreur de chargement'; + } } } diff --git a/frontend/style.css b/frontend/style.css index 000dfa8..0917097 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -5365,3 +5365,107 @@ body.popup-mode .content-area { flex-shrink: 0; margin-top: 2px; } + +/* ===== TAB BAR ===== */ +.tab-bar { + display: flex; + align-items: stretch; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + overflow: hidden; + flex-shrink: 0; + min-height: 36px; +} + +.tab-bar[hidden] { + display: none; +} + +.tab-list { + display: flex; + align-items: stretch; + overflow-x: auto; + overflow-y: hidden; + flex: 1; + scrollbar-width: thin; +} + +.tab-list::-webkit-scrollbar { + height: 3px; +} + +.tab-item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 0.8rem; + color: var(--text-muted); + cursor: pointer; + white-space: nowrap; + border-right: 1px solid var(--border); + background: transparent; + transition: background 0.15s, color 0.15s; + user-select: none; + position: relative; + min-width: 0; + flex-shrink: 0; +} + +.tab-item:hover { + background: var(--bg-hover); + color: var(--text-secondary); +} + +.tab-item.active { + background: var(--bg-primary); + color: var(--text-primary); + border-bottom: 2px solid var(--accent); + margin-bottom: -1px; +} + +.tab-item .tab-icon { + width: 14px; + height: 14px; + flex-shrink: 0; + opacity: 0.7; +} + +.tab-item .tab-name { + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; +} + +.tab-item .tab-close { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s, background 0.15s; +} + +.tab-item:hover .tab-close, +.tab-item.active .tab-close { + opacity: 0.6; +} + +.tab-item .tab-close:hover { + opacity: 1; + background: var(--bg-hover); +} + +.tab-item.dragging { + opacity: 0.5; + background: var(--bg-hover); +} + +.tab-drop-indicator { + width: 2px; + background: var(--accent); + flex-shrink: 0; +}