ajout de fonctionnalités

This commit is contained in:
Bruno Charest 2026-05-25 20:21:42 -04:00
parent e1fcbe9ce7
commit 370420aa00
7 changed files with 1412 additions and 6 deletions

10
TODO.md Normal file
View File

@ -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é.

View File

@ -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

View File

@ -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 ``<h1>`` through ``<h6>`` 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}</{tag}>'
# Match h1-h6 tags with text content (no existing id attribute)
return re.sub(
r'<(h[1-6])>([^<]*(?:<(?!/?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.

View File

@ -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 = `<span class="diag-label">${label}</span><span class="diag-value">${value}</span>`;
div.appendChild(row);
});
container.appendChild(div);
});
}).catch(() => {
container.innerHTML = '<div class="config-diag-loading">Erreur de chargement</div>';
});
}
// --- 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 = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Chargement...</div>';
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 = `<div style="padding:40px;text-align:center;color:var(--text-error)">Erreur: ${escapeHtml(err.message)}</div>`;
}
}
// 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 = '<i data-lucide="x" style="width:12px;height:12px"></i>';
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 = `
<div class="context-menu-item" data-action="close"><i data-lucide="x" class="icon"></i><span>Fermer</span></div>
<div class="context-menu-item" data-action="closeOthers"><i data-lucide="x-circle" class="icon"></i><span>Fermer les autres</span></div>
<div class="context-menu-item" data-action="closeRight"><i data-lucide="arrow-right-circle" class="icon"></i><span>Fermer à droite</span></div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="closeAll"><i data-lucide="trash-2" class="icon"></i><span>Fermer tout</span></div>
`;
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();
};
})();

View File

@ -348,6 +348,11 @@
<!-- Sidebar resize handle -->
<div class="sidebar-resize-handle" id="sidebar-resize-handle"></div>
<!-- Tab bar -->
<div class="tab-bar" id="tab-bar" hidden>
<div class="tab-list" id="tab-list"></div>
</div>
<!-- Content -->
<main class="content-area" id="content-area" aria-label="Contenu principal">
<div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
@ -620,6 +625,48 @@
<button class="config-btn-secondary" id="cfg-refresh-diag" style="margin-top:8px">Rafraîchir</button>
</section>
<!-- À propos -->
<section class="config-section">
<h2>📦 À propos</h2>
<div id="config-about" class="config-diagnostics">
<div class="config-diag-loading">Chargement...</div>
</div>
</section>
</div>
</div>
</div>
</div>
<!-- Graph View Modal -->
<div class="editor-modal" id="graph-modal">
<div class="editor-container" style="max-width:90vw;width:900px;height:80vh;display:flex;flex-direction:column;">
<div class="editor-header">
<div class="editor-title" id="graph-title">Vue Graphique</div>
<div class="editor-actions">
<span id="graph-info" style="font-size:0.75rem;color:var(--text-muted);margin-right:12px"></span>
<button class="editor-btn" id="graph-zoom-in" title="Zoom avant" aria-label="Zoom avant">
<i data-lucide="zoom-in" style="width:16px;height:16px"></i>
</button>
<button class="editor-btn" id="graph-zoom-out" title="Zoom arrière" aria-label="Zoom arrière">
<i data-lucide="zoom-out" style="width:16px;height:16px"></i>
</button>
<button class="editor-btn" id="graph-reset" title="Réinitialiser la vue" aria-label="Réinitialiser">
<i data-lucide="maximize" style="width:16px;height:16px"></i>
</button>
<button class="editor-btn" id="graph-close" title="Fermer" aria-label="Fermer">
<i data-lucide="x" style="width:16px;height:16px"></i>
</button>
</div>
</div>
<div class="editor-body" style="flex:1;overflow:hidden;position:relative;padding:0">
<canvas id="graph-canvas" style="width:100%;height:100%;cursor:grab"></canvas>
<div id="graph-legend" style="position:absolute;bottom:12px;left:12px;display:flex;gap:16px;font-size:0.7rem;color:var(--text-muted);background:var(--bg-primary);padding:6px 12px;border-radius:6px;border:1px solid var(--border-color)">
<span>🟦 Dossier</span>
<span>🟩 Fichier .md</span>
<span>⬜ Autre fichier</span>
<span style="color:var(--text-muted)">── Parent</span>
<span style="color:var(--accent-color,#2563eb)">┅┅ Wikilink</span>
</div>
</div>
</div>

View File

@ -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 = `
<div class="popout-login-card" style="text-align:center;max-width:360px;padding:24px;background:var(--bg-secondary);border-radius:8px;box-shadow:0 4px 12px var(--shadow-color)">
<div style="font-size:2rem;margin-bottom:8px">🔐</div>
<h3 style="margin:0 0 4px;color:var(--text-primary)">Authentification requise</h3>
<p style="margin:0 0 16px;color:var(--text-muted);font-size:0.85rem">Connectez-vous pour accéder à ce fichier</p>
<form id="popout-login-form" style="display:flex;flex-direction:column;gap:10px">
<input type="text" id="popout-username" placeholder="Nom d'utilisateur" required autocomplete="username"
style="padding:10px;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:0.9rem">
<input type="password" id="popout-password" placeholder="Mot de passe" required autocomplete="current-password"
style="padding:10px;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:0.9rem">
<div id="popout-login-error" style="color:var(--text-error,#e74c3c);font-size:0.8rem;display:none"></div>
<button type="submit"
style="padding:10px;background:var(--accent-color,#2563eb);color:white;border:none;border-radius:6px;cursor:pointer;font-size:0.9rem">Se connecter</button>
</form>
</div>
`;
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';
}
}
}

View File

@ -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;
}