From 3bab62733d0b39636991e862f89aaab2bdf0b13e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 25 Jun 2026 09:41:51 -0400 Subject: [PATCH] feat: watcher (phase 3+6) + navigation dashboard (version, changelog, aide) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 1 + Cargo.toml | 3 + src/cli.rs | 4 +- src/clipboard.rs | 60 +++++++++-- src/config.rs | 5 + src/frontend/changelog.html | 97 +++++++++++++++++ src/frontend/help.html | 94 +++++++++++++++++ src/frontend/index.html | 145 ++++++++++++++++++++++++++ src/main.rs | 17 ++- src/server/routes.rs | 17 ++- src/watcher.rs | 201 ++++++++++++++++++++++++++++++++---- 11 files changed, 608 insertions(+), 36 deletions(-) create mode 100644 src/frontend/changelog.html create mode 100644 src/frontend/help.html diff --git a/Cargo.lock b/Cargo.lock index fa5ea81..6d85ae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1276,6 +1276,7 @@ dependencies = [ "clap", "dashmap", "futures-util", + "image", "libc", "notify", "open", diff --git a/Cargo.toml b/Cargo.toml index e28ca97..28b6f64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/src/cli.rs b/src/cli.rs index 38e86c6..7314e4b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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)] diff --git a/src/clipboard.rs b/src/clipboard.rs index 5038e37..7462fb2 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -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 { - // 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> { - 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 = 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) } diff --git a/src/config.rs b/src/config.rs index 27e6f53..734974a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, #[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(), } } diff --git a/src/frontend/changelog.html b/src/frontend/changelog.html new file mode 100644 index 0000000..9cb892a --- /dev/null +++ b/src/frontend/changelog.html @@ -0,0 +1,97 @@ + + +Changelog + + + +

📋 Changelog — imagod-web

+ +

v0.1.0 — 2026-06-25

+ +

feat Dashboard Web

+
    +
  • Authentification par clé API Imago (login/logout, localStorage)
  • +
  • Galerie responsive (grille adaptative desktop/mobile, breakpoints 768px/400px)
  • +
  • Upload par drag & drop avec feedback visuel
  • +
  • Raccourcis clavier : Ctrl+U (upload), Ctrl+V (coller)
  • +
  • Recherche plein texte (nom, tags, description)
  • +
  • Pagination par pages de 24 images
  • +
  • Modale détail : full résolution, EXIF, OCR, description AI
  • +
  • Suppression avec boîte de confirmation
  • +
  • Gestion des tags : ajout, suppression, édition inline
  • +
  • Notifications toast (succès/erreur)
  • +
  • FAB mobile pour upload
  • +
  • Connexion WebSocket temps réel
  • +
  • Statistiques (nombre d'images, stockage, tags, uptime)
  • +
+ +

feat Client API Imago v2

+
    +
  • 8 endpoints : list, search, get, upload, thumbnail, download, delete, tags
  • +
  • Proxy des URLs signées (thumbnail-url / download-url)
  • +
  • Auth par header X-API-Key
  • +
+ +

feat Watcher (Phase 3 + 6)

+
    +
  • Windows : polling clipboard via arboard, conversion PNG automatique
  • +
  • Linux/macOS : surveillance de dossier configurable (polling)
  • +
  • Pipeline : détection → hash SHA-256 → dédoublonnage → upload → log
  • +
  • Configuration : intervalle de polling, dossier à surveiller, filtres
  • +
+ +

feat CLI

+
    +
  • Commandes : start, serve, stop, status, config, install, uninstall, logs
  • +
  • Flags : --host, --port, --api-key, --api-url, --no-watcher
  • +
  • Gestion PID pour éviter les doublons d'instance
  • +
+ +

feat Dédoublonnage

+
    +
  • Hash SHA-256 des images
  • +
  • Base SQLite locale (imagod.db)
  • +
  • Cache LRU configurable
  • +
+ +

fix Corrections

+
    +
  • Structs Imago v2 (ExifData, OcrData, AiData) match l'API réelle
  • +
  • Affichage via URLs signées (thumbnail-url/download-url)
  • +
  • Fallback placeholder DOM quand pas de thumbnail
  • +
  • Import delete inutilisé retiré
  • +
+ +

docs Documentation

+
    +
  • CHANGELOG.md dans le repo
  • +
  • ROADMAP.md avec planification des phases
  • +
  • Navigation in-app : Galerie / Changelog / Aide
  • +
+ +

Architecture

+ + + + + + + + +
ComposantTechnologieStatut
DaemonRust (tokio, clap)✅
Serveur webaxum 0.8 + tower-http✅
FrontendVanilla JS + rust-embed✅
Client APIreqwest (rustls)✅
DédoublonnageSHA-256 + SQLite✅
Temps réelWebSocket (broadcast)✅
+ + diff --git a/src/frontend/help.html b/src/frontend/help.html new file mode 100644 index 0000000..e32ba3e --- /dev/null +++ b/src/frontend/help.html @@ -0,0 +1,94 @@ + + +Aide + + + +

❓ À propos d'imagod-web

+ +

Qu'est-ce que c'est ?

+

+ imagod-web est une application de bureau (daemon + dashboard web) + qui capture automatiquement vos screenshots et les envoie vers + Imago, + un backend intelligent de gestion d'images. +

+ +
+ 🎯 Objectif : ne plus jamais perdre un screenshot. Chaque capture + est automatiquement analysée (OCR, vision IA, EXIF) et devient consultable + via ce dashboard web. +
+ +

Fonctionnalités

+
    +
  • 📸 Capture automatique — Windows : détection clipboard, Linux : surveillance de dossier
  • +
  • 🧠 Analyse IA — description du contenu, tags automatiques, texte OCR
  • +
  • 🔍 Recherche puissante — par nom, tags, description, texte extrait
  • +
  • 🏷️ Tags personnalisés — ajoutez, modifiez, supprimez des tags
  • +
  • 📊 Dashboard web — galerie responsive, upload drag & drop, modale détail
  • +
  • 🔄 Temps réel — WebSocket pour voir les nouvelles captures instantanément
  • +
  • 📱 Mobile-friendly — interface adaptative desktop et mobile
  • +
+ +

Modes de fonctionnement

+ +

Windows

+
imagod-web start
+

Lance le watcher (surveillance du presse-papier) + dashboard sur http://127.0.0.1:9090.

+ +

Linux / macOS / Serveur

+
imagod-web serve --host 0.0.0.0 --port 9199
+

Lance le dashboard + watcher de dossier. Configurez le dossier à surveiller dans ~/.config/imagod-web/imagod.toml :

+
[watcher]
+poll_interval_ms = 1000
+watch_dir = "/home/user/Pictures/Screenshots"
+ +
+ 💡 Astuce : utilisez --no-watcher pour lancer uniquement le dashboard sans surveillance. +
+ +

Raccourcis clavier

+
    +
  • Ctrl+U — Ouvrir le sélecteur de fichiers
  • +
  • Ctrl+V — Coller une image depuis le presse-papier
  • +
  • Escape — Fermer la modale
  • +
  • ← → — Navigation dans la pagination
  • +
+ +

Architecture

+
+┌─────────────┐     ┌──────────────┐     ┌──────────┐
+│  Screenshot  │────▶│  imagod-web  │────▶│  Imago   │
+│  (Windows/   │     │  (Rust)      │     │  (Python)│
+│   Linux)     │     │              │     │          │
+└─────────────┘     │  • Watcher   │     │ • Stock  │
+                    │  • Hash      │     │ • OCR    │
+                    │  • Upload    │     │ • IA     │
+                    │  • Dashboard │     │ • EXIF   │
+                    └──────────────┘     └──────────┘
+
+ +

Liens

+ + +
+ 📝 Besoin d'aide ? Contactez Bruno Charest via le repo Gitea pour toute question ou suggestion. +
+ + + diff --git a/src/frontend/index.html b/src/frontend/index.html index fac73a5..5f40c43 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.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 @@ + v0.1.0 + + +
+ + + + + +
+
+
+
📋
+ Chargement du changelog… +
+
+
+ + +
+
+
+
❓
+ Chargement de l'aide… +
+
+
@@ -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(/]*>([\s\S]*)<\/body>/i); + container.innerHTML = m ? m[1] : html; + changelogLoaded = true; + } + } catch(e) { + container.innerHTML = '
Erreur de chargement du changelog.
'; + } +} + +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(/]*>([\s\S]*)<\/body>/i); + container.innerHTML = m ? m[1] : html; + helpLoaded = true; + } + } catch(e) { + container.innerHTML = '
Erreur de chargement de l\'aide.
'; + } +} // ─── Auth Helpers ─── function getAuthHeaders() { diff --git a/src/main.rs b/src/main.rs index 3ed1d16..ed447ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { diff --git a/src/server/routes.rs b/src/server/routes.rs index 904c6d3..3589de0 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -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 { + 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( diff --git a/src/watcher.rs b/src/watcher.rs index 910c5df..32c2fa1 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -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 { - 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 = 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 = 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 { + 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 { + 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 { + 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 +}