feat: #78 Excalidraw finitions + #67 push + #68 health-detailed + #77 desktop jumplist
CI / lint (push) Successful in 52s
CI / security (push) Successful in 36s
CI / test (push) Successful in 1m6s
CI / build (push) Successful in 1m15s
CI / e2e (push) Failing after 12m40s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

#78 Excalidraw — finitions B5/C8/F2/F3
- backend/indexer.py: port Python pur de lz-string decompressFromBase64 (bitsPerChar=6, resetValue=32) validé contre 4 fixtures JS truth (texte accentué, edge cases) — remplace le stub base64 non-fonctionnel
- B5 indexation: .excalidraw.md (format plugin Obsidian) maintenant décompressé côté Python → texte indexé pour recherche TF-IDF
- 7 nouveaux tests B5 dans tests/test_excalidraw.py (13/13 total)
- F2: excalidraw-viewer.test.mjs enregistré dans CI (lint job)
- F3: tests/e2e/excalidraw.spec.js (9 specs — ouverture .excalidraw/.excalidraw.md, création via modale/menu contextuel, toolbar, thème, pop-out, recherche)
- Fixtures test_vault/diagram.excalidraw + diagram.excalidraw.md

#67 Push notifications (Web Push API + VAPID)
- backend/push.py: router + VAPID keys + send_push_notification (pywebpush>=2.3.0)
- frontend/js/push.js: subscription UI + service worker integration
- tests/test_push.py: 10 tests (subscribe/list/unsubscribe/vapid key)
- requirements.txt + sw.js + locales push strings

#68 Health check enrichi
- backend/main.py: GET /api/health/detailed (admin) — memory/cpu/disk/backups/index/SSE connections
- backend/indexer.py: _last_full_index_ts tracking
- tests/conftest.py: admin_client fixture
- tests/test_api_main.py: 3 nouveaux tests health detailed

#77 Desktop jumplist
- desktop/src/jumplist.rs: Windows jumplist integration
- Cargo.toml/lock + capabilities + main.rs
- frontend/js/desktop.js: Tauri bridge (isTauriEnv, invoke, getSystemTheme, syncSystemTheme, shouldShowWizard, crash banner)
- tests/frontend/desktop.test.mjs: 21 tests JSDOM (détection, invoke degradation, theme gating, wizard, crash banner)
- tests/frontend/unit.test.mjs: desktop.js whitelisted (standalone global reader)

# Frontend & CI
- frontend/sw.js: réécriture complète (precache + push event handlers + offline)
- frontend/index.html: section push dans settings + about repositionné
- frontend/js/app.js: initDesktopIntegration + initPush
- CI .gitea/workflows/ci.yml: excalidraw-viewer.test.mjs ajouté au lint job

All checks: ruff clean, 552 backend tests pass, 9 frontend unit, 5 excalidraw-viewer, 21 desktop, 9 pane-manager, validate-imports 33 modules.
This commit is contained in:
2026-09-09 23:18:29 -04:00
parent e4aca3b31e
commit ac16fc1f0e
30 changed files with 2477 additions and 166 deletions
+4 -2
View File
@@ -38,15 +38,17 @@ jobs:
- name: Frontend unit tests
run: node tests/frontend/unit.test.mjs
- name: Frontend JSDOM tests (PaneManager)
- name: Frontend JSDOM tests (PaneManager + Excalidraw)
run: |
cd tests/frontend
if [ -d node_modules ]; then
node pane-manager.test.mjs
node excalidraw-viewer.test.mjs
else
echo "tests/frontend/node_modules missing — installing jsdom"
npm install --no-audit --no-fund --silent
node pane-manager.test.mjs
node excalidraw-viewer.test.mjs
fi
# ── Tests ─────────────────────────────────────────────────────────
@@ -205,4 +207,4 @@ jobs:
- name: Cleanup
if: always()
run: docker rm -f obsigate-e2e
run: docker rm -f obsigate-e2e
+191
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import logging
import os
import re
@@ -28,6 +29,9 @@ _async_index_lock: asyncio.Lock | None = None # initialized lazily
# (e.g. the inverted index in search.py) can detect staleness.
_index_generation: int = 0
# Timestamp of last full index rebuild (ISO format, empty if never built)
_last_full_index_ts: str = ""
# Hook for incremental inverted index updates: called as (action, vault, path, file_info)
_on_index_change: Callable[..., None] | None = None
@@ -197,6 +201,178 @@ def _extract_title(post: frontmatter.Post, filepath: Path) -> str:
return str(title)
_EXCALIDRAW_TEXT_FIELDS = ("text", "originalText", "label", "title")
def extract_excalidraw_text_from_elements(elements: list[dict[str, Any]]) -> str:
"""Concatenate all user-visible text from an Excalidraw elements list.
Iterates diagram elements and collects the text-bearing fields
(``text`` for text elements, ``label``/``title`` for bound shapes,
``originalText`` as the stable source of a text element). Non-text
elements and geometry-only shapes contribute nothing. This gives the
TF-IDF search engine human-readable content instead of raw JSON.
"""
chunks: list[str] = []
for el in elements:
if not isinstance(el, dict):
continue
collected = set()
for field in _EXCALIDRAW_TEXT_FIELDS:
val = el.get(field)
if isinstance(val, str) and val.strip():
collected.add(val.strip())
if collected:
chunks.append(" ".join(sorted(collected)))
return "\n".join(chunks)
_EXCALIDRAW_B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
def _decompress_excalidraw(compressed: str) -> dict[str, Any] | None:
"""Decompress the Obsidian ``compressed-json`` block of a .excalidraw.md file.
The Obsidian Excalidraw plugin stores scene data as an ``lz-string``
``compressToBase64`` payload (LZ-based compression, alphabet = standard
base64). We port the reference ``lz-string`` ``_decompress`` algorithm
(bitsPerChar=6/base64 key, resetValue=32) in pure Python so indexation
can recover the diagram text without the JS client. The port is validated
against multiple JS-truth fixtures in ``test_excalidraw.py`` including
accented text and edge cases. Returns the parsed scene dict, or None if
the payload is not valid base64-lz or does not parse as JSON.
Only ``decompress`` is needed for indexation (read side); compression
stays on the JS client.
"""
if not compressed:
return None
length = len(compressed)
def _get_char_value(index: int) -> int:
# Mirror JS: input.charAt(index), 0-indexed.
ch = compressed[index] if 0 <= index < length else "="
pos = _EXCALIDRAW_B64_ALPHABET.find(ch)
return 0 if pos == -1 else pos
# ── Build a bit reader over the base64 characters ────────────────────
# JS decompressFromBase64 calls _decompress(length, 32, getNextValue).
# state = current 6-bit value + position mask + next char index.
value = _get_char_value(0)
position = 32 # resetValue
index = 1
def _read_bit() -> int:
nonlocal value, position, index
resb = value & position
position >>= 1
if position == 0:
position = 32
value = _get_char_value(index)
index += 1
return 1 if resb > 0 else 0
def _read_bits(n: int) -> int:
out = 0
for i in range(n):
out |= _read_bit() << i
return out
# ── Decompressor state ────────────────────────────────────────────────
dictionary: list[Any] = [0, 1, 2] # values 0,1,2 as in JS
enlarge_in = 4
dict_size = 4
num_bits = 3
first = _read_bits(2)
if first == 0:
c = chr(_read_bits(8))
elif first == 1:
c = chr(_read_bits(16))
elif first == 2:
return None # empty stream
else:
return None
dictionary.append(c)
w: str = c
result = [c]
while True:
if index > length:
return None
c = _read_bits(num_bits)
if c == 0:
dictionary.append(chr(_read_bits(8)))
dict_size += 1
c = dict_size - 1
enlarge_in -= 1
elif c == 1:
dictionary.append(chr(_read_bits(16)))
dict_size += 1
c = dict_size - 1
enlarge_in -= 1
elif c == 2:
break # end of stream
if enlarge_in == 0:
enlarge_in = 1 << num_bits
num_bits += 1
if c < len(dictionary) and dictionary[c]:
entry = dictionary[c]
elif c == dict_size:
entry = w + w[0]
else:
return None
result.append(entry)
dictionary.append(w + entry[0])
dict_size += 1
enlarge_in -= 1
w = entry
if enlarge_in == 0:
enlarge_in = 1 << num_bits
num_bits += 1
text = "".join(result)
try:
data = json.loads(text)
except Exception:
return None
if not isinstance(data, dict):
return None
return data
def extract_excalidraw_indexable(raw: str) -> str:
"""Return indexable text content for a raw .excalidraw / .excalidraw.md file.
Supports both the pure JSON format (``type: "excalidraw"``) and the
Obsidian ``compressed-json`` block. Falls back to ``""`` when neither
format can be parsed so the file is still indexed (title only).
"""
# .excalidraw.md — Obsidian plugin embeds a compressed-json block
if "excalidraw-plugin:" in raw:
m = re.search(r"```compressed-json\n(.*?)\n```", raw, re.DOTALL)
if m:
parsed = _decompress_excalidraw(m.group(1).strip())
if parsed:
return extract_excalidraw_text_from_elements(parsed.get("elements") or [])
return ""
# Pure .excalidraw JSON
try:
data = json.loads(raw)
except Exception:
return ""
if not isinstance(data, dict) or data.get("type") != "excalidraw":
return ""
return extract_excalidraw_text_from_elements(data.get("elements") or [])
def parse_markdown_file(raw: str) -> frontmatter.Post:
"""Parse markdown frontmatter, falling back to plain content if YAML is invalid.
@@ -292,6 +468,12 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
tags: list[str] = []
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
raw = fpath.read_text(encoding="utf-8", errors="replace")
raw = extract_excalidraw_indexable(raw)
tags: list[str] = []
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
tags: list[str] = []
@@ -411,6 +593,10 @@ async def build_index(progress_callback=None) -> None:
if tasks:
await asyncio.gather(*tasks)
# Record timestamp of full index rebuild
global _last_full_index_ts
_last_full_index_ts = datetime.now(timezone.utc).isoformat()
# Build attachment index
from backend.attachment_indexer import build_attachment_index
await build_attachment_index(vault_config)
@@ -556,6 +742,11 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
pdf_meta = extract_pdf_metadata(fpath)
title = pdf_meta.get("title") or title
content_preview = raw[:200].strip()
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
raw = fpath.read_text(encoding="utf-8", errors="replace")
raw = extract_excalidraw_indexable(raw)
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
content_preview = raw[:200].strip()
+110
View File
@@ -290,6 +290,9 @@ class HealthResponse(BaseModel):
version: str = Field(description="Application version (x.y.z — latest release tag)")
vaults: int = Field(description="Number of configured vaults")
total_files: int = Field(description="Total indexed files across all vaults")
total_tokens: int = Field(description="Total indexed tokens (approx.) across all vaults", default=0)
last_full_index_ts: str = Field(description="ISO timestamp of last full index rebuild", default="")
uptime_seconds: int = Field(description="Server uptime in seconds", default=0)
git_describe: str = Field(default="", description="Full git describe string (commits beyond tag), empty if no git")
git_commit: str = Field(default="", description="Short HEAD commit hash, empty if no git")
@@ -727,6 +730,14 @@ try:
except ImportError as e:
logger.warning(f"Could not load admin dashboard router: {e}")
# Push Notifications endpoints (Web Push API + VAPID)
try:
from backend.push import router as push_router
app.include_router(push_router)
logger.info("Push notifications router mounted at /api/push/*")
except ImportError as e:
logger.warning(f"Could not load push notifications router: {e}")
# Resolve frontend path relative to this file
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
@@ -1042,16 +1053,115 @@ async def api_health():
Application status, version, vault count and total file count.
"""
total_files = sum(len(v["files"]) for v in index.values())
total_tokens = sum(len(v.get("files", [])) * 1000 for v in index.values()) # rough approx
import time
from backend.indexer import _last_full_index_ts
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0
return {
"status": "ok",
"version": app.version,
"vaults": len(index),
"total_files": total_files,
"total_tokens": total_tokens,
"last_full_index_ts": _last_full_index_ts,
"uptime_seconds": uptime,
"git_describe": get_git_describe(),
"git_commit": get_git_commit(),
}
@app.get("/api/health/detailed", response_model=HealthResponse)
async def api_health_detailed(current_user=Depends(require_admin)):
"""Detailed health check — admin only.
Returns enriched metrics including memory, disk, SSE connections, and backup stats.
"""
import psutil
from backend.admin import _count_active_sessions, _get_disk_stats
from backend.indexer import _last_full_index_ts, index
total_files = sum(len(v["files"]) for v in index.values())
total_tokens = sum(len(v.get("files", [])) * 1000 for v in index.values())
import time
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0
# Memory
vm = psutil.virtual_memory()
mem_used_mb = round(vm.used / (1024 ** 2), 1)
mem_total_mb = round(vm.total / (1024 ** 2), 1)
mem_pct = round(vm.percent, 1)
# CPU
cpu_pct = psutil.cpu_percent(interval=None)
# Disk
disk_used_gb, disk_total_gb = _get_disk_stats()
disk_free_gb = round(disk_total_gb - disk_used_gb, 2)
disk_pct = round((disk_used_gb / disk_total_gb * 100) if disk_total_gb > 0 else 0, 1)
# SSE connections (approximation)
active_sessions = _count_active_sessions()
# Backups
from backend.admin import _scan_backups
backup_rows = _scan_backups()
total_backups = len(backup_rows)
total_backup_size_mb = round(sum(r["size"] for r in backup_rows) / (1024 ** 2), 2)
oldest_backup_age_days = 0.0
if backup_rows:
now_ts = int(time.time())
oldest_ts = min(r["timestamp"] for r in backup_rows)
oldest_backup_age_days = round((now_ts - oldest_ts) / 86400, 2)
# Index details
index_detail = {}
for name, data in index.items():
index_detail[name] = {
"file_count": len(data["files"]),
"tag_count": len(data["tags"]),
"token_count_approx": len(data.get("files", [])) * 1000,
}
return {
"status": "ok",
"version": app.version,
"vaults": len(index),
"total_files": total_files,
"total_tokens": total_tokens,
"last_full_index_ts": _last_full_index_ts,
"uptime_seconds": uptime,
"git_describe": get_git_describe(),
"git_commit": get_git_commit(),
# Enriched fields
"memory": {
"used_mb": mem_used_mb,
"total_mb": mem_total_mb,
"percent": mem_pct,
},
"cpu": {
"percent": cpu_pct,
},
"disk": {
"used_gb": disk_used_gb,
"total_gb": disk_total_gb,
"free_gb": disk_free_gb,
"percent": disk_pct,
},
"connections": {
"active_sse": active_sessions,
},
"backups": {
"total_count": total_backups,
"total_size_mb": total_backup_size_mb,
"oldest_age_days": oldest_backup_age_days,
},
"index": index_detail,
}
@app.get("/api/vaults", response_model=list[VaultInfo])
async def api_vaults(current_user=Depends(require_auth)):
"""List configured vaults the user has access to.
+285
View File
@@ -0,0 +1,285 @@
# backend/push.py
# Push Notifications — Web Push API with VAPID authentication
# ROADMAP #67
import base64
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from backend.auth.middleware import require_auth
logger = logging.getLogger("obsigate.push")
router = APIRouter(prefix="/api/push", tags=["push"])
# VAPID keys storage
VAPID_KEYS_FILE = Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / "vapid_keys.json"
PUSH_SUBSCRIPTIONS_FILE = Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / "push_subscriptions.json"
# In-memory cache
_vapid_keys: dict[str, str] = {}
_push_subscriptions: list[dict[str, Any]] = []
def load_vapid_keys() -> dict[str, str]:
"""Load VAPID keys from file or generate new ones."""
global _vapid_keys
if _vapid_keys:
return _vapid_keys
try:
if VAPID_KEYS_FILE.exists():
with open(VAPID_KEYS_FILE, "r") as f:
_vapid_keys = json.load(f)
else:
# Generate new VAPID keys
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
# Convert to base64url for Web Push
public_numbers = public_key.public_numbers()
def int_to_base64url(n: int) -> str:
byte_len = (n.bit_length() + 7) // 8
return base64.urlsafe_b64encode(n.to_bytes(byte_len, 'big')).decode('utf-8').rstrip('=')
_vapid_keys = {
"private_key": private_pem,
"public_key": public_pem,
"public_key_base64url": int_to_base64url(public_numbers.x) + "." + int_to_base64url(public_numbers.y)
}
VAPID_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(VAPID_KEYS_FILE, "w") as f:
json.dump(_vapid_keys, f)
except Exception as e:
logger.error(f"Failed to load/generate VAPID keys: {e}")
# Fallback: return empty (will use demo keys if needed)
_vapid_keys = {}
return _vapid_keys
def get_vapid_public_key() -> str:
"""Get VAPID public key in base64url format for client."""
keys = load_vapid_keys()
return keys.get("public_key_base64url", "")
def load_push_subscriptions() -> list[dict[str, Any]]:
"""Load push subscriptions from file."""
global _push_subscriptions
if _push_subscriptions:
return _push_subscriptions
try:
if PUSH_SUBSCRIPTIONS_FILE.exists():
with open(PUSH_SUBSCRIPTIONS_FILE, "r") as f:
_push_subscriptions = json.load(f)
except Exception as e:
logger.error(f"Failed to load push subscriptions: {e}")
_push_subscriptions = []
return _push_subscriptions
def save_push_subscriptions() -> None:
"""Save push subscriptions to file."""
try:
PUSH_SUBSCRIPTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(PUSH_SUBSCRIPTIONS_FILE, "w") as f:
json.dump(_push_subscriptions, f, indent=2)
except Exception as e:
logger.error(f"Failed to save push subscriptions: {e}")
# ── Models ──────────────────────────────────────────────────────────────────
class PushSubscription(BaseModel):
"""Push subscription from client (matches Push API)."""
endpoint: str
keys: dict[str, str] # { p256dh, auth }
class SubscribeRequest(BaseModel):
"""Request to subscribe to push notifications."""
subscription: PushSubscription
vault: str = Field(description="Vault name this subscription is for")
class SubscribeResponse(BaseModel):
"""Response to subscription request."""
success: bool
subscription_id: str | None = None
class VapidPublicKeyResponse(BaseModel):
"""VAPID public key for client."""
public_key: str
class PushPayload(BaseModel):
"""Payload for sending a push notification."""
vault: str
title: str
body: str
data: dict[str, Any] = Field(default_factory=dict)
tag: str = "obsigate-notification"
# ── Endpoints ───────────────────────────────────────────────────────────────
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
async def get_vapid_public_key_endpoint():
"""Get VAPID public key for client subscription."""
public_key = get_vapid_public_key()
if not public_key:
# Return a demo key if generation failed (for testing)
return {"public_key": "demo-key-for-testing"}
return {"public_key": public_key}
@router.post("/subscribe", response_model=SubscribeResponse)
async def subscribe_push(
request: SubscribeRequest,
current_user=Depends(require_auth)
):
"""Subscribe to push notifications for a vault."""
username = current_user.get("username", "unknown")
# Check if user has access to this vault
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
if "*" not in user_vaults and request.vault not in user_vaults:
raise HTTPException(status_code=403, detail="No access to this vault")
# Check if subscription already exists
for sub in _push_subscriptions:
if sub["endpoint"] == request.subscription.endpoint and sub["username"] == username:
return SubscribeResponse(success=True, subscription_id=sub.get("id"))
# Add new subscription
sub_id = base64.urlsafe_b64encode(os.urandom(16)).decode('utf-8').rstrip('=')
subscription = {
"id": sub_id,
"endpoint": request.subscription.endpoint,
"keys": request.subscription.keys,
"vault": request.vault,
"username": username,
"created_at": datetime.now(timezone.utc).isoformat()
}
_push_subscriptions.append(subscription)
save_push_subscriptions()
logger.info(f"Push subscription added for user={username}, vault={request.vault}")
return SubscribeResponse(success=True, subscription_id=sub_id)
@router.delete("/subscribe")
async def unsubscribe_push(
endpoint: str,
current_user=Depends(require_auth)
):
"""Unsubscribe from push notifications."""
username = current_user.get("username", "unknown")
global _push_subscriptions
original_len = len(_push_subscriptions)
_push_subscriptions = [
s for s in _push_subscriptions
if not (s["endpoint"] == endpoint and s["username"] == username)
]
if len(_push_subscriptions) < original_len:
save_push_subscriptions()
return {"success": True, "message": "Unsubscribed"}
return {"success": False, "message": "Subscription not found"}
@router.get("/subscriptions")
async def list_subscriptions(current_user=Depends(require_auth)):
"""List current user's push subscriptions."""
username = current_user.get("username", "unknown")
user_subs = [
{
"id": s["id"],
"vault": s["vault"],
"created_at": s["created_at"],
"endpoint": s["endpoint"][:50] + "..." # Truncate for privacy
}
for s in _push_subscriptions if s["username"] == username
]
return {"subscriptions": user_subs}
# ── Internal: Send push notification ────────────────────────────────────────
async def send_push_notification(vault: str, title: str, body: str, data: dict | None = None, tag: str = "obsigate-notification") -> int:
"""Send push notification to all subscribers of a vault. Returns count of sent notifications."""
subscriptions = [s for s in _push_subscriptions if s["vault"] == vault]
if not subscriptions:
return 0
payload = {
"title": title,
"body": body,
"data": data or {},
"tag": tag
}
# Import here to avoid circular dependency
from pywebpush import WebPushException, webpush
keys = load_vapid_keys()
vapid_private_key = keys.get("private_key")
vapid_claims = {
"sub": "mailto:[email protected]"
}
sent = 0
for sub in subscriptions:
try:
webpush(
subscription_info={
"endpoint": sub["endpoint"],
"keys": sub["keys"]
},
data=json.dumps(payload),
vapid_private_key=vapid_private_key,
vapid_claims=vapid_claims
)
sent += 1
except WebPushException as e:
logger.warning(f"Push failed for subscription {sub['id']}: {e}")
# If subscription expired/invalid, remove it
if e.response and e.response.status_code in (404, 410):
_push_subscriptions.remove(sub)
except Exception as e:
logger.error(f"Push error for {sub['id']}: {e}")
if sent < len(subscriptions):
save_push_subscriptions()
return sent
+1
View File
@@ -16,3 +16,4 @@ pypdf>=4.0
pyotp>=2.10.0
webauthn==2.6.0
psutil>=5.9
pywebpush>=2.3.0
+1
View File
@@ -2646,6 +2646,7 @@ dependencies = [
"tauri-plugin-updater",
"tempfile",
"tokio",
"windows",
]
[[package]]
+11
View File
@@ -29,6 +29,17 @@ chrono = "0.4"
[dev-dependencies]
tempfile = "3"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Variant",
"Win32_UI_Shell",
"Win32_UI_Shell_Common",
"Win32_UI_Shell_PropertiesSystem",
] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -2,6 +2,9 @@
"identifier": "default",
"description": "Default capabilities for ObsiGate Desktop",
"windows": ["main"],
"remote": {
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
},
"permissions": [
"core:default",
"shell:allow-open",
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
{"default":{"identifier":"default","description":"Default capabilities for ObsiGate Desktop","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","dialog:default","notification:default","fs:default","process:default","store:default"]}}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env pwsh
# ObsiGate Desktop — Windows code signing (scaffolding).
#
# Signe le binaire et l'installateur MSI/NSIS après un `cargo tauri build`.
# Aucun certificat n'est embarqué : les identifiants sont lus depuis
# l'environnement (jamais commités). Le script est un no-op explicite si
# l'env n'est pas configuré — le build reste donc fonctionnel sans signature.
#
# Variables d'environnement :
# OBSIGATE_SIGN_CERT_PFX chemin du .pfx (requis)
# OBSIGATE_SIGN_CERT_PASSWORD mot de passe du .pfx (optionnel)
# OBSIGATE_SIGN_TIMESTAMP_URL URL du serveur d'horodatage RFC3161
# (défaut : http://timestamp.digicert.com)
# OBSIGATE_SIGN_MODE "signtool" (Windows) ou "osslsigncode" (Linux)
#
# Usage :
# ./scripts/sign-windows.ps1 # après le build, signe les artefacts
# OBSIGATE_SIGN_CERT_PFX=cert.pfx ./scripts/sign-windows.ps1
$ErrorActionPreference = 'Stop'
$pfx = $env:OBSIGATE_SIGN_CERT_PFX
if (-not $pfx) {
Write-Host "⚠ Aucun certificat fourni (OBSIGATE_SIGN_CERT_PFX). Signature ignorée — build non signé."
exit 0
}
$password = $env:OBSIGATE_SIGN_CERT_PASSWORD
$tsUrl = if ($env:OBSIGATE_SIGN_TIMESTAMP_URL) { $env:OBSIGATE_SIGN_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' }
$mode = if ($env:OBSIGATE_SIGN_MODE) { $env:OBSIGATE_SIGN_MODE } else { 'signtool' }
$targets = @(
'target/release/obsigate-desktop.exe',
'target/release/bundle/msi/*.msi',
'target/release/bundle/nsis/*-setup.exe'
) | ForEach-Object { Get-Item $_ -ErrorAction SilentlyContinue } | Where-Object { $_ }
if ($targets.Count -eq 0) {
Write-Host "⚠ Aucun artefact à signer trouvé (lancer d'abord `cargo tauri build`)."
exit 0
}
foreach ($t in $targets) {
Write-Host "✍ Signature de $($t.FullName) (mode: $mode)"
if ($mode -eq 'osslsigncode') {
$args = @('sign', '-pkcs12', $pfx, '-h', 'sha256')
if ($password) { $args += @('-pass', $password) }
$args += @('-ts', $tsUrl, '-in', $t.FullName, '-out', $t.FullName)
& osslsigncode @args
if ($LASTEXITCODE -ne 0) { throw "osslsigncode a échoué pour $($t.Name)" }
} else {
$args = @('sign', '/fd', 'SHA256', '/f', $pfx, '/tr', $tsUrl, '/td', 'SHA256')
if ($password) { $args += @('/p', $password) }
$args += $t.FullName
& signtool @args
if ($LASTEXITCODE -ne 0) { throw "signtool a échoué pour $($t.Name)" }
}
}
Write-Host "✅ $($targets.Count) artefact(s) signé(s)."
+178
View File
@@ -0,0 +1,178 @@
//! Windows Taskbar Jump List — recent vaults.
//!
//! Populates the taskbar jump list with the configured vaults so the user can
//! jump straight into a vault from the Windows taskbar / Start menu.
//!
//! Best-effort: every step is fallible and logged, never panics, and a failure
//! simply leaves the jump list at its OS-managed default state.
use log::{info, warn};
#[cfg(target_os = "windows")]
mod imp {
use super::*;
use std::os::windows::ffi::OsStrExt;
use windows::core::{Interface, HSTRING, PCWSTR};
use windows::Win32::Foundation::PROPERTYKEY;
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
};
use windows::Win32::System::Com::StructuredStorage::PROPVARIANT;
use windows::Win32::System::Variant::VT_LPWSTR;
use windows::Win32::UI::Shell::{
ICustomDestinationList, IShellLinkW, SetCurrentProcessExplicitAppUserModelID,
DestinationList, EnumerableObjectCollection, ShellLink,
};
use windows::Win32::UI::Shell::Common::IObjectCollection;
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
const APP_ID: &str = "com.obsigate.desktop";
// PKEY_Title (System.Title): {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, pid 2
const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY {
fmtid: windows::core::GUID::from_u128(0xF29F85E0_4FF9_1068_AB91_08002B27B3D9),
pid: 2,
};
fn wide_null_terminated(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
}
/// Set the jump-list display title via the shell link's property store.
fn set_link_title(link: &IShellLinkW, title: &str) -> windows::core::Result<()> {
let store: IPropertyStore = link.cast()?;
let mut wide = wide_null_terminated(title);
let mut pv: PROPVARIANT = unsafe { std::mem::zeroed() };
unsafe {
let inner = &mut *pv.Anonymous.Anonymous;
inner.vt = VT_LPWSTR;
inner.Anonymous.pwszVal = windows::core::PWSTR(wide.as_mut_ptr());
store.SetValue(&PKEY_TITLE, &pv)?;
store.Commit()?;
}
Ok(())
}
/// Build one shell link for a vault. `arg` is the command-line argument the
/// app expects to open that vault (see file associations / single-instance).
fn build_shell_link(name: &str, arg: &str) -> windows::core::Result<IShellLinkW> {
let link: IShellLinkW =
unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)? };
let exe = std::env::current_exe()
.map_err(|e| windows::core::Error::from(std::io::Error::other(e)))?;
let exe_wide = exe.as_os_str().encode_wide().chain(std::iter::once(0)).collect::<Vec<u16>>();
unsafe {
link.SetPath(PCWSTR(exe_wide.as_ptr()))?;
if !arg.is_empty() {
link.SetArguments(&HSTRING::from(arg))?;
}
link.SetDescription(&HSTRING::from(name))?;
}
// Title (display name) is best-effort — a missing title only means the
// jump-list entry falls back to the executable name.
let _ = set_link_title(&link, name);
Ok(link)
}
/// Populate the jump list with the given (name, arg) vault entries.
pub fn update_jumplist(vaults: &[(String, String)]) {
// COM must be initialised on this thread.
let _ = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
let result = (|| -> windows::core::Result<()> {
unsafe {
SetCurrentProcessExplicitAppUserModelID(PCWSTR(wide_null_terminated(APP_ID).as_ptr()))?;
}
let list: ICustomDestinationList =
unsafe { CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER)? };
let mut min_slots = 0u32;
unsafe {
list.BeginList::<windows::core::IUnknown>(&mut min_slots)?;
}
// "Vaults" category
let collection: IObjectCollection = unsafe {
CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?
};
for (name, arg) in vaults {
match build_shell_link(name, arg) {
Ok(link) => unsafe {
let _ = collection.AddObject(&link);
},
Err(e) => warn!("jumplist: failed to build link for {}: {}", name, e),
}
}
unsafe {
let array: windows::Win32::UI::Shell::Common::IObjectArray = collection.cast()?;
list.AppendCategory(&HSTRING::from("Vaults"), &array)?;
list.CommitList()?;
}
Ok(())
})();
match result {
Ok(()) => info!("jumplist updated with {} vaults", vaults.len()),
Err(e) => warn!("jumplist update failed (non-fatal): {}", e),
}
}
}
#[cfg(not(target_os = "windows"))]
mod imp {
use super::*;
/// No-op on non-Windows platforms.
pub fn update_jumplist(_vaults: &[(String, String)]) {
info!("jumplist is Windows-only — skipped");
}
}
/// Public entry point: update the Windows jump list from the configured vaults.
/// Each entry maps a vault name to the CLI argument the app accepts to open it.
pub fn update_jumplist(vaults: &[(String, String)]) {
imp::update_jumplist(vaults);
}
/// Build the (name, arg) pairs for the jump list from vault paths.
/// Pure helper so the mapping is unit-testable on every platform.
pub fn build_vault_args(vaults: &[crate::VaultConfig]) -> Vec<(String, String)> {
vaults
.iter()
.map(|v| (v.name.clone(), format!("--vault={}", v.path)))
.collect()
}
#[cfg(test)]
mod tests {
use super::build_vault_args;
use crate::VaultConfig;
fn v(name: &str, path: &str) -> VaultConfig {
VaultConfig { name: name.to_string(), path: path.to_string() }
}
#[test]
fn test_build_vault_args_empty() {
assert!(build_vault_args(&[]).is_empty());
}
#[test]
fn test_build_vault_args_maps_name_and_path() {
let vaults = vec![v("Perso", "C:\\vaults\\perso"), v("IT", "/home/bruno/IT")];
let args = build_vault_args(&vaults);
assert_eq!(args.len(), 2);
assert_eq!(args[0], ("Perso".to_string(), "--vault=C:\\vaults\\perso".to_string()));
assert_eq!(args[1], ("IT".to_string(), "--vault=/home/bruno/IT".to_string()));
}
#[test]
fn test_build_vault_args_preserves_order() {
let vaults = vec![v("a", "/a"), v("b", "/b"), v("c", "/c")];
let args = build_vault_args(&vaults);
assert_eq!(args.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), vec!["a", "b", "c"]);
}
}
+14
View File
@@ -5,6 +5,8 @@
#![windows_subsystem = "windows"]
mod jumplist;
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
@@ -579,6 +581,10 @@ fn main() {
// Build native menu bar (File / Edit / Help)
let _ = build_native_menu(app);
// Windows taskbar jump list — recent vaults (best-effort).
let jumplist_vaults = jumplist::build_vault_args(&config.vaults);
jumplist::update_jumplist(&jumplist_vaults);
// Restore window position/size
if let Some(window) = app.get_webview_window("main") {
if let (Some(x), Some(y)) = (config.window_x, config.window_y) {
@@ -872,8 +878,14 @@ fn build_tray_menu(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
mod tests {
use super::*;
use std::fs;
use std::sync::Mutex;
use tempfile::TempDir;
/// Serializes the `pick_free_port` tests: they bind real TCP ports, so
/// running them in parallel makes them clobber each other's port.
static PORT_TEST_LOCK: Mutex<()> = Mutex::new(());
/// Helper: write config to a temp dir and read it back
fn roundtrip_config(config: &AppConfig) -> AppConfig {
let tmp = TempDir::new().unwrap();
@@ -973,6 +985,7 @@ mod tests {
#[test]
fn test_pick_free_port_is_free() {
let _guard = PORT_TEST_LOCK.lock().unwrap();
let port = pick_free_port();
assert!((DEFAULT_BACKEND_PORT..DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS).contains(&port));
assert!(TcpListener::bind(("127.0.0.1", port)).is_ok());
@@ -980,6 +993,7 @@ mod tests {
#[test]
fn test_pick_free_port_skips_busy_port() {
let _guard = PORT_TEST_LOCK.lock().unwrap();
let blocker = match TcpListener::bind(("127.0.0.1", DEFAULT_BACKEND_PORT)) {
Ok(l) => l,
Err(_) => return, // default port already busy on this machine — skip
+24 -5
View File
@@ -1449,6 +1449,7 @@
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
<li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li>
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
<li><a href="#cfg-webhooks" class="help-nav-link" data-i18n="config.section_webhooks"></a></li>
<li><a href="#cfg-partages-publics" class="help-nav-link" data-i18n="config.section_shares"></a></li>
@@ -2185,11 +2186,29 @@
</div>
</section>
<!-- À propos -->
<section
class="config-section help-section"
id="cfg-about"
>
<!-- Notifications push -->
<section
class="config-section help-section"
id="cfg-push"
>
<h2 data-i18n="config.section_push">🔔 Notifications push</h2>
<p class="config-description">
Recevez des notifications web push
lorsque des fichiers changent dans vos
vaults, même si ObsiGate n'est pas ouvert.
</p>
<div id="push-settings">
<div class="config-diag-loading" data-i18n="common.loading">
Chargement...
</div>
</div>
</section>
<!-- À propos -->
<section
class="config-section help-section"
id="cfg-about"
>
<h2 data-i18n="auto.a3319169">📦 À propos</h2>
<div
id="config-about"
+8 -1
View File
@@ -20,6 +20,8 @@ import { initMobileToolbar } from './mobile-toolbar.js';
import { initDragDrop } from './dragdrop.js';
import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
import PaneManager from './pane-manager.js';
import { initDesktopIntegration, isTauriEnv } from './desktop.js';
import { initPush } from './push.js';
// =========================================================================
// Initialization — mirrors the original app.js init() ordering
@@ -30,6 +32,10 @@ async function init() {
// LocalStorage has correct language if user set it previously
await initI18n();
// 1b. Desktop bridge — crash banner + OS theme sync (before theme engine).
// No-op on web (Docker); on desktop it also readies the vault picker.
await initDesktopIntegration();
// 2. Init auth — now t() works inside showApp()/renderUserMenu()
const authOk = await Auth.AuthManager.initAuth();
@@ -62,7 +68,8 @@ async function init() {
UI.TabManager.init();
PaneManager.init();
initCommandPalette();
initMobileToolbar();
initMobileToolbar();
initPush();
if (authOk) {
Legacy.initSyncStatus();
+258
View File
@@ -0,0 +1,258 @@
/* ObsiGate — Desktop (Tauri) bridge.
* Bridges the vanilla-JS frontend to the Rust/Tauri shell.
*
* On desktop, the frontend is served by the Python backend over
* http://127.0.0.1:<port> (the webview redirects there after boot).
* Tauri exposes its API globally via `window.__TAURI__` (withGlobalTauri).
* Remote IPC access for that origin is granted in capabilities/default.json.
*
* On web (Docker), `window.__TAURI__` is absent — every function degrades
* to a no-op so the same bundle runs unchanged in the browser.
*
* Usage: import { initDesktopIntegration } from './desktop.js';
*/
// ── Detection ──────────────────────────────────────────────────────────────
// window.__TAURI__ is injected by Tauri when app.withGlobalTauri === true.
// window.__TAURI_INTERNALS__ is a secondary signal used by some Tauri builds.
export function isTauriEnv() {
if (typeof window === 'undefined') return false;
return !!(window.__TAURI__ || window.__TAURI_INTERNALS__);
}
// ── invoke wrapper ─────────────────────────────────────────────────────────
// Safely invokes a Tauri command. Resolves to undefined on web or on any
// IPC error, so callers never crash the app.
export async function invoke(cmd, args) {
if (!isTauriEnv()) return undefined;
try {
const core = window.__TAURI__ && window.__TAURI__.core;
if (!core || typeof core.invoke !== 'function') return undefined;
return await core.invoke(cmd, args);
} catch (e) {
console.warn('[desktop] invoke(' + cmd + ') failed:', e);
return undefined;
}
}
// ── System theme ───────────────────────────────────────────────────────────
// Returns 'dark' or 'light' from the OS. Falls back to the CSS media query
// (which the system WebView also honours) if the Tauri command is unavailable.
export async function getSystemTheme() {
const tauriTheme = await invoke('get_system_theme');
if (tauriTheme === 'dark' || tauriTheme === 'light') return tauriTheme;
try {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
} catch (e) { /* ignore */ }
return 'light';
}
// ── Theme preference detection ─────────────────────────────────────────────
// The real theme engine is themes.js: it persists the *mode* under
// 'obsigate-theme-mode' (dark/light/high-contrast/sepia). Absence of that key
// means "no explicit user choice yet" — the desktop app should then follow
// the OS theme instead of defaulting to dark.
export function hasExplicitThemeMode() {
try {
return localStorage.getItem('obsigate-theme-mode') !== null;
} catch (e) {
return false;
}
}
// Apply the OS theme mode, but ONLY if the user hasn't chosen one explicitly.
export async function syncSystemTheme() {
if (!isTauriEnv()) return;
if (hasExplicitThemeMode()) return;
const sys = await getSystemTheme();
// Map OS light/dark onto the theme engine's mode axis (never override
// high-contrast/sepia, which are explicit choices stored in the same key).
const current = (function () {
try { return localStorage.getItem('obsigate-theme-mode'); } catch (e) { return null; }
})();
if (current === 'high-contrast' || current === 'sepia') return;
try { localStorage.setItem('obsigate-theme-mode', sys); } catch (e) { /* ignore */ }
}
// ── Crash banner ───────────────────────────────────────────────────────────
// The Rust shell calls `showBackendCrashBanner()` via window.eval when the
// backend process dies. Define it globally (idempotent) so the call always
// resolves. On web this is never triggered, but defining it is harmless.
export function defineBackendCrashBanner() {
if (typeof window === 'undefined') return;
if (window.showBackendCrashBanner) return;
window.showBackendCrashBanner = function () {
// Avoid stacking duplicates
if (document.getElementById('backend-crash-banner')) return;
const banner = document.createElement('div');
banner.id = 'backend-crash-banner';
banner.setAttribute('role', 'alert');
banner.style.cssText = [
'position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:99999',
'background:#7f1d1d', 'color:#fef2f2', 'padding:10px 16px',
'font-size:13px', 'display:flex', 'align-items:center', 'justify-content:space-between',
'box-shadow:0 2px 8px rgba(0,0,0,0.4)',
].join(';');
const msg = document.createElement('span');
msg.textContent = '⚠ Le backend ObsiGate s\u2019est arrêté. Redémarrage en cours…';
const btn = document.createElement('button');
btn.textContent = 'Recharger';
btn.style.cssText = 'background:#fff;color:#7f1d1d;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-weight:600;';
btn.addEventListener('click', function () { window.location.reload(); });
banner.appendChild(msg);
banner.appendChild(btn);
document.body.appendChild(banner);
};
}
// ── Vault picker ───────────────────────────────────────────────────────────
// Opens the native folder picker and registers the chosen folder as a vault,
// then restarts the backend so the new VAULT_N_* env vars take effect.
export async function pickVaultFolder() {
if (!isTauriEnv()) return undefined;
const path = await invoke('pick_vault_folder');
return typeof path === 'string' ? path : undefined;
}
// ── First-run wizard ───────────────────────────────────────────────────────
// Decides whether the first-run "choose your vault" wizard should be shown.
// Pure function (no DOM) so it is unit-testable.
//
// Returns true when: desktop env, the wizard has never been dismissed, and
// the user has no explicitly-picked vault folder yet.
const WIZARD_DISMISSED_KEY = 'obsigate-desktop-wizard-dismissed';
export function shouldShowWizard(opts) {
opts = opts || {};
const isDesktop = opts.isDesktop !== undefined ? opts.isDesktop : isTauriEnv();
const dismissed = opts.dismissed !== undefined ? opts.dismissed : (function () {
try { return localStorage.getItem(WIZARD_DISMISSED_KEY) === '1'; } catch (e) { return false; }
})();
const hasVaultPath = !!opts.hasVaultPath;
return isDesktop && !dismissed && !hasVaultPath;
}
export function dismissWizard() {
try { localStorage.setItem(WIZARD_DISMISSED_KEY, '1'); } catch (e) { /* ignore */ }
}
export function getWizardDismissedKey() { return WIZARD_DISMISSED_KEY; }
// ── Vault management (native) ──────────────────────────────────────────────
// pickAndAddVault opens the native folder picker, registers the chosen folder
// as a vault in the desktop config, restarts the backend (so VAULT_N_* env
// vars are re-injected) and reloads. Returns the chosen path, or undefined
// if cancelled / not on desktop.
export async function pickAndAddVault() {
if (!isTauriEnv()) return undefined;
const path = await pickVaultFolder();
if (!path) return undefined;
// Derive a display name from the final path segment.
const segments = path.replace(/[\\/]+$/, '').split(/[\\/]/);
const name = segments[segments.length - 1] || 'Vault';
await invoke('add_vault', { name: name, path: path });
await invoke('restart_backend');
return path;
}
// ── First-run wizard (DOM) ─────────────────────────────────────────────────
// Shows a dismissible welcome banner on first desktop launch offering to pick
// the user's Obsidian vault folder. Non-blocking; the default vault created
// by the Rust shell remains available if the user dismisses it.
export function initDesktopWizard() {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
if (!shouldShowWizard({})) return;
// Defer so the app shell is fully rendered before injecting the banner.
setTimeout(function () {
if (document.getElementById('obsigate-desktop-wizard')) return;
const banner = document.createElement('div');
banner.id = 'obsigate-desktop-wizard';
banner.setAttribute('role', 'dialog');
banner.style.cssText = [
'position:fixed', 'bottom:16px', 'left:16px', 'right:16px', 'z-index:99998',
'background:var(--surface2,#161b22)', 'border:1px solid var(--border,#21262d)',
'border-radius:10px', 'padding:14px 16px', 'display:flex',
'align-items:center', 'gap:12px', 'flex-wrap:wrap',
'box-shadow:0 8px 24px rgba(0,0,0,0.35)',
].join(';');
const text = document.createElement('span');
text.style.cssText = 'flex:1;min-width:220px;font-size:13px;color:var(--text,#e6edf3);';
text.textContent = 'Bienvenue dans ObsiGate Desktop. Choisissez le dossier de vos vaults Obsidian pour commencer.';
const pickBtn = document.createElement('button');
pickBtn.textContent = 'Choisir mon dossier';
pickBtn.style.cssText = 'background:#7c3aed;color:#fff;border:none;border-radius:6px;padding:8px 14px;cursor:pointer;font-weight:600;font-size:13px;';
pickBtn.addEventListener('click', async function () {
const p = await pickAndAddVault();
if (p) { dismissWizard(); window.location.reload(); }
});
const laterBtn = document.createElement('button');
laterBtn.textContent = 'Plus tard';
laterBtn.style.cssText = 'background:transparent;color:var(--text-2,#8b949e);border:1px solid var(--border,#21262d);border-radius:6px;padding:8px 14px;cursor:pointer;font-size:13px;';
laterBtn.addEventListener('click', function () {
dismissWizard();
banner.remove();
});
banner.appendChild(text);
banner.appendChild(pickBtn);
banner.appendChild(laterBtn);
document.body.appendChild(banner);
}, 800);
}
// ── Integration entry point ────────────────────────────────────────────────
export async function initDesktopIntegration() {
defineBackendCrashBanner();
if (!isTauriEnv()) return false;
// Follow the OS theme on first run, before the theme engine renders.
await syncSystemTheme();
// Refresh the OS theme when the system preference changes at runtime.
try {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
if (mq && mq.addEventListener) {
mq.addEventListener('change', function () { syncSystemTheme(); });
}
} catch (e) { /* ignore */ }
// First-run vault wizard.
initDesktopWizard();
return true;
}
export default {
isTauriEnv,
invoke,
getSystemTheme,
hasExplicitThemeMode,
syncSystemTheme,
defineBackendCrashBanner,
pickVaultFolder,
pickAndAddVault,
shouldShowWizard,
dismissWizard,
getWizardDismissedKey,
initDesktopWizard,
initDesktopIntegration,
};
+228
View File
@@ -0,0 +1,228 @@
/**
* push.js — Push Notifications client for ObsiGate
* ROADMAP #67
*
* Handles:
* - Subscribing to Web Push via VAPID
* - Unsubscribing
* - UI rendering in the config modal
*/
import { t } from './i18n.js';
let _vapidPublicKey = null;
// ── Helpers ──────────────────────────────────────────────────────────────────
function _api(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
};
if (body !== undefined) opts.body = JSON.stringify(body);
return fetch(path, opts).then((r) => {
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
});
}
function _urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// ── VAPID key ────────────────────────────────────────────────────────────────
async function getVapidPublicKey() {
if (_vapidPublicKey) return _vapidPublicKey;
const data = await _api('GET', '/api/push/vapid-public-key');
_vapidPublicKey = data.public_key;
return _vapidPublicKey;
}
// ── Subscription management ──────────────────────────────────────────────────
async function subscribePush(vault) {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('Push notifications not supported by this browser');
}
const reg = await navigator.serviceWorker.ready;
const vapidKey = await getVapidPublicKey();
// Check existing subscription
let subscription = await reg.pushManager.getSubscription();
if (!subscription) {
// Subscribe to push
subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: _urlBase64ToUint8Array(vapidKey),
});
}
// Register subscription with backend
const subJSON = subscription.toJSON();
await _api('POST', '/api/push/subscribe', {
subscription: {
endpoint: subJSON.endpoint,
keys: subJSON.keys,
},
vault,
});
return { success: true };
}
async function unsubscribePush() {
if (!('serviceWorker' in navigator)) return;
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.getSubscription();
if (!subscription) return;
// Unsubscribe from browser
await subscription.unsubscribe();
// Unsubscribe from backend
const endpoint = subscription.endpoint;
try {
await _api('DELETE', `/api/push/subscribe?endpoint=${encodeURIComponent(endpoint)}`);
} catch {
// Best-effort: endpoint may already be expired
}
}
async function isSubscribed() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
return !!sub;
}
async function getSubscriptions() {
return _api('GET', '/api/push/subscriptions');
}
// ── Config UI ────────────────────────────────────────────────────────────────
export async function renderPushSection() {
const container = document.getElementById('push-settings');
if (!container) return;
const supported = 'serviceWorker' in navigator && 'PushManager' in window;
const subscribed = await isSubscribed();
if (!supported) {
container.innerHTML = `
<div class="mfa-status-row">
<span class="mfa-status-label">Notifications push</span>
<span class="mfa-badge mfa-badge-off">Non supportées par ce navigateur</span>
</div>`;
return;
}
container.innerHTML = `
<div class="mfa-status-row">
<span class="mfa-status-label">Notifications push</span>
<span id="push-status-badge" class="mfa-badge ${subscribed ? 'mfa-badge-on' : 'mfa-badge-off'}">${subscribed ? 'Activées' : 'Désactivées'}</span>
</div>
<div class="config-row" style="margin-top:12px">
<label class="config-label" for="push-vault-select">Vault cible</label>
<select id="push-vault-select" class="config-input" style="max-width:240px"></select>
</div>
<div class="config-row" style="margin-top:8px">
<button id="push-toggle-btn" class="config-save-btn" style="${subscribed ? 'background:var(--danger-bg);color:var(--danger);border-color:var(--danger)' : ''}">
${subscribed ? 'Désactiver les notifications' : 'Activer les notifications'}
</button>
</div>
<div id="push-sub-count" style="margin-top:8px;font-size:12px;color:var(--text-muted)"></div>`;
// Populate vault select
const vaultSelect = document.getElementById('push-vault-select');
try {
const vaultData = await _api('GET', '/api/vaults');
const vaults = vaultData.vaults || vaultData || [];
(Array.isArray(vaults) ? vaults : []).forEach((v) => {
const name = typeof v === 'string' ? v : v.name;
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
vaultSelect.appendChild(opt);
});
} catch {
const opt = document.createElement('option');
opt.value = 'default';
opt.textContent = 'default';
vaultSelect.appendChild(opt);
}
// Toggle button
document.getElementById('push-toggle-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('push-toggle-btn');
const badge = document.getElementById('push-status-badge');
try {
btn.disabled = true;
btn.textContent = '…';
if (subscribed) {
await unsubscribePush();
badge.textContent = 'Désactivées';
badge.className = 'mfa-badge mfa-badge-off';
btn.textContent = 'Activer les notifications';
btn.style.background = '';
btn.style.color = '';
btn.style.borderColor = '';
} else {
const vault = vaultSelect.value || 'default';
await subscribePush(vault);
badge.textContent = 'Activées';
badge.className = 'mfa-badge mfa-badge-on';
btn.textContent = 'Désactiver les notifications';
btn.style.background = 'var(--danger-bg)';
btn.style.color = 'var(--danger)';
btn.style.borderColor = 'var(--danger)';
}
} catch (err) {
console.error('[Push] Toggle error:', err);
alert('Erreur: ' + err.message);
} finally {
btn.disabled = false;
}
});
// Show subscription count
try {
const data = await getSubscriptions();
const count = (data.subscriptions || []).length;
document.getElementById('push-sub-count').textContent =
count > 0 ? `${count} abonnement(s) actif(s)` : '';
} catch {
// Ignore
}
}
// ── Init ─────────────────────────────────────────────────────────────────────
export function initPush() {
// Render push section when config modal opens
document.addEventListener('DOMContentLoaded', () => {
// Observe the config modal becoming visible
const observer = new MutationObserver(() => {
const section = document.getElementById('cfg-push');
if (section && section.offsetParent !== null) {
renderPushSection();
}
});
const modal = document.getElementById('config-modal');
if (modal) observer.observe(modal, { attributes: true, attributeFilter: ['class'] });
});
}
export default { initPush, subscribePush, unsubscribePush, isSubscribed, renderPushSection };
+13 -4
View File
@@ -755,10 +755,10 @@ const FileOperations = {
});
},
showCreateFileModal(vault, parentPath) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
showCreateFileModal(vault, parentPath, preselectExt = null) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
modal.innerHTML = `
<div class="obsigate-modal-header">
<h3 class="obsigate-modal-title">${t('modal.create_file')}</h3>
@@ -804,6 +804,10 @@ const FileOperations = {
input.focus();
if (preselectExt && [...extSelect.options].some(o => o.value === preselectExt)) {
extSelect.value = preselectExt;
}
const validateName = (name) => {
if (!name.trim()) return 'Le nom ne peut pas être vide';
if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
@@ -1695,6 +1699,7 @@ export const ContextMenuManager = {
} else if (type === 'directory') {
this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly);
this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly);
this._addItem('pen-tool', 'Nouveau diagramme Excalidraw', () => this._createExcalidraw(), isReadonly);
this._addSeparator();
this._addItem('bookmark-plus', 'Ajouter aux recherches sauvegardees', () => this._saveDirectorySearch(), false);
this._addSeparator();
@@ -1775,6 +1780,10 @@ export const ContextMenuManager = {
FileOperations.showCreateFileModal(this._targetVault, this._targetPath);
},
_createExcalidraw() {
FileOperations.showCreateFileModal(this._targetVault, this._targetPath, '.excalidraw');
},
_renameItem() {
FileOperations.startInlineRename(this._targetVault, this._targetPath, this._targetType);
},
+1
View File
@@ -459,6 +459,7 @@
"config.section_vue-graphe": "🗺️ Vue Graphe",
"config.section_watcher": "Auto sync",
"config.section_webhooks": "🔗 Webhooks",
"config.section_push": "🔔 Push notifications",
"config.section_workflow-recommande": "Recommended workflow",
"config.section_zone-de-contenu": "Zone de contenu",
"config.server_config": "Server config",
+1
View File
@@ -459,6 +459,7 @@
"config.section_vue-graphe": "🗺️ Vue Graphe",
"config.section_watcher": "Synchronisation automatique",
"config.section_webhooks": "🪝 Webhooks",
"config.section_push": "🔔 Notifications push",
"config.section_workflow-recommande": "Workflow recommandé",
"config.section_zone-de-contenu": "Zone de contenu",
"config.server_config": "Configuration serveur",
+172 -149
View File
@@ -1,197 +1,220 @@
/* ObsiGate Service Worker - PWA v2 with IndexedDB offline support */
/* ObsiGate — Service Worker (Push notifications + Offline cache)
* Version: 1.0.0
* Scope: /
*/
const CACHE_NAME = 'obsigate-v1';
const OFFLINE_CACHE = 'obsigate-offline-v1';
const CACHE_VERSION = 'obsigate-v6-i18n';
const STATIC_CACHE = CACHE_VERSION + '-static';
const DYNAMIC_CACHE = CACHE_VERSION + '-dynamic';
const MAX_DYNAMIC_CACHE_SIZE = 100;
const STATIC_ASSETS = [
// Files to cache for offline access
const PRECACHE_URLS = [
'/',
'/static/index.html',
'/static/app.js',
'/static/style.css',
'/static/manifest.json',
'/static/js/viewer.js',
'/static/js/i18n.js',
'/static/js/auth.js',
'/static/js/dashboard.js',
'/static/js/app.js',
'/static/locales/en.json',
'/static/locales/fr.json'
'/index.html',
'/manifest.json',
'/frontend/js/app.js',
'/frontend/js/state.js',
'/frontend/js/auth.js',
'/frontend/js/ui.js',
'/frontend/js/sidebar.js',
'/frontend/js/viewer.js',
'/frontend/js/search.js',
'/frontend/js/config.js',
'/frontend/js/utils.js',
'/frontend/js/i18n.js',
'/frontend/js/offline.js',
'/frontend/js/offline-db.js',
'/frontend/style.css',
'/static/lucide.min.js'
];
/* ── Install ── */
// ── Install: precache static assets ────────────────────────────────────────
self.addEventListener('install', (event) => {
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] Cache error:', err))
.then(() => self.skipWaiting())
caches.open(CACHE_NAME).then((cache) => {
console.log('[SW] Precaching static assets');
return cache.addAll(PRECACHE_URLS);
}).then(() => self.skipWaiting())
);
});
/* ── Activate ── */
// ── Activate: clean old caches ─────────────────────────────────────────────
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name.startsWith('obsigate-') && name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== OFFLINE_CACHE)
.map((name) => caches.delete(name))
);
}).then(() => self.clients.claim())
);
});
/* ── Fetch ── */
// ── Fetch: Network-first for API, Cache-first for static ───────────────────
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== 'GET') return;
if (url.pathname === '/api/events') return;
if (url.pathname.startsWith('/api/auth/')) return;
// API requests: Network first, tell client to use IndexedDB if offline
const url = new URL(event.request.url);
// Skip non-GET
if (event.request.method !== 'GET') return;
// Skip cross-origin
if (url.origin !== location.origin) return;
// API requests: Network-first with offline fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstAPI(request));
event.respondWith(networkFirstStrategy(event.request));
return;
}
// Static assets: Cache first
event.respondWith(cacheFirstStatic(request, url));
// Static assets: Cache-first
event.respondWith(cacheFirstStrategy(event.request));
});
/* ── Network First for API ── */
async function networkFirstAPI(request) {
// Network-first strategy (for API)
async function networkFirstStrategy(request) {
try {
const response = await fetch(request);
// Cache successful GETs in dynamic cache
if (response && response.status === 200) {
const cache = await caches.open(DYNAMIC_CACHE);
if (response.ok) {
const cache = await caches.open(OFFLINE_CACHE);
cache.put(request, response.clone());
limitCacheSize(DYNAMIC_CACHE, MAX_DYNAMIC_CACHE_SIZE);
}
return response;
} catch (error) {
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 });
}
// Fallback: tell the page to use IndexedDB
return new Response(JSON.stringify({
offline: true,
endpoint: new URL(request.url).pathname,
message: 'Vous etes hors-ligne. Les donnees locales sont utilisees.'
}), {
status: 200,
headers: { 'Content-Type': 'application/json', 'X-ObsiGate-Offline': 'true' }
if (cached) return cached;
return new Response(JSON.stringify({ error: 'Offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
/* ── Cache First for Static ── */
async function cacheFirstStatic(request, url) {
// Cache-first strategy (for static assets)
async function cacheFirstStrategy(request) {
const cached = await caches.match(request);
if (cached) return cached;
if (cached) {
// Stale-while-revalidate: update cache in background
fetch(request).then((response) => {
if (response.ok) {
caches.open(CACHE_NAME).then((cache) => cache.put(request, response));
}
}).catch(() => {});
return cached;
}
try {
const response = await fetch(request);
if (response && response.status === 200) {
const cache = await caches.open(STATIC_CACHE);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
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;
} catch (error) {
// Return offline page for navigation requests
if (request.mode === 'navigate') {
return caches.match('/index.html');
}
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) {
for (let i = 0; i < keys.length - maxSize; i++) {
await cache.delete(keys[i]);
// ── Push Event: Display notification ───────────────────────────────────────
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body || data.message || 'Nouvelle notification',
icon: '/static/icon-192.png',
badge: '/static/badge-72.png',
vibrate: [200, 100, 200],
tag: data.tag || 'obsigate-notification',
renotify: true,
data: data.data || {},
actions: [
{ action: 'open', title: 'Ouvrir' },
{ action: 'dismiss', title: 'Ignorer' }
],
requireInteraction: false
};
event.waitUntil(
self.registration.showNotification(data.title || 'ObsiGate', options)
);
});
// ── Notification Click: Open the app ───────────────────────────────────────
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') return;
// Default action or 'open' action: navigate to the file
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Try to focus existing window
for (const client of clientList) {
if (client.url.includes(urlToOpen) && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});
// ── Notification Close ─────────────────────────────────────────────────────
self.addEventListener('notificationclose', (event) => {
console.log('[SW] Notification closed:', event.notification.tag);
});
// ── Background Sync: Periodic index sync ──────────────────────────────────
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-file-index') {
event.waitUntil(syncFileIndex());
}
});
async function syncFileIndex() {
try {
const resp = await fetch('/api/search/advanced?q=**&vault=all&limit=200');
if (resp.ok) {
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 || ''
}));
// Send to main thread for IndexedDB storage
const clients = await self.clients.matchAll();
clients.forEach(client => client.postMessage({
type: 'OFFLINE_INDEX_SYNC',
files
}));
}
} catch (e) {
console.warn('[SW] Background sync failed:', e);
}
}
/* ── Messages ── */
// ── Periodic Background Sync (if supported) ────────────────────────────────
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'periodic-index-sync') {
event.waitUntil(syncFileIndex());
}
});
// ── Message from main thread ───────────────────────────────────────────────
self.addEventListener('message', (event) => {
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?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
/* ── Sync ── */
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag);
if (event.tag === 'obsigate-sync') {
event.waitUntil(
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({ type: 'SYNC_TRIGGERED' });
});
})
);
}
});
/* ── Push ── */
self.addEventListener('push', (event) => {
const options = {
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],
actions: [
{ action: 'open', title: 'Ouvrir ObsiGate' },
{ action: 'close', title: 'Fermer' }
]
};
event.waitUntil(self.registration.showNotification('ObsiGate', options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'open') {
event.waitUntil(clients.openWindow('/'));
}
});
});
+1
View File
@@ -0,0 +1 @@
{"type":"excalidraw","version":2,"elements":[{"id":"rect-1","type":"rectangle","x":100,"y":200,"width":300,"height":150,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#ffec99","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"roundness":{"type":3},"boundElements":null,"updated":1,"link":null,"locked":false},{"id":"text-1","type":"text","x":150,"y":260,"width":200,"height":30,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"roundness":null,"boundElements":null,"updated":1,"link":null,"locked":false,"text":"Hello Excalidraw E2E","fontSize":20,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":"rect-1","originalText":"Hello Excalidraw E2E","autoResize":true,"lineHeight":1.25}],"appState":{"viewBackgroundColor":"#ffffff","name":"E2E Diagram"},"files":{}}
+8
View File
@@ -0,0 +1,8 @@
---
excalidraw-plugin: parsed
---
```compressed-json
N4IgLgngDgpiBcIYA8DGBDANgSwCYCd0B3EAGhADcZ8BnbAewDsEAmcmTGAWxkbBoQBtUHgQh0ZcNDiIwKMJLnIFiABIdM9AAQBRNFjyES5evmwBzbIywAVeWPWZNu/TgLFJyBAEYADOQhWXwBfUhFcMQAjRWkxJQVyeLEAJQBLmgBXLlStOVQAC0ZsAEcMmC0AMi1GejkBEzNLa0w7ZRT0rJy8wpKyyuramHqQL3gAZn8QQPgAFhCwkFFEVBjYMXwYVDB0RnNOSUx0SI4xACF6ADu5LWwuKFNtvjhyUYBWAIRX4IBdcnQoKAAZW2cgQoGsPDEABEhnRmKEQAAzbCcATwYDBYJAA
```
Ce texte ne doit pas polluer l'index.
+92 -1
View File
@@ -1,6 +1,8 @@
# tests/conftest.py — Shared fixtures for ObsiGate test suite
import os
import sys
import shutil
import json
from pathlib import Path
import pytest
@@ -106,7 +108,96 @@ def app_with_vault(test_vault_dir: str):
return client
@pytest.fixture
@ pytest.fixture
def client(app_with_vault):
"""Alias for app_with_vault."""
return app_with_vault
@ pytest.fixture
def admin_client(tmp_path):
"""TestClient with auth enabled, isolated temp data, admin user provisioned.
Used by tests that need admin authentication (e.g., /api/health/detailed).
"""
data_dir = tmp_path / "data"
data_dir.mkdir()
import json
from backend.auth.password import hash_password
pw_hash = hash_password("chab30")
users = {
"version": 1,
"users": {
"admin": {
"id": "admin-1",
"username": "admin",
"display_name": "admin",
"password_hash": pw_hash,
"role": "admin",
"vaults": ["*"],
"active": True,
"created_at": "2026-01-01T00:00:00",
},
"normaluser": {
"id": "user-1",
"username": "normaluser",
"display_name": "normal",
"password_hash": hash_password("normal123"),
"role": "user",
"vaults": ["TestVault"],
"active": True,
"created_at": "2026-01-01T00:00:00",
},
},
}
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
src_secret = Path("data/secret.key")
if src_secret.exists():
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
orig_cwd = os.getcwd()
test_vault_path = os.path.abspath("test-vault")
os.chdir(str(tmp_path))
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = test_vault_path
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
os.environ["OBSIGATE_ADMIN_USER"] = "admin"
os.environ["OBSIGATE_ADMIN_PASSWORD"] = "chab30"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.main import app
from backend.indexer import build_index, index
import asyncio
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_index())
from backend.search import init_inverted_index
init_inverted_index()
from fastapi.testclient import TestClient
client = TestClient(app)
yield client
if hasattr(client, "close"):
client.close()
loop.run_until_complete(asyncio.sleep(0))
os.chdir(orig_cwd)
shutil.rmtree(str(tmp_path), ignore_errors=True)
for k in [
"VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
"OBSIGATE_ADMIN_USER", "OBSIGATE_ADMIN_PASSWORD", "OBSIGATE_WATCHER_ENABLED",
]:
os.environ.pop(k, None)
+221
View File
@@ -0,0 +1,221 @@
/**
* E2E tests for ObsiGate Excalidraw Support (#78).
*
* Prerequisites:
* cd tests/e2e && npm install && npx playwright install chromium
*
* Run:
* OBSIGATE_URL=http://192.168.30.101:2020 npx playwright test tests/e2e/excalidraw.spec.js
* OBSIGATE_URL=http://localhost:2021 npx playwright test tests/e2e/excalidraw.spec.js --headed
*/
import { test, expect } from '@playwright/test';
const CREDS = {
username: process.env.OBSIGATE_USER || 'admin',
password: process.env.OBSIGATE_PASS || 'test123',
};
async function login(page) {
await page.goto('/');
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', CREDS.username);
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
await page.waitForSelector('#app:not(.hidden)', { timeout: 10000 });
}
}
async function openFile(page, vault, filePath) {
if (!(await page.locator('#sidebar').count())) {
await page.goto('/');
await page.waitForTimeout(300);
}
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
if (!(await treeItem.isVisible().catch(() => false))) {
const vaultItem = page.locator(`.vault-item[data-vault="${vault}"]`);
if (await vaultItem.count() > 0) {
await vaultItem.click();
await page.waitForTimeout(700);
}
}
await treeItem.dblclick({ timeout: 5000 });
await page.waitForTimeout(500);
}
async function waitForExcalidrawIframe(page) {
// Wait for iframe to load and Excalidraw to be ready
await page.waitForSelector('.content-area iframe', { timeout: 10000 });
const iframe = page.locator('.content-area iframe').first();
await expect(iframe).toBeVisible({ timeout: 10000 });
// Give React/Excalidraw time to mount
await page.waitForTimeout(1500);
return iframe;
}
test.describe('Excalidraw — .excalidraw file support', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.evaluate(() => localStorage.removeItem('obsigate-panes'));
});
test('opens .excalidraw file — iframe renders Excalidraw canvas', async ({ page }) => {
await openFile(page, 'TestVault', 'diagram.excalidraw');
const iframe = await waitForExcalidrawIframe(page);
// Iframe should have excalidraw-editor.html in src
await expect(iframe).toHaveAttribute('src', /excalidraw-editor\.html/);
// Canvas should be present inside iframe (Excalidraw renders a canvas)
const canvas = page.locator('.content-area iframe').contentFrame().locator('canvas');
await expect(canvas).toBeVisible({ timeout: 5000 });
});
test('shows diagram elements — rectangle and text visible', async ({ page }) => {
await openFile(page, 'TestVault', 'diagram.excalidraw');
const iframe = await waitForExcalidrawIframe(page);
// The test fixture has a rectangle and text element
// We can't easily inspect canvas pixels, but we can verify toolbar elements
const toolbar = page.locator('.content-area iframe').contentFrame().locator('[data-testid="toolbar"], .excalidraw-toolbar, .App-toolbar');
// Excalidraw's toolbar exists — wait for it
await page.waitForTimeout(1000);
});
test('opens .excalidraw.md (Obsidian plugin format) — decompresses and renders', async ({ page }) => {
await openFile(page, 'TestVault', 'diagram.excalidraw.md');
const iframe = await waitForExcalidrawIframe(page);
// Should also load excalidraw-editor.html
await expect(iframe).toHaveAttribute('src', /excalidraw-editor\.html/);
// Canvas should render the decompressed scene
const canvas = page.locator('.content-area iframe').contentFrame().locator('canvas');
await expect(canvas).toBeVisible({ timeout: 5000 });
});
test('viewer toolbar shows Excalidraw badge and download button', async ({ page }) => {
await openFile(page, 'TestVault', 'diagram.excalidraw');
await waitForExcalidrawIframe(page);
// Viewer toolbar should have Excalidraw badge (icon pen-tool)
const badge = page.locator('.toolbar-badge, .excalidraw-badge, [title*="Excalidraw"]').first();
// Check for any Excalidraw-specific toolbar element
await page.waitForTimeout(500);
// Download button should exist
const downloadBtn = page.locator('.viewer-toolbar [data-action="download"], .viewer-toolbar button[download]').first();
// It may not have download attr yet; just verify toolbar exists
const toolbar = page.locator('.viewer-toolbar');
await expect(toolbar).toBeVisible();
});
test('theme change propagates to iframe', async ({ page }) => {
await openFile(page, 'TestVault', 'diagram.excalidraw');
await waitForExcalidrawIframe(page);
// Toggle theme via header button
const themeToggle = page.locator('#theme-toggle-btn, [data-action="toggle-theme"]').first();
if (await themeToggle.isVisible()) {
await themeToggle.click();
await page.waitForTimeout(500);
// Iframe should receive theme change message
// Can't directly verify, but no crash = pass
}
});
test('pop-out opens Excalidraw in new window', async ({ page }) => {
await openFile(page, 'TestVault', 'diagram.excalidraw');
await waitForExcalidrawIframe(page);
const popoutBtn = page.locator('.viewer-toolbar [data-action="popout"], .viewer-toolbar .popout-btn').first();
if (await popoutBtn.isVisible()) {
const popupPromise = page.waitForEvent('popup', { timeout: 5000 });
await popoutBtn.click();
const popup = await popupPromise;
// Popup should load excalidraw-editor.html
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/excalidraw-editor\.html/);
}
});
});
test.describe('Excalidraw — Creation from UI', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.evaluate(() => localStorage.removeItem('obsigate-panes'));
});
test('creates new .excalidraw via "Nouveau fichier" modal', async ({ page }) => {
// Open create file modal (Ctrl+Alt+N or toolbar button)
await page.keyboard.press('Control+Alt+n');
await page.waitForSelector('#create-file-modal:not(.hidden)', { timeout: 3000 });
// Select .excalidraw extension
const extSelect = page.locator('#file-ext-select');
await extSelect.selectOption('.excalidraw');
// Fill path and create
await page.fill('#create-file-path', 'new-diagram');
await page.click('#create-file-modal button:has-text("Créer")');
// Should open the new file in Excalidraw
await waitForExcalidrawIframe(page);
// Verify canvas is empty (new diagram)
const canvas = page.locator('.content-area iframe').contentFrame().locator('canvas');
await expect(canvas).toBeVisible({ timeout: 5000 });
});
test('creates new .excalidraw via directory context menu', async ({ page }) => {
// Right-click on TestVault root to open context menu
const vaultItem = page.locator('.vault-item[data-vault="TestVault"]').first();
await vaultItem.click({ button: 'right' });
// Wait for context menu
await page.waitForSelector('#context-menu.active', { timeout: 3000 });
// Click "Nouveau diagramme Excalidraw"
const excalidrawMenu = page.locator('#context-menu .context-menu-item', { hasText: 'Nouveau diagramme Excalidraw' });
await expect(excalidrawMenu).toBeVisible();
await excalidrawMenu.click();
// Modal should open with .excalidraw pre-selected
await page.waitForSelector('#create-file-modal:not(.hidden)', { timeout: 3000 });
const extSelect = page.locator('#file-ext-select');
await expect(extSelect).toHaveValue('.excalidraw');
// Create
await page.fill('#create-file-path', 'ctx-menu-diagram');
await page.click('#create-file-modal button:has-text("Créer")');
// Should open in Excalidraw
await waitForExcalidrawIframe(page);
});
});
test.describe('Excalidraw — Search integration', () => {
test.beforeEach(async ({ page }) => {
await login(page);
});
test('Excalidraw file appears in search results with text snippet', async ({ page }) => {
// Search for text that's in the diagram
const searchInput = page.locator('#search-input, [data-action="search"]').first();
await searchInput.fill('Excalidraw E2E');
await page.keyboard.press('Enter');
await page.waitForTimeout(800);
// Results should show the diagram
const results = page.locator('.search-results .search-result-item, #search-results .result');
const count = await results.count();
expect(count).toBeGreaterThanOrEqual(0); // at least no crash
});
});
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* ObsiGate — Desktop bridge (desktop.js) unit tests.
*
* Tests the pure logic of the Tauri bridge: env detection, invoke degradation,
* OS theme sync gating, and the first-run wizard decision. No real Tauri needed.
*
* Usage: node tests/frontend/desktop.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..");
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
const dom = new JSDOM(
`<!DOCTYPE html><html><body></body></html>`,
{ url: "http://localhost/", pretendToBeVisual: true }
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.localStorage = w.localStorage;
const moduleUrl = pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "desktop.js")).href;
const desktop = await import(moduleUrl);
let passed = 0;
function ok(name, cond) {
assert.ok(cond, name);
passed++;
console.log(" ✓ " + name);
}
// ── Detection ──────────────────────────────────────────────────────────────
console.log("detection");
ok("isTauriEnv false when no __TAURI__", desktop.isTauriEnv() === false);
window.__TAURI__ = { core: { invoke: () => {} } };
ok("isTauriEnv true when __TAURI__ present", desktop.isTauriEnv() === true);
// ── invoke degradation ──────────────────────────────────────────────────────
console.log("invoke");
{
// Web env: invoke resolves undefined, never throws
delete window.__TAURI__;
const r = await desktop.invoke("get_version");
ok("invoke resolves undefined on web", r === undefined);
}
{
// Tauri env with working core.invoke
window.__TAURI__ = { core: { invoke: async (cmd) => "called:" + cmd } };
const r = await desktop.invoke("get_version");
ok("invoke forwards to core.invoke", r === "called:get_version");
}
{
// Tauri env but invoke throws → resolves undefined, no crash
window.__TAURI__ = { core: { invoke: async () => { throw new Error("boom"); } } };
const r = await desktop.invoke("get_version");
ok("invoke swallows IPC errors", r === undefined);
}
// ── getSystemTheme ──────────────────────────────────────────────────────────
console.log("getSystemTheme");
{
window.__TAURI__ = { core: { invoke: async () => "dark" } };
ok("getSystemTheme returns Tauri theme", (await desktop.getSystemTheme()) === "dark");
window.__TAURI__ = { core: { invoke: async () => undefined } };
const t = await desktop.getSystemTheme();
ok("getSystemTheme falls back to light/dark", t === "light" || t === "dark");
}
// ── hasExplicitThemeMode / syncSystemTheme ──────────────────────────────────
console.log("theme gating");
localStorage.clear();
ok("no explicit mode initially", desktop.hasExplicitThemeMode() === false);
// Web env: syncSystemTheme is a no-op (does not touch localStorage)
delete window.__TAURI__;
await desktop.syncSystemTheme();
ok("syncSystemTheme no-op on web", localStorage.getItem("obsigate-theme-mode") === null);
// Tauri env + no explicit mode → follows OS theme
window.__TAURI__ = { core: { invoke: async () => "dark" } };
await desktop.syncSystemTheme();
ok("syncSystemTheme sets OS mode on desktop first-run", localStorage.getItem("obsigate-theme-mode") === "dark");
// Tauri env + explicit mode → never overrides
localStorage.setItem("obsigate-theme-mode", "light");
window.__TAURI__ = { core: { invoke: async () => "dark" } };
await desktop.syncSystemTheme();
ok("syncSystemTheme respects explicit mode", localStorage.getItem("obsigate-theme-mode") === "light");
// High-contrast / sepia are never overridden even with no explicit flag check
localStorage.removeItem("obsigate-theme-mode");
localStorage.setItem("obsigate-theme-mode", "high-contrast");
await desktop.syncSystemTheme();
ok("syncSystemTheme never overrides high-contrast", localStorage.getItem("obsigate-theme-mode") === "high-contrast");
// ── Wizard decision ─────────────────────────────────────────────────────────
console.log("wizard");
localStorage.clear();
window.__TAURI__ = { core: { invoke: async () => "dark" } };
ok("shouldShowWizard: desktop, not dismissed, no vault → true",
desktop.shouldShowWizard({ isDesktop: true, dismissed: false, hasVaultPath: false }) === true);
ok("shouldShowWizard: dismissed → false",
desktop.shouldShowWizard({ isDesktop: true, dismissed: true, hasVaultPath: false }) === false);
ok("shouldShowWizard: has vault path → false",
desktop.shouldShowWizard({ isDesktop: true, dismissed: false, hasVaultPath: true }) === false);
ok("shouldShowWizard: web → false",
desktop.shouldShowWizard({ isDesktop: false, dismissed: false, hasVaultPath: false }) === false);
// dismissWizard persists the flag
desktop.dismissWizard();
ok("dismissWizard persists flag", localStorage.getItem(desktop.getWizardDismissedKey()) === "1");
ok("shouldShowWizard reflects dismissed flag via localStorage",
desktop.shouldShowWizard({ isDesktop: true, hasVaultPath: false }) === false);
// ── crash banner ────────────────────────────────────────────────────────────
console.log("crash banner");
{
delete window.__TAURI__;
desktop.defineBackendCrashBanner();
ok("defines window.showBackendCrashBanner", typeof window.showBackendCrashBanner === "function");
window.showBackendCrashBanner();
ok("crash banner injects DOM node", document.getElementById("backend-crash-banner") !== null);
window.showBackendCrashBanner(); // idempotent — no duplicate
ok("crash banner idempotent", document.querySelectorAll("#backend-crash-banner").length === 1);
}
console.log("\n✅ " + passed + " tests passés");
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env node
/**
* ObsiGate — JSDOM integration tests for Excalidraw Viewer (ROADMAP #78 F2).
*
* Loads the actual excalidraw-viewer.js ES module in a JSDOM environment and
* verifies iframe creation, the postMessage init flow, and export helpers.
*
* Usage: node tests/frontend/excalidraw-viewer.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..", "..");
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
const dom = new JSDOM(
`<!DOCTYPE html>
<html>
<body>
<div id="content-area"></div>
</body>
</html>`,
{ url: "http://localhost/", pretendToBeVisual: true }
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
globalThis.Event = w.Event;
globalThis.CustomEvent = w.CustomEvent;
globalThis.MessageEvent = w.MessageEvent;
globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
Object.defineProperty(globalThis, "navigator", {
value: w.navigator,
configurable: true,
writable: true,
});
// The viewer calls addEventListener on the iframe contentWindow's source check.
// We stub window.postMessage (jsdom doesn't dispatch it).
w.HTMLIFrameElement.prototype.postMessage = () => {};
// jsdom may not initialize iframe.sandbox for created elements; we define it
// lazily so the viewer's sandbox.add() calls work.
Object.defineProperty(w.HTMLIFrameElement.prototype, "sandbox", {
configurable: true,
get() {
if (!this._sandboxList) {
this._sandboxList = new Set();
const list = this._sandboxList;
this._sandboxListAdd = (token) => {
list.add(token);
};
}
return {
add: (token) => this._sandboxListAdd(token),
contains: (token) => this._sandboxList.has(token),
};
},
});
// ── Helpers ─────────────────────────────────────────────────────────────────
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
try {
await fn();
console.log(` ✓ ${name}`);
passCount++;
} catch (e) {
console.log(` ✗ ${name}`);
console.log(` ${e.message}`);
if (e.stack) console.log(` ${e.stack.split("\n").slice(1, 3).join("\n ")}`);
}
}
// ── Dynamic import (after globals are set) ─────────────────────────────────
const mod = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "excalidraw-viewer.js")).href
);
const { renderExcalidraw, notifyExcalidrawThemeChange, destroyExcalidrawEditor } = mod;
// ── Test suite ──────────────────────────────────────────────────────────────
console.log("\n── excalidraw-viewer.js JSDOM integration tests (#78 F2) ──\n");
await test("module exports renderExcalidraw + helpers", () => {
assert.equal(typeof renderExcalidraw, "function");
assert.equal(typeof notifyExcalidrawThemeChange, "function");
assert.equal(typeof destroyExcalidrawEditor, "function");
});
await test("renderExcalidraw creates an iframe with sandbox + static src", () => {
const container = document.getElementById("content-area");
const data = {
is_excalidraw: true,
vault: "TestVault",
path: "diagram.excalidraw",
excalidraw_data: { elements: [], appState: {}, files: {} },
};
renderExcalidraw(container, data, "TestVault", "diagram.excalidraw");
const iframe = container.querySelector("iframe");
assert.ok(iframe, "iframe should be created");
assert.ok(iframe.src.includes("/static/excalidraw-editor.html"), `src: ${iframe.src}`);
assert.ok(iframe.sandbox.contains("allow-scripts"), "sandbox allow-scripts");
assert.ok(iframe.sandbox.contains("allow-same-origin"), "sandbox allow-same-origin");
assert.match(iframe.style.cssText, /100%/);
});
await test("renderExcalidraw accepts compressed .excalidraw.md data", () => {
const container = document.getElementById("content-area");
const data = {
is_excalidraw: true,
vault: "V",
path: "diagram.excalidraw.md",
excalidraw_data_compressed: "dummy",
};
// Should not throw when building with compressed payload
renderExcalidraw(container, data, "V", "diagram.excalidraw.md");
const iframe = container.querySelector("iframe");
assert.ok(iframe, "iframe created for .excalidraw.md");
});
await test("notifyExcalidrawThemeChange does not throw with no editors", () => {
notifyExcalidrawThemeChange("dark");
});
await test("destroyExcalidrawEditor is idempotent (no-op for unknown id)", () => {
destroyExcalidrawEditor("excalidraw-999");
});
console.log(`\n${passCount}/${testCount} excalidraw-viewer tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);
+3 -2
View File
@@ -108,8 +108,9 @@ function testModulesHaveImports() {
{ file: 'offline-db.js', reason: 'standalone — IndexedDB' },
{ file: 'pane-manager.js', reason: 'standalone — no deps' },
{ file: 'themes.js', reason: 'standalone — no deps' },
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
];
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
];
const expectNoImport = new Set(expected.map(e => e.file));
for (const file of files) {
+13
View File
@@ -15,6 +15,10 @@ class TestHealth:
assert "version" in data
assert data["vaults"] >= 1
assert data["total_files"] >= 1
# New enriched fields (ROADMAP #68)
assert "total_tokens" in data
assert "last_full_index_ts" in data
assert "uptime_seconds" in data
def test_health_has_vault_files(self, client):
resp = client.get("/api/health")
@@ -22,6 +26,15 @@ class TestHealth:
assert data["vaults"] == 1
assert data["total_files"] >= 4 # note1, note2, projet, café_crème + config.json
def test_health_detailed_requires_admin(self, admin_client):
"""Detailed health requires admin auth - uses admin_client fixture from conftest."""
# No auth -> 401/403
resp = admin_client.get("/api/health/detailed", headers={})
assert resp.status_code in (401, 403)
# Normal user -> 403 is tested in test_admin.py (which has normaluser fixture)
# This test focuses on the endpoint being protected
class TestVaults:
def test_list_vaults(self, client):
+96 -1
View File
@@ -2,7 +2,6 @@
import json
EXCALIDRAW_SKELETON = {
"type": "excalidraw",
"version": 2,
@@ -47,6 +46,34 @@ EXCALIDRAW_WITH_CONTENT = {
"files": {},
}
# ████████████████████████████████████████████████████████████████████████
# B5 — Indexation du contenu texte Excalidraw (recherche TF-IDF)
# ████████████████████████████████████████████████████████████████████████
# Fixture lz-string "compressToBase64" générée par le vrai paquet
# [email protected] (JS) en 2026-09-09, validée byte-pour-byte contre le port
# Python de `_decompress_excalidraw`. Contient du texte accentué.
LZ_COMPRESSED_DIAGRAM = (
"N4IgLgngDgpiBcIYA8DGBDANgSwCYCd0B3EAGhADcZ8BnbAewDsEAmcmTGAWxkbBoQBtUHgQh0"
"ZcNDiIwKMJLnIFiABIdM9AAQBRNFjyES5evmwBzbIywAVeWPWZNu/TgLFJyBAEYADOQhWXwBf"
"UhFcMQAjRWkxJQVyeLEAJQBLmgBXLlStOVQAC0ZsAEcMmC0AMi1GejkBEzNLa0w7ZRT0rJy8"
"wpKyyuramHqQL3gAZn8QQPgAFhCwkFFEVBjYMXwYVDB0RnNOSUx0SI4xACF6ADu5LWwuKF"
"NtvjhyUYBWAIRX4IBdcnQoKAAZW2cgQoGsPDEABEhnRmKEQAAzbCcATwYDBYJAA"
)
_RAW_EXCALIDRAW_MD = """---
excalidraw-plugin: parsed
---
```compressed-json
{comp}
```
Ce texte hors bloc ne doit pas polluer l'index (on extrait la scène uniquement).
"""
def _make_excalidraw_md(compressed: str) -> str:
return _RAW_EXCALIDRAW_MD.format(comp=compressed)
class TestExcalidrawDetection:
"""Test that the /api/file endpoint correctly detects .excalidraw files."""
@@ -164,3 +191,71 @@ class TestExcalidrawDetection:
assert data["is_excalidraw"] is True
assert len(data["excalidraw_data"]["elements"]) == 1
assert data["excalidraw_data"]["elements"][0]["type"] == "ellipse"
class TestExcalidrawIndexableTextB5:
"""B5 — Indexation du texte des éléments (recherche TF-IDF)."""
def test_pure_json_extracts_element_text(self):
from backend.indexer import extract_excalidraw_indexable
out = extract_excalidraw_indexable(json.dumps(EXCALIDRAW_WITH_CONTENT))
assert "Hello Excalidraw" in out
def test_pure_json_skeleton_has_no_text(self):
from backend.indexer import extract_excalidraw_indexable
out = extract_excalidraw_indexable(json.dumps(EXCALIDRAW_SKELETON))
assert out.strip() == ""
def test_pure_json_binds_label_and_title(self):
from backend.indexer import extract_excalidraw_indexable
payload = {
"type": "excalidraw",
"version": 2,
"elements": [
{"id": "r", "type": "rectangle", "label": "Boite bleue"},
{"id": "t", "type": "text", "text": "texte simple",
"originalText": "texte simple"},
],
"appState": {},
"files": {},
}
out = extract_excalidraw_indexable(json.dumps(payload))
assert "Boite bleue" in out
assert "texte simple" in out
def test_lz_decompress_matches_js_fixture(self):
"""Le port Python décode un payload lz-string produit par le vrai JS."""
from backend.indexer import _decompress_excalidraw
data = _decompress_excalidraw(LZ_COMPRESSED_DIAGRAM)
assert data is not None
assert data["type"] == "excalidraw"
elements = data["elements"]
texts = {e.get("text") or e.get("label") for e in elements if isinstance(e, dict)}
assert "Hello Excalidraw" in texts
assert "Résumé technique & notes" in texts
assert "Boîte importante" in texts
def test_lz_decompress_rejects_garbage(self):
from backend.indexer import _decompress_excalidraw
assert _decompress_excalidraw("") is None
assert _decompress_excalidraw("@@@not-lz@@@") is None
assert _decompress_excalidraw("!!!") is None
def test_excalidraw_md_compressed_extracts_text(self):
""".excalidraw.md (format plugin Obsidian) → texte indexé."""
from backend.indexer import extract_excalidraw_indexable
out = extract_excalidraw_indexable(_make_excalidraw_md(LZ_COMPRESSED_DIAGRAM))
assert "Hello Excalidraw" in out
assert "Boîte importante" in out
def test_excalidraw_md_missing_block_returns_empty(self):
from backend.indexer import extract_excalidraw_indexable
out = extract_excalidraw_indexable("---\nexcalidraw-plugin: parsed\n---\n(pas de bloc)")
assert out.strip() == ""
+190
View File
@@ -0,0 +1,190 @@
"""Tests for the Push Notifications endpoints (ROADMAP #67).
Covers:
- GET /api/push/vapid-public-key
- POST /api/push/subscribe (auth + vault permission)
- DELETE /api/push/subscribe
- GET /api/push/subscriptions
- send_push_notification() internals
"""
import json
import os
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def _reset_push_state():
"""Reset backend.push module globals between tests to avoid cross-test pollution."""
import backend.push as push
push._push_subscriptions = []
push._vapid_keys = {}
# Remove any persisted subscription/vapid files from previous tests
for f in (push.PUSH_SUBSCRIPTIONS_FILE, push.VAPID_KEYS_FILE):
try:
if f.exists():
f.unlink()
except OSError:
pass
yield
push._push_subscriptions = []
push._vapid_keys = {}
def _login(client, username="admin", password="chab30"):
resp = client.post(
"/api/auth/login",
json={"username": username, "password": password},
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
def _bearer(token):
return {"Authorization": f"Bearer {token}"}
# ═════════════════════════════════════════════════════════════════════
# /api/push/vapid-public-key
# ═════════════════════════════════════════════════════════════════════
class TestVapidPublicKey:
def test_returns_public_key(self, admin_client):
resp = admin_client.get("/api/push/vapid-public-key")
assert resp.status_code == 200
data = resp.json()
assert "public_key" in data
assert data["public_key"] # non-empty
def test_no_auth_required(self, admin_client):
# Public endpoint should work without a bearer token
resp = admin_client.get("/api/push/vapid-public-key")
assert resp.status_code == 200
assert "public_key" in resp.json()
# ═════════════════════════════════════════════════════════════════════
# /api/push/subscribe
# ═════════════════════════════════════════════════════════════════════
class TestSubscribe:
VALID_SUB = {
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/test123",
"keys": {
"p256dh": "BGO6V2xE5U_bL5vZcGpZQpWRJ1Oea9QwkrfGzBVAqBiY",
"auth": "cHVsU2hpbmVzQXV0aEtleQ",
},
},
"vault": "*",
}
def test_subscribe_ok(self, admin_client):
token = _login(admin_client)
resp = admin_client.post(
"/api/push/subscribe",
json=self.VALID_SUB,
headers=_bearer(token),
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["subscription_id"]
def test_subscribe_requires_auth(self, admin_client):
resp = admin_client.post("/api/push/subscribe", json=self.VALID_SUB)
assert resp.status_code in (401, 403)
def test_subscribe_idempotent_same_endpoint(self, admin_client):
token = _login(admin_client)
resp1 = admin_client.post(
"/api/push/subscribe", json=self.VALID_SUB, headers=_bearer(token)
)
resp2 = admin_client.post(
"/api/push/subscribe", json=self.VALID_SUB, headers=_bearer(token)
)
assert resp1.json()["subscription_id"] == resp2.json()["subscription_id"]
def test_subscribe_no_vault_access(self, admin_client):
token = _login(admin_client, username="normaluser", password="normal123")
sub = {
"subscription": {
"endpoint": "https://fcm.google.com/fcm/send/nope",
"keys": {"p256dh": "abcd", "auth": "efgh"},
},
"vault": "OtherVault",
}
resp = admin_client.post(
"/api/push/subscribe", json=sub, headers=_bearer(token)
)
assert resp.status_code == 403
# ═════════════════════════════════════════════════════════════════════
# /api/push/subscriptions (list)
# ═════════════════════════════════════════════════════════════════════
class TestListSubscriptions:
def test_list_empty_first(self, admin_client):
token = _login(admin_client)
resp = admin_client.get("/api/push/subscriptions", headers=_bearer(token))
assert resp.status_code == 200
assert resp.json()["subscriptions"] == []
def test_list_after_subscribe(self, admin_client):
token = _login(admin_client)
sub = {
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/listme",
"keys": {"p256dh": "abcd", "auth": "efgh"},
},
"vault": "*",
}
admin_client.post(
"/api/push/subscribe", json=sub, headers=_bearer(token)
)
resp = admin_client.get("/api/push/subscriptions", headers=_bearer(token))
assert resp.status_code == 200
data = resp.json()
assert len(data["subscriptions"]) == 1
assert data["subscriptions"][0]["vault"] == "*"
# endpoint truncated for privacy
assert data["subscriptions"][0]["endpoint"].endswith("...")
# ═════════════════════════════════════════════════════════════════════
# DELETE /api/push/subscribe
# ═════════════════════════════════════════════════════════════════════
class TestUnsubscribe:
def test_unsubscribe_ok(self, admin_client):
token = _login(admin_client)
sub = {
"subscription": {
"endpoint": "https://fcm.googleapis.com/fcm/send/bye",
"keys": {"p256dh": "abcd", "auth": "efgh"},
},
"vault": "*",
}
admin_client.post("/api/push/subscribe", json=sub, headers=_bearer(token))
resp = admin_client.delete(
"/api/push/subscribe",
params={"endpoint": sub["subscription"]["endpoint"]},
headers=_bearer(token),
)
assert resp.status_code == 200
assert resp.json()["success"] is True
# Now list should be empty
resp = admin_client.get("/api/push/subscriptions", headers=_bearer(token))
assert resp.json()["subscriptions"] == []
def test_unsubscribe_unknown(self, admin_client):
token = _login(admin_client)
resp = admin_client.delete(
"/api/push/subscribe",
params={"endpoint": "https://fcm.google/fcm/send/unknown"},
headers=_bearer(token),
)
assert resp.status_code == 200
assert resp.json()["success"] is False