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
338 lines
10 KiB
Rust
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(())
|
|
}
|
|
}
|