feat: finalisation éditeur Forge + mode hors-ligne PWA + consolidation roadmap
- 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:
@@ -5,6 +5,7 @@ __pycache__/
|
||||
.env.*
|
||||
!.env.example
|
||||
test_vault/
|
||||
test_dir/
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
@@ -14,3 +15,4 @@ config.json
|
||||
data/
|
||||
.coverage
|
||||
.coverage
|
||||
odysseus/
|
||||
|
||||
+44
-21
@@ -5,7 +5,9 @@ Configured via environment variables.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
from typing import Optional, Literal
|
||||
|
||||
@@ -13,27 +15,48 @@ logger = logging.getLogger("obsigate.ai")
|
||||
|
||||
ProviderName = Literal["deepseek", "openrouter", "gemini"]
|
||||
|
||||
# Provider configurations
|
||||
PROVIDERS = {
|
||||
"deepseek": {
|
||||
"api_key": os.getenv("DEEPSEEK_API_KEY", "").strip(),
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
"openrouter": {
|
||||
"api_key": os.getenv("OPENROUTER_API_KEY", "").strip(),
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"model": os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini"),
|
||||
"auth_header": "Bearer {api_key}",
|
||||
},
|
||||
"gemini": {
|
||||
"api_key": os.getenv("GEMINI_API_KEY", "").strip(),
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"),
|
||||
"auth_header": None, # Uses query param ?key=
|
||||
},
|
||||
}
|
||||
# Provider configurations — keys loaded from file or .env
|
||||
AI_KEYS_FILE = Path("data/api_keys.json")
|
||||
|
||||
def _read_ai_keys() -> dict:
|
||||
if not AI_KEYS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(AI_KEYS_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_ai_key(env_name: str) -> str:
|
||||
"""Get AI key: stored file first, then .env fallback."""
|
||||
keys = _read_ai_keys()
|
||||
if env_name in keys and keys[env_name]:
|
||||
return keys[env_name]
|
||||
return os.getenv(env_name, "").strip()
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ async def api_status():
|
||||
Reads environment variables at runtime so that changes to
|
||||
.env or Docker environment are reflected immediately.
|
||||
"""
|
||||
import os as _os
|
||||
from backend.ai import get_ai_key
|
||||
provider_keys = {
|
||||
"deepseek": "DEEPSEEK_API_KEY",
|
||||
"openrouter": "OPENROUTER_API_KEY",
|
||||
@@ -32,7 +32,7 @@ async def api_status():
|
||||
}
|
||||
providers = {}
|
||||
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] = {
|
||||
"available": has_key,
|
||||
"model": PROVIDERS[name]["model"] if has_key else None,
|
||||
|
||||
@@ -123,3 +123,46 @@ def is_bookmarked(username: str, vault: str, path: str) -> bool:
|
||||
return False
|
||||
bookmarks = _read_data(_get_bookmarks_file(username))
|
||||
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
@@ -9,6 +9,7 @@ import secrets
|
||||
import shutil
|
||||
import string
|
||||
import time
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
@@ -50,7 +51,7 @@ from backend.vault_settings import (
|
||||
get_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(
|
||||
level=logging.INFO,
|
||||
@@ -545,9 +546,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"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:; "
|
||||
"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;"
|
||||
)
|
||||
# Cache static assets aggressively (fingerprinted by PWA manifest hash)
|
||||
@@ -654,7 +655,7 @@ except OSError:
|
||||
build_pdf_html = None # type: ignore[assignment]
|
||||
import logging
|
||||
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.saved_searches import get_saved, save_search, delete_saved # noqa: E402
|
||||
from backend.ai_routes import router as ai_router # noqa: E402
|
||||
@@ -1814,6 +1815,11 @@ async def api_file_rename(
|
||||
# Update index
|
||||
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
|
||||
await sse_manager.broadcast("file_renamed", {
|
||||
"vault": vault_name,
|
||||
@@ -3602,6 +3608,114 @@ async def api_set_config(body: dict = Body(...), current_user=Depends(require_ad
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -109,3 +109,16 @@ def list_shares(vault_filter: Optional[str] = None) -> list:
|
||||
# Most recent first
|
||||
shares.sort(key=lambda s: s.get("created_at", ""), reverse=True)
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
@@ -9,11 +9,14 @@ services:
|
||||
ports:
|
||||
- "2020:8080"
|
||||
volumes:
|
||||
- C:\dev\git\python\obsigate\test-vault:/vaults/TestVault
|
||||
- C:\dev\git\python\obsigate\data:/app/data
|
||||
- /c/dev/git/python/obsigate/test_vault:/vaults/TestVault
|
||||
- /c/dev/git/python/obsigate/test_dir:/vaults/TestDir
|
||||
- /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
|
||||
|
||||
+421
-39
@@ -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:hover { border-color: var(--border); }
|
||||
.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; }
|
||||
.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); }
|
||||
@@ -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.active { background: var(--ai); color: #fff; }
|
||||
.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.ok { color: var(--ok); } .save-dot.ok .dot { background: var(--ok); }
|
||||
.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); }
|
||||
@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 */
|
||||
.ws { display: flex; flex: 1; overflow: hidden; }
|
||||
.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::placeholder { color: var(--text3); }
|
||||
|
||||
/* Quick insert + */
|
||||
.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; }
|
||||
/* Line numbers */
|
||||
.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); }
|
||||
.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 { 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; }
|
||||
|
||||
/* 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; }
|
||||
@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; }
|
||||
@@ -175,6 +195,19 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
.toast.on { display: block; }
|
||||
.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 */
|
||||
.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); } }
|
||||
@@ -191,12 +224,18 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
<div class="bar-left">
|
||||
<button class="bar-hamburger" title="Menu">☰</button>
|
||||
<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 class="bar-right">
|
||||
<button class="btn" id="btn-preview" title="Toggle preview">👁 Preview</button>
|
||||
<button class="btn btn-ai off" id="btn-ai" title="AI Panel (Ctrl+J)">✨ AI</button>
|
||||
<button class="btn" id="btn-help" title="Aide / Raccourcis (F1)">?</button>
|
||||
<button class="btn" id="btn-preview" title="Toggle preview">👁</button>
|
||||
<button class="btn btn-ai off" id="btn-ai" title="AI Panel (Ctrl+J)">✨</button>
|
||||
<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">🗑</button>
|
||||
<button class="btn btn-square" id="forge-cancel" title="Annuler">✕</button>
|
||||
<button class="btn btn-square primary" id="forge-save" title="Sauvegarder (Ctrl+S)">✓</button>
|
||||
</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">🖼</div><div class="drop-title">Drop files here</div><div class="drop-sub">Images, PDFs, documents</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Editor pane (textarea) -->
|
||||
<!-- Editor pane (textarea) with line numbers -->
|
||||
<div class="editor-pane" id="editor-pane">
|
||||
<div class="editor-wrap" id="editor-wrap">
|
||||
<button class="qi-btn" id="qi-btn" title="Quick insert">⊕</button>
|
||||
<textarea class="textarea" id="ta" placeholder="Type / for commands..." spellcheck="false"></textarea>
|
||||
<div class="ln-wrap">
|
||||
<div class="ln-gutter" id="ln-gutter">
|
||||
<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 -->
|
||||
<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)">✨</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>
|
||||
|
||||
<!-- Resize handle -->
|
||||
@@ -344,11 +390,57 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
<!-- Toast -->
|
||||
<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 -->
|
||||
<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>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/atom-one-dark.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/highlight.min.js"></script>
|
||||
<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/gh/highlightjs/[email protected]/build/highlight.min.js"></script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
@@ -358,6 +450,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
var ta = document.getElementById('ta');
|
||||
var editorWrap = document.getElementById('editor-wrap');
|
||||
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 previewContent = document.getElementById('preview-content');
|
||||
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 toast = document.getElementById('toast');
|
||||
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 ----
|
||||
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);
|
||||
isDirty = false; originalContent = content;
|
||||
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) {
|
||||
saveDot.className = 'save-dot err'; saveLabel.textContent = 'Erreur';
|
||||
showToast('Echec sauvegarde: ' + e.message, 'err');
|
||||
@@ -524,13 +627,19 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.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();
|
||||
originalContent = ta.value;
|
||||
// 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);
|
||||
if (m) title = m[1];
|
||||
document.getElementById('poc-title').value = title;
|
||||
pocFilename.value = filePath.split('/').pop();
|
||||
statFile.textContent = fileVault + '/' + filePath;
|
||||
document.title = title + ' — ObsiGate Forge';
|
||||
updateStats();
|
||||
schedulePreview();
|
||||
})
|
||||
.catch(function(e) {
|
||||
console.error('[Forge] Load error:', url, e);
|
||||
ta.value = '# Erreur\n\nImpossible de charger: ' + (fileVault || '?') + '/' + (filePath || '?') + '\n\n' + e.message;
|
||||
statFile.textContent = 'Erreur chargement';
|
||||
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')); }
|
||||
|
||||
var SLASH_INSERT = {
|
||||
h1: function() { replaceLine('# '); },
|
||||
h2: function() { replaceLine('## '); },
|
||||
h3: function() { replaceLine('### '); },
|
||||
bullet: function() { replaceLine('- '); },
|
||||
numbered: function() { replaceLine('1. '); },
|
||||
checklist: function() { replaceLine('- [ ] '); },
|
||||
quote: function() { replaceLine('> '); },
|
||||
h1: function() { prefixLine('# '); },
|
||||
h2: function() { prefixLine('## '); },
|
||||
h3: function() { prefixLine('### '); },
|
||||
bullet: function() { prefixLine('- '); },
|
||||
numbered: function() { prefixLine('1. '); },
|
||||
checklist: function() { prefixLine('- [ ] '); },
|
||||
quote: function() { prefixLine('> '); },
|
||||
divider: function() { insertAtCursor('\n---\n'); },
|
||||
image: function() { insertAtCursor('', 2); },
|
||||
link: function() { insertAtCursor('[text](url)', 1); },
|
||||
link: function() { insertAtCursor('[text](https://)', 7); },
|
||||
image: function() { insertAtCursor('', 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() {
|
||||
slashMenu.classList.add('on'); slashVisible = true; slashIdx = -1;
|
||||
slashSearch.value = ''; slashSearch.focus();
|
||||
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 pt = caretToPixel(pos);
|
||||
slashMenu.style.left = pt.x + 'px';
|
||||
slashMenu.style.top = pt.y + 'px';
|
||||
var wr = editorWrap.getBoundingClientRect();
|
||||
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(); }
|
||||
@@ -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 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 m = line.text.match(/^\/\S*\s*/);
|
||||
var m = line.text.match(/^\/\s*/);
|
||||
if (m) {
|
||||
ta.value = val().substring(0, line.start) + val().substring(line.start + m[0].length);
|
||||
setPos(line.start);
|
||||
@@ -1064,6 +1197,59 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
|
||||
// ---- Event handlers ----
|
||||
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
|
||||
if (e.key === '/') {
|
||||
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(); }
|
||||
});
|
||||
|
||||
// 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)
|
||||
slashSearch.addEventListener('keydown', function(e) {
|
||||
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)' : ''; });
|
||||
}
|
||||
|
||||
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() {
|
||||
updateStats(); markDirty();
|
||||
autoHeight();
|
||||
@@ -1201,6 +1407,27 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
|
||||
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(); });
|
||||
|
||||
// ---- 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(' AI: checking...');
|
||||
console.log(' Type / for commands | Select text for bubble | Ctrl+J AI | Ctrl+S save');
|
||||
|
||||
+71
-49
@@ -1137,46 +1137,23 @@
|
||||
<div class="editor-modal" id="editor-modal">
|
||||
<div class="editor-container">
|
||||
<div class="editor-header">
|
||||
<div class="editor-title" id="editor-title">Édition</div>
|
||||
<div class="editor-actions">
|
||||
<button
|
||||
class="editor-btn danger"
|
||||
id="editor-delete"
|
||||
title="Supprimer"
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<i
|
||||
data-lucide="trash-2"
|
||||
style="width: 16px; height: 16px"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
class="editor-btn"
|
||||
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>
|
||||
<span class="editor-brand">ObsiGate</span>
|
||||
<input class="editor-title-input" id="editor-title-input" placeholder="Titre..." spellcheck="false" title="Titre (sync avec # Heading)">
|
||||
<span class="editor-sep">/</span>
|
||||
<input class="editor-filename-input" id="editor-filename-input" placeholder="nom.md" spellcheck="false" title="Renommer le fichier">
|
||||
<div class="editor-spacer"></div>
|
||||
<span class="editor-save-dot ok" id="editor-save-dot"><span class="dot"></span><span id="editor-save-label">Saved</span></span>
|
||||
<button class="editor-btn danger" id="editor-delete" title="Supprimer" aria-label="Supprimer">🗑</button>
|
||||
<button class="editor-btn" id="editor-cancel" title="Annuler" aria-label="Annuler">✕</button>
|
||||
<button class="editor-btn primary" id="editor-save" title="Sauvegarder" aria-label="Sauvegarder">✓</button>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -1199,10 +1176,26 @@
|
||||
</button>
|
||||
</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">
|
||||
<!-- Performance Settings - Frontend -->
|
||||
<section class="config-section">
|
||||
<section class="config-section help-section" id="cfg-search">
|
||||
<h2>Paramètres de recherche</h2>
|
||||
<p class="config-description">
|
||||
Ces paramètres s'appliquent immédiatement côté
|
||||
@@ -1286,7 +1279,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Historique récent -->
|
||||
<section class="config-section">
|
||||
<section class="config-section help-section">
|
||||
<h2>
|
||||
📋 Historique récent
|
||||
<span class="config-badge-restart"
|
||||
@@ -1335,7 +1328,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Performance Settings - Backend -->
|
||||
<section class="config-section">
|
||||
<section class="config-section help-section">
|
||||
<h2>
|
||||
Paramètres backend
|
||||
<span class="config-badge-restart"
|
||||
@@ -1465,7 +1458,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Tag Filtering (existing) -->
|
||||
<section class="config-section">
|
||||
<section class="config-section help-section">
|
||||
<h2>Filtrage de tags</h2>
|
||||
<p class="config-description">
|
||||
Définissez les patterns de tags à masquer dans
|
||||
@@ -1505,7 +1498,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Watcher / Synchronisation -->
|
||||
<section class="config-section">
|
||||
<section class="config-section help-section" id="cfg-sync">
|
||||
<h2>Synchronisation automatique</h2>
|
||||
<p class="config-description">
|
||||
Surveillance des fichiers en temps réel via
|
||||
@@ -1595,7 +1588,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Hidden Files/Folders Configuration -->
|
||||
<section class="config-section">
|
||||
<section class="config-section help-section">
|
||||
<h2>🗂️ Fichiers cachés</h2>
|
||||
<p class="config-description">
|
||||
Contrôlez l'affichage des fichiers/dossiers
|
||||
@@ -1635,7 +1628,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Diagnostics -->
|
||||
<section class="config-section">
|
||||
<section class="config-section help-section" id="cfg-diags">
|
||||
<h2>Diagnostics</h2>
|
||||
<p class="config-description">
|
||||
Statistiques de l'index et du moteur de
|
||||
@@ -1658,8 +1651,36 @@
|
||||
</button>
|
||||
</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 -->
|
||||
<section class="config-section">
|
||||
<section class="config-section help-section" id="cfg-about">
|
||||
<h2>📦 À propos</h2>
|
||||
<div id="config-about" class="config-diagnostics">
|
||||
<div class="config-diag-loading">
|
||||
@@ -1669,7 +1690,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Webhooks -->
|
||||
<section class="config-section">
|
||||
<section class="config-section help-section">
|
||||
<h2>🔔 Webhooks</h2>
|
||||
<p class="config-description">
|
||||
Notifications HTTP vers des services externes
|
||||
@@ -1701,7 +1722,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Partages publics -->
|
||||
<section class="config-section">
|
||||
<section class="config-section help-section">
|
||||
<h2>📤 Partages publics</h2>
|
||||
<p class="config-description">
|
||||
Liens de partage publics pour des documents
|
||||
@@ -1710,6 +1731,7 @@
|
||||
<div id="shares-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2968,7 +2990,7 @@
|
||||
<li>
|
||||
<strong>Barre d'outils AI</strong> :
|
||||
Complétion, réécriture, traduction (voir
|
||||
section <a href="#help-ia">IA</a>)
|
||||
section <a href="#help-ia">🤖 IA</a>)
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -609,6 +609,30 @@ function initConfigModal() {
|
||||
const saveHiddenBtn = document.getElementById("cfg-save-hidden-files");
|
||||
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) => {
|
||||
if (e.key === "Escape" && modal.classList.contains("active")) {
|
||||
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 {
|
||||
initSidebarTabs,
|
||||
initConfigModal,
|
||||
|
||||
@@ -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); };
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { api } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { showLoading, el, hideProgressBar, showWelcome, highlightSearchText, loadSavedSearches } from './viewer.js';
|
||||
import { searchOffline } from './offline.js';
|
||||
import { _getEffective } from './config.js';
|
||||
import { TabManager, showToast } from './ui.js';
|
||||
import { addTagFilter, buildSearchResultsHeader, shouldDisplayPath, removeTagFilter, TagFilterService } from './sidebar.js';
|
||||
@@ -804,6 +805,27 @@ export async function performAdvancedSearch(query, vaultFilter, tagFilter, offse
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === "AbortError") 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();
|
||||
} finally {
|
||||
hideProgressBar();
|
||||
|
||||
+39
-1
@@ -1,10 +1,11 @@
|
||||
/* ObsiGate — Sync: SSE client + PWA registration */
|
||||
/* ObsiGate — Sync: SSE client + PWA registration + Offline mode */
|
||||
import { state } from './state.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { api } from './auth.js';
|
||||
import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { loadRecentFiles } from './config.js';
|
||||
import { openFile } from './viewer.js';
|
||||
import { initOffline, syncFileIndexFromServer } from './offline.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE Client — IndexUpdateManager
|
||||
@@ -550,5 +551,42 @@ function initDashboardTabs() {
|
||||
export { initSyncStatus, initDashboardTabs };
|
||||
export function init() {
|
||||
registerServiceWorker();
|
||||
initOffline();
|
||||
// Sync file index after a short delay (let vaults load first)
|
||||
setTimeout(() => syncFileIndexFromServer(), 3000);
|
||||
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
@@ -517,6 +517,9 @@ export function initTagResize() {
|
||||
// Frontmatter Accent Card Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Persist toggle state across document openings
|
||||
let _frontmatterOpen = false;
|
||||
|
||||
export function buildFrontmatterCard(frontmatter) {
|
||||
// Helper: format date
|
||||
function formatDate(iso) {
|
||||
@@ -530,11 +533,11 @@ export function buildFrontmatterCard(frontmatter) {
|
||||
// Extract boolean flags
|
||||
const booleanFlags = ["publish", "favoris", "template", "task", "archive", "draft", "private"].map((key) => ({ key, value: !!frontmatter[key] }));
|
||||
|
||||
// Toggle state
|
||||
let isOpen = true;
|
||||
// Toggle state (persisted across documents)
|
||||
let isOpen = _frontmatterOpen;
|
||||
|
||||
// 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>';
|
||||
|
||||
const fmHeader = el("div", { class: "fm-header" }, [chevron, document.createTextNode("Frontmatter")]);
|
||||
@@ -612,9 +615,13 @@ export function buildFrontmatterCard(frontmatter) {
|
||||
// Assemble the card
|
||||
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
|
||||
fmHeader.addEventListener("click", () => {
|
||||
isOpen = !isOpen;
|
||||
_frontmatterOpen = isOpen;
|
||||
if (isOpen) {
|
||||
acCard.style.display = "block";
|
||||
chevron.classList.remove("closed");
|
||||
|
||||
+49
-29
@@ -275,10 +275,22 @@ async function openEditor(vaultName, filePath) {
|
||||
state.editorPath = filePath;
|
||||
|
||||
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");
|
||||
|
||||
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
|
||||
const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`;
|
||||
@@ -322,6 +334,11 @@ async function openEditor(vaultName, filePath) {
|
||||
if (update.docChanged) {
|
||||
clearTimeout(window._obsigateAutoSaveTimer);
|
||||
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 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 {
|
||||
// Update save dot to saving state
|
||||
if (saveDot) { saveDot.className = 'editor-save-dot saving'; }
|
||||
if (saveLabel) saveLabel.textContent = 'Saving...';
|
||||
if (saveBtn && !silent) {
|
||||
saveBtn.disabled = true;
|
||||
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");
|
||||
}
|
||||
|
||||
// Update save dot to saved state
|
||||
if (saveDot) { saveDot.className = 'editor-save-dot ok'; }
|
||||
if (saveLabel) saveLabel.textContent = 'Saved';
|
||||
|
||||
if (silent) {
|
||||
// Auto-save: brief green flash on save button
|
||||
if (saveBtn) {
|
||||
saveBtn.style.background = '#22c55e';
|
||||
saveBtn.style.color = '#fff';
|
||||
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 {
|
||||
saveBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px"></i>';
|
||||
safeCreateIcons();
|
||||
|
||||
setTimeout(() => closeEditor(), 800);
|
||||
closeEditor();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Save error:", err);
|
||||
alert(`Erreur: ${err.message}`);
|
||||
saveBtn.innerHTML = originalHTML;
|
||||
saveBtn.disabled = false;
|
||||
safeCreateIcons();
|
||||
if (saveDot) { saveDot.className = 'editor-save-dot err'; }
|
||||
if (saveLabel) saveLabel.textContent = 'Erreur';
|
||||
// If offline, queue the save for later sync
|
||||
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);
|
||||
deleteBtn.addEventListener("click", deleteFile);
|
||||
saveBtn.addEventListener("click", saveFile);
|
||||
|
||||
// Close on overlay click
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) {
|
||||
closeEditor();
|
||||
}
|
||||
});
|
||||
saveBtn.addEventListener("click", () => saveFile());
|
||||
|
||||
// ESC to close
|
||||
document.addEventListener("keydown", (e) => {
|
||||
|
||||
@@ -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")]);
|
||||
forgeBtn.addEventListener("click", () => {
|
||||
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");
|
||||
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.style.overflow = "hidden";
|
||||
var iframe = document.createElement("iframe");
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Editor save dot state colors ── */
|
||||
:root {
|
||||
--ok: #34d399;
|
||||
--warn: #fbbf24;
|
||||
--ai: #8b5cf6;
|
||||
--err: #f87171;
|
||||
}
|
||||
|
||||
/* ===== THEME — DARK (default) ===== */
|
||||
:root[data-theme="dark"] {
|
||||
--bg-primary: #0f1117;
|
||||
@@ -189,6 +197,98 @@ a:hover {
|
||||
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 */
|
||||
.vault-ctx-all {
|
||||
background: color-mix(in srgb, #3b82f6 14%, transparent) !important;
|
||||
@@ -2215,6 +2315,29 @@ select {
|
||||
color: var(--text-primary);
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -3335,6 +3458,17 @@ body.resizing-v {
|
||||
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 {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--accent);
|
||||
|
||||
+97
-144
@@ -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 STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||
const DYNAMIC_CACHE = `${CACHE_VERSION}-dynamic`;
|
||||
const MAX_DYNAMIC_CACHE_SIZE = 50;
|
||||
const CACHE_VERSION = 'obsigate-v2-pwa';
|
||||
const STATIC_CACHE = CACHE_VERSION + '-static';
|
||||
const DYNAMIC_CACHE = CACHE_VERSION + '-dynamic';
|
||||
const MAX_DYNAMIC_CACHE_SIZE = 100;
|
||||
|
||||
// Assets to cache on install
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/static/index.html',
|
||||
@@ -14,25 +13,23 @@ const STATIC_ASSETS = [
|
||||
'/static/manifest.json'
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
/* ── Install ── */
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing service worker...');
|
||||
console.log('[SW] Installing v' + CACHE_VERSION);
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE)
|
||||
.then((cache) => {
|
||||
console.log('[SW] Caching static assets');
|
||||
return cache.addAll(STATIC_ASSETS.map(url => new Request(url, { cache: 'reload' })));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[SW] Failed to cache static assets:', err);
|
||||
})
|
||||
.catch((err) => console.error('[SW] Cache error:', err))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
/* ── Activate ── */
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating service worker...');
|
||||
console.log('[SW] Activating...');
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((cacheNames) => {
|
||||
@@ -49,189 +46,145 @@ self.addEventListener('activate', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - serve from cache, fallback to network
|
||||
/* ── Fetch ── */
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
if (request.method !== 'GET') return;
|
||||
if (url.pathname === '/api/events') return;
|
||||
if (url.pathname.startsWith('/api/auth/')) return;
|
||||
|
||||
// Skip SSE connections
|
||||
if (url.pathname === '/api/events') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip authentication endpoints
|
||||
if (url.pathname.startsWith('/api/auth/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// API requests - Network first, cache fallback
|
||||
// API requests: Network first, tell client to use IndexedDB if offline
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(networkFirstStrategy(request));
|
||||
event.respondWith(networkFirstAPI(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets - Cache first, network fallback
|
||||
event.respondWith(cacheFirstStrategy(request));
|
||||
// Static assets: Cache first
|
||||
event.respondWith(cacheFirstStatic(request, url));
|
||||
});
|
||||
|
||||
// Cache first strategy (for static assets)
|
||||
async function cacheFirstStrategy(request) {
|
||||
/* ── Network First for API ── */
|
||||
async function networkFirstAPI(request) {
|
||||
try {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
const response = await fetch(request);
|
||||
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
// 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') {
|
||||
// Cache successful GETs in dynamic cache
|
||||
if (response && response.status === 200) {
|
||||
const cache = await caches.open(DYNAMIC_CACHE);
|
||||
cache.put(request, networkResponse.clone());
|
||||
|
||||
// Limit dynamic cache size
|
||||
cache.put(request, response.clone());
|
||||
limitCacheSize(DYNAMIC_CACHE, MAX_DYNAMIC_CACHE_SIZE);
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log('[SW] Network failed, trying cache:', request.url);
|
||||
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
console.log('[SW] Offline — checking cache for:', request.url);
|
||||
|
||||
// Try dynamic cache first
|
||||
const cached = await caches.match(request);
|
||||
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({
|
||||
error: 'Offline',
|
||||
message: 'Unable to fetch data. Please check your connection.'
|
||||
offline: true,
|
||||
endpoint: new URL(request.url).pathname,
|
||||
message: 'Vous etes hors-ligne. Les donnees locales sont utilisees.'
|
||||
}), {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'X-ObsiGate-Offline': 'true' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
|
||||
if (keys.length > maxSize) {
|
||||
// Delete oldest entries
|
||||
const deleteCount = keys.length - maxSize;
|
||||
for (let i = 0; i < deleteCount; i++) {
|
||||
for (let i = 0; i < keys.length - maxSize; i++) {
|
||||
await cache.delete(keys[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message event - handle messages from clients
|
||||
/* ── Messages ── */
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
const data = event.data;
|
||||
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(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((name) => caches.delete(name))
|
||||
);
|
||||
self.clients.matchAll().then(clients => {
|
||||
clients.forEach(client => {
|
||||
client.postMessage({ type: 'SYNC_TRIGGERED' });
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Sync event - background sync for offline actions
|
||||
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
|
||||
/* ── Push ── */
|
||||
self.addEventListener('push', (event) => {
|
||||
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',
|
||||
badge: '/static/icons/icon-72x72.svg',
|
||||
vibrate: [200, 100, 200],
|
||||
data: {
|
||||
dateOfArrival: Date.now(),
|
||||
primaryKey: 1
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
action: 'explore',
|
||||
title: 'Ouvrir ObsiGate'
|
||||
},
|
||||
{
|
||||
action: 'close',
|
||||
title: 'Fermer'
|
||||
}
|
||||
{ action: 'open', 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) => {
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'explore') {
|
||||
event.waitUntil(
|
||||
clients.openWindow('/')
|
||||
);
|
||||
if (event.action === 'open') {
|
||||
event.waitUntil(clients.openWindow('/'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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]].
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
tags: [caché, test]
|
||||
---
|
||||
|
||||
# Document dans un sous-dossier
|
||||
|
||||
Ce document teste l'arborescence.
|
||||
Reference in New Issue
Block a user