diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6007105..64aa99b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 \ No newline at end of file diff --git a/backend/indexer.py b/backend/indexer.py index 8700c62..0325c49 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -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() diff --git a/backend/main.py b/backend/main.py index 71422c2..55f4dc8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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. diff --git a/backend/push.py b/backend/push.py new file mode 100644 index 0000000..92fdd21 --- /dev/null +++ b/backend/push.py @@ -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:admin@obsigate.local" + } + + 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 \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index eee76e2..a9e897e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,3 +16,4 @@ pypdf>=4.0 pyotp>=2.10.0 webauthn==2.6.0 psutil>=5.9 +pywebpush>=2.3.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index d446efd..b6ea8dd 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2646,6 +2646,7 @@ dependencies = [ "tauri-plugin-updater", "tempfile", "tokio", + "windows", ] [[package]] diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index e2e5962..c10d053 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -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"] diff --git a/desktop/capabilities/default.json b/desktop/capabilities/default.json index 25d8d5e..abf130a 100644 --- a/desktop/capabilities/default.json +++ b/desktop/capabilities/default.json @@ -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", diff --git a/desktop/gen/schemas/capabilities.json b/desktop/gen/schemas/capabilities.json index 1213aef..4ab0e46 100644 --- a/desktop/gen/schemas/capabilities.json +++ b/desktop/gen/schemas/capabilities.json @@ -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"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/desktop/scripts/sign-windows.ps1 b/desktop/scripts/sign-windows.ps1 new file mode 100644 index 0000000..5b8124f --- /dev/null +++ b/desktop/scripts/sign-windows.ps1 @@ -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)." diff --git a/desktop/src/jumplist.rs b/desktop/src/jumplist.rs new file mode 100644 index 0000000..76cd07c --- /dev/null +++ b/desktop/src/jumplist.rs @@ -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 { + 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 { + 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::>(); + + 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::(&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!["a", "b", "c"]); + } +} diff --git a/desktop/src/main.rs b/desktop/src/main.rs index d26a923..37cc874 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -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> { 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 diff --git a/frontend/index.html b/frontend/index.html index 5885e9a..ac06bed 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1449,6 +1449,7 @@
  • +
  • Notifications push
  • @@ -2185,11 +2186,29 @@ - -
    + +
    +

    🔔 Notifications push

    +

    + Recevez des notifications web push + lorsque des fichiers changent dans vos + vaults, même si ObsiGate n'est pas ouvert. +

    +
    +
    + Chargement... +
    +
    +
    + + +

    📦 À propos

    (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, +}; diff --git a/frontend/js/push.js b/frontend/js/push.js new file mode 100644 index 0000000..f2b0a8f --- /dev/null +++ b/frontend/js/push.js @@ -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 = ` +
    + Notifications push + Non supportées par ce navigateur +
    `; + return; + } + + container.innerHTML = ` +
    + Notifications push + ${subscribed ? 'Activées' : 'Désactivées'} +
    +
    + + +
    +
    + +
    +
    `; + + // 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 }; diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 78f8cee..c50c4e4 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -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 = `

    ${t('modal.create_file')}

    @@ -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); }, diff --git a/frontend/locales/en.json b/frontend/locales/en.json index b57b823..b0e3620 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 17a0ae8..e5b1cf5 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -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", diff --git a/frontend/sw.js b/frontend/sw.js index 073b93d..5bdb524 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -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('/')); - } -}); +}); \ No newline at end of file diff --git a/test_vault/diagram.excalidraw b/test_vault/diagram.excalidraw new file mode 100644 index 0000000..fd37acf --- /dev/null +++ b/test_vault/diagram.excalidraw @@ -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":{}} \ No newline at end of file diff --git a/test_vault/diagram.excalidraw.md b/test_vault/diagram.excalidraw.md new file mode 100644 index 0000000..46684aa --- /dev/null +++ b/test_vault/diagram.excalidraw.md @@ -0,0 +1,8 @@ +--- +excalidraw-plugin: parsed +--- +```compressed-json +N4IgLgngDgpiBcIYA8DGBDANgSwCYCd0B3EAGhADcZ8BnbAewDsEAmcmTGAWxkbBoQBtUHgQh0ZcNDiIwKMJLnIFiABIdM9AAQBRNFjyES5evmwBzbIywAVeWPWZNu/TgLFJyBAEYADOQhWXwBfUhFcMQAjRWkxJQVyeLEAJQBLmgBXLlStOVQAC0ZsAEcMmC0AMi1GejkBEzNLa0w7ZRT0rJy8wpKyyuramHqQL3gAZn8QQPgAFhCwkFFEVBjYMXwYVDB0RnNOSUx0SI4xACF6ADu5LWwuKFNtvjhyUYBWAIRX4IBdcnQoKAAZW2cgQoGsPDEABEhnRmKEQAAzbCcATwYDBYJAA +``` + +Ce texte ne doit pas polluer l'index. \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 4941bf7..dd0f8ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/e2e/excalidraw.spec.js b/tests/e2e/excalidraw.spec.js new file mode 100644 index 0000000..156f6f0 --- /dev/null +++ b/tests/e2e/excalidraw.spec.js @@ -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 + }); +}); \ No newline at end of file diff --git a/tests/frontend/desktop.test.mjs b/tests/frontend/desktop.test.mjs new file mode 100644 index 0000000..8225257 --- /dev/null +++ b/tests/frontend/desktop.test.mjs @@ -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( + ``, + { 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"); diff --git a/tests/frontend/excalidraw-viewer.test.mjs b/tests/frontend/excalidraw-viewer.test.mjs new file mode 100644 index 0000000..f4657e4 --- /dev/null +++ b/tests/frontend/excalidraw-viewer.test.mjs @@ -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( + ` + + +
    + +`, + { 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); \ No newline at end of file diff --git a/tests/frontend/unit.test.mjs b/tests/frontend/unit.test.mjs index 71cab66..9290624 100644 --- a/tests/frontend/unit.test.mjs +++ b/tests/frontend/unit.test.mjs @@ -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) { diff --git a/tests/test_api_main.py b/tests/test_api_main.py index 8674348..69d3250 100644 --- a/tests/test_api_main.py +++ b/tests/test_api_main.py @@ -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): diff --git a/tests/test_excalidraw.py b/tests/test_excalidraw.py index 928f86b..c8d15fa 100644 --- a/tests/test_excalidraw.py +++ b/tests/test_excalidraw.py @@ -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 +# lz-string@1.5.0 (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() == "" \ No newline at end of file diff --git a/tests/test_push.py b/tests/test_push.py new file mode 100644 index 0000000..aa63959 --- /dev/null +++ b/tests/test_push.py @@ -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 \ No newline at end of file