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:
+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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user