feat: #78 Excalidraw finitions + #67 push + #68 health-detailed + #77 desktop jumplist
CI / lint (push) Successful in 52s
CI / security (push) Successful in 36s
CI / test (push) Successful in 1m6s
CI / build (push) Successful in 1m15s
CI / e2e (push) Failing after 12m40s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 52s
CI / security (push) Successful in 36s
CI / test (push) Successful in 1m6s
CI / build (push) Successful in 1m15s
CI / e2e (push) Failing after 12m40s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
#78 Excalidraw — finitions B5/C8/F2/F3 - backend/indexer.py: port Python pur de lz-string decompressFromBase64 (bitsPerChar=6, resetValue=32) validé contre 4 fixtures JS truth (texte accentué, edge cases) — remplace le stub base64 non-fonctionnel - B5 indexation: .excalidraw.md (format plugin Obsidian) maintenant décompressé côté Python → texte indexé pour recherche TF-IDF - 7 nouveaux tests B5 dans tests/test_excalidraw.py (13/13 total) - F2: excalidraw-viewer.test.mjs enregistré dans CI (lint job) - F3: tests/e2e/excalidraw.spec.js (9 specs — ouverture .excalidraw/.excalidraw.md, création via modale/menu contextuel, toolbar, thème, pop-out, recherche) - Fixtures test_vault/diagram.excalidraw + diagram.excalidraw.md #67 Push notifications (Web Push API + VAPID) - backend/push.py: router + VAPID keys + send_push_notification (pywebpush>=2.3.0) - frontend/js/push.js: subscription UI + service worker integration - tests/test_push.py: 10 tests (subscribe/list/unsubscribe/vapid key) - requirements.txt + sw.js + locales push strings #68 Health check enrichi - backend/main.py: GET /api/health/detailed (admin) — memory/cpu/disk/backups/index/SSE connections - backend/indexer.py: _last_full_index_ts tracking - tests/conftest.py: admin_client fixture - tests/test_api_main.py: 3 nouveaux tests health detailed #77 Desktop jumplist - desktop/src/jumplist.rs: Windows jumplist integration - Cargo.toml/lock + capabilities + main.rs - frontend/js/desktop.js: Tauri bridge (isTauriEnv, invoke, getSystemTheme, syncSystemTheme, shouldShowWizard, crash banner) - tests/frontend/desktop.test.mjs: 21 tests JSDOM (détection, invoke degradation, theme gating, wizard, crash banner) - tests/frontend/unit.test.mjs: desktop.js whitelisted (standalone global reader) # Frontend & CI - frontend/sw.js: réécriture complète (precache + push event handlers + offline) - frontend/index.html: section push dans settings + about repositionné - frontend/js/app.js: initDesktopIntegration + initPush - CI .gitea/workflows/ci.yml: excalidraw-viewer.test.mjs ajouté au lint job All checks: ruff clean, 552 backend tests pass, 9 frontend unit, 5 excalidraw-viewer, 21 desktop, 9 pane-manager, validate-imports 33 modules.
This commit is contained in:
+24
-5
@@ -1449,6 +1449,7 @@
|
||||
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
|
||||
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
|
||||
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
|
||||
<li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li>
|
||||
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
|
||||
<li><a href="#cfg-webhooks" class="help-nav-link" data-i18n="config.section_webhooks"></a></li>
|
||||
<li><a href="#cfg-partages-publics" class="help-nav-link" data-i18n="config.section_shares"></a></li>
|
||||
@@ -2185,11 +2186,29 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- À propos -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-about"
|
||||
>
|
||||
<!-- Notifications push -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-push"
|
||||
>
|
||||
<h2 data-i18n="config.section_push">🔔 Notifications push</h2>
|
||||
<p class="config-description">
|
||||
Recevez des notifications web push
|
||||
lorsque des fichiers changent dans vos
|
||||
vaults, même si ObsiGate n'est pas ouvert.
|
||||
</p>
|
||||
<div id="push-settings">
|
||||
<div class="config-diag-loading" data-i18n="common.loading">
|
||||
Chargement...
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- À propos -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
id="cfg-about"
|
||||
>
|
||||
<h2 data-i18n="auto.a3319169">📦 À propos</h2>
|
||||
<div
|
||||
id="config-about"
|
||||
|
||||
+8
-1
@@ -20,6 +20,8 @@ import { initMobileToolbar } from './mobile-toolbar.js';
|
||||
import { initDragDrop } from './dragdrop.js';
|
||||
import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
|
||||
import PaneManager from './pane-manager.js';
|
||||
import { initDesktopIntegration, isTauriEnv } from './desktop.js';
|
||||
import { initPush } from './push.js';
|
||||
|
||||
// =========================================================================
|
||||
// Initialization — mirrors the original app.js init() ordering
|
||||
@@ -30,6 +32,10 @@ async function init() {
|
||||
// LocalStorage has correct language if user set it previously
|
||||
await initI18n();
|
||||
|
||||
// 1b. Desktop bridge — crash banner + OS theme sync (before theme engine).
|
||||
// No-op on web (Docker); on desktop it also readies the vault picker.
|
||||
await initDesktopIntegration();
|
||||
|
||||
// 2. Init auth — now t() works inside showApp()/renderUserMenu()
|
||||
const authOk = await Auth.AuthManager.initAuth();
|
||||
|
||||
@@ -62,7 +68,8 @@ async function init() {
|
||||
UI.TabManager.init();
|
||||
PaneManager.init();
|
||||
initCommandPalette();
|
||||
initMobileToolbar();
|
||||
initMobileToolbar();
|
||||
initPush();
|
||||
|
||||
if (authOk) {
|
||||
Legacy.initSyncStatus();
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/* ObsiGate — Desktop (Tauri) bridge.
|
||||
* Bridges the vanilla-JS frontend to the Rust/Tauri shell.
|
||||
*
|
||||
* On desktop, the frontend is served by the Python backend over
|
||||
* http://127.0.0.1:<port> (the webview redirects there after boot).
|
||||
* Tauri exposes its API globally via `window.__TAURI__` (withGlobalTauri).
|
||||
* Remote IPC access for that origin is granted in capabilities/default.json.
|
||||
*
|
||||
* On web (Docker), `window.__TAURI__` is absent — every function degrades
|
||||
* to a no-op so the same bundle runs unchanged in the browser.
|
||||
*
|
||||
* Usage: import { initDesktopIntegration } from './desktop.js';
|
||||
*/
|
||||
|
||||
// ── Detection ──────────────────────────────────────────────────────────────
|
||||
// window.__TAURI__ is injected by Tauri when app.withGlobalTauri === true.
|
||||
// window.__TAURI_INTERNALS__ is a secondary signal used by some Tauri builds.
|
||||
|
||||
export function isTauriEnv() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !!(window.__TAURI__ || window.__TAURI_INTERNALS__);
|
||||
}
|
||||
|
||||
// ── invoke wrapper ─────────────────────────────────────────────────────────
|
||||
// Safely invokes a Tauri command. Resolves to undefined on web or on any
|
||||
// IPC error, so callers never crash the app.
|
||||
|
||||
export async function invoke(cmd, args) {
|
||||
if (!isTauriEnv()) return undefined;
|
||||
try {
|
||||
const core = window.__TAURI__ && window.__TAURI__.core;
|
||||
if (!core || typeof core.invoke !== 'function') return undefined;
|
||||
return await core.invoke(cmd, args);
|
||||
} catch (e) {
|
||||
console.warn('[desktop] invoke(' + cmd + ') failed:', e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ── System theme ───────────────────────────────────────────────────────────
|
||||
// Returns 'dark' or 'light' from the OS. Falls back to the CSS media query
|
||||
// (which the system WebView also honours) if the Tauri command is unavailable.
|
||||
|
||||
export async function getSystemTheme() {
|
||||
const tauriTheme = await invoke('get_system_theme');
|
||||
if (tauriTheme === 'dark' || tauriTheme === 'light') return tauriTheme;
|
||||
try {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return 'light';
|
||||
}
|
||||
|
||||
// ── Theme preference detection ─────────────────────────────────────────────
|
||||
// The real theme engine is themes.js: it persists the *mode* under
|
||||
// 'obsigate-theme-mode' (dark/light/high-contrast/sepia). Absence of that key
|
||||
// means "no explicit user choice yet" — the desktop app should then follow
|
||||
// the OS theme instead of defaulting to dark.
|
||||
|
||||
export function hasExplicitThemeMode() {
|
||||
try {
|
||||
return localStorage.getItem('obsigate-theme-mode') !== null;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the OS theme mode, but ONLY if the user hasn't chosen one explicitly.
|
||||
export async function syncSystemTheme() {
|
||||
if (!isTauriEnv()) return;
|
||||
if (hasExplicitThemeMode()) return;
|
||||
const sys = await getSystemTheme();
|
||||
// Map OS light/dark onto the theme engine's mode axis (never override
|
||||
// high-contrast/sepia, which are explicit choices stored in the same key).
|
||||
const current = (function () {
|
||||
try { return localStorage.getItem('obsigate-theme-mode'); } catch (e) { return null; }
|
||||
})();
|
||||
if (current === 'high-contrast' || current === 'sepia') return;
|
||||
try { localStorage.setItem('obsigate-theme-mode', sys); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Crash banner ───────────────────────────────────────────────────────────
|
||||
// The Rust shell calls `showBackendCrashBanner()` via window.eval when the
|
||||
// backend process dies. Define it globally (idempotent) so the call always
|
||||
// resolves. On web this is never triggered, but defining it is harmless.
|
||||
|
||||
export function defineBackendCrashBanner() {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (window.showBackendCrashBanner) return;
|
||||
|
||||
window.showBackendCrashBanner = function () {
|
||||
// Avoid stacking duplicates
|
||||
if (document.getElementById('backend-crash-banner')) return;
|
||||
const banner = document.createElement('div');
|
||||
banner.id = 'backend-crash-banner';
|
||||
banner.setAttribute('role', 'alert');
|
||||
banner.style.cssText = [
|
||||
'position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:99999',
|
||||
'background:#7f1d1d', 'color:#fef2f2', 'padding:10px 16px',
|
||||
'font-size:13px', 'display:flex', 'align-items:center', 'justify-content:space-between',
|
||||
'box-shadow:0 2px 8px rgba(0,0,0,0.4)',
|
||||
].join(';');
|
||||
const msg = document.createElement('span');
|
||||
msg.textContent = '⚠ Le backend ObsiGate s\u2019est arrêté. Redémarrage en cours…';
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Recharger';
|
||||
btn.style.cssText = 'background:#fff;color:#7f1d1d;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-weight:600;';
|
||||
btn.addEventListener('click', function () { window.location.reload(); });
|
||||
banner.appendChild(msg);
|
||||
banner.appendChild(btn);
|
||||
document.body.appendChild(banner);
|
||||
};
|
||||
}
|
||||
|
||||
// ── Vault picker ───────────────────────────────────────────────────────────
|
||||
// Opens the native folder picker and registers the chosen folder as a vault,
|
||||
// then restarts the backend so the new VAULT_N_* env vars take effect.
|
||||
|
||||
export async function pickVaultFolder() {
|
||||
if (!isTauriEnv()) return undefined;
|
||||
const path = await invoke('pick_vault_folder');
|
||||
return typeof path === 'string' ? path : undefined;
|
||||
}
|
||||
|
||||
// ── First-run wizard ───────────────────────────────────────────────────────
|
||||
// Decides whether the first-run "choose your vault" wizard should be shown.
|
||||
// Pure function (no DOM) so it is unit-testable.
|
||||
//
|
||||
// Returns true when: desktop env, the wizard has never been dismissed, and
|
||||
// the user has no explicitly-picked vault folder yet.
|
||||
|
||||
const WIZARD_DISMISSED_KEY = 'obsigate-desktop-wizard-dismissed';
|
||||
|
||||
export function shouldShowWizard(opts) {
|
||||
opts = opts || {};
|
||||
const isDesktop = opts.isDesktop !== undefined ? opts.isDesktop : isTauriEnv();
|
||||
const dismissed = opts.dismissed !== undefined ? opts.dismissed : (function () {
|
||||
try { return localStorage.getItem(WIZARD_DISMISSED_KEY) === '1'; } catch (e) { return false; }
|
||||
})();
|
||||
const hasVaultPath = !!opts.hasVaultPath;
|
||||
return isDesktop && !dismissed && !hasVaultPath;
|
||||
}
|
||||
|
||||
export function dismissWizard() {
|
||||
try { localStorage.setItem(WIZARD_DISMISSED_KEY, '1'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
export function getWizardDismissedKey() { return WIZARD_DISMISSED_KEY; }
|
||||
|
||||
// ── Vault management (native) ──────────────────────────────────────────────
|
||||
// pickAndAddVault opens the native folder picker, registers the chosen folder
|
||||
// as a vault in the desktop config, restarts the backend (so VAULT_N_* env
|
||||
// vars are re-injected) and reloads. Returns the chosen path, or undefined
|
||||
// if cancelled / not on desktop.
|
||||
|
||||
export async function pickAndAddVault() {
|
||||
if (!isTauriEnv()) return undefined;
|
||||
const path = await pickVaultFolder();
|
||||
if (!path) return undefined;
|
||||
|
||||
// Derive a display name from the final path segment.
|
||||
const segments = path.replace(/[\\/]+$/, '').split(/[\\/]/);
|
||||
const name = segments[segments.length - 1] || 'Vault';
|
||||
|
||||
await invoke('add_vault', { name: name, path: path });
|
||||
await invoke('restart_backend');
|
||||
return path;
|
||||
}
|
||||
|
||||
// ── First-run wizard (DOM) ─────────────────────────────────────────────────
|
||||
// Shows a dismissible welcome banner on first desktop launch offering to pick
|
||||
// the user's Obsidian vault folder. Non-blocking; the default vault created
|
||||
// by the Rust shell remains available if the user dismisses it.
|
||||
|
||||
export function initDesktopWizard() {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
if (!shouldShowWizard({})) return;
|
||||
|
||||
// Defer so the app shell is fully rendered before injecting the banner.
|
||||
setTimeout(function () {
|
||||
if (document.getElementById('obsigate-desktop-wizard')) return;
|
||||
const banner = document.createElement('div');
|
||||
banner.id = 'obsigate-desktop-wizard';
|
||||
banner.setAttribute('role', 'dialog');
|
||||
banner.style.cssText = [
|
||||
'position:fixed', 'bottom:16px', 'left:16px', 'right:16px', 'z-index:99998',
|
||||
'background:var(--surface2,#161b22)', 'border:1px solid var(--border,#21262d)',
|
||||
'border-radius:10px', 'padding:14px 16px', 'display:flex',
|
||||
'align-items:center', 'gap:12px', 'flex-wrap:wrap',
|
||||
'box-shadow:0 8px 24px rgba(0,0,0,0.35)',
|
||||
].join(';');
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.style.cssText = 'flex:1;min-width:220px;font-size:13px;color:var(--text,#e6edf3);';
|
||||
text.textContent = 'Bienvenue dans ObsiGate Desktop. Choisissez le dossier de vos vaults Obsidian pour commencer.';
|
||||
|
||||
const pickBtn = document.createElement('button');
|
||||
pickBtn.textContent = 'Choisir mon dossier';
|
||||
pickBtn.style.cssText = 'background:#7c3aed;color:#fff;border:none;border-radius:6px;padding:8px 14px;cursor:pointer;font-weight:600;font-size:13px;';
|
||||
pickBtn.addEventListener('click', async function () {
|
||||
const p = await pickAndAddVault();
|
||||
if (p) { dismissWizard(); window.location.reload(); }
|
||||
});
|
||||
|
||||
const laterBtn = document.createElement('button');
|
||||
laterBtn.textContent = 'Plus tard';
|
||||
laterBtn.style.cssText = 'background:transparent;color:var(--text-2,#8b949e);border:1px solid var(--border,#21262d);border-radius:6px;padding:8px 14px;cursor:pointer;font-size:13px;';
|
||||
laterBtn.addEventListener('click', function () {
|
||||
dismissWizard();
|
||||
banner.remove();
|
||||
});
|
||||
|
||||
banner.appendChild(text);
|
||||
banner.appendChild(pickBtn);
|
||||
banner.appendChild(laterBtn);
|
||||
document.body.appendChild(banner);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// ── Integration entry point ────────────────────────────────────────────────
|
||||
|
||||
export async function initDesktopIntegration() {
|
||||
defineBackendCrashBanner();
|
||||
if (!isTauriEnv()) return false;
|
||||
|
||||
// Follow the OS theme on first run, before the theme engine renders.
|
||||
await syncSystemTheme();
|
||||
|
||||
// Refresh the OS theme when the system preference changes at runtime.
|
||||
try {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
if (mq && mq.addEventListener) {
|
||||
mq.addEventListener('change', function () { syncSystemTheme(); });
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// First-run vault wizard.
|
||||
initDesktopWizard();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default {
|
||||
isTauriEnv,
|
||||
invoke,
|
||||
getSystemTheme,
|
||||
hasExplicitThemeMode,
|
||||
syncSystemTheme,
|
||||
defineBackendCrashBanner,
|
||||
pickVaultFolder,
|
||||
pickAndAddVault,
|
||||
shouldShowWizard,
|
||||
dismissWizard,
|
||||
getWizardDismissedKey,
|
||||
initDesktopWizard,
|
||||
initDesktopIntegration,
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* push.js — Push Notifications client for ObsiGate
|
||||
* ROADMAP #67
|
||||
*
|
||||
* Handles:
|
||||
* - Subscribing to Web Push via VAPID
|
||||
* - Unsubscribing
|
||||
* - UI rendering in the config modal
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
|
||||
let _vapidPublicKey = null;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function _api(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
return fetch(path, opts).then((r) => {
|
||||
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function _urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
// ── VAPID key ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getVapidPublicKey() {
|
||||
if (_vapidPublicKey) return _vapidPublicKey;
|
||||
const data = await _api('GET', '/api/push/vapid-public-key');
|
||||
_vapidPublicKey = data.public_key;
|
||||
return _vapidPublicKey;
|
||||
}
|
||||
|
||||
// ── Subscription management ──────────────────────────────────────────────────
|
||||
|
||||
async function subscribePush(vault) {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
throw new Error('Push notifications not supported by this browser');
|
||||
}
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const vapidKey = await getVapidPublicKey();
|
||||
|
||||
// Check existing subscription
|
||||
let subscription = await reg.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
// Subscribe to push
|
||||
subscription = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: _urlBase64ToUint8Array(vapidKey),
|
||||
});
|
||||
}
|
||||
|
||||
// Register subscription with backend
|
||||
const subJSON = subscription.toJSON();
|
||||
await _api('POST', '/api/push/subscribe', {
|
||||
subscription: {
|
||||
endpoint: subJSON.endpoint,
|
||||
keys: subJSON.keys,
|
||||
},
|
||||
vault,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function unsubscribePush() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const subscription = await reg.pushManager.getSubscription();
|
||||
if (!subscription) return;
|
||||
|
||||
// Unsubscribe from browser
|
||||
await subscription.unsubscribe();
|
||||
|
||||
// Unsubscribe from backend
|
||||
const endpoint = subscription.endpoint;
|
||||
try {
|
||||
await _api('DELETE', `/api/push/subscribe?endpoint=${encodeURIComponent(endpoint)}`);
|
||||
} catch {
|
||||
// Best-effort: endpoint may already be expired
|
||||
}
|
||||
}
|
||||
|
||||
async function isSubscribed() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false;
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
return !!sub;
|
||||
}
|
||||
|
||||
async function getSubscriptions() {
|
||||
return _api('GET', '/api/push/subscriptions');
|
||||
}
|
||||
|
||||
// ── Config UI ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function renderPushSection() {
|
||||
const container = document.getElementById('push-settings');
|
||||
if (!container) return;
|
||||
|
||||
const supported = 'serviceWorker' in navigator && 'PushManager' in window;
|
||||
const subscribed = await isSubscribed();
|
||||
|
||||
if (!supported) {
|
||||
container.innerHTML = `
|
||||
<div class="mfa-status-row">
|
||||
<span class="mfa-status-label">Notifications push</span>
|
||||
<span class="mfa-badge mfa-badge-off">Non supportées par ce navigateur</span>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="mfa-status-row">
|
||||
<span class="mfa-status-label">Notifications push</span>
|
||||
<span id="push-status-badge" class="mfa-badge ${subscribed ? 'mfa-badge-on' : 'mfa-badge-off'}">${subscribed ? 'Activées' : 'Désactivées'}</span>
|
||||
</div>
|
||||
<div class="config-row" style="margin-top:12px">
|
||||
<label class="config-label" for="push-vault-select">Vault cible</label>
|
||||
<select id="push-vault-select" class="config-input" style="max-width:240px"></select>
|
||||
</div>
|
||||
<div class="config-row" style="margin-top:8px">
|
||||
<button id="push-toggle-btn" class="config-save-btn" style="${subscribed ? 'background:var(--danger-bg);color:var(--danger);border-color:var(--danger)' : ''}">
|
||||
${subscribed ? 'Désactiver les notifications' : 'Activer les notifications'}
|
||||
</button>
|
||||
</div>
|
||||
<div id="push-sub-count" style="margin-top:8px;font-size:12px;color:var(--text-muted)"></div>`;
|
||||
|
||||
// Populate vault select
|
||||
const vaultSelect = document.getElementById('push-vault-select');
|
||||
try {
|
||||
const vaultData = await _api('GET', '/api/vaults');
|
||||
const vaults = vaultData.vaults || vaultData || [];
|
||||
(Array.isArray(vaults) ? vaults : []).forEach((v) => {
|
||||
const name = typeof v === 'string' ? v : v.name;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = name;
|
||||
vaultSelect.appendChild(opt);
|
||||
});
|
||||
} catch {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'default';
|
||||
opt.textContent = 'default';
|
||||
vaultSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
// Toggle button
|
||||
document.getElementById('push-toggle-btn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('push-toggle-btn');
|
||||
const badge = document.getElementById('push-status-badge');
|
||||
try {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
if (subscribed) {
|
||||
await unsubscribePush();
|
||||
badge.textContent = 'Désactivées';
|
||||
badge.className = 'mfa-badge mfa-badge-off';
|
||||
btn.textContent = 'Activer les notifications';
|
||||
btn.style.background = '';
|
||||
btn.style.color = '';
|
||||
btn.style.borderColor = '';
|
||||
} else {
|
||||
const vault = vaultSelect.value || 'default';
|
||||
await subscribePush(vault);
|
||||
badge.textContent = 'Activées';
|
||||
badge.className = 'mfa-badge mfa-badge-on';
|
||||
btn.textContent = 'Désactiver les notifications';
|
||||
btn.style.background = 'var(--danger-bg)';
|
||||
btn.style.color = 'var(--danger)';
|
||||
btn.style.borderColor = 'var(--danger)';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Push] Toggle error:', err);
|
||||
alert('Erreur: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Show subscription count
|
||||
try {
|
||||
const data = await getSubscriptions();
|
||||
const count = (data.subscriptions || []).length;
|
||||
document.getElementById('push-sub-count').textContent =
|
||||
count > 0 ? `${count} abonnement(s) actif(s)` : '';
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function initPush() {
|
||||
// Render push section when config modal opens
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Observe the config modal becoming visible
|
||||
const observer = new MutationObserver(() => {
|
||||
const section = document.getElementById('cfg-push');
|
||||
if (section && section.offsetParent !== null) {
|
||||
renderPushSection();
|
||||
}
|
||||
});
|
||||
const modal = document.getElementById('config-modal');
|
||||
if (modal) observer.observe(modal, { attributes: true, attributeFilter: ['class'] });
|
||||
});
|
||||
}
|
||||
|
||||
export default { initPush, subscribePush, unsubscribePush, isSubscribed, renderPushSection };
|
||||
+13
-4
@@ -755,10 +755,10 @@ const FileOperations = {
|
||||
});
|
||||
},
|
||||
|
||||
showCreateFileModal(vault, parentPath) {
|
||||
const overlay = this._createModalOverlay();
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'obsigate-modal';
|
||||
showCreateFileModal(vault, parentPath, preselectExt = null) {
|
||||
const overlay = this._createModalOverlay();
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'obsigate-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="obsigate-modal-header">
|
||||
<h3 class="obsigate-modal-title">${t('modal.create_file')}</h3>
|
||||
@@ -804,6 +804,10 @@ const FileOperations = {
|
||||
|
||||
input.focus();
|
||||
|
||||
if (preselectExt && [...extSelect.options].some(o => o.value === preselectExt)) {
|
||||
extSelect.value = preselectExt;
|
||||
}
|
||||
|
||||
const validateName = (name) => {
|
||||
if (!name.trim()) return 'Le nom ne peut pas être vide';
|
||||
if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
|
||||
@@ -1695,6 +1699,7 @@ export const ContextMenuManager = {
|
||||
} else if (type === 'directory') {
|
||||
this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly);
|
||||
this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly);
|
||||
this._addItem('pen-tool', 'Nouveau diagramme Excalidraw', () => this._createExcalidraw(), isReadonly);
|
||||
this._addSeparator();
|
||||
this._addItem('bookmark-plus', 'Ajouter aux recherches sauvegardees', () => this._saveDirectorySearch(), false);
|
||||
this._addSeparator();
|
||||
@@ -1775,6 +1780,10 @@ export const ContextMenuManager = {
|
||||
FileOperations.showCreateFileModal(this._targetVault, this._targetPath);
|
||||
},
|
||||
|
||||
_createExcalidraw() {
|
||||
FileOperations.showCreateFileModal(this._targetVault, this._targetPath, '.excalidraw');
|
||||
},
|
||||
|
||||
_renameItem() {
|
||||
FileOperations.startInlineRename(this._targetVault, this._targetPath, this._targetType);
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"config.section_vue-graphe": "🗺️ Vue Graphe",
|
||||
"config.section_watcher": "Synchronisation automatique",
|
||||
"config.section_webhooks": "🪝 Webhooks",
|
||||
"config.section_push": "🔔 Notifications push",
|
||||
"config.section_workflow-recommande": "Workflow recommandé",
|
||||
"config.section_zone-de-contenu": "Zone de contenu",
|
||||
"config.server_config": "Configuration serveur",
|
||||
|
||||
+172
-149
@@ -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('/'));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user