Files
imagod-web/src/imago_client.rs
T
bruno 1ec0d4fbe8 feat: suppression images + gestion tags (ajout/suppression)
Backend:
- ImagoClient::delete_image() — DELETE /api/v1/images/{id}
- ImagoClient::update_tags() — PUT /api/v1/images/{id}/tags
- Route DELETE /api/images/{id}
- Route PUT /api/images/{id}/tags

Frontend:
- Bouton 🗑️ sur chaque carte (visible au hover)
- Boîte de confirmation avant suppression
- Tags cliquables avec × pour suppression
- Bouton + tag pour ajouter un tag (Enter pour valider)
- Bouton Supprimer + tags éditables dans la modale
- Suppression + modif tags aussi dans la modale

⚠️ Nécessite redéploiement Imago pour le endpoint PUT /tags
2026-06-23 15:20:26 -04:00

338 lines
10 KiB
Rust

//! Client HTTP pour l'API Imago v2
//!
//! API base: /api/v1/images
//! Auth: X-API-Key header
use anyhow::Result;
use reqwest::{Client, multipart};
use serde::{Deserialize, Serialize};
// ─── Response types ────────────────────────────────────────────────────────
#[derive(Debug, Deserialize, Serialize)]
pub struct UploadResponse {
pub id: i64,
pub uuid: String,
pub original_name: String,
pub status: String,
pub message: String,
}
/// Vue allégée pour les listes (ImageSummary)
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ImageSummary {
pub id: i64,
pub uuid: String,
pub original_name: String,
pub mime_type: Option<String>,
pub file_size: Option<i64>,
pub width: Option<i32>,
pub height: Option<i32>,
pub uploaded_at: Option<String>,
pub processing_status: Option<String>,
pub client_id: Option<String>,
pub client_name: Option<String>,
pub ai_tags: Option<Vec<String>>,
pub ai_description: Option<String>,
pub thumbnail_path: Option<String>,
}
/// Vue détaillée (ImageDetail)
#[derive(Debug, Deserialize, Serialize)]
pub struct ImageDetail {
// ImageBase
pub id: i64,
pub uuid: String,
pub original_name: String,
pub mime_type: Option<String>,
pub file_size: Option<i64>,
pub width: Option<i32>,
pub height: Option<i32>,
pub uploaded_at: Option<String>,
pub processing_status: Option<String>,
pub client_id: Option<String>,
pub client_name: Option<String>,
// Nested objects
pub exif: Option<ExifData>,
pub ocr: Option<OcrData>,
pub ai: Option<AiData>,
pub processing: Option<ProcessingInfo>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ExifCamera {
pub make: Option<String>,
pub model: Option<String>,
pub lens: Option<String>,
pub iso: Option<i32>,
pub aperture: Option<f64>,
pub shutter_speed: Option<String>,
pub focal_length: Option<String>,
pub flash: Option<bool>,
pub orientation: Option<String>,
pub software: Option<String>,
pub taken_at: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ExifGps {
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub altitude: Option<f64>,
pub has_gps: bool,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ExifData {
pub camera: Option<ExifCamera>,
pub gps: Option<ExifGps>,
#[serde(default)]
pub raw: serde_json::Value,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct OcrData {
pub text: Option<String>,
pub language: Option<String>,
pub confidence: Option<f64>,
pub has_text: bool,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct AiData {
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub confidence: Option<f64>,
pub model_used: Option<String>,
pub processed_at: Option<String>,
pub prompt_tokens: Option<i64>,
pub output_tokens: Option<i64>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ProcessingInfo {
pub status: Option<String>,
pub error: Option<String>,
pub started_at: Option<String>,
pub done_at: Option<String>,
}
/// Réponse paginée
#[derive(Debug, Deserialize, Serialize)]
pub struct PaginatedImages {
pub total: usize,
pub page: usize,
pub page_size: usize,
pub pages: usize,
pub items: Vec<ImageSummary>,
pub storage_used_mb: Option<f64>,
pub storage_quota_mb: Option<i64>,
pub quota_pct: Option<f64>,
}
// ─── Client ────────────────────────────────────────────────────────────────
/// Client pour l'API Imago v2
#[derive(Clone)]
pub struct ImagoClient {
client: Client,
pub base_url: String,
api_key: String,
}
impl ImagoClient {
/// Crée un nouveau client Imago
pub fn new(base_url: String, api_key: String) -> Result<Self> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
Ok(Self { client, base_url, api_key })
}
/// Construit l'URL de base pour l'API images
fn images_base(&self) -> String {
format!("{}/api/v1/images", self.base_url.trim_end_matches('/'))
}
/// Upload une image vers Imago
pub async fn upload_image(
&self,
data: Vec<u8>,
filename: &str,
window_title: Option<&str>,
) -> Result<UploadResponse> {
let part = multipart::Part::bytes(data)
.file_name(filename.to_string())
.mime_str("image/png")?;
let mut form = multipart::Form::new()
.part("file", part);
if let Some(title) = window_title {
form = form.text("window_title", title.to_string());
}
let response = self.client
.post(format!("{}/upload", self.images_base()))
.header("X-API-Key", &self.api_key)
.multipart(form)
.send()
.await?;
let upload: UploadResponse = response
.error_for_status()?
.json()
.await?;
Ok(upload)
}
/// Liste les images (paginated)
pub async fn list_images(&self, page: usize, limit: usize) -> Result<PaginatedImages> {
let response = self.client
.get(self.images_base())
.header("X-API-Key", &self.api_key)
.query(&[("page", &page.to_string()), ("page_size", &limit.to_string())])
.send()
.await?;
let result: PaginatedImages = response
.error_for_status()?
.json()
.await?;
Ok(result)
}
/// Recherche des images
pub async fn search(&self, query: &str, limit: usize, page: usize) -> Result<PaginatedImages> {
let response = self.client
.get(self.images_base())
.header("X-API-Key", &self.api_key)
.query(&[("search", query), ("page_size", &limit.to_string()), ("page", &page.to_string())])
.send()
.await?;
let result: PaginatedImages = response
.error_for_status()?
.json()
.await?;
Ok(result)
}
/// Récupère les détails d'une image
pub async fn get_image(&self, id: i64) -> Result<ImageDetail> {
let response = self.client
.get(format!("{}/{}", self.images_base(), id))
.header("X-API-Key", &self.api_key)
.send()
.await?;
let detail: ImageDetail = response
.error_for_status()?
.json()
.await?;
Ok(detail)
}
/// Récupère le thumbnail d'une image via URL signée
pub async fn get_thumbnail(&self, id: i64) -> Result<(Vec<u8>, String)> {
// Étape 1 : obtenir l'URL signée
let sign_response: serde_json::Value = self.client
.get(format!("{}/{}/thumbnail-url", self.images_base(), id))
.header("X-API-Key", &self.api_key)
.send()
.await?
.error_for_status()?
.json()
.await?;
let signed_url = sign_response["url"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("URL signée manquante dans la réponse"))?;
// Si c'est un placeholder (pas de thumbnail), retourner une erreur propre
if signed_url.contains("placeholder") {
return Err(anyhow::anyhow!("Thumbnail non disponible (placeholder)"));
}
// Étape 2 : télécharger depuis l'URL signée
let full_url = format!("{}{}", self.base_url.trim_end_matches('/'), signed_url);
let response = self.client
.get(&full_url)
.send()
.await?
.error_for_status()?;
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("image/png")
.to_string();
let data = response.bytes().await?.to_vec();
Ok((data, content_type))
}
/// Récupère le fichier original via URL signée
pub async fn get_file(&self, id: i64) -> Result<(Vec<u8>, String)> {
// Étape 1 : obtenir l'URL signée
let sign_response: serde_json::Value = self.client
.get(format!("{}/{}/download-url", self.images_base(), id))
.header("X-API-Key", &self.api_key)
.send()
.await?
.error_for_status()?
.json()
.await?;
let signed_url = sign_response["url"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("URL signée manquante dans la réponse"))?;
// Étape 2 : télécharger depuis l'URL signée
let full_url = format!("{}{}", self.base_url.trim_end_matches('/'), signed_url);
let response = self.client
.get(&full_url)
.send()
.await?
.error_for_status()?;
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let data = response.bytes().await?.to_vec();
Ok((data, content_type))
}
/// Supprime une image (nécessite le scope images:delete)
pub async fn delete_image(&self, id: i64) -> Result<()> {
self.client
.delete(format!("{}/{}", self.images_base(), id))
.header("X-API-Key", &self.api_key)
.send()
.await?
.error_for_status()?;
Ok(())
}
/// Met à jour les tags d'une image
pub async fn update_tags(&self, id: i64, tags: Vec<String>) -> Result<()> {
self.client
.put(format!("{}/{}/tags", self.images_base(), id))
.header("X-API-Key", &self.api_key)
.json(&serde_json::json!({"tags": tags}))
.send()
.await?
.error_for_status()?;
Ok(())
}
}