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:
+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