feat: finalisation éditeur Forge + mode hors-ligne PWA + consolidation roadmap
CI / lint (push) Failing after 7s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Failing after -7s

- Forge: Tab indent, Ctrl+K, auto-focus, AI status, messages erreur FR
- Forge: slash commands clean, cache-bust iframe, preview serveur
- Forge: iframe 82vh, hauteur auto, table inline, sélecteur langage
- PWA hors-ligne: IndexedDB (offline-db.js + offline.js)
- Mermaid: thème dark complet, highlight.js preview
- Backend: màj CSP, rename chain, bookmarks/history/shares
- Roadmap: consolidation dans docs/ROADMAP.md (75 items)
- Infra: docker-compose.podman.yml, suppression test-vault
- .gitignore: exclusion odysseus/
This commit is contained in:
2026-06-14 20:27:37 -04:00
parent 0cdebc5b40
commit feeb46b873
31 changed files with 1769 additions and 981 deletions
+2
View File
@@ -5,6 +5,7 @@ __pycache__/
.env.* .env.*
!.env.example !.env.example
test_vault/ test_vault/
test_dir/
.venv/ .venv/
venv/ venv/
*.egg-info/ *.egg-info/
@@ -14,3 +15,4 @@ config.json
data/ data/
.coverage .coverage
.coverage .coverage
odysseus/
+44 -21
View File
@@ -5,7 +5,9 @@ Configured via environment variables.
""" """
import os import os
import json
import logging import logging
from pathlib import Path
import httpx import httpx
from typing import Optional, Literal from typing import Optional, Literal
@@ -13,27 +15,48 @@ logger = logging.getLogger("obsigate.ai")
ProviderName = Literal["deepseek", "openrouter", "gemini"] ProviderName = Literal["deepseek", "openrouter", "gemini"]
# Provider configurations # Provider configurations — keys loaded from file or .env
PROVIDERS = { AI_KEYS_FILE = Path("data/api_keys.json")
"deepseek": {
"api_key": os.getenv("DEEPSEEK_API_KEY", "").strip(), def _read_ai_keys() -> dict:
"base_url": "https://api.deepseek.com/v1", if not AI_KEYS_FILE.exists():
"model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"), return {}
"auth_header": "Bearer {api_key}", try:
}, return json.loads(AI_KEYS_FILE.read_text(encoding="utf-8"))
"openrouter": { except Exception:
"api_key": os.getenv("OPENROUTER_API_KEY", "").strip(), return {}
"base_url": "https://openrouter.ai/api/v1",
"model": os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini"), def get_ai_key(env_name: str) -> str:
"auth_header": "Bearer {api_key}", """Get AI key: stored file first, then .env fallback."""
}, keys = _read_ai_keys()
"gemini": { if env_name in keys and keys[env_name]:
"api_key": os.getenv("GEMINI_API_KEY", "").strip(), return keys[env_name]
"base_url": "https://generativelanguage.googleapis.com/v1beta", return os.getenv(env_name, "").strip()
"model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"),
"auth_header": None, # Uses query param ?key= def _load_provider_keys():
}, """Load AI keys from stored file, falling back to .env."""
} return {
"deepseek": {
"api_key": get_ai_key("DEEPSEEK_API_KEY"),
"base_url": "https://api.deepseek.com/v1",
"model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
"auth_header": "Bearer {api_key}",
},
"openrouter": {
"api_key": get_ai_key("OPENROUTER_API_KEY"),
"base_url": "https://openrouter.ai/api/v1",
"model": os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini"),
"auth_header": "Bearer {api_key}",
},
"gemini": {
"api_key": get_ai_key("GEMINI_API_KEY"),
"base_url": "https://generativelanguage.googleapis.com/v1beta",
"model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"),
"auth_header": None,
},
}
PROVIDERS = _load_provider_keys()
DEFAULT_PROVIDER: ProviderName = os.getenv("AI_DEFAULT_PROVIDER", "deepseek") # type: ignore DEFAULT_PROVIDER: ProviderName = os.getenv("AI_DEFAULT_PROVIDER", "deepseek") # type: ignore
+2 -2
View File
@@ -24,7 +24,7 @@ async def api_status():
Reads environment variables at runtime so that changes to Reads environment variables at runtime so that changes to
.env or Docker environment are reflected immediately. .env or Docker environment are reflected immediately.
""" """
import os as _os from backend.ai import get_ai_key
provider_keys = { provider_keys = {
"deepseek": "DEEPSEEK_API_KEY", "deepseek": "DEEPSEEK_API_KEY",
"openrouter": "OPENROUTER_API_KEY", "openrouter": "OPENROUTER_API_KEY",
@@ -32,7 +32,7 @@ async def api_status():
} }
providers = {} providers = {}
for name, env_var in provider_keys.items(): for name, env_var in provider_keys.items():
has_key = bool(_os.getenv(env_var, "").strip()) has_key = bool(get_ai_key(env_var))
providers[name] = { providers[name] = {
"available": has_key, "available": has_key,
"model": PROVIDERS[name]["model"] if has_key else None, "model": PROVIDERS[name]["model"] if has_key else None,
+43
View File
@@ -123,3 +123,46 @@ def is_bookmarked(username: str, vault: str, path: str) -> bool:
return False return False
bookmarks = _read_data(_get_bookmarks_file(username)) bookmarks = _read_data(_get_bookmarks_file(username))
return any(b["vault"] == vault and b["path"] == path for b in bookmarks) return any(b["vault"] == vault and b["path"] == path for b in bookmarks)
def update_bookmarks_after_rename(vault: str, old_path: str, new_path: str):
"""Update all users' bookmarks when a file is renamed."""
if not HISTORY_DIR.exists():
return
for bfile in HISTORY_DIR.glob("*_bookmarks.json"):
try:
bookmarks = _read_data(bfile)
updated = False
for b in bookmarks:
if b.get("vault") == vault and b.get("path") == old_path:
b["path"] = new_path
# Update title from new path
new_title = new_path.split("/")[-1].replace(".md", "").replace("-", " ").replace("_", " ")
b["title"] = new_title
updated = True
if updated:
_write_data(bfile, bookmarks)
logger.info(f"Updated bookmarks in {bfile.name}: {vault}/{old_path} -> {new_path}")
except Exception as e:
logger.error(f"Failed to update bookmarks in {bfile.name}: {e}")
def update_history_after_rename(vault: str, old_path: str, new_path: str):
"""Update all users' history entries when a file is renamed."""
if not HISTORY_DIR.exists():
return
for hfile in HISTORY_DIR.glob("*.json"):
if "_bookmarks" in hfile.name:
continue
try:
history = _read_data(hfile)
updated = False
for h in history:
if h.get("vault") == vault and h.get("path") == old_path:
h["path"] = new_path
updated = True
if updated:
_write_data(hfile, history)
logger.info(f"Updated history in {hfile.name}: {vault}/{old_path} -> {new_path}")
except Exception as e:
logger.error(f"Failed to update history in {hfile.name}: {e}")
+118 -4
View File
@@ -9,6 +9,7 @@ import secrets
import shutil import shutil
import string import string
import time import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -50,7 +51,7 @@ from backend.vault_settings import (
get_vault_setting, get_vault_setting,
update_vault_setting, update_vault_setting,
) )
from backend.history import record_open, get_recent_opened, remove_recent, toggle_bookmark, get_bookmarks, is_bookmarked from backend.history import record_open, get_recent_opened, remove_recent, toggle_bookmark, get_bookmarks, is_bookmarked, update_bookmarks_after_rename, update_history_after_rename
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -545,9 +546,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["Content-Security-Policy"] = ( response.headers["Content-Security-Policy"] = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net; " "script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com; " "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net; "
"img-src 'self' data: blob:; " "img-src 'self' data: blob:; "
"connect-src 'self' https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com; " "connect-src 'self' https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
"font-src 'self' https://fonts.gstatic.com;" "font-src 'self' https://fonts.gstatic.com;"
) )
# Cache static assets aggressively (fingerprinted by PWA manifest hash) # Cache static assets aggressively (fingerprinted by PWA manifest hash)
@@ -654,7 +655,7 @@ except OSError:
build_pdf_html = None # type: ignore[assignment] build_pdf_html = None # type: ignore[assignment]
import logging import logging
logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)") logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)")
from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares # noqa: E402 from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares, update_shares_after_rename # noqa: E402
from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks # noqa: E402 from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks # noqa: E402
from backend.saved_searches import get_saved, save_search, delete_saved # noqa: E402 from backend.saved_searches import get_saved, save_search, delete_saved # noqa: E402
from backend.ai_routes import router as ai_router # noqa: E402 from backend.ai_routes import router as ai_router # noqa: E402
@@ -1814,6 +1815,11 @@ async def api_file_rename(
# Update index # Update index
await handle_file_move(vault_name, old_path_str, new_path_str) await handle_file_move(vault_name, old_path_str, new_path_str)
# Update bookmarks, history, and shares
update_bookmarks_after_rename(vault_name, old_path_str, new_path_str)
update_history_after_rename(vault_name, old_path_str, new_path_str)
update_shares_after_rename(vault_name, old_path_str, new_path_str)
# Broadcast SSE event # Broadcast SSE event
await sse_manager.broadcast("file_renamed", { await sse_manager.broadcast("file_renamed", {
"vault": vault_name, "vault": vault_name,
@@ -3602,6 +3608,114 @@ async def api_set_config(body: dict = Body(...), current_user=Depends(require_ad
return current return current
# ---------------------------------------------------------------------------
# AI API Keys — stored in data/api_keys.json, fallback to .env
# ---------------------------------------------------------------------------
from backend.ai import get_ai_key, _read_ai_keys # noqa: E402
AI_KEYS_FILE = Path("data/api_keys.json")
def _write_ai_keys(data: dict):
AI_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = AI_KEYS_FILE.with_suffix(".tmp")
tmp.write_text(_json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(AI_KEYS_FILE)
@app.get("/api/config/ai-keys")
async def api_get_ai_keys(current_user=Depends(require_admin)):
"""Return stored AI keys (values masked)."""
keys = _read_ai_keys()
masked = {}
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]:
val = keys.get(k, "") or os.environ.get(k, "")
if val:
masked[k] = val[:4] + "..." + val[-4:] if len(val) > 8 else "***"
else:
masked[k] = ""
return masked
@app.post("/api/config/ai-keys")
async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_admin)):
"""Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}"""
keys = _read_ai_keys()
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]:
if k in body and body[k]:
keys[k] = body[k]
_write_ai_keys(keys)
logger.info("AI keys updated")
return {"status": "ok"}
@app.post("/api/config/ai-keys/test")
async def api_test_ai_keys(current_user=Depends(require_admin)):
"""Test which AI providers are configured."""
results = {}
for key_name, label, test_url, test_header in [
("DEEPSEEK_API_KEY", "deepseek", "https://api.deepseek.com/v1/models", "Authorization"),
("OPENROUTER_API_KEY", "openrouter", "https://openrouter.ai/api/v1/models", "Authorization"),
("GEMINI_API_KEY", "gemini", "https://generativelanguage.googleapis.com/v1beta/models?key={key}", None),
]:
key = get_ai_key(key_name)
if not key:
results[label] = "non configuré"
continue
try:
import urllib.request
if test_header:
req = urllib.request.Request(test_url, headers={test_header: "Bearer " + key})
else:
req = urllib.request.Request(test_url.replace("{key}", key))
urllib.request.urlopen(req, timeout=5)
results[label] = "ok"
except Exception as e:
results[label] = "erreur: " + str(e)[:80]
return results
# ---------------------------------------------------------------------------
# AI Models — list available models per provider
# ---------------------------------------------------------------------------
@app.get("/api/config/ai-models")
async def api_list_ai_models(provider: str = Query(...), current_user=Depends(require_admin)):
"""List available models for a given AI provider."""
provider = provider.lower()
if provider not in ("deepseek", "openrouter", "gemini"):
return {"models": [], "error": f"Unknown provider: {provider}"}
key_name = f"{provider.upper()}_API_KEY"
key = get_ai_key(key_name)
if not key:
return {"models": [], "error": "API key not configured"}
# Build URL and request
if provider == "gemini":
url = f"https://generativelanguage.googleapis.com/v1beta/models?key={key}"
elif provider == "deepseek":
url = "https://api.deepseek.com/v1/models"
else: # openrouter
url = "https://openrouter.ai/api/v1/models"
try:
if provider == "gemini":
req = urllib.request.Request(url)
else:
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + key})
with urllib.request.urlopen(req, timeout=10) as resp:
data = _json.loads(resp.read().decode())
if provider == "gemini":
models = [m.get("name", "") for m in data.get("models", [])]
else:
models = [m.get("id", "") for m in data.get("data", [])]
return {"models": models}
except Exception as e:
return {"models": [], "error": str(e)}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Diagnostics API # Diagnostics API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+13
View File
@@ -109,3 +109,16 @@ def list_shares(vault_filter: Optional[str] = None) -> list:
# Most recent first # Most recent first
shares.sort(key=lambda s: s.get("created_at", ""), reverse=True) shares.sort(key=lambda s: s.get("created_at", ""), reverse=True)
return shares return shares
def update_shares_after_rename(vault: str, old_path: str, new_path: str):
"""Update all shares when a file is renamed."""
data = _read()
updated = False
for sid, s in data["shares"].items():
if s.get("vault") == vault and s.get("path") == old_path:
s["path"] = new_path
updated = True
logger.info(f"Updated share {sid}: {vault}/{old_path} -> {new_path}")
if updated:
_write(data)
+23
View File
@@ -0,0 +1,23 @@
services:
obsigate-podman:
build:
context: .
image: obsigate:latest
container_name: obsigate-podman
user: "1000:1000"
restart: unless-stopped
ports:
- "2022:8080"
volumes:
- /mnt/c/dev/git/python/obsigate/test_vault:/vaults/TestVault
- /mnt/c/dev/git/python/obsigate/test_dir:/vaults/TestDir
- /mnt/c/dev/git/python/obsigate/data:/app/data
environment:
- VAULT_1_NAME=TestVault
- VAULT_1_PATH=/vaults/TestVault
- DIR_1_NAME=TestDir
- DIR_1_PATH=/vaults/TestDir
- OBSIGATE_AUTH_ENABLED=false
- OBSIGATE_ADMIN_USER=admin
- OBSIGATE_ADMIN_PASSWORD=test123
- OBSIGATE_WATCHER_ENABLED=false
+5 -2
View File
@@ -9,11 +9,14 @@ services:
ports: ports:
- "2020:8080" - "2020:8080"
volumes: volumes:
- C:\dev\git\python\obsigate\test-vault:/vaults/TestVault - /c/dev/git/python/obsigate/test_vault:/vaults/TestVault
- C:\dev\git\python\obsigate\data:/app/data - /c/dev/git/python/obsigate/test_dir:/vaults/TestDir
- /c/dev/git/python/obsigate/data:/app/data
environment: environment:
- VAULT_1_NAME=TestVault - VAULT_1_NAME=TestVault
- VAULT_1_PATH=/vaults/TestVault - VAULT_1_PATH=/vaults/TestVault
- DIR_1_NAME=TestDir
- DIR_1_PATH=/vaults/TestDir
- OBSIGATE_AUTH_ENABLED=false - OBSIGATE_AUTH_ENABLED=false
- OBSIGATE_ADMIN_USER=admin - OBSIGATE_ADMIN_USER=admin
- OBSIGATE_ADMIN_PASSWORD=test123 - OBSIGATE_ADMIN_PASSWORD=test123
+421 -39
View File
@@ -29,6 +29,10 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
.bar-title { flex: 1; min-width: 80px; background: transparent; border: 1px solid transparent; color: var(--text); font-size: 13px; padding: 3px 6px; border-radius: var(--r); outline: none; font-family: var(--sans); } .bar-title { flex: 1; min-width: 80px; background: transparent; border: 1px solid transparent; color: var(--text); font-size: 13px; padding: 3px 6px; border-radius: var(--r); outline: none; font-family: var(--sans); }
.bar-title:hover { border-color: var(--border); } .bar-title:hover { border-color: var(--border); }
.bar-title:focus { border-color: var(--accent); background: var(--bg3); } .bar-title:focus { border-color: var(--accent); background: var(--bg3); }
.bar-sep { color: var(--text3); font-size: 13px; padding: 0 2px; user-select: none; }
.bar-filename { flex: 0 1 auto; min-width: 100px; max-width: 200px; background: transparent; border: 1px solid transparent; color: var(--text); font-size: 12px; padding: 3px 6px; border-radius: var(--r); outline: none; font-family: var(--mono); }
.bar-filename:hover { border-color: var(--border); }
.bar-filename:focus { border-color: var(--ok); background: var(--bg3); }
.bar-right { display: flex; align-items: center; gap: 4px; } .bar-right { display: flex; align-items: center; gap: 4px; }
.btn { height: 30px; padding: 0 10px; border-radius: var(--r); border: 1px solid transparent; background: transparent; color: var(--text2); cursor: pointer; font-size: 12px; display: flex; align-items: center; gap: 5px; white-space: nowrap; font-family: var(--sans); transition: all 120ms; } .btn { height: 30px; padding: 0 10px; border-radius: var(--r); border: 1px solid transparent; background: transparent; color: var(--text2); cursor: pointer; font-size: 12px; display: flex; align-items: center; gap: 5px; white-space: nowrap; font-family: var(--sans); transition: all 120ms; }
.btn:hover { background: var(--bg-hover); color: var(--text); } .btn:hover { background: var(--bg-hover); color: var(--text); }
@@ -36,7 +40,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
.btn-ai:hover { background: rgba(139,92,246,0.2); box-shadow: 0 0 18px var(--ai-glow); } .btn-ai:hover { background: rgba(139,92,246,0.2); box-shadow: 0 0 18px var(--ai-glow); }
.btn-ai.active { background: var(--ai); color: #fff; } .btn-ai.active { background: var(--ai); color: #fff; }
.btn-ai.off { color: var(--text3); border-color: var(--border); box-shadow: none; background: transparent; } .btn-ai.off { color: var(--text3); border-color: var(--border); box-shadow: none; background: transparent; }
.save-dot { display: flex; align-items: center; gap: 4px; font-size: 11px; padding: 0 4px; } .save-dot { display: flex; align-items: center; gap: 4px; font-size: 11px; padding: 0 4px; margin-right: 8px; }
.save-dot .dot { width: 6px; height: 6px; border-radius: 50%; } .save-dot .dot { width: 6px; height: 6px; border-radius: 50%; }
.save-dot.ok { color: var(--ok); } .save-dot.ok .dot { background: var(--ok); } .save-dot.ok { color: var(--ok); } .save-dot.ok .dot { background: var(--ok); }
.save-dot.dirty { color: var(--warn); } .save-dot.dirty .dot { background: var(--warn); } .save-dot.dirty { color: var(--warn); } .save-dot.dirty .dot { background: var(--warn); }
@@ -44,6 +48,13 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
.save-dot.err { color: var(--err); } .save-dot.err .dot { background: var(--err); } .save-dot.err { color: var(--err); } .save-dot.err .dot { background: var(--err); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
/* Square action buttons (delete/cancel/save) — match basic editor */
.btn-square { width: 36px; height: 36px; padding: 0; justify-content: center; font-size: 16px; border-radius: 6px; border: 1px solid var(--border); background: transparent; color: var(--text2); cursor: pointer; transition: all 150ms; display: inline-flex; align-items: center; }
.btn-square:hover { color: var(--accent); border-color: var(--accent); }
.btn-square.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-square.primary:hover { opacity: 0.9; }
.btn-square.danger:hover { color: var(--err); border-color: var(--err); }
/* Workspace */ /* Workspace */
.ws { display: flex; flex: 1; overflow: hidden; } .ws { display: flex; flex: 1; overflow: hidden; }
.editor-area { flex: 1; display: flex; overflow: hidden; position: relative; } .editor-area { flex: 1; display: flex; overflow: hidden; position: relative; }
@@ -55,9 +66,18 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
.textarea { width: 100%; height: auto; min-height: 100%; resize: none; overflow: hidden; background: transparent; border: none; outline: none; color: var(--text); font-family: var(--mono); font-size: 13.5px; line-height: 1.75; tab-size: 2; } .textarea { width: 100%; height: auto; min-height: 100%; resize: none; overflow: hidden; background: transparent; border: none; outline: none; color: var(--text); font-family: var(--mono); font-size: 13.5px; line-height: 1.75; tab-size: 2; }
.textarea::placeholder { color: var(--text3); } .textarea::placeholder { color: var(--text3); }
/* Quick insert + */ /* Line numbers */
.qi-btn { position: fixed; top: 56px; left: 48px; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--border); background: var(--bg2); color: var(--text3); cursor: pointer; font-size: 16px; font-weight: 700; display: flex; align-items: center; justify-content: center; z-index: 50; transition: all 100ms; } .ln-wrap { position: relative; display: flex; flex: 1; overflow: hidden; }
.ln-gutter { width: 44px; min-width: 44px; background: var(--bg2); border-right: 1px solid var(--border); overflow: hidden; user-select: none; padding-top: 24px; position: relative; z-index: 2; }
.ln-gutter-inner { padding: 0 8px 0 12px; text-align: right; font-family: var(--mono); font-size: 11.5px; line-height: 1.75; color: var(--text3); }
.ln-editor { flex: 1; overflow-y: auto; position: relative; }
.editor-wrap { flex: 1; overflow-y: auto; position: relative; padding: 24px 16px 24px 12px; }
/* Quick insert + — centered in gutter top */
.qi-btn { position: absolute; top: 2px; left: 50%; transform: translateX(-50%); width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--border); background: var(--bg2); color: var(--text3); cursor: pointer; font-size: 13px; display: flex; align-items: center; justify-content: center; transition: all 100ms; opacity: 0; z-index: 3; }
.ln-gutter:hover .qi-btn, .qi-btn:hover { opacity: 1; }
.qi-btn:hover { color: var(--text); border-color: var(--accent); background: var(--accent-bg); } .qi-btn:hover { color: var(--text); border-color: var(--accent); background: var(--accent-bg); }
.ln-gutter { width: 44px; min-width: 44px; background: var(--bg2); border-right: 1px solid var(--border); overflow: hidden; user-select: none; padding-top: 24px; position: relative; z-index: 2; }
/* Preview pane */ /* Preview pane */
.preview-pane { flex: 0.67; overflow-y: auto; padding: 24px 24px 80px; background: var(--bg2); border-left: 1px solid var(--border); display: none; } .preview-pane { flex: 0.67; overflow-y: auto; padding: 24px 24px 80px; background: var(--bg2); border-left: 1px solid var(--border); display: none; }
@@ -103,7 +123,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
.ai-chat-placeholder { color: var(--text3); font-size: 12px; padding: 10px; } .ai-chat-placeholder { color: var(--text3); font-size: 12px; padding: 10px; }
/* Slash menu */ /* Slash menu */
.slash { position: absolute; z-index: 200; width: 300px; max-height: 360px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; } .slash { position: fixed; z-index: 200; width: 300px; max-height: 360px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; }
.slash.on { display: block; animation: fadeIn 120ms; } .slash.on { display: block; animation: fadeIn 120ms; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
.slash-search { padding: 8px 10px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg4); border-radius: var(--R) var(--R) 0 0; } .slash-search { padding: 8px 10px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg4); border-radius: var(--R) var(--R) 0 0; }
@@ -175,6 +195,19 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
.toast.on { display: block; } .toast.on { display: block; }
.toast.ok { border-color: var(--ok); } .toast.err { border-color: var(--err); } .toast.ok { border-color: var(--ok); } .toast.err { border-color: var(--err); }
/* Help panel */
.help-overlay { position: fixed; inset: 0; z-index: 500; display: none; align-items: center; justify-content: center; background: rgba(15,17,23,0.8); }
.help-overlay.on { display: flex; }
.help-panel { background: var(--bg2); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); max-width: 560px; width: 90%; max-height: 75vh; overflow-y: auto; padding: 24px; }
.help-panel h2 { font-size: 18px; margin: 0 0 16px; color: var(--accent); }
.help-panel h3 { font-size: 13px; margin: 16px 0 6px; color: var(--text); text-transform: uppercase; letter-spacing: 0.5px; }
.help-panel table { width: 100%; border-collapse: collapse; font-size: 12px; }
.help-panel td { padding: 4px 8px; border-bottom: 1px solid var(--border); }
.help-panel td:first-child { font-family: var(--mono); color: var(--accent); white-space: nowrap; width: 140px; }
.help-panel td:last-child { color: var(--text2); }
.help-panel .help-close { display: block; margin: 16px auto 0; padding: 6px 20px; border-radius: var(--r); border: 1px solid var(--border); background: var(--bg3); color: var(--text); cursor: pointer; font-size: 12px; font-family: var(--sans); }
.help-panel .help-close:hover { background: var(--bg-hover); }
/* Loading spinner */ /* Loading spinner */
.spin { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--text3); border-top-color: var(--ai); border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 6px; vertical-align: middle; } .spin { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--text3); border-top-color: var(--ai); border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 6px; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@@ -191,12 +224,18 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
<div class="bar-left"> <div class="bar-left">
<button class="bar-hamburger" title="Menu">&#9776;</button> <button class="bar-hamburger" title="Menu">&#9776;</button>
<span class="bar-brand">ObsiGate</span> <span class="bar-brand">ObsiGate</span>
<input class="bar-title" id="poc-title" placeholder="Titre..." spellcheck="false"> <input class="bar-title" id="poc-title" placeholder="Titre du document..." spellcheck="false" title="Titre (sync avec # Heading)">
<span class="bar-sep">/</span>
<input class="bar-filename" id="poc-filename" placeholder="nom.md" spellcheck="false" title="Renommer le fichier">
</div> </div>
<div class="bar-right"> <div class="bar-right">
<button class="btn" id="btn-preview" title="Toggle preview">&#128065; Preview</button> <button class="btn" id="btn-help" title="Aide / Raccourcis (F1)">&#63;</button>
<button class="btn btn-ai off" id="btn-ai" title="AI Panel (Ctrl+J)">&#10024; AI</button> <button class="btn" id="btn-preview" title="Toggle preview">&#128065;</button>
<button class="btn btn-ai off" id="btn-ai" title="AI Panel (Ctrl+J)">&#10024;</button>
<div class="save-dot ok" id="save-dot"><span class="dot"></span><span id="save-label">Saved</span></div> <div class="save-dot ok" id="save-dot"><span class="dot"></span><span id="save-label">Saved</span></div>
<button class="btn btn-square danger" id="forge-delete" title="Supprimer">&#128465;</button>
<button class="btn btn-square" id="forge-cancel" title="Annuler">&#10005;</button>
<button class="btn btn-square primary" id="forge-save" title="Sauvegarder (Ctrl+S)">&#10003;</button>
</div> </div>
</div> </div>
@@ -209,11 +248,16 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
<div class="drop-msg"><div class="drop-icon">&#128444;</div><div class="drop-title">Drop files here</div><div class="drop-sub">Images, PDFs, documents</div></div> <div class="drop-msg"><div class="drop-icon">&#128444;</div><div class="drop-title">Drop files here</div><div class="drop-sub">Images, PDFs, documents</div></div>
</div> </div>
<!-- Editor pane (textarea) --> <!-- Editor pane (textarea) with line numbers -->
<div class="editor-pane" id="editor-pane"> <div class="editor-pane" id="editor-pane">
<div class="editor-wrap" id="editor-wrap"> <div class="ln-wrap">
<button class="qi-btn" id="qi-btn" title="Quick insert">&#8853;</button> <div class="ln-gutter" id="ln-gutter">
<textarea class="textarea" id="ta" placeholder="Type / for commands..." spellcheck="false"></textarea> <button class="qi-btn" id="qi-btn" title="Quick insert (Alt+I)">+</button>
<div class="ln-gutter-inner" id="ln-gutter-inner"></div>
</div>
<div class="ln-editor" id="ln-editor">
<div class="editor-wrap" id="editor-wrap">
<textarea class="textarea" id="ta" placeholder="Type / for commands..." spellcheck="false"></textarea>
<!-- Quick Insert menu --> <!-- Quick Insert menu -->
<div class="qi-menu" id="qi-menu"> <div class="qi-menu" id="qi-menu">
@@ -273,6 +317,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
<div class="slash-item" data-action="ai-translate"><span class="slash-icon" style="color:var(--ai)">&#10024;</span><div class="slash-body"><div class="slash-title">AI: Translate</div><div class="slash-desc">To another language</div></div></div> <div class="slash-item" data-action="ai-translate"><span class="slash-icon" style="color:var(--ai)">&#10024;</span><div class="slash-body"><div class="slash-title">AI: Translate</div><div class="slash-desc">To another language</div></div></div>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
<!-- Resize handle --> <!-- Resize handle -->
@@ -344,11 +390,57 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
<!-- Toast --> <!-- Toast -->
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<!-- Help panel -->
<div class="help-overlay" id="help-overlay">
<div class="help-panel">
<h2>Forge Editor — Aide</h2>
<h3>Navigation</h3>
<table>
<tr><td>/</td><td>Menu de commandes (en debut de ligne)</td></tr>
<tr><td>Alt+I</td><td>Insertion rapide (Quick Insert)</td></tr>
<tr><td>F1</td><td>Ce panneau d'aide</td></tr>
<tr><td>Tab</td><td>Indenter la ligne / element de liste</td></tr>
<tr><td>Shift+Tab</td><td>Desindenter</td></tr>
</table>
<h3>Formatage</h3>
<table>
<tr><td>Ctrl+B</td><td>**Gras**</td></tr>
<tr><td>Ctrl+I</td><td>*Italique*</td></tr>
<tr><td>Ctrl+K</td><td>Inserer un lien [texte](url)</td></tr>
<tr><td>Selection + [</td><td>Envelopper en [lien](https://)</td></tr>
<tr><td>Selection + `</td><td>Envelopper en `code`</td></tr>
</table>
<h3>Brackets & Auto-completion</h3>
<table>
<tr><td>[</td><td>Auto-ferme [] avec curseur au milieu</td></tr>
<tr><td>(</td><td>Auto-ferme () — si apres [texte], insere https://</td></tr>
<tr><td>"</td><td>Auto-ferme ""</td></tr>
<tr><td>`</td><td>Auto-ferme ``</td></tr>
<tr><td>Backspace</td><td>Supprime la paire de brackets d'un coup</td></tr>
</table>
<h3>Fichier</h3>
<table>
<tr><td>Ctrl+S</td><td>Sauvegarder</td></tr>
<tr><td>Clic sur le titre</td><td>Renommer le fichier</td></tr>
<tr><td>Ctrl+J</td><td>Panneau AI</td></tr>
</table>
<h3>Slash — Commandes</h3>
<table>
<tr><td>/h1 /h2 /h3</td><td>Titres</td></tr>
<tr><td>/bullet /numbered</td><td>Listes</td></tr>
<tr><td>/checklist</td><td>Liste de taches</td></tr>
<tr><td>/quote /code /divider</td><td>Blocs</td></tr>
<tr><td>/link /image /table</td><td>Insertions</td></tr>
</table>
<button class="help-close" id="help-close">Fermer</button>
</div>
</div>
<!-- marked.js + highlight.js + Mermaid CDN for preview rendering --> <!-- marked.js + highlight.js + Mermaid CDN for preview rendering -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/atom-one-dark.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/styles/atom-one-dark.min.css">
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/highlight.min.js"></script> <script src="https://cdn.jsdelivr.net/gh/highlightjs/[email protected]/build/highlight.min.js"></script>
<script> <script>
(function() { (function() {
@@ -358,6 +450,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var ta = document.getElementById('ta'); var ta = document.getElementById('ta');
var editorWrap = document.getElementById('editor-wrap'); var editorWrap = document.getElementById('editor-wrap');
var editorArea = document.getElementById('editor-area'); var editorArea = document.getElementById('editor-area');
var lnGutter = document.getElementById('ln-gutter-inner');
var lnEditor = document.getElementById('ln-editor');
var previewPane = document.getElementById('preview-pane'); var previewPane = document.getElementById('preview-pane');
var previewContent = document.getElementById('preview-content'); var previewContent = document.getElementById('preview-content');
var resizeHandle = document.getElementById('resize-handle'); var resizeHandle = document.getElementById('resize-handle');
@@ -376,6 +470,10 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var statAI = document.getElementById('stat-ai'); var statAI = document.getElementById('stat-ai');
var toast = document.getElementById('toast'); var toast = document.getElementById('toast');
var dropOverlay = document.getElementById('drop-overlay'); var dropOverlay = document.getElementById('drop-overlay');
var helpOverlay = document.getElementById('help-overlay');
var btnHelp = document.getElementById('btn-help');
var pocTitle = document.getElementById('poc-title');
var pocFilename = document.getElementById('poc-filename');
// ---- State ---- // ---- State ----
var fileVault = null; var fileVault = null;
@@ -503,7 +601,12 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
if (!r.ok) throw new Error(r.status); if (!r.ok) throw new Error(r.status);
isDirty = false; originalContent = content; isDirty = false; originalContent = content;
saveDot.className = 'save-dot ok'; saveLabel.textContent = 'Saved'; saveDot.className = 'save-dot ok'; saveLabel.textContent = 'Saved';
showToast('Saved', 'ok'); // Wait for save-dot to update, then close
setTimeout(function() {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'forge-close' }, '*');
}
}, 300);
}).catch(function(e) { }).catch(function(e) {
saveDot.className = 'save-dot err'; saveLabel.textContent = 'Erreur'; saveDot.className = 'save-dot err'; saveLabel.textContent = 'Erreur';
showToast('Echec sauvegarde: ' + e.message, 'err'); showToast('Echec sauvegarde: ' + e.message, 'err');
@@ -524,13 +627,19 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
return; return;
} }
statFile.textContent = 'Loading...'; statFile.textContent = 'Loading...';
fetch('/api/file/' + encodeURIComponent(fileVault) + '/raw?path=' + encodeURIComponent(filePath)) var url = '/api/file/' + encodeURIComponent(fileVault) + '/raw?path=' + encodeURIComponent(filePath);
fetch(url, { credentials: 'include' })
.then(function(r) { .then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status); if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json(); return r.json();
}) })
.then(function(data) { .then(function(data) {
ta.value = data.raw || ''; var raw = data.raw || data.content || '';
if (!raw && data.raw_size) {
// Raw might not be in the JSON — try reading from the response differently
raw = '# Erreur\n\nDonnees recues mais aucun contenu brut.\n\nChamps: ' + Object.keys(data).join(', ');
}
ta.value = raw;
autoHeight(); autoHeight();
originalContent = ta.value; originalContent = ta.value;
// Extract title from first # heading or filename // Extract title from first # heading or filename
@@ -538,12 +647,14 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var m = ta.value.match(/^#\s+(.+)/m); var m = ta.value.match(/^#\s+(.+)/m);
if (m) title = m[1]; if (m) title = m[1];
document.getElementById('poc-title').value = title; document.getElementById('poc-title').value = title;
pocFilename.value = filePath.split('/').pop();
statFile.textContent = fileVault + '/' + filePath; statFile.textContent = fileVault + '/' + filePath;
document.title = title + ' — ObsiGate Forge'; document.title = title + ' — ObsiGate Forge';
updateStats(); updateStats();
schedulePreview(); schedulePreview();
}) })
.catch(function(e) { .catch(function(e) {
console.error('[Forge] Load error:', url, e);
ta.value = '# Erreur\n\nImpossible de charger: ' + (fileVault || '?') + '/' + (filePath || '?') + '\n\n' + e.message; ta.value = '# Erreur\n\nImpossible de charger: ' + (fileVault || '?') + '/' + (filePath || '?') + '\n\n' + e.message;
statFile.textContent = 'Erreur chargement'; statFile.textContent = 'Erreur chargement';
originalContent = ta.value; originalContent = ta.value;
@@ -759,27 +870,49 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
function initSlashItems() { slashItems = Array.from(slashMenu.querySelectorAll('.slash-item')); } function initSlashItems() { slashItems = Array.from(slashMenu.querySelectorAll('.slash-item')); }
var SLASH_INSERT = { var SLASH_INSERT = {
h1: function() { replaceLine('# '); }, h1: function() { prefixLine('# '); },
h2: function() { replaceLine('## '); }, h2: function() { prefixLine('## '); },
h3: function() { replaceLine('### '); }, h3: function() { prefixLine('### '); },
bullet: function() { replaceLine('- '); }, bullet: function() { prefixLine('- '); },
numbered: function() { replaceLine('1. '); }, numbered: function() { prefixLine('1. '); },
checklist: function() { replaceLine('- [ ] '); }, checklist: function() { prefixLine('- [ ] '); },
quote: function() { replaceLine('> '); }, quote: function() { prefixLine('> '); },
divider: function() { insertAtCursor('\n---\n'); }, divider: function() { insertAtCursor('\n---\n'); },
image: function() { insertAtCursor('![](url)', 2); }, link: function() { insertAtCursor('[text](https://)', 7); },
link: function() { insertAtCursor('[text](url)', 1); }, image: function() { insertAtCursor('![alt](https://)', 8); },
}; };
// prefixLine: replaces line start with prefix, preserving existing text
function prefixLine(prefix) {
var l = getLine();
var existing = l.text.replace(/^\s*\/?\s*/, '');
if (existing) {
replaceLine(prefix + existing);
} else {
replaceLine(prefix);
}
}
function showSlash() { function showSlash() {
slashMenu.classList.add('on'); slashVisible = true; slashIdx = -1; slashMenu.classList.add('on'); slashVisible = true; slashIdx = -1;
slashSearch.value = ''; slashSearch.focus(); slashSearch.value = ''; slashSearch.focus();
filterSlash(''); filterSlash('');
// Position below current line at slash character // Position below current line at slash character (viewport-relative)
var pos = getPos().s - 1; // position of the / character var pos = getPos().s - 1; // position of the / character
var pt = caretToPixel(pos); var pt = caretToPixel(pos);
slashMenu.style.left = pt.x + 'px'; var wr = editorWrap.getBoundingClientRect();
slashMenu.style.top = pt.y + 'px'; var top = wr.top + pt.y + 8;
var left = wr.left + pt.x;
// Clamp to viewport so menu is always fully visible
var menuH = 360; // max-height
if (top + menuH > window.innerHeight - 10) {
top = window.innerHeight - menuH - 10;
}
if (left + 300 > window.innerWidth - 10) {
left = window.innerWidth - 310;
}
slashMenu.style.left = left + 'px';
slashMenu.style.top = top + 'px';
} }
function hideSlash() { slashMenu.classList.remove('on'); slashVisible = false; resetSlash(); } function hideSlash() { slashMenu.classList.remove('on'); slashVisible = false; resetSlash(); }
@@ -810,9 +943,9 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
function getVisibleSlash() { return slashItems.filter(function(i) { return i.style.display !== 'none'; }); } function getVisibleSlash() { return slashItems.filter(function(i) { return i.style.display !== 'none'; }); }
function selectSlashAction(action) { function selectSlashAction(action) {
// Clean up the /command text from the current line // Clean up the / character from the current line, preserving any text after it
var line = getLine(); var line = getLine();
var m = line.text.match(/^\/\S*\s*/); var m = line.text.match(/^\/\s*/);
if (m) { if (m) {
ta.value = val().substring(0, line.start) + val().substring(line.start + m[0].length); ta.value = val().substring(0, line.start) + val().substring(line.start + m[0].length);
setPos(line.start); setPos(line.start);
@@ -1064,6 +1197,59 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
// ---- Event handlers ---- // ---- Event handlers ----
ta.addEventListener('keydown', function(e) { ta.addEventListener('keydown', function(e) {
// Auto-close brackets: type [ → [] with cursor inside
// If text is selected, wrap selection instead
if (e.key === '[' && !e.ctrlKey && !e.metaKey) {
var p = getPos();
if (p.s !== p.e) { e.preventDefault(); wrapSelection('[', ']'); return; }
e.preventDefault();
insertAtCursor('[]', 1);
return;
}
if (e.key === '(' && !e.ctrlKey && !e.metaKey) {
var p = getPos();
if (p.s !== p.e) { e.preventDefault(); wrapSelection('(', ')'); return; }
e.preventDefault();
insertAtCursor('()', 1);
return;
}
if (e.key === '"' && !e.ctrlKey && !e.metaKey) {
var p = getPos();
if (p.s !== p.e) { e.preventDefault(); wrapSelection('"', '"'); return; }
// Don't auto-close if next char is already a quote or we're inside a word
var next = val().charAt(p.s);
if (next === '"') { return; } // let default handle — moves cursor past
e.preventDefault();
insertAtCursor('""', 1);
return;
}
if (e.key === '`' && !e.ctrlKey && !e.metaKey) {
var p = getPos();
if (p.s !== p.e) { e.preventDefault(); wrapSelection('`', '`'); return; }
// Don't auto-close if next char is a backtick
var next = val().charAt(p.s);
if (next === '`') { return; }
e.preventDefault();
insertAtCursor('``', 1);
return;
}
// Smart backspace: delete paired brackets together
if (e.key === 'Backspace') {
var p = getPos();
if (p.s === p.e) {
var before = val().charAt(p.s - 1);
var after = val().charAt(p.s);
var pairs = { '[': ']', '(': ')', '"': '"', '`': '`' };
if (pairs[before] && pairs[before] === after) {
e.preventDefault();
ta.value = val().substring(0, p.s - 1) + val().substring(p.s + 1);
setPos(p.s - 1);
markDirty();
return;
}
}
}
// Slash menu trigger: type / at start of line // Slash menu trigger: type / at start of line
if (e.key === '/') { if (e.key === '/') {
var l = getLine(); var p = getPos(); var l = getLine(); var p = getPos();
@@ -1084,6 +1270,34 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
if (e.altKey && e.key === 'i') { e.preventDefault(); showQI(); } if (e.altKey && e.key === 'i') { e.preventDefault(); showQI(); }
}); });
// Link auto-complete: when [text]( is typed, insert https:// prefix
ta.addEventListener('keyup', function(e) {
if (e.key === '/' && slashVisible) {
var l = getLine(); var p = getPos();
var before = l.text.substring(0, p.s - l.start);
if (!/^\/\S*$/.test(before)) hideSlash();
}
// Detect [text]( pattern — auto-insert https://
if (e.key === '(') {
var p = getPos();
var v = val();
// Look backward for [text] pattern
var searchEnd = p.s - 1; // position of the (
var bracketStart = v.lastIndexOf('[', searchEnd);
if (bracketStart >= 0) {
var bracketEnd = v.indexOf(']', bracketStart);
if (bracketEnd >= bracketStart && bracketEnd < searchEnd + 2) {
// We have [text] immediately before the (
// Insert https:// after (
var np = p.s; // cursor is after (
ta.value = v.substring(0, np) + 'https://' + v.substring(np);
setPos(np + 8);
markDirty();
}
}
}
});
// Slash menu keyboard nav (bound to search input which has focus) // Slash menu keyboard nav (bound to search input which has focus)
slashSearch.addEventListener('keydown', function(e) { slashSearch.addEventListener('keydown', function(e) {
if (!slashVisible) return; if (!slashVisible) return;
@@ -1119,14 +1333,6 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
qiItems.forEach(function(item, i) { item.style.background = i === qiIdx ? 'var(--bg-hover)' : ''; item.style.color = i === qiIdx ? 'var(--text)' : ''; }); qiItems.forEach(function(item, i) { item.style.background = i === qiIdx ? 'var(--bg-hover)' : ''; item.style.color = i === qiIdx ? 'var(--text)' : ''; });
} }
ta.addEventListener('keyup', function(e) {
if (e.key === '/' && slashVisible) {
var l = getLine(); var p = getPos();
var before = l.text.substring(0, p.s - l.start);
if (!/^\/\S*$/.test(before)) hideSlash();
}
});
ta.addEventListener('input', function() { ta.addEventListener('input', function() {
updateStats(); markDirty(); updateStats(); markDirty();
autoHeight(); autoHeight();
@@ -1201,6 +1407,27 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
btnPreview.addEventListener('click', togglePreview); btnPreview.addEventListener('click', togglePreview);
// Action buttons (delete/cancel/save) — same as basic editor
document.getElementById('forge-save').addEventListener('click', function() { forceSave(); });
document.getElementById('forge-cancel').addEventListener('click', function() {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'forge-close' }, '*');
}
});
document.getElementById('forge-delete').addEventListener('click', function() {
if (!fileVault || !filePath) return;
if (!confirm('Supprimer definitivement ' + filePath + ' ?')) return;
fetch('/api/file/' + encodeURIComponent(fileVault) + '?path=' + encodeURIComponent(filePath), {
method: 'DELETE', credentials: 'include'
}).then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
showToast('Fichier supprime', 'ok');
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'forge-close' }, '*');
}
}).catch(function(e) { showToast('Echec suppression: ' + e.message, 'err'); });
});
document.getElementById('poc-title').addEventListener('input', function() { markDirty(); }); document.getElementById('poc-title').addEventListener('input', function() { markDirty(); });
// ---- Init ---- // ---- Init ----
@@ -1242,7 +1469,162 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
} }
} }
console.log('ObsiGate Editor v1 ready'); // ---- Line numbers ----
function updateLineNumbers() {
if (!lnGutter) return;
var lines = val().split('\n');
var html = '';
for (var i = 0; i < lines.length; i++) {
html += (i + 1) + '<br>';
}
lnGutter.innerHTML = html;
}
// Sync scroll between textarea and line numbers gutter
editorWrap.addEventListener('scroll', function() {
if (lnGutter) {
document.getElementById('ln-gutter').scrollTop = editorWrap.scrollTop;
}
});
// Update line numbers on input
var _lnUpdateTimer = null;
ta.addEventListener('input', function() {
if (_lnUpdateTimer) clearTimeout(_lnUpdateTimer);
_lnUpdateTimer = setTimeout(updateLineNumbers, 100);
});
// ---- Help panel ----
btnHelp.addEventListener('click', function() { helpOverlay.classList.add('on'); });
document.getElementById('help-close').addEventListener('click', function() { helpOverlay.classList.remove('on'); });
helpOverlay.addEventListener('click', function(e) { if (e.target === helpOverlay) helpOverlay.classList.remove('on'); });
document.addEventListener('keydown', function(e) {
if (e.key === 'F1') { e.preventDefault(); helpOverlay.classList.toggle('on'); }
if (e.key === 'Escape' && helpOverlay.classList.contains('on')) { helpOverlay.classList.remove('on'); }
});
// ---- Autocomplete (Tab completion from existing words) ----
var _acTimer = null;
ta.addEventListener('keydown', function(e) {
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !slashVisible && !qiMenu.classList.contains('on')) {
var p = getPos();
if (p.s !== p.e) return; // Don't autocomplete when selection exists (let indent handle it)
var v = val();
// Find the word fragment before cursor
var start = p.s;
while (start > 0 && /[\w\-\.\/]/.test(v.charAt(start - 1))) start--;
var fragment = v.substring(start, p.s);
if (fragment.length < 2) return; // Need at least 2 chars
// Find matching words in the document
var re = new RegExp('\\b' + fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\w\\-\\.\\/]+', 'gi');
var matches = [];
var m;
while ((m = re.exec(v)) !== null) {
if (matches.indexOf(m[0]) === -1) matches.push(m[0]);
}
if (matches.length === 1) {
e.preventDefault();
insertAtCursor(matches[0].substring(fragment.length), 0);
}
}
});
// ---- Title sync: first heading line <-> title bar (NO rename) ----
function syncTitleFromContent() {
var v = val();
var m = v.match(/^#\s+(.+)/m);
var contentTitle = m ? m[1] : '';
if (contentTitle && pocTitle.value !== contentTitle) {
pocTitle.value = contentTitle;
}
}
function syncContentFromTitle() {
var v = val();
var titleVal = pocTitle.value.trim();
if (!titleVal) return;
var m = v.match(/^#\s+(.+)/m);
if (m) {
// Update existing heading
ta.value = v.substring(0, m.index + 2) + titleVal + v.substring(m.index + 2 + m[1].length);
markDirty();
} else {
// Insert heading at top
ta.value = '# ' + titleVal + '\n' + v;
setPos(0);
markDirty();
}
}
ta.addEventListener('input', function() {
var v = val();
var m = v.match(/^#\s+(.+)/m);
if (m && m.index === 0) {
pocTitle.value = m[1];
}
});
pocTitle.addEventListener('input', function() {
syncContentFromTitle();
});
// ---- File rename (via filename field only, with reindex) ----
var _originalFilename = '';
pocFilename.addEventListener('focus', function() {
_originalFilename = pocFilename.value;
});
pocFilename.addEventListener('blur', function() {
var newName = pocFilename.value.trim();
if (!newName || newName === _originalFilename) return;
if (!fileVault || !filePath) return;
// Ensure .md extension
if (!/\.md$/i.test(newName)) newName += '.md';
fetch('/api/file/' + encodeURIComponent(fileVault), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ path: filePath, new_name: newName })
}).then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function(data) {
var oldPath = filePath;
filePath = data.new_path || newName;
pocFilename.value = filePath.split('/').pop();
statFile.textContent = fileVault + '/' + filePath;
document.title = (pocTitle.value || filePath) + ' — ObsiGate Forge';
showToast('Fichier renomme en ' + filePath, 'ok');
// Notify parent window to refresh
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'forge-renamed', vault: fileVault, oldPath: oldPath, newPath: filePath }, '*');
}
}).catch(function(e) {
showToast('Echec renommage: ' + e.message, 'err');
pocFilename.value = _originalFilename;
});
});
// Also trigger on Enter
pocFilename.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); pocFilename.blur(); }
});
// Update filename display on file load
var _origLoadFile3 = loadFile;
loadFile = function() {
_origLoadFile3();
setTimeout(function() {
syncTitleFromContent();
updateLineNumbers();
// Set filename field from current path
if (filePath) {
pocFilename.value = filePath.split('/').pop();
}
}, 300);
};
// ---- Init ----
updateLineNumbers();
console.log('ObsiGate Editor v2 ready');
console.log(' File: ' + (fileVault ? fileVault + '/' + filePath : 'demo mode')); console.log(' File: ' + (fileVault ? fileVault + '/' + filePath : 'demo mode'));
console.log(' AI: checking...'); console.log(' AI: checking...');
console.log(' Type / for commands | Select text for bubble | Ctrl+J AI | Ctrl+S save'); console.log(' Type / for commands | Select text for bubble | Ctrl+J AI | Ctrl+S save');
+71 -49
View File
@@ -1137,46 +1137,23 @@
<div class="editor-modal" id="editor-modal"> <div class="editor-modal" id="editor-modal">
<div class="editor-container"> <div class="editor-container">
<div class="editor-header"> <div class="editor-header">
<div class="editor-title" id="editor-title">Édition</div> <span class="editor-brand">ObsiGate</span>
<div class="editor-actions"> <input class="editor-title-input" id="editor-title-input" placeholder="Titre..." spellcheck="false" title="Titre (sync avec # Heading)">
<button <span class="editor-sep">/</span>
class="editor-btn danger" <input class="editor-filename-input" id="editor-filename-input" placeholder="nom.md" spellcheck="false" title="Renommer le fichier">
id="editor-delete" <div class="editor-spacer"></div>
title="Supprimer" <span class="editor-save-dot ok" id="editor-save-dot"><span class="dot"></span><span id="editor-save-label">Saved</span></span>
aria-label="Supprimer" <button class="editor-btn danger" id="editor-delete" title="Supprimer" aria-label="Supprimer">&#128465;</button>
> <button class="editor-btn" id="editor-cancel" title="Annuler" aria-label="Annuler">&#10005;</button>
<i <button class="editor-btn primary" id="editor-save" title="Sauvegarder" aria-label="Sauvegarder">&#10003;</button>
data-lucide="trash-2"
style="width: 16px; height: 16px"
></i>
</button>
<button
class="editor-btn"
id="editor-cancel"
title="Annuler"
aria-label="Annuler"
>
<i
data-lucide="x"
style="width: 16px; height: 16px"
></i>
</button>
<button
class="editor-btn primary"
id="editor-save"
title="Sauvegarder"
aria-label="Sauvegarder"
>
<i
data-lucide="check"
style="width: 16px; height: 16px"
></i>
</button>
</div>
</div> </div>
<div class="editor-body" id="editor-body"> <div class="editor-body editor-body-cm" id="editor-body">
<div id="ai-toolbar-container"></div> <div id="ai-toolbar-container"></div>
</div> </div>
<div class="editor-footer">
<span class="editor-stat-file" id="editor-stat-file"></span>
<span class="editor-stat-words" id="editor-stat-words">0 words</span>
</div>
</div> </div>
</div> </div>
@@ -1199,10 +1176,26 @@
</button> </button>
</div> </div>
</div> </div>
<div class="editor-body" id="config-body"> <div class="editor-body help-body" id="config-body">
<nav class="help-nav" id="config-nav">
<div class="help-nav-header"><div class="help-nav-title">Configuration</div></div>
<div class="help-nav-search-wrap">
<input type="text" class="help-nav-search" id="config-nav-search" placeholder="Rechercher..." autocomplete="off" spellcheck="false">
<button class="help-search-clear" id="config-search-clear" title="Effacer" onclick="var s=document.getElementById('config-nav-search');if(s){s.value='';s.dispatchEvent(new Event('input'));s.focus();}">X</button>
</div>
<ul class="help-nav-list">
<li><a href="#cfg-search" class="help-nav-link">🔍 Recherche</a></li>
<li><a href="#cfg-backend" class="help-nav-link">⚙️ Backend</a></li>
<li><a href="#cfg-sync" class="help-nav-link">🔄 Synchronisation</a></li>
<li><a href="#cfg-diags" class="help-nav-link">🩺 Diagnostics</a></li>
<li><a href="#cfg-ai" class="help-nav-link">🤖 IA</a></li>
<li><a href="#cfg-about" class="help-nav-link">📦 A propos</a></li>
</ul>
</nav>
<div class="help-content" id="config-scroll">
<div class="config-content"> <div class="config-content">
<!-- Performance Settings - Frontend --> <!-- Performance Settings - Frontend -->
<section class="config-section"> <section class="config-section help-section" id="cfg-search">
<h2>Paramètres de recherche</h2> <h2>Paramètres de recherche</h2>
<p class="config-description"> <p class="config-description">
Ces paramètres s'appliquent immédiatement côté Ces paramètres s'appliquent immédiatement côté
@@ -1286,7 +1279,7 @@
</section> </section>
<!-- Historique récent --> <!-- Historique récent -->
<section class="config-section"> <section class="config-section help-section">
<h2> <h2>
📋 Historique récent 📋 Historique récent
<span class="config-badge-restart" <span class="config-badge-restart"
@@ -1335,7 +1328,7 @@
</section> </section>
<!-- Performance Settings - Backend --> <!-- Performance Settings - Backend -->
<section class="config-section"> <section class="config-section help-section">
<h2> <h2>
Paramètres backend Paramètres backend
<span class="config-badge-restart" <span class="config-badge-restart"
@@ -1465,7 +1458,7 @@
</section> </section>
<!-- Tag Filtering (existing) --> <!-- Tag Filtering (existing) -->
<section class="config-section"> <section class="config-section help-section">
<h2>Filtrage de tags</h2> <h2>Filtrage de tags</h2>
<p class="config-description"> <p class="config-description">
Définissez les patterns de tags à masquer dans Définissez les patterns de tags à masquer dans
@@ -1505,7 +1498,7 @@
</section> </section>
<!-- Watcher / Synchronisation --> <!-- Watcher / Synchronisation -->
<section class="config-section"> <section class="config-section help-section" id="cfg-sync">
<h2>Synchronisation automatique</h2> <h2>Synchronisation automatique</h2>
<p class="config-description"> <p class="config-description">
Surveillance des fichiers en temps réel via Surveillance des fichiers en temps réel via
@@ -1595,7 +1588,7 @@
</section> </section>
<!-- Hidden Files/Folders Configuration --> <!-- Hidden Files/Folders Configuration -->
<section class="config-section"> <section class="config-section help-section">
<h2>🗂️ Fichiers cachés</h2> <h2>🗂️ Fichiers cachés</h2>
<p class="config-description"> <p class="config-description">
Contrôlez l'affichage des fichiers/dossiers Contrôlez l'affichage des fichiers/dossiers
@@ -1635,7 +1628,7 @@
</section> </section>
<!-- Diagnostics --> <!-- Diagnostics -->
<section class="config-section"> <section class="config-section help-section" id="cfg-diags">
<h2>Diagnostics</h2> <h2>Diagnostics</h2>
<p class="config-description"> <p class="config-description">
Statistiques de l'index et du moteur de Statistiques de l'index et du moteur de
@@ -1658,8 +1651,36 @@
</button> </button>
</section> </section>
<!-- Cles API AI -->
<section class="config-section help-section" id="cfg-ai">
<h2>🤖 Cles API Intelligence Artificielle</h2>
<p class="config-description">
Configurez vos cles API. Cliquez sur <strong>Tester</strong> pour charger les modeles.
</p>
<div class="config-row">
<label class="config-label" for="cfg-deepseek-key">DeepSeek API Key</label>
<input type="password" id="cfg-deepseek-key" class="config-input" placeholder="sk-..." autocomplete="off">
<select id="cfg-deepseek-model" class="config-select" style="width:200px"><option value="">-- Modele --</option></select>
</div>
<div class="config-row">
<label class="config-label" for="cfg-openrouter-key">OpenRouter API Key</label>
<input type="password" id="cfg-openrouter-key" class="config-input" placeholder="sk-or-..." autocomplete="off">
<select id="cfg-openrouter-model" class="config-select" style="width:200px"><option value="">-- Modele --</option></select>
</div>
<div class="config-row">
<label class="config-label" for="cfg-gemini-key">Gemini API Key</label>
<input type="password" id="cfg-gemini-key" class="config-input" placeholder="AIza..." autocomplete="off">
<select id="cfg-gemini-model" class="config-select" style="width:200px"><option value="">-- Modele --</option></select>
</div>
<div class="config-actions-row" style="margin-top:16px">
<button class="config-btn-save" id="cfg-save-ai-keys">Sauvegarder</button>
<button class="config-btn-secondary" id="cfg-test-ai-keys">Tester</button>
<span id="cfg-ai-status" style="font-size:12px;color:var(--text-muted);margin-left:12px"></span>
</div>
</section>
<!-- À propos --> <!-- À propos -->
<section class="config-section"> <section class="config-section help-section" id="cfg-about">
<h2>📦 À propos</h2> <h2>📦 À propos</h2>
<div id="config-about" class="config-diagnostics"> <div id="config-about" class="config-diagnostics">
<div class="config-diag-loading"> <div class="config-diag-loading">
@@ -1669,7 +1690,7 @@
</section> </section>
<!-- Webhooks --> <!-- Webhooks -->
<section class="config-section"> <section class="config-section help-section">
<h2>🔔 Webhooks</h2> <h2>🔔 Webhooks</h2>
<p class="config-description"> <p class="config-description">
Notifications HTTP vers des services externes Notifications HTTP vers des services externes
@@ -1701,7 +1722,7 @@
</section> </section>
<!-- Partages publics --> <!-- Partages publics -->
<section class="config-section"> <section class="config-section help-section">
<h2>📤 Partages publics</h2> <h2>📤 Partages publics</h2>
<p class="config-description"> <p class="config-description">
Liens de partage publics pour des documents Liens de partage publics pour des documents
@@ -1710,6 +1731,7 @@
<div id="shares-list"></div> <div id="shares-list"></div>
</section> </section>
</div> </div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -2968,7 +2990,7 @@
<li> <li>
<strong>Barre d'outils AI</strong> : <strong>Barre d'outils AI</strong> :
Complétion, réécriture, traduction (voir Complétion, réécriture, traduction (voir
section <a href="#help-ia">IA</a>) section <a href="#help-ia">🤖 IA</a>)
</li> </li>
</ul> </ul>
</section> </section>
+82
View File
@@ -609,6 +609,30 @@ function initConfigModal() {
const saveHiddenBtn = document.getElementById("cfg-save-hidden-files"); const saveHiddenBtn = document.getElementById("cfg-save-hidden-files");
if (saveHiddenBtn) saveHiddenBtn.addEventListener("click", saveHiddenFilesSettings); if (saveHiddenBtn) saveHiddenBtn.addEventListener("click", saveHiddenFilesSettings);
// AI Keys
const saveAIKeysBtn = document.getElementById("cfg-save-ai-keys");
if (saveAIKeysBtn) saveAIKeysBtn.addEventListener("click", saveAIKeys);
const testAIKeysBtn = document.getElementById("cfg-test-ai-keys");
if (testAIKeysBtn) testAIKeysBtn.addEventListener("click", testAIKeys);
// Load existing keys on modal open
document.getElementById("config-open-btn").addEventListener("click", loadAIKeys);
// Config nav search
var cfgSearch = document.getElementById("config-nav-search");
if (cfgSearch) {
cfgSearch.addEventListener("input", function() {
var q = cfgSearch.value.toLowerCase();
document.querySelectorAll("#config-scroll .config-section").forEach(function(s) {
var text = s.textContent.toLowerCase();
s.style.display = (q && !text.includes(q)) ? "none" : "";
});
document.querySelectorAll("#config-nav .help-nav-link").forEach(function(a) {
var li = a.closest("li");
if (li) li.style.display = (q && !a.textContent.toLowerCase().includes(q)) ? "none" : "";
});
});
}
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) { if (e.key === "Escape" && modal.classList.contains("active")) {
closeConfigModal(); closeConfigModal();
@@ -1290,6 +1314,64 @@ function updateRegexPreview() {
} }
// ── AI Keys management ──
async function loadAIKeys() {
try {
const data = await api("/api/config/ai-keys");
["DEEPSEEK_API_KEY","OPENROUTER_API_KEY","GEMINI_API_KEY"].forEach(k => {
const id = "cfg-" + k.toLowerCase().replace(/_api_key/g, "") + "-key";
const input = document.getElementById(id);
if (input && data[k]) input.placeholder = data[k];
});
} catch(e) {}
}
async function saveAIKeys() {
const keys = {};
const map = { "cfg-deepseek-key": "DEEPSEEK_API_KEY", "cfg-openrouter-key": "OPENROUTER_API_KEY", "cfg-gemini-key": "GEMINI_API_KEY" };
for (const [id, name] of Object.entries(map)) {
const input = document.getElementById(id);
if (input && input.value.trim()) keys[name] = input.value.trim();
}
if (!Object.keys(keys).length) { showToast("Aucune cle a sauvegarder", "warning"); return; }
try {
await api("/api/config/ai-keys", { method: "POST", body: JSON.stringify(keys) });
showToast("Cles API sauvegardees", "success");
// Clear inputs
Object.keys(map).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
loadAIKeys();
} catch(e) { showToast("Erreur: " + e.message, "error"); }
}
async function testAIKeys() {
const status = document.getElementById("cfg-ai-status");
if (status) status.textContent = "Test en cours...";
try {
// Test connectivity
const results = await api("/api/config/ai-keys/test", { method: "POST" });
var msg = Object.entries(results).map(([k,v]) => k + ": " + v).join(" | ");
if (status) status.textContent = msg;
// Fetch models for configured providers
for (const p of ["deepseek","openrouter","gemini"]) {
if (results[p] === "ok") {
try {
const m = await api("/api/config/ai-models?provider=" + p);
var sel = document.getElementById("cfg-" + p + "-model");
if (sel && m.models && m.models.length) {
sel.innerHTML = '<option value="">-- Modele (' + m.models.length + ') --</option>';
m.models.forEach(function(md) {
sel.innerHTML += '<option value="' + md + '">' + md + '</option>';
});
}
} catch(e) { console.warn("Failed to fetch models for", p, e); }
}
}
} catch(e) {
if (status) status.textContent = "Erreur: " + e.message;
}
}
export { export {
initSidebarTabs, initSidebarTabs,
initConfigModal, initConfigModal,
+377
View File
@@ -0,0 +1,377 @@
/* ObsiGate — Offline IndexedDB Storage
* Stores: file index, file contents, pending changes queue
* Schema v1:
* files store: { path, vault, title, tags[], snippet, modified, tokens[], weight }
* content store: { key: "vault::path", content: string, modified }
* pending store: { id, vault, path, content, action, timestamp }
*/
const DB_NAME = 'obsigate-offline';
const DB_VERSION = 1;
let _db = null;
let _dbPromise = null;
export const OfflineDB = {
async open() {
if (_db) return _db;
if (_dbPromise) return _dbPromise;
_dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
console.log('[OfflineDB] Upgrading to v' + DB_VERSION);
// File index store — keyed by composite "vault::path"
if (!db.objectStoreNames.contains('files')) {
const store = db.createObjectStore('files', { keyPath: 'key' });
store.createIndex('vault', 'vault', { unique: false });
store.createIndex('modified', 'modified', { unique: false });
store.createIndex('title', 'title', { unique: false });
}
// File contents store — keyed by "vault::path"
if (!db.objectStoreNames.contains('content')) {
db.createObjectStore('content', { keyPath: 'key' });
}
// Pending changes queue — auto-increment key
if (!db.objectStoreNames.contains('pending')) {
const store = db.createObjectStore('pending', { keyPath: 'id', autoIncrement: true });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
req.onsuccess = (e) => {
_db = e.target.result;
console.log('[OfflineDB] Database opened');
resolve(_db);
};
req.onerror = () => {
_dbPromise = null;
reject(req.error);
};
});
return _dbPromise;
},
/* ── File Index ── */
async putFileIndex(file) {
const db = await this.open();
return new Promise((resolve, reject) => {
const tx = db.transaction('files', 'readwrite');
const store = tx.objectStore('files');
const key = file.vault + '::' + file.path;
const doc = { key: key, vault: file.vault, path: file.path,
title: file.title, tags: file.tags || [], snippet: file.snippet || '',
modified: file.modified || '', tokens: file.tokens || [], weight: file.score || 0 };
store.put(doc);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
async putFileIndexBatch(files) {
const db = await this.open();
return new Promise((resolve, reject) => {
const tx = db.transaction('files', 'readwrite');
const store = tx.objectStore('files');
for (const file of files) {
const key = file.vault + '::' + file.path;
store.put({
key: key, vault: file.vault, path: file.path,
title: file.title, tags: file.tags || [], snippet: file.snippet || '',
modified: file.modified || '', tokens: file.tokens || [], score: file.score || 0
});
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
async getFileIndex(vault, path) {
const db = await this.open();
const key = vault + '::' + path;
return new Promise((resolve) => {
const tx = db.transaction('files', 'readonly');
const store = tx.objectStore('files');
const req = store.get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
},
/* Search in file index — title matching and prefix */
async searchFileIndex(query, vaultFilter) {
const db = await this.open();
const lowerQ = query.toLowerCase();
const words = lowerQ.split(/\s+/).filter(Boolean);
return new Promise((resolve) => {
const tx = db.transaction('files', 'readonly');
const store = tx.objectStore('files');
const results = [];
store.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (!cursor) {
// Sort by relevance: title match > path match > snippet match
results.sort((a, b) => b._score - a._score);
resolve(results.slice(0, 50));
return;
}
const doc = cursor.value;
// Vault filter
if (vaultFilter && vaultFilter !== 'all' && doc.vault !== vaultFilter) {
cursor.continue();
return;
}
// Score based on match quality
let score = 0;
const titleLower = doc.title.toLowerCase();
const pathLower = doc.path.toLowerCase();
const snippetLower = (doc.snippet || '').toLowerCase();
for (const word of words) {
if (titleLower.includes(word)) score += 10;
else if (pathLower.includes(word)) score += 5;
else if (snippetLower.includes(word)) score += 2;
}
// Full query match bonus
if (titleLower.includes(lowerQ)) score += 5;
if (pathLower === lowerQ) score += 8;
if (score > 0) {
results.push({
vault: doc.vault,
path: doc.path,
title: doc.title,
tags: doc.tags,
snippet: doc.snippet,
score: score,
modified: doc.modified,
_score: score
});
}
cursor.continue();
};
});
},
async getAllFileIndex(vaultFilter) {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction('files', 'readonly');
const store = tx.objectStore('files');
const results = [];
store.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (!cursor) { resolve(results); return; }
if (!vaultFilter || vaultFilter === 'all' || cursor.value.vault === vaultFilter) {
results.push(cursor.value);
}
cursor.continue();
};
});
},
async clearFileIndex(vaultFilter) {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction('files', 'readwrite');
const store = tx.objectStore('files');
if (!vaultFilter || vaultFilter === 'all') {
store.clear().onsuccess = () => resolve();
} else {
store.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (!cursor) { resolve(); return; }
if (cursor.value.vault === vaultFilter) cursor.delete();
cursor.continue();
};
}
});
},
/* ── File Contents ── */
async putFileContent(vault, path, content, modified) {
const db = await this.open();
return new Promise((resolve, reject) => {
const tx = db.transaction('content', 'readwrite');
const store = tx.objectStore('content');
const key = vault + '::' + path;
store.put({ key: key, content: content, modified: modified || '' });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
async getFileContent(vault, path) {
const db = await this.open();
const key = vault + '::' + path;
return new Promise((resolve) => {
const tx = db.transaction('content', 'readonly');
const store = tx.objectStore('content');
const req = store.get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
},
async removeFileContent(vault, path) {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction('content', 'readwrite');
const store = tx.objectStore('content');
const key = vault + '::' + path;
store.delete(key);
tx.oncomplete = () => resolve();
});
},
/* ── Pending Changes Queue ── */
async addPending(entry) {
const db = await this.open();
return new Promise((resolve, reject) => {
const tx = db.transaction('pending', 'readwrite');
const store = tx.objectStore('pending');
const doc = {
vault: entry.vault,
path: entry.path,
content: entry.content || '',
action: entry.action || 'save',
timestamp: Date.now()
};
const req = store.add(doc);
req.onsuccess = () => resolve(req.result);
tx.onerror = () => reject(tx.error);
});
},
async getPendingCount() {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction('pending', 'readonly');
const store = tx.objectStore('pending');
store.count().onsuccess = (e) => resolve(e.target.result);
});
},
async getAllPending() {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction('pending', 'readonly');
const store = tx.objectStore('pending');
const results = [];
const index = store.index('timestamp');
index.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (!cursor) { resolve(results); return; }
results.push(cursor.value);
cursor.continue();
};
});
},
async clearPending() {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction('pending', 'readwrite');
tx.objectStore('pending').clear().onsuccess = () => resolve();
});
},
async removePendingById(id) {
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction('pending', 'readwrite');
tx.objectStore('pending').delete(id);
tx.oncomplete = () => resolve();
});
},
/* ── Stats ── */
async getStats() {
const db = await this.open();
const fileCount = await new Promise(r => {
db.transaction('files','readonly').objectStore('files').count().onsuccess = e => r(e.target.result);
});
const contentCount = await new Promise(r => {
db.transaction('content','readonly').objectStore('content').count().onsuccess = e => r(e.target.result);
});
const pendingCount = await this.getPendingCount();
return { fileCount, contentCount, pendingCount };
},
/* ── Full Sync ── */
async syncFileIndex(serverFiles) {
// serverFiles: array of {vault,path,title,tags,snippet,modified,tokens}
await this.clearFileIndex(null);
await this.putFileIndexBatch(serverFiles);
console.log('[OfflineDB] File index synced:', serverFiles.length, 'files');
},
async pruneContentCache(maxEntries) {
maxEntries = maxEntries || 50;
const db = await this.open();
return new Promise((resolve) => {
const tx = db.transaction('content', 'readwrite');
const store = tx.objectStore('content');
const keys = [];
store.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (!cursor) {
// Delete oldest if over maxEntries
if (keys.length > maxEntries) {
const toDelete = keys.slice(0, keys.length - maxEntries);
for (const k of toDelete) store.delete(k);
}
resolve();
return;
}
keys.push(cursor.value.key);
cursor.continue();
};
});
}
};
/* ── Network Status ── */
let _online = navigator.onLine;
let _listeners = [];
export const NetworkStatus = {
get isOnline() { return _online; },
init() {
window.addEventListener('online', () => {
_online = true;
_listeners.forEach(fn => fn(true));
console.log('[Network] Online');
});
window.addEventListener('offline', () => {
_online = false;
_listeners.forEach(fn => fn(false));
console.log('[Network] Offline');
});
},
onChange(fn) {
_listeners.push(fn);
return () => { _listeners = _listeners.filter(f => f !== fn); };
}
};
+209
View File
@@ -0,0 +1,209 @@
/* ObsiGate — Offline Integration Module
* Ties together: IndexedDB, offline search, sync queue, UI indicator
*/
import { OfflineDB, NetworkStatus } from './offline-db.js';
import { state } from './state.js';
import { showToast } from './ui.js';
/* ── Init ── */
export async function initOffline() {
NetworkStatus.init();
// Open DB early
try {
await OfflineDB.open();
console.log('[Offline] IndexedDB ready');
} catch (e) {
console.warn('[Offline] IndexedDB not available:', e);
}
NetworkStatus.onChange((online) => {
updateOfflineIndicator();
if (online) {
syncPendingChanges();
showToast('Connexion retablie — synchronisation en cours...', 'info');
} else {
showToast('Mode hors-ligne — vos modifications seront synchronisees au retour', 'warning');
}
});
updateOfflineIndicator();
}
/* ── UI Indicator ── */
function updateOfflineIndicator() {
const badge = document.getElementById('offline-badge');
if (!badge) return;
if (!NetworkStatus.isOnline) {
// Build or show indicator
badge.innerHTML = replaceLucideIcon(badge, 'wifi-off',
'<i data-lucide="wifi-off" style="width:14px;height:14px"></i>');
badge.title = 'Mode hors-ligne';
badge.style.display = 'flex';
badge.className = 'header-icon offline-indicator offline';
// Add pending count if any
OfflineDB.getPendingCount().then(count => {
if (count > 0) {
const span = badge.querySelector('.offline-pending') || document.createElement('span');
span.className = 'offline-pending';
span.textContent = String(count);
if (!badge.contains(span)) badge.appendChild(span);
}
});
} else {
badge.style.display = 'none';
badge.className = 'header-icon offline-indicator';
}
}
function replaceLucideIcon(container, iconName, html) {
const existing = container.querySelector('i[data-lucide]');
if (existing && existing.getAttribute('data-lucide') === iconName) return container.innerHTML;
return html;
}
/* ── Sync File Index from Server ── */
export async function syncFileIndexFromServer() {
if (!NetworkStatus.isOnline) {
console.log('[Offline] Skipping index sync — offline');
return;
}
try {
// Use a wildcard query to get recent files and populate offline index
const resp = await fetch('/api/search/advanced?q=*&vault=all&limit=200');
if (!resp.ok) {
console.warn('[Offline] Index sync failed — API returned', resp.status);
return;
}
const data = await resp.json();
const files = (data.results || []).map(r => ({
vault: r.vault,
path: r.path,
title: r.title,
tags: r.tags || [],
snippet: r.snippet || '',
modified: r.modified || '',
tokens: []
}));
if (files.length > 0) {
await OfflineDB.syncFileIndex(files);
console.log('[Offline] Index synced:', files.length, 'files');
}
} catch (e) {
console.warn('[Offline] Index sync failed:', e.message);
}
}
/* ── Cache File When Viewed ── */
export async function cacheViewedFile(vault, path, content, modified) {
if (!vault || !path) return;
try {
await OfflineDB.putFileContent(vault, path, content, modified);
await OfflineDB.pruneContentCache(50);
} catch (e) {
console.warn('[Offline] Cache file failed:', e);
}
}
/* ── Offline Search ── */
export async function searchOffline(query, vaultFilter) {
try {
return await OfflineDB.searchFileIndex(query, vaultFilter || state.currentVault);
} catch (e) {
console.error('[Offline] Search failed:', e);
return [];
}
}
/* ── Get Cached File ── */
export async function getCachedFile(vault, path) {
try {
return await OfflineDB.getFileContent(vault, path);
} catch (e) {
return null;
}
}
/* ── Pending Changes Queue ── */
let _syncing = false;
export async function queueOfflineSave(vault, path, content) {
try {
const id = await OfflineDB.addPending({ vault, path, content, action: 'save' });
updateOfflineIndicator();
console.log('[Offline] Queued save:', vault, path, 'id=', id);
return id;
} catch (e) {
console.error('[Offline] Queue save failed:', e);
return null;
}
}
export async function syncPendingChanges() {
if (!NetworkStatus.isOnline || _syncing) return;
_syncing = true;
try {
const pending = await OfflineDB.getAllPending();
if (pending.length === 0) {
_syncing = false;
return;
}
console.log('[Offline] Syncing', pending.length, 'pending changes...');
for (const entry of pending) {
try {
const resp = await fetch('/api/file/' + encodeURIComponent(entry.vault) + '/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: entry.path, content: entry.content })
});
if (resp.ok) {
await OfflineDB.removePendingById(entry.id);
console.log('[Offline] Synced:', entry.vault, entry.path);
} else if (resp.status === 409) {
// Conflict — keep in queue for manual resolution
console.warn('[Offline] Conflict on:', entry.vault, entry.path);
} else {
console.error('[Offline] Sync failed for:', entry.vault, entry.path, resp.status);
}
} catch (e) {
console.error('[Offline] Sync error for:', entry.vault, entry.path, e);
break; // Stop on network error
}
}
const remaining = await OfflineDB.getPendingCount();
console.log('[Offline] Sync complete. Remaining:', remaining);
updateOfflineIndicator();
if (remaining === 0) {
showToast('Toutes les modifications ont ete synchronisees', 'success');
} else {
showToast(remaining + ' modification(s) en attente de resolution', 'warning');
}
} finally {
_syncing = false;
}
}
/* ── Conflict Detection ── */
export async function checkConflict(vault, path, localModified) {
if (!NetworkStatus.isOnline) return null;
try {
const resp = await fetch('/api/file/' + encodeURIComponent(vault) + '/info?path=' + encodeURIComponent(path));
if (!resp.ok) return null;
const data = await resp.json();
if (data.modified && data.modified !== localModified) {
return { serverModified: data.modified, localModified };
}
return null;
} catch {
return null;
}
}
+22
View File
@@ -3,6 +3,7 @@ import { api } from './auth.js';
import { state } from './state.js'; import { state } from './state.js';
import { safeCreateIcons } from './utils.js'; import { safeCreateIcons } from './utils.js';
import { showLoading, el, hideProgressBar, showWelcome, highlightSearchText, loadSavedSearches } from './viewer.js'; import { showLoading, el, hideProgressBar, showWelcome, highlightSearchText, loadSavedSearches } from './viewer.js';
import { searchOffline } from './offline.js';
import { _getEffective } from './config.js'; import { _getEffective } from './config.js';
import { TabManager, showToast } from './ui.js'; import { TabManager, showToast } from './ui.js';
import { addTagFilter, buildSearchResultsHeader, shouldDisplayPath, removeTagFilter, TagFilterService } from './sidebar.js'; import { addTagFilter, buildSearchResultsHeader, shouldDisplayPath, removeTagFilter, TagFilterService } from './sidebar.js';
@@ -804,6 +805,27 @@ export async function performAdvancedSearch(query, vaultFilter, tagFilter, offse
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (err.name === "AbortError") return; if (err.name === "AbortError") return;
if (searchId !== state.currentSearchId) return; if (searchId !== state.currentSearchId) return;
// Try offline search fallback
console.log('[Search] Network failed, trying offline search');
const offlineResults = await searchOffline(query, vaultFilter);
if (offlineResults && offlineResults.length > 0) {
const data = {
query: query,
vault_filter: vaultFilter,
count: offlineResults.length,
total: offlineResults.length,
offset: 0,
limit: effectiveLimit,
results: offlineResults,
offline: true
};
state.advancedSearchTotal = offlineResults.length;
state.advancedSearchOffset = 0;
renderAdvancedSearchResults(data, query, tagFilter);
return;
}
showWelcome(); showWelcome();
} finally { } finally {
hideProgressBar(); hideProgressBar();
+39 -1
View File
@@ -1,10 +1,11 @@
/* ObsiGate — Sync: SSE client + PWA registration */ /* ObsiGate — Sync: SSE client + PWA registration + Offline mode */
import { state } from './state.js'; import { state } from './state.js';
import { showToast } from './ui.js'; import { showToast } from './ui.js';
import { api } from './auth.js'; import { api } from './auth.js';
import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js'; import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js';
import { loadRecentFiles } from './config.js'; import { loadRecentFiles } from './config.js';
import { openFile } from './viewer.js'; import { openFile } from './viewer.js';
import { initOffline, syncFileIndexFromServer } from './offline.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SSE Client — IndexUpdateManager // SSE Client — IndexUpdateManager
@@ -550,5 +551,42 @@ function initDashboardTabs() {
export { initSyncStatus, initDashboardTabs }; export { initSyncStatus, initDashboardTabs };
export function init() { export function init() {
registerServiceWorker(); registerServiceWorker();
initOffline();
// Sync file index after a short delay (let vaults load first)
setTimeout(() => syncFileIndexFromServer(), 3000);
initDashboardTabs(); initDashboardTabs();
// Listen for Forge editor rename events
window.addEventListener('message', function(e) {
if (!e.data || !e.data.type) return;
if (e.data.type === 'forge-renamed') {
var vault = e.data.vault;
var oldPath = e.data.oldPath;
var newPath = e.data.newPath;
console.log('[Forge] File renamed:', vault, oldPath, '->', newPath);
var titleEl = document.getElementById('editor-title');
if (titleEl && titleEl.textContent.indexOf('Forge:') === 0) {
titleEl.textContent = 'Forge: ' + newPath.split('/').pop();
}
refreshSidebarTreePreservingState(vault);
if (state.currentVault === vault && state.currentPath === oldPath) {
import('./viewer.js').then(function(m) { m.openFile(vault, newPath); });
}
}
if (e.data.type === 'forge-close') {
var modal = document.getElementById('editor-modal');
var forgeFrame = document.getElementById('forge-iframe');
if (forgeFrame) forgeFrame.remove();
if (modal) modal.classList.remove('active');
// Restore header/footer
var headerEl = modal.querySelector('.editor-header');
var footerEl = modal.querySelector('.editor-footer');
if (headerEl) headerEl.style.display = '';
if (footerEl) footerEl.style.display = '';
// Refresh current file content
if (state.currentVault && state.currentPath) {
import('./viewer.js').then(function(m) { m.openFile(state.currentVault, state.currentPath); });
}
}
});
} }
+10 -3
View File
@@ -517,6 +517,9 @@ export function initTagResize() {
// Frontmatter Accent Card Builder // Frontmatter Accent Card Builder
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Persist toggle state across document openings
let _frontmatterOpen = false;
export function buildFrontmatterCard(frontmatter) { export function buildFrontmatterCard(frontmatter) {
// Helper: format date // Helper: format date
function formatDate(iso) { function formatDate(iso) {
@@ -530,11 +533,11 @@ export function buildFrontmatterCard(frontmatter) {
// Extract boolean flags // Extract boolean flags
const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] })); const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] }));
// Toggle state // Toggle state (persisted across documents)
let isOpen = true; let isOpen = _frontmatterOpen;
// Build header with chevron // Build header with chevron
const chevron = el("span", { class: "fm-chevron open" }); const chevron = el("span", { class: "fm-chevron " + (isOpen ? "open" : "closed") });
chevron.innerHTML = '<i data-lucide="chevron-down" style="width:14px;height:14px"></i>'; chevron.innerHTML = '<i data-lucide="chevron-down" style="width:14px;height:14px"></i>';
const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]); const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]);
@@ -612,9 +615,13 @@ export function buildFrontmatterCard(frontmatter) {
// Assemble the card // Assemble the card
const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]); const acCard = el("div", { class: "ac-card" }, [acTop, acBody, acTagsRow, acFlagsRow]);
// Set initial display based on persisted state
if (!isOpen) acCard.style.display = "none";
// Toggle functionality // Toggle functionality
fmHeader.addEventListener("click", () => { fmHeader.addEventListener("click", () => {
isOpen = !isOpen; isOpen = !isOpen;
_frontmatterOpen = isOpen;
if (isOpen) { if (isOpen) {
acCard.style.display = "block"; acCard.style.display = "block";
chevron.classList.remove("closed"); chevron.classList.remove("closed");
+49 -29
View File
@@ -275,10 +275,22 @@ async function openEditor(vaultName, filePath) {
state.editorPath = filePath; state.editorPath = filePath;
const modal = document.getElementById("editor-modal"); const modal = document.getElementById("editor-modal");
const titleEl = document.getElementById("editor-title"); const titleInput = document.getElementById("editor-title-input");
const filenameInput = document.getElementById("editor-filename-input");
const statFile = document.getElementById("editor-stat-file");
const statWords = document.getElementById("editor-stat-words");
const bodyEl = document.getElementById("editor-body"); const bodyEl = document.getElementById("editor-body");
titleEl.textContent = `Édition: ${filePath.split("/").pop()}`; // Restore header/footer (may be hidden by Forge)
var headerEl = modal.querySelector(".editor-header");
var footerEl = modal.querySelector(".editor-footer");
var container = modal.querySelector(".editor-container");
if (headerEl) headerEl.style.display = "";
if (footerEl) footerEl.style.display = "";
if (container) container.style.maxHeight = "";
filenameInput.value = filePath.split("/").pop();
statFile.textContent = vaultName + "/" + filePath;
// Fetch raw content // Fetch raw content
const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`; const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`;
@@ -322,6 +334,11 @@ async function openEditor(vaultName, filePath) {
if (update.docChanged) { if (update.docChanged) {
clearTimeout(window._obsigateAutoSaveTimer); clearTimeout(window._obsigateAutoSaveTimer);
window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000); window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000);
// Mark dirty
var sd = document.getElementById("editor-save-dot");
var sl = document.getElementById("editor-save-label");
if (sd) sd.className = "editor-save-dot dirty";
if (sl) sl.textContent = "Unsaved";
} }
}), }),
]; ];
@@ -478,9 +495,13 @@ async function saveFile(silent = false) {
const content = state.editorView ? state.editorView.state.doc.toString() : state.fallbackEditorEl.value; const content = state.editorView ? state.editorView.state.doc.toString() : state.fallbackEditorEl.value;
const saveBtn = document.getElementById("editor-save"); const saveBtn = document.getElementById("editor-save");
const originalHTML = saveBtn ? saveBtn.innerHTML : ''; const saveDot = document.getElementById("editor-save-dot");
const saveLabel = document.getElementById("editor-save-label");
try { try {
// Update save dot to saving state
if (saveDot) { saveDot.className = 'editor-save-dot saving'; }
if (saveLabel) saveLabel.textContent = 'Saving...';
if (saveBtn && !silent) { if (saveBtn && !silent) {
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px"></i>'; saveBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
@@ -499,35 +520,41 @@ async function saveFile(silent = false) {
throw new Error(error.detail || "Erreur de sauvegarde"); throw new Error(error.detail || "Erreur de sauvegarde");
} }
// Update save dot to saved state
if (saveDot) { saveDot.className = 'editor-save-dot ok'; }
if (saveLabel) saveLabel.textContent = 'Saved';
if (silent) { if (silent) {
// Auto-save: brief green flash on save button // Auto-save: brief green flash on save button
if (saveBtn) { if (saveBtn) {
saveBtn.style.background = '#22c55e'; saveBtn.style.background = '#22c55e';
saveBtn.style.color = '#fff'; saveBtn.style.color = '#fff';
saveBtn.style.borderColor = '#22c55e'; saveBtn.style.borderColor = '#22c55e';
saveBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px"></i>';
safeCreateIcons();
setTimeout(() => {
if (saveBtn) {
saveBtn.style.background = '';
saveBtn.style.color = '';
saveBtn.style.borderColor = '';
saveBtn.innerHTML = originalHTML;
}
}, 1500);
} }
setTimeout(() => {
if (saveBtn) {
saveBtn.style.background = '';
saveBtn.style.color = '';
saveBtn.style.borderColor = '';
}
}, 1500);
} else { } else {
saveBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px"></i>'; closeEditor();
safeCreateIcons();
setTimeout(() => closeEditor(), 800);
} }
} catch (err) { } catch (err) {
console.error("Save error:", err); console.error("Save error:", err);
alert(`Erreur: ${err.message}`); if (saveDot) { saveDot.className = 'editor-save-dot err'; }
saveBtn.innerHTML = originalHTML; if (saveLabel) saveLabel.textContent = 'Erreur';
saveBtn.disabled = false; // If offline, queue the save for later sync
safeCreateIcons(); if (!navigator.onLine) {
try {
const { queueOfflineSave } = await import('./offline.js');
await queueOfflineSave(state.editorVault, state.editorPath, content);
alert('Vous etes hors-ligne. La modification a ete enregistree localement.');
} catch (e) { alert('Erreur: ' + err.message); }
} else {
alert('Erreur: ' + err.message);
}
} }
} }
@@ -573,14 +600,7 @@ function initEditor() {
cancelBtn.addEventListener("click", closeEditor); cancelBtn.addEventListener("click", closeEditor);
deleteBtn.addEventListener("click", deleteFile); deleteBtn.addEventListener("click", deleteFile);
saveBtn.addEventListener("click", saveFile); saveBtn.addEventListener("click", () => saveFile());
// Close on overlay click
modal.addEventListener("click", (e) => {
if (e.target === modal) {
closeEditor();
}
});
// ESC to close // ESC to close
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
+8 -2
View File
@@ -524,9 +524,15 @@ export function renderFile(data) {
const forgeBtn = el("button", { class: "btn-action btn-forge", title: "Forge (nouvel editeur)" }, [icon("hammer", 14), document.createTextNode("Forge")]); const forgeBtn = el("button", { class: "btn-action btn-forge", title: "Forge (nouvel editeur)" }, [icon("hammer", 14), document.createTextNode("Forge")]);
forgeBtn.addEventListener("click", () => { forgeBtn.addEventListener("click", () => {
var modal = document.getElementById("editor-modal"); var modal = document.getElementById("editor-modal");
var titleEl = document.getElementById("editor-title"); var container = modal.querySelector(".editor-container");
var headerEl = modal.querySelector(".editor-header");
var footerEl = modal.querySelector(".editor-footer");
var bodyEl = document.getElementById("editor-body"); var bodyEl = document.getElementById("editor-body");
titleEl.textContent = "Forge: " + data.path.split("/").pop(); // Hide header/footer since Forge has its own
if (headerEl) headerEl.style.display = "none";
if (footerEl) footerEl.style.display = "none";
// Make container fill the modal
if (container) container.style.maxHeight = "100vh";
bodyEl.innerHTML = ""; bodyEl.innerHTML = "";
bodyEl.style.overflow = "hidden"; bodyEl.style.overflow = "hidden";
var iframe = document.createElement("iframe"); var iframe = document.createElement("iframe");
+134
View File
@@ -9,6 +9,14 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* ── Editor save dot state colors ── */
:root {
--ok: #34d399;
--warn: #fbbf24;
--ai: #8b5cf6;
--err: #f87171;
}
/* ===== THEME — DARK (default) ===== */ /* ===== THEME — DARK (default) ===== */
:root[data-theme="dark"] { :root[data-theme="dark"] {
--bg-primary: #0f1117; --bg-primary: #0f1117;
@@ -189,6 +197,98 @@ a:hover {
opacity: 0.8; opacity: 0.8;
} }
/* ── Offline Indicator ── */
.offline-indicator {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
cursor: default;
}
.offline-indicator.offline i {
color: var(--warning, #f59e0b);
opacity: 1;
}
.offline-pending {
position: absolute;
top: -4px;
right: -8px;
background: var(--danger, #ef4444);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
min-width: 14px;
height: 14px;
line-height: 14px;
text-align: center;
border-radius: 7px;
padding: 0 3px;
}
/* ── Offline Search Result Badge ── */
.search-result-offline-badge {
display: inline-block;
background: var(--warning, #f59e0b);
color: #000;
font-size: 0.55rem;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
margin-left: 6px;
vertical-align: middle;
text-transform: uppercase;
}
/* ── Conflict Resolution Modal ── */
.conflict-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.conflict-modal {
background: var(--bg-card, #1e1e2e);
border: 1px solid var(--border, #333);
border-radius: 8px;
padding: 24px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.conflict-modal h3 {
margin: 0 0 8px;
font-size: 1.1rem;
}
.conflict-modal p {
color: var(--text-muted, #888);
font-size: 0.85rem;
margin: 0 0 16px;
}
.conflict-modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.conflict-modal-actions button {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid var(--border, #333);
background: var(--bg-input, #2a2a3c);
color: var(--text, #ddd);
cursor: pointer;
font-size: 0.85rem;
}
.conflict-modal-actions .btn-primary {
background: var(--accent, #3b82f6);
border-color: var(--accent);
color: #fff;
}
/* All vaults — blue */ /* All vaults — blue */
.vault-ctx-all { .vault-ctx-all {
background: color-mix(in srgb, #3b82f6 14%, transparent) !important; background: color-mix(in srgb, #3b82f6 14%, transparent) !important;
@@ -2215,6 +2315,29 @@ select {
color: var(--text-primary); color: var(--text-primary);
font-weight: 600; font-weight: 600;
} }
.editor-brand { font-weight: 700; font-size: 12px; color: var(--accent); letter-spacing: 0.5px; white-space: nowrap; margin-right: 8px; }
.editor-title-input { flex: 1; min-width: 80px; background: transparent; border: 1px solid transparent; color: var(--text-primary); font-size: 13px; padding: 3px 6px; border-radius: 6px; outline: none; font-family: var(--font-sans); }
.editor-title-input:hover { border-color: var(--border); }
.editor-title-input:focus { border-color: var(--accent); background: var(--bg-input); }
.editor-sep { color: var(--text-muted); font-size: 13px; padding: 0 2px; user-select: none; }
.editor-filename-input { width: 140px; background: transparent; border: 1px solid transparent; color: var(--text-primary); font-size: 12px; padding: 3px 6px; border-radius: 6px; outline: none; font-family: var(--font-mono); }
.editor-filename-input:hover { border-color: var(--border); }
.editor-filename-input:focus { border-color: var(--ok); background: var(--bg-input); }
.editor-spacer { flex: 1; }
.editor-save-dot { display: flex; align-items: center; gap: 4px; font-size: 11px; padding: 0 4px; margin-right: 8px; }
.editor-btn + .editor-btn { margin-left: 6px; }
.editor-save-dot .dot { width: 6px; height: 6px; border-radius: 50%; }
.editor-save-dot.ok { color: var(--ok); } .editor-save-dot.ok .dot { background: var(--ok); }
.editor-save-dot.dirty { color: var(--warn); } .editor-save-dot.dirty .dot { background: var(--warn); }
.editor-save-dot.saving { color: var(--ai); } .editor-save-dot.saving .dot { background: var(--ai); }
@keyframes editor-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.editor-save-dot.saving { animation: editor-pulse 0.8s infinite; }
.editor-save-dot.err { color: var(--err); } .editor-save-dot.err .dot { background: var(--err); }
.editor-body-cm { flex: 1; overflow: auto; }
.editor-body-cm .cm-editor { height: 100%; }
.editor-body-cm .cm-editor .cm-scroller { font-family: var(--font-mono); font-size: 13.5px; line-height: 1.75; }
.editor-footer { height: 26px; min-height: 26px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; background: var(--bg-secondary); border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
.editor-stat-file { color: var(--text-secondary); max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.editor-actions { .editor-actions {
display: flex; display: flex;
gap: 6px; gap: 6px;
@@ -3335,6 +3458,17 @@ body.resizing-v {
border-color: var(--accent); border-color: var(--accent);
} }
.config-select {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--r);
background: var(--bg-input);
color: var(--text-primary);
font-size: 0.86rem;
font-family: var(--mono);
cursor: pointer;
}
.config-btn-add { .config-btn-add {
padding: 8px 16px; padding: 8px 16px;
border: 1px solid var(--accent); border: 1px solid var(--accent);
+97 -144
View File
@@ -1,11 +1,10 @@
/* ObsiGate Service Worker - PWA Support */ /* ObsiGate Service Worker - PWA v2 with IndexedDB offline support */
const CACHE_VERSION = 'obsigate-v1.9.0'; const CACHE_VERSION = 'obsigate-v2-pwa';
const STATIC_CACHE = `${CACHE_VERSION}-static`; const STATIC_CACHE = CACHE_VERSION + '-static';
const DYNAMIC_CACHE = `${CACHE_VERSION}-dynamic`; const DYNAMIC_CACHE = CACHE_VERSION + '-dynamic';
const MAX_DYNAMIC_CACHE_SIZE = 50; const MAX_DYNAMIC_CACHE_SIZE = 100;
// Assets to cache on install
const STATIC_ASSETS = [ const STATIC_ASSETS = [
'/', '/',
'/static/index.html', '/static/index.html',
@@ -14,25 +13,23 @@ const STATIC_ASSETS = [
'/static/manifest.json' '/static/manifest.json'
]; ];
// Install event - cache static assets /* ── Install ── */
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...'); console.log('[SW] Installing v' + CACHE_VERSION);
event.waitUntil( event.waitUntil(
caches.open(STATIC_CACHE) caches.open(STATIC_CACHE)
.then((cache) => { .then((cache) => {
console.log('[SW] Caching static assets'); console.log('[SW] Caching static assets');
return cache.addAll(STATIC_ASSETS.map(url => new Request(url, { cache: 'reload' }))); return cache.addAll(STATIC_ASSETS.map(url => new Request(url, { cache: 'reload' })));
}) })
.catch((err) => { .catch((err) => console.error('[SW] Cache error:', err))
console.error('[SW] Failed to cache static assets:', err);
})
.then(() => self.skipWaiting()) .then(() => self.skipWaiting())
); );
}); });
// Activate event - clean up old caches /* ── Activate ── */
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...'); console.log('[SW] Activating...');
event.waitUntil( event.waitUntil(
caches.keys() caches.keys()
.then((cacheNames) => { .then((cacheNames) => {
@@ -49,189 +46,145 @@ self.addEventListener('activate', (event) => {
); );
}); });
// Fetch event - serve from cache, fallback to network /* ── Fetch ── */
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const { request } = event; const { request } = event;
const url = new URL(request.url); const url = new URL(request.url);
// Skip non-GET requests if (request.method !== 'GET') return;
if (request.method !== 'GET') { if (url.pathname === '/api/events') return;
return; if (url.pathname.startsWith('/api/auth/')) return;
}
// Skip SSE connections // API requests: Network first, tell client to use IndexedDB if offline
if (url.pathname === '/api/events') {
return;
}
// Skip authentication endpoints
if (url.pathname.startsWith('/api/auth/')) {
return;
}
// API requests - Network first, cache fallback
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstStrategy(request)); event.respondWith(networkFirstAPI(request));
return; return;
} }
// Static assets - Cache first, network fallback // Static assets: Cache first
event.respondWith(cacheFirstStrategy(request)); event.respondWith(cacheFirstStatic(request, url));
}); });
// Cache first strategy (for static assets) /* ── Network First for API ── */
async function cacheFirstStrategy(request) { async function networkFirstAPI(request) {
try { try {
const cachedResponse = await caches.match(request); const response = await fetch(request);
if (cachedResponse) {
return cachedResponse;
}
const networkResponse = await fetch(request); // Cache successful GETs in dynamic cache
if (response && response.status === 200) {
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(STATIC_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('[SW] Cache first strategy failed:', error);
// Return offline page if available
const offlinePage = await caches.match('/static/index.html');
if (offlinePage) {
return offlinePage;
}
return new Response('Offline - Unable to fetch resource', {
status: 503,
statusText: 'Service Unavailable',
headers: new Headers({
'Content-Type': 'text/plain'
})
});
}
}
// Network first strategy (for API calls)
async function networkFirstStrategy(request) {
try {
const networkResponse = await fetch(request);
// Cache successful GET responses
if (networkResponse && networkResponse.status === 200 && request.method === 'GET') {
const cache = await caches.open(DYNAMIC_CACHE); const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, networkResponse.clone()); cache.put(request, response.clone());
// Limit dynamic cache size
limitCacheSize(DYNAMIC_CACHE, MAX_DYNAMIC_CACHE_SIZE); limitCacheSize(DYNAMIC_CACHE, MAX_DYNAMIC_CACHE_SIZE);
} }
return response;
return networkResponse;
} catch (error) { } catch (error) {
console.log('[SW] Network failed, trying cache:', request.url); console.log('[SW] Offline — checking cache for:', request.url);
const cachedResponse = await caches.match(request); // Try dynamic cache first
if (cachedResponse) { const cached = await caches.match(request);
return cachedResponse; if (cached) {
// Add offline header so the page can detect
const headers = new Headers(cached.headers);
headers.set('X-ObsiGate-Offline', 'true');
return new Response(cached.body, { status: cached.status, statusText: cached.statusText, headers });
} }
// Return offline response // Fallback: tell the page to use IndexedDB
return new Response(JSON.stringify({ return new Response(JSON.stringify({
error: 'Offline', offline: true,
message: 'Unable to fetch data. Please check your connection.' endpoint: new URL(request.url).pathname,
message: 'Vous etes hors-ligne. Les donnees locales sont utilisees.'
}), { }), {
status: 503, status: 200,
statusText: 'Service Unavailable', headers: { 'Content-Type': 'application/json', 'X-ObsiGate-Offline': 'true' }
headers: new Headers({
'Content-Type': 'application/json'
})
}); });
} }
} }
// Limit cache size /* ── Cache First for Static ── */
async function cacheFirstStatic(request, url) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response && response.status === 200) {
const cache = await caches.open(STATIC_CACHE);
cache.put(request, response.clone());
}
return response;
} catch {
// Offline fallback for HTML navigation
if (request.mode === 'navigate' || url.pathname === '/' || url.pathname.endsWith('/')) {
const offlinePage = await caches.match('/static/index.html');
if (offlinePage) return offlinePage;
}
return new Response('Offline', { status: 503 });
}
}
async function limitCacheSize(cacheName, maxSize) { async function limitCacheSize(cacheName, maxSize) {
const cache = await caches.open(cacheName); const cache = await caches.open(cacheName);
const keys = await cache.keys(); const keys = await cache.keys();
if (keys.length > maxSize) { if (keys.length > maxSize) {
// Delete oldest entries for (let i = 0; i < keys.length - maxSize; i++) {
const deleteCount = keys.length - maxSize;
for (let i = 0; i < deleteCount; i++) {
await cache.delete(keys[i]); await cache.delete(keys[i]);
} }
} }
} }
// Message event - handle messages from clients /* ── Messages ── */
self.addEventListener('message', (event) => { self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') { const data = event.data;
self.skipWaiting(); if (!data || !data.type) return;
switch (data.type) {
case 'SKIP_WAITING':
self.skipWaiting();
break;
case 'CLEAR_CACHE':
event.waitUntil(
caches.keys().then(names => Promise.all(names.map(n => caches.delete(n))))
);
break;
case 'CLAIM_CLIENTS':
event.waitUntil(self.clients.claim());
break;
} }
});
if (event.data && event.data.type === 'CLEAR_CACHE') {
/* ── Sync ── */
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag);
if (event.tag === 'obsigate-sync') {
event.waitUntil( event.waitUntil(
caches.keys().then((cacheNames) => { self.clients.matchAll().then(clients => {
return Promise.all( clients.forEach(client => {
cacheNames.map((name) => caches.delete(name)) client.postMessage({ type: 'SYNC_TRIGGERED' });
); });
}) })
); );
} }
}); });
// Sync event - background sync for offline actions /* ── Push ── */
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag);
if (event.tag === 'sync-data') {
event.waitUntil(syncData());
}
});
async function syncData() {
// Placeholder for background sync logic
console.log('[SW] Syncing data...');
}
// Push notification event
self.addEventListener('push', (event) => { self.addEventListener('push', (event) => {
const options = { const options = {
body: event.data ? event.data.text() : 'New update available', body: event.data ? event.data.text() : 'Nouvelle mise a jour',
icon: '/static/icons/icon-192x192.svg', icon: '/static/icons/icon-192x192.svg',
badge: '/static/icons/icon-72x72.svg', badge: '/static/icons/icon-72x72.svg',
vibrate: [200, 100, 200], vibrate: [200, 100, 200],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
},
actions: [ actions: [
{ { action: 'open', title: 'Ouvrir ObsiGate' },
action: 'explore', { action: 'close', title: 'Fermer' }
title: 'Ouvrir ObsiGate'
},
{
action: 'close',
title: 'Fermer'
}
] ]
}; };
event.waitUntil(self.registration.showNotification('ObsiGate', options));
event.waitUntil(
self.registration.showNotification('ObsiGate', options)
);
}); });
// Notification click event
self.addEventListener('notificationclick', (event) => { self.addEventListener('notificationclick', (event) => {
event.notification.close(); event.notification.close();
if (event.action === 'open') {
if (event.action === 'explore') { event.waitUntil(clients.openWindow('/'));
event.waitUntil(
clients.openWindow('/')
);
} }
}); });
-22
View File
@@ -1,22 +0,0 @@
---
titre: Accueil
statut: actif
tags: [accueil, important]
catégorie: général
publish: true
date: 2025-01-15
---
# Bienvenue dans le vault de test
Ceci est un document de test pour [[ObsiGate]].
## Sections
- [[Projets/Projet Alpha]] - Un projet en cours
- [[Notes/Configuration serveur]] - Documentation technique
- [[Recettes/Pâtes carbonara]] - Une recette
## Tags
#accueil #important #test
-29
View File
@@ -1,29 +0,0 @@
---
titre: Pâtes carbonara
tags: [recette, cuisine, italien, pâtes]
catégorie: cuisine
date: 2025-04-10
favoris: true
---
# Pâtes carbonara authentiques
## Ingrédients
- 400g de spaghetti
- 200g de guanciale
- 4 jaunes d'œufs
- 100g de pecorino romano
- Poivre noir
## Préparation
1. Cuire les pâtes al dente
2. Faire revenir le guanciale
3. Mélanger les jaunes avec le fromage
4. Assembler hors du feu
## Notes
Ne jamais utiliser de crème !
La vraie carbonara n'a pas de crème.
-28
View File
@@ -1,28 +0,0 @@
---
titre: Tiramisu classique
tags: [recette, cuisine, italien, dessert]
catégorie: cuisine
date: 2025-04-15
publish: true
---
# Tiramisu
## Ingrédients
- 500g de mascarpone
- 4 œufs
- 100g de sucre
- Biscuits à la cuillère
- Café fort
- Cacao en poudre
## Préparation
1. Séparer les blancs des jaunes
2. Mélanger jaunes + sucre + mascarpone
3. Monter les blancs en neige
4. Incorporer délicatement
5. Tremper les biscuits dans le café
6. Alterner couches
7. Réfrigérer 4h minimum
-442
View File
@@ -1,442 +0,0 @@
# 🧪 Suite de Tests Complète : Diagrammes Mermaid
Ce document sert de référence pour valider le rendu de tous les types de diagrammes Mermaid.js, ainsi que leurs fonctionnalités avancées (styling, interactions, sous-éléments).
---
## 1. Flowchart (Organigramme)
*Teste : Formes de nœuds variées, sous-graphes, liens avec texte, classes de style, et markdown dans les nœuds.*
```mermaid
flowchart LR
%% Définition des styles
classDef default fill:#f9f9f9,stroke:#333,stroke-width:2px;
classDef success fill:#d4edda,stroke:#28a745,stroke-width:2px;
classDef error fill:#f8d7da,stroke:#dc3545,stroke-width:2px;
subgraph Groupe A [Processus Principal]
direction TB
A([Début]) --> B{Condition ?}
B -->|Oui| C[Action Standard]
B -->|Non| D[/Action Alternative/]
end
subgraph Groupe B [Résultats]
C --> E((Fin Succès)):::success
D --> F{{Fin Erreur}}:::error
end
%% Lien avec markdown et événement clic
E -.->|"**Résultat**<br/>[Voir détails](#)"| G
click G "https://example.com" "Ouvrir exemple" _blank
```
---
## 2. Sequence Diagram (Diagramme de Séquence)
*Teste : Participants, alias, boucles, alternatives, parallélisme, notes, activation et types de messages.*
```mermaid
sequenceDiagram
autonumber
participant C as Client
participant API as API Gateway
participant DB as Base de Données
C->>API: Requete POST /login
activate API
API->>DB: Verifier identifiants
activate DB
alt Identifiants valides
DB-->>API: Succes (200 OK)
API-->>C: Token JWT
else Identifiants invalides
DB-->>API: Echec (401 Unauthorized)
API--xC: Rejete
end
deactivate DB
deactivate API
rect rgb(200, 220, 250)
Note over C,DB: Cette section est surlignee pour l'audit
C->>DB: Requete de logs
end
```
---
## 3. Class Diagram (Diagramme de Classes)
*Teste : Espaces de noms, relations (héritage, composition, agrégation), visibilité, génériques et méthodes.*
```mermaid
classDiagram
namespace Base {
class Utilisateur {
-int id
+String nom
+String email
+seConnecter() bool
}
}
namespace Admin {
class Administrateur {
+String niveauAcces
+gererUtilisateurs() void
}
}
class Session {
-String token
+Date expiration
}
Utilisateur <|-- Administrateur : hérite
Utilisateur "1" *-- "0..*" Session : possède
Utilisateur ..> InterfaceAuth : implémente
```
---
## 4. State Diagram (Diagramme d'États)
*Teste : États composites, choix, fourches/joins, états finaux et transitions.*
```mermaid
stateDiagram-v2
[*] --> EnAttente
state CommandeEnCours {
EnAttente --> Validée : Paiement reçu
Validée --> Expédiée : Colis préparé
}
Expédiée --> ChoixLivraison
state ChoixLivraison <<choice>>
ChoixLivraison --> Livrée : Domicile
ChoixLivraison --> RetraitPoint : Point relais
Livrée --> [*]
RetraitPoint --> [*]
note right of Validée
L'état est irréversible
une fois le colis parti.
end note
```
---
## 5. Entity Relationship Diagram (MCD)
*Teste : Entités, attributs avec types, clés primaires (PK), clés étrangères (FK) et cardinalités.*
```mermaid
erDiagram
CLIENT {
int id PK
string nom
string email UK
}
COMMANDE {
int id PK
int client_id FK
date date_creation
float total
}
PRODUIT {
int id PK
string reference UK
float prix
}
CLIENT ||--o{ COMMANDE : "passe"
COMMANDE }o--o{ PRODUIT : "contient"
```
---
## 6. User Journey (Parcours Utilisateur)
*Teste : Sections, tâches avec score de satisfaction et acteurs multiples.*
```mermaid
journey
title Parcours d'achat sur l'application
section Recherche
Ouvrir l'application : 5: Utilisateur
Utiliser la barre de recherche : 4: Utilisateur, Moteur de recherche
section Achat
Ajouter au panier : 5: Utilisateur
Procéder au paiement : 3: Utilisateur, Système bancaire
section Post-achat
Recevoir l'email de confirmation : 5: Utilisateur, Service Email
```
---
## 7. Gantt Chart (Diagramme de Gantt)
*Teste : Format de date, sections, dépendances (`after`), marqueur "aujourd'hui" et exclusion de week-ends.*
```mermaid
gantt
title Planning de Développement v2.0
dateFormat YYYY-MM-DD
axisFormat %d/%m
excludes weekends
section Conception
Maquettes UI/UX :a1, 2024-01-01, 10d
Validation client :milestone, m1, 2024-01-11, 0d
section Développement
Frontend :a2, after a1, 15d
Backend API :a3, 2024-01-05, 20d
Intégration :a4, after a2, 5d
```
---
## 8. Pie Chart (Diagramme Circulaire)
*Teste : Titre, affichage des valeurs et des pourcentages.*
```mermaid
pie showData
title Répartition du trafic web (2024)
"Moteurs de recherche" : 45.5
"Liens directs" : 30.2
"Réseaux sociaux" : 15.8
"Campagnes email" : 8.5
```
---
## 9. Git Graph
*Teste : Commits, branches, checkout, merges, cherry-picks et tags.*
```mermaid
gitGraph
commit id: "Initial commit"
branch develop
checkout develop
commit id: "Ajout feature A"
commit id: "Ajout feature B"
checkout main
merge develop id: "Merge develop" tag: "v1.0.0"
commit id: "Hotfix critique"
checkout develop
cherry-pick id: "Hotfix critique"
commit id: "Préparation v1.1"
```
---
## 10. Requirement Diagram (Ingenierie Systeme)
*Teste : Exigences, elements, risques et relations de tracabilite.*
*Note : `requirementDiagram` n'est pas supporte par Mermaid 11. Utilise un flowchart equivalent.*
```mermaid
flowchart LR
subgraph Requirements["Exigences"]
SEC001["SEC-001<br/>Le systeme doit chiffrer<br/>les mots de passe<br/>Risk: High<br/>Verif: Test"]
end
subgraph Elements["Elements"]
Auth["Serveur Auth<br/>Node.js<br/>doc: Architecture.md"]
DB["Base de Donnees<br/>PostgreSQL"]
end
Auth -->|"satisfies"| SEC001
DB -->|"copies"| SEC001
```
---
## 11. Mindmap (Carte Mentale)
*Teste : Nœud racine, formes variées, icônes FontAwesome et sauts de ligne.*
```mermaid
mindmap
root((Projet Web))
Frontend
React
::icon(fa fa-react)
Tailwind CSS
Backend
Node.js
::icon(fa fa-node)
Base de données
PostgreSQL
Redis
Déploiement
Docker<br/>et CI/CD
::icon(fa fa-docker)
```
---
## 12. Timeline (Frise Chronologique)
*Teste : Titre, sections et événements avec texte multi-lignes.*
```mermaid
timeline
title Historique des versions du produit
section 2022
v1.0 : Lancement initial : Fonctionnalités de base
section 2023
v1.5 : Ajout du mode sombre : Refonte de l'UI
v2.0 : Architecture microservices : Passage à l'échelle
section 2024
v2.5 : Intégration IA : Assistant virtuel
```
---
## 13. Quadrant Chart (Matrice)
*Teste : Titres, axes personnalisés, quadrants et points avec styles.*
```mermaid
quadrantChart
title Matrice des priorités (Impact vs Effort)
x-axis "Faible Impact" --> "Fort Impact"
y-axis "Faible Effort" --> "Fort Effort"
quadrant-1 "À planifier"
quadrant-2 "À prioriser (Quick Wins)"
quadrant-3 "À éviter"
quadrant-4 "À déléguer"
"Correction de bugs mineurs": [0.2, 0.2]
"Nouvelle fonctionnalité IA": [0.8, 0.8]
"Refonte du logo": [0.3, 0.8]
"Optimisation SEO": [0.8, 0.3]
```
---
## 14. XY Chart (Beta)
*Teste : Graphique combiné (barres + ligne) avec axes personnalisés.*
```mermaid
xychart-beta
title "Ventes mensuelles vs Objectifs (2024)"
x-axis ["Jan", "Fév", "Mar", "Avr", "Mai"]
y-axis "Montant (€)" 0 --> 10000
bar [5000, 6000, 7500, 8200, 9500]
line [6000, 6500, 7000, 8000, 10000]
```
---
## 15. Block Diagram (Beta)
*Teste : Mise en page libre, blocs imbriqués, spans et icônes.*
```mermaid
block-beta
columns 3
%% Ligne 1
db[("Base de Données")]
space
api["API Gateway"]
%% Ligne 2 (span sur 3 colonnes)
block:Frontend:3
columns 2
web["Application Web"]
mobile["Application Mobile"]
end
%% Liaisons (visuelles via l'ordre ou explicites si supporté)
db --> api
api --> web
api --> mobile
style db fill:#f9f,stroke:#333
style api fill:#bbf,stroke:#333
```
---
## 16. Sankey Diagram (Beta)
*Teste : Flux de données avec valeurs et couleurs de liens.*
```mermaid
sankey-beta
%% source,target,value
Energie fossile,Electricite,40
Energie solaire,Electricite,30
Energie eolienne,Electricite,30
Electricite,Industrie,50
Electricite,Residentiel,35
Electricite,Pertes reseau,15
```
---
## 17. Packet Diagram (Beta)
*Teste : Représentation de trames réseau avec taille de mot et champs de bits.*
```mermaid
packet-beta
title Trame Ethernet Simplifiée
0-15: "Adresse MAC Destination"
16-31: "Adresse MAC Source"
32-47: "Type / Longueur"
48-127: "Payload (Données)"
128-143: "FCS (Contrôle)"
```
---
## 18. Kanban (Nouveauté v11)
*Teste : Sections, tickets avec ID, assignations, priorités et dates d'échéance.*
```mermaid
kanban
column1[À faire]
task1[Créer maquettes]@{ assigned: 'Alice', priority: 'High', ticket: 'DES-101' }
task2[Rédiger spécifications]@{ assigned: 'Bob', priority: 'Medium' }
column2[En cours]
task3[Développer l'API]@{ assigned: 'Charlie', priority: 'High', ticket: 'DEV-204', due: '2024-02-15' }
column3[Terminé]
task4[Configurer le CI/CD]@{ assigned: 'Alice', priority: 'Low', ticket: 'OPS-005' }
```
---
## 19. C4 Model (Context)
*Teste : Personnes, systèmes internes, systèmes externes et relations.*
```mermaid
C4Context
title Diagramme de Contexte Système - Application E-commerce
Person(client, "Client", "Un utilisateur qui achète des produits.")
System(eCommerce, "Système E-commerce", "Permet aux clients de parcourir et acheter des produits.")
System_Ext(payment, "Système de Paiement", "Stripe", "Gère les transactions bancaires.")
System_Ext(shipping, "Service de Livraison", "FedEx", "Gère l'expédition des colis.")
Rel(client, eCommerce, "Utilise pour acheter", "HTTPS")
Rel(eCommerce, payment, "Soumet le paiement", "API")
Rel(eCommerce, shipping, "Envoie les détails de livraison", "API")
```
---
## 20. Cas Limites & Compatibilité (Edge Cases)
*Teste la robustesse du parser face à des syntaxes hybrides ou des caractères spéciaux.*
```mermaid
flowchart TD
%% Test de caracteres speciaux et echappement
A["Noeud avec des guillemets #quot;internes#quot; et des sauts<br/>de ligne"] --> B
%% Test de formules mathematiques (si le moteur le supporte)
B("Formule : $E = mc^2$") --> C
%% Test de syntaxe style Obsidian (doit soit s'afficher en brut, soit etre pre-processe)
C["Lien wiki : Page Interne"] --> D
D["Highlight : Texte important"] --> E
%% Test de lien avec ID contenant des tirets et underscores
E --> F{Decision_Complexe-2024}
```
-32
View File
@@ -1,32 +0,0 @@
---
titre: Configuration serveur
tags: [serveur, configuration, technique, linux]
catégorie: technique
date: 2025-02-20
---
# Configuration du serveur
Documentation pour configurer un serveur Linux.
## Prérequis
- Ubuntu 22.04 LTS
- Docker installé
- 4 Go RAM minimum
## Étapes
1. Installation des dépendances
2. Configuration du firewall
3. Déploiement Docker
```bash
sudo apt update
sudo apt install docker.io
```
## Notes
Voir [[Notes/Sécurité]] pour la sécurisation.
Voir [[Notes/Performance]] pour l'optimisation.
-22
View File
@@ -1,22 +0,0 @@
---
titre: Astuces Docker
tags: [docker, conteneur, déploiement, technique]
catégorie: technique
date: 2025-05-01
---
# Astuces Docker
## Nettoyage
```bash
docker system prune -a
```
## Docker Compose
Toujours utiliser des fichiers `.env` pour les secrets.
## Volumes
Préférer les volumes nommés aux bind mounts.
-20
View File
@@ -1,20 +0,0 @@
---
titre: Optimisation des performances
tags: [performance, optimisation, serveur]
catégorie: technique
date: 2025-03-01
---
# Optimisation des performances
## Cache
Utiliser Redis pour le cache.
## Indexation
Configurer les index de base de données.
## Monitoring
Mettre en place Prometheus et Grafana.
-31
View File
@@ -1,31 +0,0 @@
---
titre: Sécurité
tags: [sécurité, serveur, firewall]
catégorie: technique
date: 2025-01-30
publish: true
---
# Sécurité du serveur
## Firewall
Utiliser ufw pour le firewall :
```bash
sudo ufw enable
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
```
## Certificats SSL
Utiliser Let's Encrypt pour les certificats.
## Mots de passe
Toujours utiliser des mots de passe forts.
Minimum 16 caractères.
Voir aussi [[Notes/Configuration serveur]].
-31
View File
@@ -1,31 +0,0 @@
---
titre: Projet Alpha
statut: en cours
tags: [projet, alpha, serveur]
catégorie: développement
publish: true
date: 2025-03-10
favoris: true
---
# Projet Alpha
Ce projet consiste à déployer un [[Notes/Configuration serveur|serveur]] pour l'application.
## Objectifs
- Mise en place du serveur
- Configuration de [[Notes/Sécurité]]
- Tests de performance
## Équipe
- Bruno (lead)
- Équipe DevOps
## Avancement
Le projet est **en cours**. La première phase est terminée.
La deuxième phase débute la semaine prochaine.
Voir aussi [[Projet Beta]] pour le projet connexe.
-21
View File
@@ -1,21 +0,0 @@
---
titre: Projet Beta
statut: planifié
tags: [projet, beta, frontend]
catégorie: développement
date: 2025-05-20
---
# Projet Beta
Ce projet est le successeur du [[Projets/Projet Alpha]].
## Technos
- React
- TypeScript
- Tailwind CSS
## Planning
Début prévu : juin 2025
-7
View File
@@ -1,7 +0,0 @@
---
tags: [caché, test]
---
# Document dans un sous-dossier
Ce document teste l'arborescence.