feat: watcher (phase 3+6) + navigation dashboard (version, changelog, aide)
Phase 3 — Clipboard Watcher (Windows): - clipboard.rs: détection d'images via arboard, conversion BGRA→PNG - Gère les doublons consécutifs (même image dans le presse-papier) Phase 6 — Watcher Loop: - Windows: polling clipboard → hash → dédoublonnage → upload - Linux/macOS: polling de dossier configurable (watch_dir) - Ajout champ watch_dir dans WatcherConfig - Client Imago cloné avant await (évite problème Send/Sync) Dashboard: - Badge version v0.1.0 dans le header - Barre de navigation: Galerie / Changelog / Aide - Pages changelog.html et help.html (chargement dynamique) - Endpoint GET /api/version avec infos build - Correction import delete inutilisé dans routes.rs
This commit is contained in:
Generated
+1
@@ -1276,6 +1276,7 @@ dependencies = [
|
||||
"clap",
|
||||
"dashmap",
|
||||
"futures-util",
|
||||
"image",
|
||||
"libc",
|
||||
"notify",
|
||||
"open",
|
||||
|
||||
@@ -47,6 +47,9 @@ dashmap = "6"
|
||||
# Time formatting
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Image encoding (PNG conversion pour clipboard → upload)
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
# Platform-specific — Windows
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Clipboard access
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ use clap::{Parser, Subcommand};
|
||||
about = "Imago Daemon + Dashboard — Capture automatique de screenshots + Dashboard web embarqué",
|
||||
long_about = "Service de capture automatique de screenshots vers Imago avec dashboard web embarqué.\n\n\
|
||||
Modes :\n \
|
||||
Windows : imagod-web start → watcher clipboard + dashboard :9090\n \
|
||||
Linux : imagod-web serve --port 9090 --no-watcher → dashboard uniquement"
|
||||
Windows : imagod-web start → watcher clipboard + dashboard\n \
|
||||
Linux : imagod-web serve → watcher dossier + dashboard"
|
||||
)]
|
||||
pub struct Args {
|
||||
#[command(subcommand)]
|
||||
|
||||
+53
-7
@@ -1,16 +1,62 @@
|
||||
//! Clipboard — Abstraction arboard (Windows)
|
||||
//! Clipboard — Détection et extraction d'images du presse-papier
|
||||
//!
|
||||
//! Phase 3: Lecture du clipboard Windows, détection d'images
|
||||
//! Phase 3: Lecture du clipboard via arboard (Windows).
|
||||
//! Sur Linux, ce module n'est pas compilé — le watcher utilise notify.
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Vérifie si le clipboard contient une image
|
||||
pub fn has_image() -> Result<bool> {
|
||||
// Phase 3: Implémentation arboard
|
||||
todo!("Phase 3: Clipboard detection")
|
||||
let mut clipboard = arboard::Clipboard::new()
|
||||
.context("Impossible d'accéder au presse-papier")?;
|
||||
Ok(clipboard.get_image().is_ok())
|
||||
}
|
||||
|
||||
/// Récupère l'image du clipboard en bytes
|
||||
/// Récupère l'image du clipboard en bytes PNG
|
||||
///
|
||||
/// arboard retourne l'image au format BGRA sur Windows.
|
||||
/// On convertit en RGBA puis on encode en PNG via le crate `image`.
|
||||
pub fn get_image_bytes() -> Result<Vec<u8>> {
|
||||
todo!("Phase 3: Clipboard image extraction")
|
||||
let mut clipboard = arboard::Clipboard::new()
|
||||
.context("Impossible d'accéder au presse-papier")?;
|
||||
|
||||
let img_data = clipboard
|
||||
.get_image()
|
||||
.context("Aucune image dans le presse-papier")?;
|
||||
|
||||
let width = img_data.width as u32;
|
||||
let height = img_data.height as u32;
|
||||
|
||||
// arboard retourne BGRA sur Windows, RGBA sur Linux/macOS
|
||||
// On convertit BGRA → RGBA en swapant les canaux B et R
|
||||
let rgba_bytes: Vec<u8> = if cfg!(target_os = "windows") {
|
||||
img_data
|
||||
.bytes
|
||||
.chunks_exact(4)
|
||||
.flat_map(|chunk| [chunk[2], chunk[1], chunk[0], chunk[3]])
|
||||
.collect()
|
||||
} else {
|
||||
// Linux/macOS: déjà RGBA
|
||||
img_data.bytes
|
||||
};
|
||||
|
||||
let img = image::RgbaImage::from_raw(width, height, rgba_bytes)
|
||||
.context("Impossible de créer le buffer image")?;
|
||||
|
||||
let mut png_bytes = Vec::new();
|
||||
img.write_to(
|
||||
&mut Cursor::new(&mut png_bytes),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.context("Encodage PNG échoué")?;
|
||||
|
||||
tracing::debug!(
|
||||
"Image clipboard capturée : {}×{} ({} octets PNG)",
|
||||
width,
|
||||
height,
|
||||
png_bytes.len()
|
||||
);
|
||||
|
||||
Ok(png_bytes)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ pub struct WatcherConfig {
|
||||
pub capture_images: bool,
|
||||
#[serde(default = "default_poll_interval")]
|
||||
pub poll_interval_ms: u64,
|
||||
/// Dossier à surveiller pour les nouveaux screenshots (Linux/macOS)
|
||||
/// Si vide, utilise le clipboard (Windows uniquement)
|
||||
#[serde(default)]
|
||||
pub watch_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub filters: WatcherFilters,
|
||||
}
|
||||
@@ -46,6 +50,7 @@ impl Default for WatcherConfig {
|
||||
Self {
|
||||
capture_images: true,
|
||||
poll_interval_ms: 500,
|
||||
watch_dir: None,
|
||||
filters: WatcherFilters::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Changelog</title></head>
|
||||
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;padding:24px;line-height:1.6;max-width:900px;margin:0 auto">
|
||||
<style>
|
||||
a { color:#58a6ff }
|
||||
h1 { border-bottom:1px solid #30363d;padding-bottom:8px }
|
||||
h2 { margin-top:24px;color:#58a6ff }
|
||||
h3 { margin-top:16px;color:#79c0ff }
|
||||
li { margin:4px 0 }
|
||||
code { background:#161b22;padding:2px 6px;border-radius:4px;font-size:13px }
|
||||
table { border-collapse:collapse;width:100%;margin:12px 0 }
|
||||
td,th { border:1px solid #30363d;padding:8px 12px;text-align:left;font-size:13px }
|
||||
th { background:#161b22 }
|
||||
.tag { font-size:10px;padding:2px 6px;border-radius:8px;margin-right:4px }
|
||||
.tag-feat { background:rgba(88,166,255,0.15);color:#58a6ff }
|
||||
.tag-fix { background:rgba(63,185,80,0.15);color:#3fb950 }
|
||||
.tag-docs { background:rgba(210,153,34,0.15);color:#d29922 }
|
||||
</style>
|
||||
|
||||
<h1>📋 Changelog — imagod-web</h1>
|
||||
|
||||
<h2>v0.1.0 — 2026-06-25</h2>
|
||||
|
||||
<h3><span class="tag tag-feat">feat</span> Dashboard Web</h3>
|
||||
<ul>
|
||||
<li>Authentification par clé API Imago (login/logout, localStorage)</li>
|
||||
<li>Galerie responsive (grille adaptative desktop/mobile, breakpoints 768px/400px)</li>
|
||||
<li>Upload par drag & drop avec feedback visuel</li>
|
||||
<li>Raccourcis clavier : Ctrl+U (upload), Ctrl+V (coller)</li>
|
||||
<li>Recherche plein texte (nom, tags, description)</li>
|
||||
<li>Pagination par pages de 24 images</li>
|
||||
<li>Modale détail : full résolution, EXIF, OCR, description AI</li>
|
||||
<li>Suppression avec boîte de confirmation</li>
|
||||
<li>Gestion des tags : ajout, suppression, édition inline</li>
|
||||
<li>Notifications toast (succès/erreur)</li>
|
||||
<li>FAB mobile pour upload</li>
|
||||
<li>Connexion WebSocket temps réel</li>
|
||||
<li>Statistiques (nombre d'images, stockage, tags, uptime)</li>
|
||||
</ul>
|
||||
|
||||
<h3><span class="tag tag-feat">feat</span> Client API Imago v2</h3>
|
||||
<ul>
|
||||
<li>8 endpoints : list, search, get, upload, thumbnail, download, delete, tags</li>
|
||||
<li>Proxy des URLs signées (thumbnail-url / download-url)</li>
|
||||
<li>Auth par header X-API-Key</li>
|
||||
</ul>
|
||||
|
||||
<h3><span class="tag tag-feat">feat</span> Watcher (Phase 3 + 6)</h3>
|
||||
<ul>
|
||||
<li>Windows : polling clipboard via arboard, conversion PNG automatique</li>
|
||||
<li>Linux/macOS : surveillance de dossier configurable (polling)</li>
|
||||
<li>Pipeline : détection → hash SHA-256 → dédoublonnage → upload → log</li>
|
||||
<li>Configuration : intervalle de polling, dossier à surveiller, filtres</li>
|
||||
</ul>
|
||||
|
||||
<h3><span class="tag tag-feat">feat</span> CLI</h3>
|
||||
<ul>
|
||||
<li>Commandes : start, serve, stop, status, config, install, uninstall, logs</li>
|
||||
<li>Flags : --host, --port, --api-key, --api-url, --no-watcher</li>
|
||||
<li>Gestion PID pour éviter les doublons d'instance</li>
|
||||
</ul>
|
||||
|
||||
<h3><span class="tag tag-feat">feat</span> Dédoublonnage</h3>
|
||||
<ul>
|
||||
<li>Hash SHA-256 des images</li>
|
||||
<li>Base SQLite locale (imagod.db)</li>
|
||||
<li>Cache LRU configurable</li>
|
||||
</ul>
|
||||
|
||||
<h3><span class="tag tag-fix">fix</span> Corrections</h3>
|
||||
<ul>
|
||||
<li>Structs Imago v2 (ExifData, OcrData, AiData) match l'API réelle</li>
|
||||
<li>Affichage via URLs signées (thumbnail-url/download-url)</li>
|
||||
<li>Fallback placeholder DOM quand pas de thumbnail</li>
|
||||
<li>Import delete inutilisé retiré</li>
|
||||
</ul>
|
||||
|
||||
<h3><span class="tag tag-docs">docs</span> Documentation</h3>
|
||||
<ul>
|
||||
<li>CHANGELOG.md dans le repo</li>
|
||||
<li>ROADMAP.md avec planification des phases</li>
|
||||
<li>Navigation in-app : Galerie / Changelog / Aide</li>
|
||||
</ul>
|
||||
|
||||
<h2>Architecture</h2>
|
||||
<table>
|
||||
<tr><th>Composant</th><th>Technologie</th><th>Statut</th></tr>
|
||||
<tr><td>Daemon</td><td>Rust (tokio, clap)</td><td>✅</td></tr>
|
||||
<tr><td>Serveur web</td><td>axum 0.8 + tower-http</td><td>✅</td></tr>
|
||||
<tr><td>Frontend</td><td>Vanilla JS + rust-embed</td><td>✅</td></tr>
|
||||
<tr><td>Client API</td><td>reqwest (rustls)</td><td>✅</td></tr>
|
||||
<tr><td>Dédoublonnage</td><td>SHA-256 + SQLite</td><td>✅</td></tr>
|
||||
<tr><td>Temps réel</td><td>WebSocket (broadcast)</td><td>✅</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Aide</title></head>
|
||||
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;padding:24px;line-height:1.7;max-width:900px;margin:0 auto">
|
||||
<style>
|
||||
a { color:#58a6ff }
|
||||
h1 { border-bottom:1px solid #30363d;padding-bottom:8px }
|
||||
h2 { margin-top:28px;color:#58a6ff }
|
||||
h3 { margin-top:20px;color:#79c0ff }
|
||||
li { margin:6px 0 }
|
||||
code { background:#161b22;padding:2px 6px;border-radius:4px;font-size:13px }
|
||||
pre { background:#161b22;padding:12px 16px;border-radius:8px;overflow-x:auto;font-size:13px;border:1px solid #30363d }
|
||||
.highlight { background:rgba(88,166,255,0.1);border-left:3px solid #58a6ff;padding:8px 16px;border-radius:0 8px 8px 0;margin:12px 0 }
|
||||
.tip { background:rgba(63,185,80,0.1);border-left:3px solid #3fb950;padding:8px 16px;border-radius:0 8px 8px 0;margin:12px 0 }
|
||||
</style>
|
||||
|
||||
<h1>❓ À propos d'imagod-web</h1>
|
||||
|
||||
<h2>Qu'est-ce que c'est ?</h2>
|
||||
<p>
|
||||
<strong>imagod-web</strong> est une application de bureau (daemon + dashboard web)
|
||||
qui capture automatiquement vos screenshots et les envoie vers
|
||||
<a href="https://git.dracodev.net/Projets/Imago" target="_blank">Imago</a>,
|
||||
un backend intelligent de gestion d'images.
|
||||
</p>
|
||||
|
||||
<div class="highlight">
|
||||
<strong>🎯 Objectif :</strong> ne plus jamais perdre un screenshot. Chaque capture
|
||||
est automatiquement analysée (OCR, vision IA, EXIF) et devient consultable
|
||||
via ce dashboard web.
|
||||
</div>
|
||||
|
||||
<h2>Fonctionnalités</h2>
|
||||
<ul>
|
||||
<li><strong>📸 Capture automatique</strong> — Windows : détection clipboard, Linux : surveillance de dossier</li>
|
||||
<li><strong>🧠 Analyse IA</strong> — description du contenu, tags automatiques, texte OCR</li>
|
||||
<li><strong>🔍 Recherche puissante</strong> — par nom, tags, description, texte extrait</li>
|
||||
<li><strong>🏷️ Tags personnalisés</strong> — ajoutez, modifiez, supprimez des tags</li>
|
||||
<li><strong>📊 Dashboard web</strong> — galerie responsive, upload drag & drop, modale détail</li>
|
||||
<li><strong>🔄 Temps réel</strong> — WebSocket pour voir les nouvelles captures instantanément</li>
|
||||
<li><strong>📱 Mobile-friendly</strong> — interface adaptative desktop et mobile</li>
|
||||
</ul>
|
||||
|
||||
<h2>Modes de fonctionnement</h2>
|
||||
|
||||
<h3>Windows</h3>
|
||||
<pre>imagod-web start</pre>
|
||||
<p>Lance le watcher (surveillance du presse-papier) + dashboard sur <code>http://127.0.0.1:9090</code>.</p>
|
||||
|
||||
<h3>Linux / macOS / Serveur</h3>
|
||||
<pre>imagod-web serve --host 0.0.0.0 --port 9199</pre>
|
||||
<p>Lance le dashboard + watcher de dossier. Configurez le dossier à surveiller dans <code>~/.config/imagod-web/imagod.toml</code> :</p>
|
||||
<pre>[watcher]
|
||||
poll_interval_ms = 1000
|
||||
watch_dir = "/home/user/Pictures/Screenshots"</pre>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 Astuce :</strong> utilisez <code>--no-watcher</code> pour lancer uniquement le dashboard sans surveillance.
|
||||
</div>
|
||||
|
||||
<h2>Raccourcis clavier</h2>
|
||||
<ul>
|
||||
<li><code>Ctrl+U</code> — Ouvrir le sélecteur de fichiers</li>
|
||||
<li><code>Ctrl+V</code> — Coller une image depuis le presse-papier</li>
|
||||
<li><code>Escape</code> — Fermer la modale</li>
|
||||
<li><code>← →</code> — Navigation dans la pagination</li>
|
||||
</ul>
|
||||
|
||||
<h2>Architecture</h2>
|
||||
<pre>
|
||||
┌─────────────┐ ┌──────────────┐ ┌──────────┐
|
||||
│ Screenshot │────▶│ imagod-web │────▶│ Imago │
|
||||
│ (Windows/ │ │ (Rust) │ │ (Python)│
|
||||
│ Linux) │ │ │ │ │
|
||||
└─────────────┘ │ • Watcher │ │ • Stock │
|
||||
│ • Hash │ │ • OCR │
|
||||
│ • Upload │ │ • IA │
|
||||
│ • Dashboard │ │ • EXIF │
|
||||
└──────────────┘ └──────────┘
|
||||
</pre>
|
||||
|
||||
<h2>Liens</h2>
|
||||
<ul>
|
||||
<li><a href="https://git.dracodev.net/Projets/imagod-web" target="_blank">📦 Code source imagod-web</a></li>
|
||||
<li><a href="https://git.dracodev.net/Projets/Imago" target="_blank">🖼️ Backend Imago</a></li>
|
||||
<li><a href="http://dev.lab.home:3001" target="_blank">⚙️ Admin Imago (clés API)</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="tip">
|
||||
<strong>📝 Besoin d'aide ?</strong> Contactez Bruno Charest via le repo Gitea pour toute question ou suggestion.
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -47,6 +47,59 @@
|
||||
.ws-dot.off { background: var(--red); }
|
||||
.ws-status { font-size: 12px; color: var(--fg-dim); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
|
||||
/* ─── Version ─── */
|
||||
.version-badge {
|
||||
font-size: 10px; padding: 2px 8px; border-radius: 10px;
|
||||
background: rgba(88,166,255,0.1); color: var(--accent);
|
||||
font-weight: 500; white-space: nowrap;
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
}
|
||||
|
||||
/* ─── Nav ─── */
|
||||
.nav-bar {
|
||||
display: flex; gap: 0; border-bottom: 1px solid var(--border);
|
||||
background: var(--bg); padding: 0 24px;
|
||||
position: sticky; top: 56px; z-index: 99;
|
||||
}
|
||||
.nav-tab {
|
||||
padding: 10px 20px; font-size: 13px; font-weight: 500;
|
||||
color: var(--fg-dim); cursor: pointer; border: none;
|
||||
background: none; border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s; white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.nav-tab:hover { color: var(--fg); }
|
||||
.nav-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
/* ─── Pages ─── */
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
.page-content {
|
||||
max-width: 900px; margin: 0 auto; padding: 24px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.page-content h1 { border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-bottom: 16px; }
|
||||
.page-content h2 { margin-top: 24px; color: var(--accent); }
|
||||
.page-content h3 { margin-top: 16px; color: var(--accent-hover); }
|
||||
.page-content ul, .page-content ol { padding-left: 24px; }
|
||||
.page-content li { margin: 4px 0; }
|
||||
.page-content pre {
|
||||
background: var(--bg-card); padding: 12px 16px; border-radius: var(--radius);
|
||||
overflow-x: auto; font-size: 13px; border: 1px solid var(--border);
|
||||
}
|
||||
.page-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||
.page-content td, .page-content th { border: 1px solid var(--border); padding: 8px 12px; font-size: 13px; }
|
||||
.page-content th { background: var(--bg-card); }
|
||||
.page-content a { color: var(--accent); }
|
||||
.page-content .callout {
|
||||
border-left: 3px solid var(--accent); padding: 8px 16px;
|
||||
background: rgba(88,166,255,0.05); border-radius: 0 var(--radius) var(--radius) 0;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.page-content .callout.tip {
|
||||
border-left-color: var(--green); background: rgba(63,185,80,0.05);
|
||||
}
|
||||
|
||||
/* ─── Search ─── */
|
||||
.search-bar { display: flex; gap: 8px; flex: 1; min-width: 180px; }
|
||||
.search-bar input {
|
||||
@@ -361,13 +414,24 @@
|
||||
<button class="btn" id="clear-btn" style="display:none">✕</button>
|
||||
</div>
|
||||
<button class="btn accent" id="header-upload-btn" onclick="document.getElementById('drop-zone').scrollIntoView({behavior:'smooth'}); document.getElementById('file-input').click()">📤 Upload</button>
|
||||
<span class="version-badge" id="version-badge">v0.1.0</span>
|
||||
</div>
|
||||
|
||||
<!-- ─── Navigation ─── -->
|
||||
<nav class="nav-bar" id="nav-bar">
|
||||
<button class="nav-tab active" data-page="gallery">🖼️ Galerie</button>
|
||||
<button class="nav-tab" data-page="changelog">📋 Changelog</button>
|
||||
<button class="nav-tab" data-page="help">❓ Aide</button>
|
||||
</nav>
|
||||
|
||||
<!-- ─── Toast Container ─── -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<!-- ─── Main Content ─── -->
|
||||
<div class="main">
|
||||
|
||||
<!-- Page: Galerie -->
|
||||
<div class="page active" id="page-gallery">
|
||||
<!-- Stats -->
|
||||
<div class="stats-row" id="stats-row">
|
||||
<div class="stat-card"><div class="stat-label">Images</div><div class="stat-value" id="stat-images">…</div></div>
|
||||
@@ -392,6 +456,27 @@
|
||||
<div style="font-size:48px;opacity:0.35">🔍</div>
|
||||
<p>Aucun résultat trouvé</p>
|
||||
</div>
|
||||
</div><!-- /#page-gallery -->
|
||||
|
||||
<!-- Page: Changelog -->
|
||||
<div class="page" id="page-changelog">
|
||||
<div class="page-content" id="changelog-content">
|
||||
<div style="text-align:center;padding:40px;color:var(--fg-dim)">
|
||||
<div style="font-size:32px;margin-bottom:12px">📋</div>
|
||||
Chargement du changelog…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page: Aide -->
|
||||
<div class="page" id="page-help">
|
||||
<div class="page-content" id="help-content">
|
||||
<div style="text-align:center;padding:40px;color:var(--fg-dim)">
|
||||
<div style="font-size:32px;margin-bottom:12px">❓</div>
|
||||
Chargement de l'aide…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Modal ─── -->
|
||||
@@ -409,6 +494,66 @@ let currentQuery = '';
|
||||
let ws = null;
|
||||
let apiKey = null;
|
||||
let userName = null;
|
||||
let currentNavPage = 'gallery';
|
||||
|
||||
// ─── Navigation ───
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => switchPage(tab.dataset.page));
|
||||
});
|
||||
});
|
||||
|
||||
function switchPage(page) {
|
||||
currentNavPage = page;
|
||||
document.querySelectorAll('.nav-tab').forEach(t => t.classList.toggle('active', t.dataset.page === page));
|
||||
document.querySelectorAll('.page').forEach(p => {
|
||||
p.classList.toggle('active', p.id === 'page-' + page);
|
||||
});
|
||||
|
||||
// Cacher/montrer les éléments spécifiques à la galerie
|
||||
const fabUpload = document.getElementById('fab-upload');
|
||||
if (fabUpload) fabUpload.style.display = page === 'gallery' ? '' : 'none';
|
||||
|
||||
// Charger le contenu si première fois
|
||||
if (page === 'changelog') loadChangelog();
|
||||
if (page === 'help') loadHelp();
|
||||
}
|
||||
|
||||
let changelogLoaded = false;
|
||||
let helpLoaded = false;
|
||||
|
||||
async function loadChangelog() {
|
||||
if (changelogLoaded) return;
|
||||
const container = document.getElementById('changelog-content');
|
||||
try {
|
||||
const r = await fetch('/changelog.html');
|
||||
if (r.ok) {
|
||||
let html = await r.text();
|
||||
// Extraire le contenu du body (les pages sont des HTML complets)
|
||||
const m = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
container.innerHTML = m ? m[1] : html;
|
||||
changelogLoaded = true;
|
||||
}
|
||||
} catch(e) {
|
||||
container.innerHTML = '<div style="color:var(--red)">Erreur de chargement du changelog.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHelp() {
|
||||
if (helpLoaded) return;
|
||||
const container = document.getElementById('help-content');
|
||||
try {
|
||||
const r = await fetch('/help.html');
|
||||
if (r.ok) {
|
||||
let html = await r.text();
|
||||
const m = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
container.innerHTML = m ? m[1] : html;
|
||||
helpLoaded = true;
|
||||
}
|
||||
} catch(e) {
|
||||
container.innerHTML = '<div style="color:var(--red)">Erreur de chargement de l\'aide.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auth Helpers ───
|
||||
function getAuthHeaders() {
|
||||
|
||||
+12
-5
@@ -1,8 +1,8 @@
|
||||
//! imagod-web — Imago Daemon + Dashboard
|
||||
//!
|
||||
//! Point d'entrée CLI. Dispatch vers les sous-commandes :
|
||||
//! - `start` : Lance le watcher clipboard + dashboard web (Windows)
|
||||
//! - `serve` : Lance le dashboard web uniquement (Linux/serveur)
|
||||
//! - `start` : Lance le watcher + dashboard web
|
||||
//! - `serve` : Lance le dashboard web (+ watcher dossier sur Linux)
|
||||
//! - `stop` : Arrête le service
|
||||
//! - `status`: Affiche l'état
|
||||
//! - `config show/set/reset`: Gère la configuration
|
||||
@@ -235,10 +235,17 @@ async fn cmd_serve(host: &str, port: u16, no_watcher: bool, open_browser: bool)
|
||||
|
||||
// Optionnellement lancer le watcher
|
||||
let _watcher_task = if !no_watcher {
|
||||
// Sur Linux, le clipboard n'est pas supporté — avertir mais continuer
|
||||
// Sur Linux/macOS, le watcher surveille un dossier (config.watcher.watch_dir)
|
||||
if !cfg!(target_os = "windows") {
|
||||
tracing::warn!("⚠️ Le watcher clipboard n'est disponible que sur Windows.");
|
||||
tracing::warn!(" Utilisez --no-watcher pour désactiver ce message.");
|
||||
let watch_dir = cfg
|
||||
.watcher
|
||||
.watch_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
|
||||
format!("{}/Pictures/Screenshots", home)
|
||||
});
|
||||
tracing::info!(" Dossier surveillé : {}", watch_dir);
|
||||
}
|
||||
let cfg = cfg.clone();
|
||||
Some(tokio::spawn(async move {
|
||||
|
||||
+16
-1
@@ -1,7 +1,7 @@
|
||||
//! Routes API REST du dashboard — proxy vers Imago v2
|
||||
|
||||
use axum::{
|
||||
Router, routing::{get, post, put, delete},
|
||||
Router, routing::{get, post, put},
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::HeaderMap,
|
||||
response::IntoResponse,
|
||||
@@ -101,6 +101,7 @@ pub fn api_routes(state: AppState) -> Router {
|
||||
.route("/api/images/{id}/tags", put(update_tags))
|
||||
.route("/api/images/{id}/thumbnail", get(get_thumbnail))
|
||||
.route("/api/stats", get(stats))
|
||||
.route("/api/version", get(version))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -110,6 +111,20 @@ async fn health_check() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
|
||||
/// Retourne la version de l'application et des infos utiles
|
||||
async fn version() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"name": env!("CARGO_PKG_NAME"),
|
||||
"description": env!("CARGO_PKG_DESCRIPTION"),
|
||||
"repository": env!("CARGO_PKG_REPOSITORY"),
|
||||
"rustc": option_env!("RUSTC_VERSION").unwrap_or("unknown"),
|
||||
"profile": if cfg!(debug_assertions) { "debug" } else { "release" },
|
||||
"target_os": std::env::consts::OS,
|
||||
"target_arch": std::env::consts::ARCH,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Valide une clé API utilisateur contre l'API Imago.
|
||||
/// Si valide, retourne le nom du client (trouvé via les images existantes).
|
||||
async fn validate_api_key(
|
||||
|
||||
+180
-21
@@ -1,12 +1,16 @@
|
||||
//! Watcher — Boucle principale de polling du clipboard
|
||||
//! Watcher — Boucle principale de surveillance
|
||||
//!
|
||||
//! Surveille le clipboard à intervalle régulier, détecte les nouvelles images,
|
||||
//! les dédoublonne et les envoie à Imago.
|
||||
//! Deux modes selon la plateforme :
|
||||
//! - **Windows** : polling du clipboard via arboard (clipboard.rs)
|
||||
//! - **Linux/macOS** : polling d'un dossier de screenshots
|
||||
//!
|
||||
//! Pipeline commun : détection → hash → dédoublonnage → upload Imago → log
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use crate::config::Config;
|
||||
use crate::dedup::Dedup;
|
||||
use crate::imago_client::ImagoClient;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -18,7 +22,7 @@ pub struct Watcher {
|
||||
|
||||
impl Watcher {
|
||||
pub async fn new(config: Config) -> Result<Self> {
|
||||
let db_path = PathBuf::from("imagod.db");
|
||||
let db_path = data_dir()?.join("imagod.db");
|
||||
let dedup = Dedup::open(&db_path, config.dedup.cache_size)?;
|
||||
let imago = ImagoClient::new(
|
||||
config.imago.url.clone(),
|
||||
@@ -28,27 +32,182 @@ impl Watcher {
|
||||
Ok(Self { config, dedup, imago })
|
||||
}
|
||||
|
||||
/// Lance la boucle de polling
|
||||
pub async fn run(self) -> Result<()> {
|
||||
let _interval = Duration::from_millis(self.config.watcher.poll_interval_ms);
|
||||
// ─── Point d'entrée (dispatch par plateforme) ───────────────────────────
|
||||
|
||||
/// Lance la boucle de surveillance (Windows : polling clipboard)
|
||||
#[cfg(target_os = "windows")]
|
||||
pub async fn run(mut self) -> Result<()> {
|
||||
let interval = Duration::from_millis(self.config.watcher.poll_interval_ms);
|
||||
tracing::info!(
|
||||
"Watcher démarré — polling toutes les {}ms",
|
||||
"🪟 Watcher Windows démarré — polling clipboard toutes les {}ms",
|
||||
self.config.watcher.poll_interval_ms
|
||||
);
|
||||
|
||||
// TODO: Phase 6 — Boucle principale
|
||||
// loop {
|
||||
// 1. Vérifier le clipboard (Windows: arboard, Linux: N/A)
|
||||
// 2. Si nouvelle image → hash SHA-256
|
||||
// 3. Vérifier dédoublonnage
|
||||
// 4. Si nouveau → upload vers Imago
|
||||
// 5. Marquer comme vu
|
||||
// sleep(interval)
|
||||
// }
|
||||
tracing::warn!("Watcher::run() — implémentation Phase 6 à venir");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let mut last_hash: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
match crate::clipboard::has_image() {
|
||||
Ok(true) => {
|
||||
match crate::clipboard::get_image_bytes() {
|
||||
Ok(png_bytes) => {
|
||||
let hash = Dedup::hash_bytes(&png_bytes);
|
||||
if hash == last_hash {
|
||||
tokio::time::sleep(interval).await;
|
||||
continue;
|
||||
}
|
||||
last_hash = hash.clone();
|
||||
if self.dedup.already_seen(&hash)? {
|
||||
tracing::debug!("Image déjà connue, ignorée");
|
||||
tokio::time::sleep(interval).await;
|
||||
continue;
|
||||
}
|
||||
let imago = self.imago.clone();
|
||||
if let Some(image_id) = upload_to_imago(&imago, &png_bytes).await {
|
||||
if let Err(e) = self.dedup.mark_seen(&hash, &image_id.to_string()) {
|
||||
tracing::warn!("Erreur dédoublonnage : {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Erreur extraction clipboard : {:#}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!("Erreur accès clipboard : {:#}", e);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
/// Lance la boucle de surveillance (Linux/macOS : polling de dossier)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub async fn run(mut self) -> Result<()> {
|
||||
let interval = Duration::from_millis(self.config.watcher.poll_interval_ms);
|
||||
let watch_dir = self
|
||||
.config
|
||||
.watcher
|
||||
.watch_dir
|
||||
.clone()
|
||||
.unwrap_or_else(default_screenshots_dir);
|
||||
|
||||
let path = PathBuf::from(&watch_dir);
|
||||
std::fs::create_dir_all(&path)
|
||||
.with_context(|| format!("Impossible de créer le dossier {}", watch_dir))?;
|
||||
|
||||
let mut known: HashSet<PathBuf> = list_image_files(&path);
|
||||
|
||||
tracing::info!(
|
||||
"🐧 Watcher Linux démarré — polling {} (toutes les {}ms, {} existants ignorés)",
|
||||
path.display(),
|
||||
interval.as_millis(),
|
||||
known.len()
|
||||
);
|
||||
|
||||
loop {
|
||||
let current = list_image_files(&path);
|
||||
for f in ¤t {
|
||||
if !known.contains(f) {
|
||||
tracing::info!("📸 Nouveau fichier : {}", f.display());
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
match std::fs::read(f) {
|
||||
Ok(data) => {
|
||||
let hash = Dedup::hash_bytes(&data);
|
||||
if !self.dedup.already_seen(&hash).unwrap_or(false) {
|
||||
let imago = self.imago.clone();
|
||||
if let Some(image_id) = upload_to_imago(&imago, &data).await {
|
||||
if let Err(e) = self.dedup.mark_seen(&hash, &image_id.to_string()) {
|
||||
tracing::warn!("Erreur dédoublonnage : {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("Image déjà connue : {}", f.display());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Erreur lecture {} : {}", f.display(), e);
|
||||
}
|
||||
}
|
||||
known.insert(f.clone());
|
||||
}
|
||||
}
|
||||
known.retain(|f| f.exists());
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pipeline d'upload (fonction libre, pas de borrow problématique) ──────
|
||||
|
||||
/// Upload l'image vers Imago. Retourne l'ID si succès.
|
||||
/// Ne prend PAS `&Dedup` pour éviter le problème `!Sync` au-dessus du await.
|
||||
async fn upload_to_imago(imago: &ImagoClient, data: &[u8]) -> Option<i64> {
|
||||
let filename = format!(
|
||||
"screenshot_{}.png",
|
||||
chrono::Utc::now().format("%Y%m%d_%H%M%S")
|
||||
);
|
||||
|
||||
tracing::info!("⬆️ Upload : {} ({} octets)", filename, data.len());
|
||||
|
||||
match imago
|
||||
.upload_image(data.to_vec(), &filename, None)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
tracing::info!(
|
||||
"✅ Upload réussi : {} (ID: {}) — l'IA va analyser...",
|
||||
filename,
|
||||
response.id
|
||||
);
|
||||
Some(response.id)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("❌ Échec upload {} : {:#}", filename, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn data_dir() -> Result<PathBuf> {
|
||||
let base = if cfg!(target_os = "windows") {
|
||||
let appdata =
|
||||
std::env::var("APPDATA").context("Variable APPDATA non définie")?;
|
||||
PathBuf::from(appdata)
|
||||
} else {
|
||||
let home = std::env::var("HOME").context("Variable HOME non définie")?;
|
||||
PathBuf::from(home).join(".config")
|
||||
};
|
||||
Ok(base.join("imagod-web"))
|
||||
}
|
||||
|
||||
fn default_screenshots_dir() -> String {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
|
||||
format!("{}/Pictures/Screenshots", home)
|
||||
}
|
||||
|
||||
fn is_image_file(path: &std::path::Path) -> bool {
|
||||
match path.extension().and_then(|e| e.to_str()) {
|
||||
Some(ext) => matches!(
|
||||
ext.to_lowercase().as_str(),
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tiff"
|
||||
),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn list_image_files(dir: &std::path::Path) -> HashSet<PathBuf> {
|
||||
let mut files = HashSet::new();
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() && is_image_file(&path) {
|
||||
files.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user