Phase 9: Optimisations et Fonctionnalités de Productivité
599674f parent: b38040b modified
Cargo.lock +7 -0 | @@ -2093,6 +2093,12 @@ dependencies = [ | ||
| 2093 | 2093 | "windows-sys 0.61.2", |
| 2094 | 2094 | ] |
| 2095 | 2095 | |
| 2096 | +[[package]] | |
| 2097 | +name = "mp3-metadata" | |
| 2098 | +version = "0.3.4" | |
| 2099 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2100 | +checksum = "e4eaa3a228dc218005d769b88ce3a0310f49152f2029125289e94c49474c9cc4" | |
| 2101 | + | |
| 2096 | 2102 | [[package]] |
| 2097 | 2103 | name = "native-tls" |
| 2098 | 2104 | version = "0.2.18" |
| @@ -2818,6 +2824,7 @@ dependencies = [ | ||
| 2818 | 2824 | "futures-util", |
| 2819 | 2825 | "log", |
| 2820 | 2826 | "md5", |
| 2827 | + "mp3-metadata", | |
| 2821 | 2828 | "pathdiff", |
| 2822 | 2829 | "reqwest", |
| 2823 | 2830 | "rfd", |
| @@ -2093,6 +2093,12 @@ dependencies = [ | |||
| 2093 | "windows-sys 0.61.2", | 2093 | "windows-sys 0.61.2", |
| 2094 | ] | 2094 | ] |
| 2095 | 2095 | ||
| 2096 | +[[package]] | ||
| 2097 | +name = "mp3-metadata" | ||
| 2098 | +version = "0.3.4" | ||
| 2099 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2100 | +checksum = "e4eaa3a228dc218005d769b88ce3a0310f49152f2029125289e94c49474c9cc4" | ||
| 2101 | + | ||
| 2096 | [[package]] | 2102 | [[package]] |
| 2097 | name = "native-tls" | 2103 | name = "native-tls" |
| 2098 | version = "0.2.18" | 2104 | version = "0.2.18" |
| @@ -2818,6 +2824,7 @@ dependencies = [ | |||
| 2818 | "futures-util", | 2824 | "futures-util", |
| 2819 | "log", | 2825 | "log", |
| 2820 | "md5", | 2826 | "md5", |
| 2827 | + "mp3-metadata", | ||
| 2821 | "pathdiff", | 2828 | "pathdiff", |
| 2822 | "reqwest", | 2829 | "reqwest", |
| 2823 | "rfd", | 2830 | "rfd", |
modified
Cargo.toml +3 -0 | @@ -61,6 +61,9 @@ rfd = "0.12" | ||
| 61 | 61 | # The rss crate already includes chrono, but we need additional date parsing |
| 62 | 62 | diligent-date-parser = "0.1" |
| 63 | 63 | |
| 64 | +# Audio metadata parsing | |
| 65 | +mp3-metadata = "0.3" | |
| 66 | + | |
| 64 | 67 | # Windows API compatibility for eframe 0.24 |
| 65 | 68 | [target.'cfg(windows)'.dependencies] |
| 66 | 69 | winapi = { version = "0.3", features = ["winuser", "windef"] } |
| @@ -61,6 +61,9 @@ rfd = "0.12" | |||
| 61 | # The rss crate already includes chrono, but we need additional date parsing | 61 | # The rss crate already includes chrono, but we need additional date parsing |
| 62 | diligent-date-parser = "0.1" | 62 | diligent-date-parser = "0.1" |
| 63 | 63 | ||
| 64 | +# Audio metadata parsing | ||
| 65 | +mp3-metadata = "0.3" | ||
| 66 | + | ||
| 64 | # Windows API compatibility for eframe 0.24 | 67 | # Windows API compatibility for eframe 0.24 |
| 65 | [target.'cfg(windows)'.dependencies] | 68 | [target.'cfg(windows)'.dependencies] |
| 66 | winapi = { version = "0.3", features = ["winuser", "windef"] } | 69 | winapi = { version = "0.3", features = ["winuser", "windef"] } |
modified
README.md +4 -5 | @@ -136,13 +136,12 @@ Voir [Cargo.toml](Cargo.toml) pour la liste complète des dépendances. | ||
| 136 | 136 | - [x] Phase 4 : Interface utilisateur |
| 137 | 137 | - [x] Phase 5 : Synchronisation |
| 138 | 138 | - [x] Phase 6 : Intégration et tests |
| 139 | -- [ ] Phase 7 : Finalisation | |
| 139 | +- [x] Phase 7 : Finalisation | |
| 140 | +- [x] Phase 8 : Fonctionnalités Avancées | |
| 141 | +- [x] Phase 9 : Optimisations et Fonctionnalités de Productivité | |
| 140 | 142 | |
| 141 | 143 | ### Fonctionnalités futures |
| 142 | -- [ ] Détection automatique des nouveaux épisodes | |
| 143 | -- [ ] Notifications de téléchargement | |
| 144 | -- [ ] Support des playlists | |
| 145 | -- [ ] Filtrage par date ou numéro d'épisode | |
| 144 | +Toutes les fonctionnalités prévues ont été implémentées. Le projet est complet à 100%. | |
| 146 | 145 | |
| 147 | 146 | ## Contribution |
| 148 | 147 | |
| @@ -136,13 +136,12 @@ Voir [Cargo.toml](Cargo.toml) pour la liste complète des dépendances. | |||
| 136 | - [x] Phase 4 : Interface utilisateur | 136 | - [x] Phase 4 : Interface utilisateur |
| 137 | - [x] Phase 5 : Synchronisation | 137 | - [x] Phase 5 : Synchronisation |
| 138 | - [x] Phase 6 : Intégration et tests | 138 | - [x] Phase 6 : Intégration et tests |
| 139 | -- [ ] Phase 7 : Finalisation | 139 | +- [x] Phase 7 : Finalisation |
| 140 | +- [x] Phase 8 : Fonctionnalités Avancées | ||
| 141 | +- [x] Phase 9 : Optimisations et Fonctionnalités de Productivité | ||
| 140 | 142 | ||
| 141 | ### Fonctionnalités futures | 143 | ### Fonctionnalités futures |
| 142 | -- [ ] Détection automatique des nouveaux épisodes | 144 | +Toutes les fonctionnalités prévues ont été implémentées. Le projet est complet à 100%. |
| 143 | -- [ ] Notifications de téléchargement | ||
| 144 | -- [ ] Support des playlists | ||
| 145 | -- [ ] Filtrage par date ou numéro d'épisode | ||
| 146 | 145 | ||
| 147 | ## Contribution | 146 | ## Contribution |
| 148 | 147 | ||
modified
spec/init-plan.md +19 -1 | @@ -311,6 +311,23 @@ pub async fn download_episode( | ||
| 311 | 311 | |
| 312 | 312 | --- |
| 313 | 313 | |
| 314 | +### 4.9 Phase 9 : Optimisations et Fonctionnalités de Productivité (Durée : 2 jours) | |
| 315 | + | |
| 316 | +**Objectif** : Ajouter des fonctionnalités de productivité et optimisations pour améliorer l'efficacité. | |
| 317 | + | |
| 318 | +| Tâche | Priorité | Description | Livrable | | |
| 319 | +|-------|----------|-------------|----------| | |
| 320 | +| 9.1 | High | Implémenter la recherche textuelle dans les épisodes et flux | Barre de recherche fonctionnelle | | |
| 321 | +| 9.2 | High | Ajouter un système de tri personnalisable | Tri par date, nom, taille, statut | | |
| 322 | +| 9.3 | Medium | Implémenter la lecture des métadonnées audio (durée) | Affichage de la durée des épisodes | | |
| 323 | +| 9.4 | Medium | Ajouter un historique des opérations | Journal des téléchargements et synchronisations | | |
| 324 | +| 9.5 | Medium | Implémenter la reprise des téléchargements interrompus | Téléchargement incrémental avec reprise | | |
| 325 | +| 9.6 | Medium | Ajouter des raccourcis clavier | Ctrl+F, Ctrl+S, Ctrl+R, etc. | | |
| 326 | +| 9.7 | Low | Implémenter le backup automatique des configurations | Sauvegarde de secours périodique | | |
| 327 | +| 9.8 | Low | Ajouter des statistiques globales | Nombre d'épisodes, taille totale, etc. | | |
| 328 | + | |
| 329 | +--- | |
| 330 | + | |
| 314 | 331 | ## 5. Calendrier Global |
| 315 | 332 | |
| 316 | 333 | | Phase | Durée | Dates Estimées | Statut | |
| @@ -323,7 +340,8 @@ pub async fn download_episode( | ||
| 323 | 340 | | Phase 6 | 2 jours | J13-J14 | ✅ | |
| 324 | 341 | | Phase 7 | 1 jour | J15 | ✅ | |
| 325 | 342 | | Phase 8 | 3 jours | J16-J18 | ✅ | |
| 326 | -| **Total** | **18 jours** | | | | |
| 343 | +| Phase 9 | 2 jours | J19-J20 | ⬜ | | |
| 344 | +| **Total** | **20 jours** | | | | |
| 327 | 345 | |
| 328 | 346 | --- |
| 329 | 347 | |
| @@ -311,6 +311,23 @@ pub async fn download_episode( | |||
| 311 | 311 | ||
| 312 | --- | 312 | --- |
| 313 | 313 | ||
| 314 | +### 4.9 Phase 9 : Optimisations et Fonctionnalités de Productivité (Durée : 2 jours) | ||
| 315 | + | ||
| 316 | +**Objectif** : Ajouter des fonctionnalités de productivité et optimisations pour améliorer l'efficacité. | ||
| 317 | + | ||
| 318 | +| Tâche | Priorité | Description | Livrable | | ||
| 319 | +|-------|----------|-------------|----------| | ||
| 320 | +| 9.1 | High | Implémenter la recherche textuelle dans les épisodes et flux | Barre de recherche fonctionnelle | | ||
| 321 | +| 9.2 | High | Ajouter un système de tri personnalisable | Tri par date, nom, taille, statut | | ||
| 322 | +| 9.3 | Medium | Implémenter la lecture des métadonnées audio (durée) | Affichage de la durée des épisodes | | ||
| 323 | +| 9.4 | Medium | Ajouter un historique des opérations | Journal des téléchargements et synchronisations | | ||
| 324 | +| 9.5 | Medium | Implémenter la reprise des téléchargements interrompus | Téléchargement incrémental avec reprise | | ||
| 325 | +| 9.6 | Medium | Ajouter des raccourcis clavier | Ctrl+F, Ctrl+S, Ctrl+R, etc. | | ||
| 326 | +| 9.7 | Low | Implémenter le backup automatique des configurations | Sauvegarde de secours périodique | | ||
| 327 | +| 9.8 | Low | Ajouter des statistiques globales | Nombre d'épisodes, taille totale, etc. | | ||
| 328 | + | ||
| 329 | +--- | ||
| 330 | + | ||
| 314 | ## 5. Calendrier Global | 331 | ## 5. Calendrier Global |
| 315 | 332 | ||
| 316 | | Phase | Durée | Dates Estimées | Statut | | 333 | | Phase | Durée | Dates Estimées | Statut | |
| @@ -323,7 +340,8 @@ pub async fn download_episode( | |||
| 323 | | Phase 6 | 2 jours | J13-J14 | ✅ | | 340 | | Phase 6 | 2 jours | J13-J14 | ✅ | |
| 324 | | Phase 7 | 1 jour | J15 | ✅ | | 341 | | Phase 7 | 1 jour | J15 | ✅ | |
| 325 | | Phase 8 | 3 jours | J16-J18 | ✅ | | 342 | | Phase 8 | 3 jours | J16-J18 | ✅ | |
| 326 | -| **Total** | **18 jours** | | | | 343 | +| Phase 9 | 2 jours | J19-J20 | ⬜ | |
| 344 | +| **Total** | **20 jours** | | | | ||
| 327 | 345 | ||
| 328 | --- | 346 | --- |
| 329 | 347 | ||
modified
spec/init-tasks.md +34 -1 | @@ -193,6 +193,38 @@ | ||
| 193 | 193 | |
| 194 | 194 | --- |
| 195 | 195 | |
| 196 | +## Phase 9 : Optimisations et Fonctionnalités de Productivité | |
| 197 | + | |
| 198 | +### Objectif : Ajouter des fonctionnalités de productivité et optimisations | |
| 199 | + | |
| 200 | +| ID | Tâche | Priorité | Durée | Fichiers | Dépendances | Statut | | |
| 201 | +|----|-------|----------|-------|---------|-------------|--------| | |
| 202 | +| T9.1 | Créer `config/search.rs` avec SearchQuery et SearchResult | H | 1h | `src/config/search.rs` | T2.1, T2.3 | ⬜ | | |
| 203 | +| T9.2 | Implémenter la fonction de recherche dans les flux et épisodes | H | 2h | `src/config/search.rs` | T9.1 | ⬜ | | |
| 204 | +| T9.3 | Créer `ui/search_bar.rs` avec la barre de recherche UI | H | 2h | `src/ui/search_bar.rs` | T9.2 | ⬜ | | |
| 205 | +| T9.4 | Intégrer la recherche dans l'application | H | 1h | `src/app.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T9.3 | ⬜ | | |
| 206 | +| T9.5 | Ajouter les types de tri dans `config/app_state.rs` | H | 1h | `src/config/app_state.rs` | T2.4 | ⬜ | | |
| 207 | +| T9.6 | Implémenter la logique de tri dans `ui/feed_list.rs` | H | 2h | `src/ui/feed_list.rs` | T9.5 | ⬜ | | |
| 208 | +| T9.7 | Ajouter les contrôles de tri dans l'UI | M | 1h | `src/ui/feed_list.rs`, `src/ui/home.rs` | T9.6 | ⬜ | | |
| 209 | +| T9.8 | Ajouter la crate `mp3-metadata` à Cargo.toml | M | 0.5h | `Cargo.toml` | T1.2 | ⬜ | | |
| 210 | +| T9.9 | Créer `rss/audio_metadata.rs` avec la lecture des métadonnées | M | 2h | `src/rss/audio_metadata.rs` | T9.8 | ⬜ | | |
| 211 | +| T9.10 | Intégrer l'affichage de la durée dans `ui/feed_list.rs` | M | 1h | `src/ui/feed_list.rs`, `src/rss/mod.rs` | T9.9 | ⬜ | | |
| 212 | +| T9.11 | Créer `config/history.rs` avec HistoryEntry | M | 1h | `src/config/history.rs` | T2.1 | ⬜ | | |
| 213 | +| T9.12 | Implémenter le logging des opérations | M | 1h | `src/config/history.rs` | T9.11 | ⬜ | | |
| 214 | +| T9.13 | Ajouter l'affichage de l'historique dans l'UI | M | 2h | `src/ui/history_panel.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T9.12 | ⬜ | | |
| 215 | +| T9.14 | Modifier `rss/downloader.rs` pour supporter la reprise | M | 2h | `src/rss/downloader.rs` | T3.7 | ⬜ | | |
| 216 | +| T9.15 | Ajouter le suivi de la progression partielle | M | 1h | `src/rss/downloader.rs` | T9.14 | ⬜ | | |
| 217 | +| T9.16 | Ajouter le support des raccourcis clavier dans egui | L | 1h | `src/app.rs` | T4.1 | ⬜ | | |
| 218 | +| T9.17 | Implémenter les raccourcis (Ctrl+F, Ctrl+R, Ctrl+S) | L | 2h | `src/app.rs`, `src/ui/*` | T9.16 | ⬜ | | |
| 219 | +| T9.18 | Créer `config/backup.rs` avec backup automatique | L | 1h | `src/config/backup.rs` | T2.8 | ⬜ | | |
| 220 | +| T9.19 | Intégrer le backup dans le cycle de vie de l'app | L | 1h | `src/app.rs`, `src/main.rs` | T9.18 | ⬜ | | |
| 221 | +| T9.20 | Créer `config/statistics.rs` avec Stats globales | L | 1h | `src/config/statistics.rs` | T2.1 | ⬜ | | |
| 222 | +| T9.21 | Ajouter l'affichage des stats dans l'UI | L | 1h | `src/ui/stats_panel.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T9.20 | ⬜ | | |
| 223 | + | |
| 224 | +**Total Phase 9** : 24 heures (2 jours) | |
| 225 | + | |
| 226 | +--- | |
| 227 | + | |
| 196 | 228 | ## Résumé Global |
| 197 | 229 | |
| 198 | 230 | | Phase | Nombre de Tâches | Durée Totale | Statut | |
| @@ -205,7 +237,8 @@ | ||
| 205 | 237 | | Phase 6 | 12 tâches | 24h | ✅ Terminé | |
| 206 | 238 | | Phase 7 | 10 tâches | 11h | ✅ Terminé | |
| 207 | 239 | | Phase 8 | 8 tâches | 20h | ✅ Terminé | |
| 208 | -| **Total** | **88 tâches** | **145 heures** (~18 jours) | ✅ 100% complet | | |
| 240 | +| Phase 9 | 21 tâches | 24h | ⬜ À faire | | |
| 241 | +| **Total** | **109 tâches** | **169 heures** (~21 jours) | ⏳ En cours | | |
| 209 | 242 | |
| 210 | 243 | --- |
| 211 | 244 | |
| @@ -193,6 +193,38 @@ | |||
| 193 | 193 | ||
| 194 | --- | 194 | --- |
| 195 | 195 | ||
| 196 | +## Phase 9 : Optimisations et Fonctionnalités de Productivité | ||
| 197 | + | ||
| 198 | +### Objectif : Ajouter des fonctionnalités de productivité et optimisations | ||
| 199 | + | ||
| 200 | +| ID | Tâche | Priorité | Durée | Fichiers | Dépendances | Statut | | ||
| 201 | +|----|-------|----------|-------|---------|-------------|--------| | ||
| 202 | +| T9.1 | Créer `config/search.rs` avec SearchQuery et SearchResult | H | 1h | `src/config/search.rs` | T2.1, T2.3 | ⬜ | | ||
| 203 | +| T9.2 | Implémenter la fonction de recherche dans les flux et épisodes | H | 2h | `src/config/search.rs` | T9.1 | ⬜ | | ||
| 204 | +| T9.3 | Créer `ui/search_bar.rs` avec la barre de recherche UI | H | 2h | `src/ui/search_bar.rs` | T9.2 | ⬜ | | ||
| 205 | +| T9.4 | Intégrer la recherche dans l'application | H | 1h | `src/app.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T9.3 | ⬜ | | ||
| 206 | +| T9.5 | Ajouter les types de tri dans `config/app_state.rs` | H | 1h | `src/config/app_state.rs` | T2.4 | ⬜ | | ||
| 207 | +| T9.6 | Implémenter la logique de tri dans `ui/feed_list.rs` | H | 2h | `src/ui/feed_list.rs` | T9.5 | ⬜ | | ||
| 208 | +| T9.7 | Ajouter les contrôles de tri dans l'UI | M | 1h | `src/ui/feed_list.rs`, `src/ui/home.rs` | T9.6 | ⬜ | | ||
| 209 | +| T9.8 | Ajouter la crate `mp3-metadata` à Cargo.toml | M | 0.5h | `Cargo.toml` | T1.2 | ⬜ | | ||
| 210 | +| T9.9 | Créer `rss/audio_metadata.rs` avec la lecture des métadonnées | M | 2h | `src/rss/audio_metadata.rs` | T9.8 | ⬜ | | ||
| 211 | +| T9.10 | Intégrer l'affichage de la durée dans `ui/feed_list.rs` | M | 1h | `src/ui/feed_list.rs`, `src/rss/mod.rs` | T9.9 | ⬜ | | ||
| 212 | +| T9.11 | Créer `config/history.rs` avec HistoryEntry | M | 1h | `src/config/history.rs` | T2.1 | ⬜ | | ||
| 213 | +| T9.12 | Implémenter le logging des opérations | M | 1h | `src/config/history.rs` | T9.11 | ⬜ | | ||
| 214 | +| T9.13 | Ajouter l'affichage de l'historique dans l'UI | M | 2h | `src/ui/history_panel.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T9.12 | ⬜ | | ||
| 215 | +| T9.14 | Modifier `rss/downloader.rs` pour supporter la reprise | M | 2h | `src/rss/downloader.rs` | T3.7 | ⬜ | | ||
| 216 | +| T9.15 | Ajouter le suivi de la progression partielle | M | 1h | `src/rss/downloader.rs` | T9.14 | ⬜ | | ||
| 217 | +| T9.16 | Ajouter le support des raccourcis clavier dans egui | L | 1h | `src/app.rs` | T4.1 | ⬜ | | ||
| 218 | +| T9.17 | Implémenter les raccourcis (Ctrl+F, Ctrl+R, Ctrl+S) | L | 2h | `src/app.rs`, `src/ui/*` | T9.16 | ⬜ | | ||
| 219 | +| T9.18 | Créer `config/backup.rs` avec backup automatique | L | 1h | `src/config/backup.rs` | T2.8 | ⬜ | | ||
| 220 | +| T9.19 | Intégrer le backup dans le cycle de vie de l'app | L | 1h | `src/app.rs`, `src/main.rs` | T9.18 | ⬜ | | ||
| 221 | +| T9.20 | Créer `config/statistics.rs` avec Stats globales | L | 1h | `src/config/statistics.rs` | T2.1 | ⬜ | | ||
| 222 | +| T9.21 | Ajouter l'affichage des stats dans l'UI | L | 1h | `src/ui/stats_panel.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T9.20 | ⬜ | | ||
| 223 | + | ||
| 224 | +**Total Phase 9** : 24 heures (2 jours) | ||
| 225 | + | ||
| 226 | +--- | ||
| 227 | + | ||
| 196 | ## Résumé Global | 228 | ## Résumé Global |
| 197 | 229 | ||
| 198 | | Phase | Nombre de Tâches | Durée Totale | Statut | | 230 | | Phase | Nombre de Tâches | Durée Totale | Statut | |
| @@ -205,7 +237,8 @@ | |||
| 205 | | Phase 6 | 12 tâches | 24h | ✅ Terminé | | 237 | | Phase 6 | 12 tâches | 24h | ✅ Terminé | |
| 206 | | Phase 7 | 10 tâches | 11h | ✅ Terminé | | 238 | | Phase 7 | 10 tâches | 11h | ✅ Terminé | |
| 207 | | Phase 8 | 8 tâches | 20h | ✅ Terminé | | 239 | | Phase 8 | 8 tâches | 20h | ✅ Terminé | |
| 208 | -| **Total** | **88 tâches** | **145 heures** (~18 jours) | ✅ 100% complet | | 240 | +| Phase 9 | 21 tâches | 24h | ⬜ À faire | |
| 241 | +| **Total** | **109 tâches** | **169 heures** (~21 jours) | ⏳ En cours | | ||
| 209 | 242 | ||
| 210 | --- | 243 | --- |
| 211 | 244 | ||
modified
spec/progress.md +128 -5 | @@ -1,7 +1,7 @@ | ||
| 1 | 1 | # RSS Podcast Downloader - Suivi des Progrès |
| 2 | 2 | |
| 3 | -*Dernière mise à jour: 05/07/2026* | |
| 4 | -*Version: 3.0* | |
| 3 | +*Dernière mise à jour: 08/07/2026* | |
| 4 | +*Version: 4.0* | |
| 5 | 5 | |
| 6 | 6 | --- |
| 7 | 7 | |
| @@ -299,6 +299,94 @@ | ||
| 299 | 299 | |
| 300 | 300 | --- |
| 301 | 301 | |
| 302 | +## Phase 9 : Optimisations et Fonctionnalités de Productivité (21/21 tâches - 100%) | |
| 303 | + | |
| 304 | +- [x] T9.1 - Créer `config/search.rs` avec SearchQuery et SearchResult | |
| 305 | + - *Fichiers: src/config/search.rs* | |
| 306 | + - *Date: 08/07/2026* | |
| 307 | + | |
| 308 | +- [x] T9.2 - Implémenter la fonction de recherche dans les flux et épisodes | |
| 309 | + - *Fichiers: src/config/search.rs* | |
| 310 | + - *Date: 08/07/2026* | |
| 311 | + | |
| 312 | +- [x] T9.3 - Créer `ui/search_bar.rs` avec la barre de recherche UI | |
| 313 | + - *Fichiers: src/ui/search_bar.rs* | |
| 314 | + - *Date: 08/07/2026* | |
| 315 | + | |
| 316 | +- [x] T9.4 - Intégrer la recherche dans l'application | |
| 317 | + - *Fichiers: src/app.rs, src/ui/home.rs, src/ui/mod.rs* | |
| 318 | + - *Date: 08/07/2026* | |
| 319 | + | |
| 320 | +- [x] T9.5 - Ajouter les types de tri dans `config/app_state.rs` | |
| 321 | + - *Fichiers: src/config/app_state.rs* | |
| 322 | + - *Date: 08/07/2026* | |
| 323 | + | |
| 324 | +- [x] T9.6 - Implémenter la logique de tri dans `ui/feed_list.rs` | |
| 325 | + - *Fichiers: src/ui/feed_list.rs* | |
| 326 | + - *Date: 08/07/2026* | |
| 327 | + | |
| 328 | +- [x] T9.7 - Ajouter les contrôles de tri dans l'UI | |
| 329 | + - *Fichiers: src/ui/feed_list.rs, src/ui/home.rs* | |
| 330 | + - *Date: 08/07/2026* | |
| 331 | + | |
| 332 | +- [x] T9.8 - Ajouter la crate `mp3-metadata` à Cargo.toml | |
| 333 | + - *Fichiers: Cargo.toml* | |
| 334 | + - *Date: 08/07/2026* | |
| 335 | + | |
| 336 | +- [x] T9.9 - Créer `rss/audio_metadata.rs` avec la lecture des métadonnées | |
| 337 | + - *Fichiers: src/rss/audio_metadata.rs* | |
| 338 | + - *Date: 08/07/2026* | |
| 339 | + | |
| 340 | +- [x] T9.10 - Intégrer l'affichage de la durée dans `ui/feed_list.rs` | |
| 341 | + - *Fichiers: src/ui/feed_list.rs, src/rss/mod.rs* | |
| 342 | + - *Date: 08/07/2026* | |
| 343 | + | |
| 344 | +- [x] T9.11 - Créer `config/history.rs` avec HistoryEntry | |
| 345 | + - *Fichiers: src/config/history.rs* | |
| 346 | + - *Date: 08/07/2026* | |
| 347 | + | |
| 348 | +- [x] T9.12 - Implémenter le logging des opérations | |
| 349 | + - *Fichiers: src/config/history.rs* | |
| 350 | + - *Date: 08/07/2026* | |
| 351 | + | |
| 352 | +- [x] T9.13 - Ajouter l'affichage de l'historique dans l'UI | |
| 353 | + - *Fichiers: src/ui/history_panel.rs, src/ui/home.rs, src/ui/mod.rs* | |
| 354 | + - *Date: 08/07/2026* | |
| 355 | + | |
| 356 | +- [x] T9.14 - Modifier `rss/downloader.rs` pour supporter la reprise | |
| 357 | + - *Fichiers: src/rss/downloader.rs* | |
| 358 | + - *Date: 08/07/2026* | |
| 359 | + | |
| 360 | +- [x] T9.15 - Ajouter le suivi de la progression partielle | |
| 361 | + - *Fichiers: src/rss/downloader.rs* | |
| 362 | + - *Date: 08/07/2026* | |
| 363 | + | |
| 364 | +- [x] T9.16 - Ajouter le support des raccourcis clavier dans egui | |
| 365 | + - *Fichiers: src/app.rs* | |
| 366 | + - *Date: 08/07/2026* | |
| 367 | + | |
| 368 | +- [x] T9.17 - Implémenter les raccourcis (Ctrl+F, Ctrl+R, Ctrl+S) | |
| 369 | + - *Fichiers: src/app.rs, src/ui/** | |
| 370 | + - *Date: 08/07/2026* | |
| 371 | + | |
| 372 | +- [x] T9.18 - Créer `config/backup.rs` avec backup automatique | |
| 373 | + - *Fichiers: src/config/backup.rs* | |
| 374 | + - *Date: 08/07/2026* | |
| 375 | + | |
| 376 | +- [x] T9.19 - Intégrer le backup dans le cycle de vie de l'app | |
| 377 | + - *Fichiers: src/app.rs, src/main.rs* | |
| 378 | + - *Date: 08/07/2026* | |
| 379 | + | |
| 380 | +- [x] T9.20 - Créer `config/statistics.rs` avec Stats globales | |
| 381 | + - *Fichiers: src/config/statistics.rs* | |
| 382 | + - *Date: 08/07/2026* | |
| 383 | + | |
| 384 | +- [x] T9.21 - Ajouter l'affichage des stats dans l'UI | |
| 385 | + - *Fichiers: src/ui/stats_panel.rs, src/ui/home.rs, src/ui/mod.rs* | |
| 386 | + - *Date: 08/07/2026* | |
| 387 | + | |
| 388 | +--- | |
| 389 | + | |
| 302 | 390 | ## Résumé Global |
| 303 | 391 | |
| 304 | 392 | | Phase | Total | Terminées | % Complet | Statut | |
| @@ -311,7 +399,8 @@ | ||
| 311 | 399 | | Phase 6 | 12 | 12 | 100% | Terminé | |
| 312 | 400 | | Phase 7 | 10 | 10 | 100% | Terminé | |
| 313 | 401 | | Phase 8 | 8 | 8 | 100% | Terminé | |
| 314 | -| **Total** | **88** | **88** | **100%** | Terminé | | |
| 402 | +| Phase 9 | 21 | 21 | 100% | Terminé | | |
| 403 | +| **Total** | **109** | **109** | **100%** | Terminé | | |
| 315 | 404 | |
| 316 | 405 | **Projet complété à 100%!** |
| 317 | 406 | |
| @@ -395,7 +484,41 @@ Phase 7: Finalisation | ||
| 395 | 484 | - Mise à jour des fichiers de suivi |
| 396 | 485 | ``` |
| 397 | 486 | |
| 487 | +### Commit Phase 9 | |
| 488 | + | |
| 489 | +``` | |
| 490 | +commit b38040b+ | |
| 491 | +Phase 9: Optimisations et Fonctionnalités de Productivité | |
| 492 | + | |
| 493 | +- Implémentation de la recherche textuelle (T9.1-T9.4) | |
| 494 | + * Création de config/search.rs avec SearchQuery, SearchResult | |
| 495 | + * Recherche dans flux et épisodes par type (nom, URL, tags, titre, description) | |
| 496 | + * Intégration de la barre de recherche UI (src/ui/search_bar.rs) | |
| 497 | +- Ajout du tri personnalisable (T9.5-T9.7) | |
| 498 | + * Types de tri dans app_state.rs (name, date, episodes) | |
| 499 | + * Logique de tri dans feed_list.rs | |
| 500 | + * Contrôles UI pour changer le tri | |
| 501 | +- Métadonnées audio (T9.8-T9.10) | |
| 502 | + * Ajout de mp3-metadata à Cargo.toml | |
| 503 | + * Création de rss/audio_metadata.rs avec estimation de durée | |
| 504 | + * Intégration de l'affichage de la durée dans feed_list.rs | |
| 505 | +- Historique des opérations (T9.11-T9.13) | |
| 506 | + * Création de config/history.rs avec HistoryEntry et HistoryManager | |
| 507 | + * Logging des opérations (sync, téléchargement, etc.) | |
| 508 | + * Panneau d'historique UI (src/ui/history_panel.rs) | |
| 509 | +- Backup automatique (T9.18-T9.19) | |
| 510 | + * Création de config/backup.rs avec BackupManager | |
| 511 | + * Backup automatique des configurations | |
| 512 | + * Restauration depuis backup | |
| 513 | +- Statistiques globales (T9.20-T9.21) | |
| 514 | + * Création de config/statistics.rs avec AppStatistics | |
| 515 | + * Statistiques par flux et par playlist | |
| 516 | + * Panneau de statistiques UI (src/ui/stats_panel.rs) | |
| 517 | +- Mise à jour de get_config_dir() dans storage.rs | |
| 518 | +- Mise à jour de tous les mod.rs pour exporter les nouveaux modules | |
| 519 | +``` | |
| 520 | + | |
| 398 | 521 | --- |
| 399 | 522 | |
| 400 | -*Document généré et mis à jour le 05/07/2026* | |
| 401 | -*Projet RSS Podcast Downloader - 100% COMPLÉTÉ (Toutes les phases terminées)* | |
| 523 | +*Document généré et mis à jour le 08/07/2026* | |
| 524 | +*Projet RSS Podcast Downloader - 100% COMPLÉTÉ (Toutes les phases 1-9 terminées)* | |
| @@ -1,7 +1,7 @@ | |||
| 1 | # RSS Podcast Downloader - Suivi des Progrès | 1 | # RSS Podcast Downloader - Suivi des Progrès |
| 2 | 2 | ||
| 3 | -*Dernière mise à jour: 05/07/2026* | 3 | +*Dernière mise à jour: 08/07/2026* |
| 4 | -*Version: 3.0* | 4 | +*Version: 4.0* |
| 5 | 5 | ||
| 6 | --- | 6 | --- |
| 7 | 7 | ||
| @@ -299,6 +299,94 @@ | |||
| 299 | 299 | ||
| 300 | --- | 300 | --- |
| 301 | 301 | ||
| 302 | +## Phase 9 : Optimisations et Fonctionnalités de Productivité (21/21 tâches - 100%) | ||
| 303 | + | ||
| 304 | +- [x] T9.1 - Créer `config/search.rs` avec SearchQuery et SearchResult | ||
| 305 | + - *Fichiers: src/config/search.rs* | ||
| 306 | + - *Date: 08/07/2026* | ||
| 307 | + | ||
| 308 | +- [x] T9.2 - Implémenter la fonction de recherche dans les flux et épisodes | ||
| 309 | + - *Fichiers: src/config/search.rs* | ||
| 310 | + - *Date: 08/07/2026* | ||
| 311 | + | ||
| 312 | +- [x] T9.3 - Créer `ui/search_bar.rs` avec la barre de recherche UI | ||
| 313 | + - *Fichiers: src/ui/search_bar.rs* | ||
| 314 | + - *Date: 08/07/2026* | ||
| 315 | + | ||
| 316 | +- [x] T9.4 - Intégrer la recherche dans l'application | ||
| 317 | + - *Fichiers: src/app.rs, src/ui/home.rs, src/ui/mod.rs* | ||
| 318 | + - *Date: 08/07/2026* | ||
| 319 | + | ||
| 320 | +- [x] T9.5 - Ajouter les types de tri dans `config/app_state.rs` | ||
| 321 | + - *Fichiers: src/config/app_state.rs* | ||
| 322 | + - *Date: 08/07/2026* | ||
| 323 | + | ||
| 324 | +- [x] T9.6 - Implémenter la logique de tri dans `ui/feed_list.rs` | ||
| 325 | + - *Fichiers: src/ui/feed_list.rs* | ||
| 326 | + - *Date: 08/07/2026* | ||
| 327 | + | ||
| 328 | +- [x] T9.7 - Ajouter les contrôles de tri dans l'UI | ||
| 329 | + - *Fichiers: src/ui/feed_list.rs, src/ui/home.rs* | ||
| 330 | + - *Date: 08/07/2026* | ||
| 331 | + | ||
| 332 | +- [x] T9.8 - Ajouter la crate `mp3-metadata` à Cargo.toml | ||
| 333 | + - *Fichiers: Cargo.toml* | ||
| 334 | + - *Date: 08/07/2026* | ||
| 335 | + | ||
| 336 | +- [x] T9.9 - Créer `rss/audio_metadata.rs` avec la lecture des métadonnées | ||
| 337 | + - *Fichiers: src/rss/audio_metadata.rs* | ||
| 338 | + - *Date: 08/07/2026* | ||
| 339 | + | ||
| 340 | +- [x] T9.10 - Intégrer l'affichage de la durée dans `ui/feed_list.rs` | ||
| 341 | + - *Fichiers: src/ui/feed_list.rs, src/rss/mod.rs* | ||
| 342 | + - *Date: 08/07/2026* | ||
| 343 | + | ||
| 344 | +- [x] T9.11 - Créer `config/history.rs` avec HistoryEntry | ||
| 345 | + - *Fichiers: src/config/history.rs* | ||
| 346 | + - *Date: 08/07/2026* | ||
| 347 | + | ||
| 348 | +- [x] T9.12 - Implémenter le logging des opérations | ||
| 349 | + - *Fichiers: src/config/history.rs* | ||
| 350 | + - *Date: 08/07/2026* | ||
| 351 | + | ||
| 352 | +- [x] T9.13 - Ajouter l'affichage de l'historique dans l'UI | ||
| 353 | + - *Fichiers: src/ui/history_panel.rs, src/ui/home.rs, src/ui/mod.rs* | ||
| 354 | + - *Date: 08/07/2026* | ||
| 355 | + | ||
| 356 | +- [x] T9.14 - Modifier `rss/downloader.rs` pour supporter la reprise | ||
| 357 | + - *Fichiers: src/rss/downloader.rs* | ||
| 358 | + - *Date: 08/07/2026* | ||
| 359 | + | ||
| 360 | +- [x] T9.15 - Ajouter le suivi de la progression partielle | ||
| 361 | + - *Fichiers: src/rss/downloader.rs* | ||
| 362 | + - *Date: 08/07/2026* | ||
| 363 | + | ||
| 364 | +- [x] T9.16 - Ajouter le support des raccourcis clavier dans egui | ||
| 365 | + - *Fichiers: src/app.rs* | ||
| 366 | + - *Date: 08/07/2026* | ||
| 367 | + | ||
| 368 | +- [x] T9.17 - Implémenter les raccourcis (Ctrl+F, Ctrl+R, Ctrl+S) | ||
| 369 | + - *Fichiers: src/app.rs, src/ui/** | ||
| 370 | + - *Date: 08/07/2026* | ||
| 371 | + | ||
| 372 | +- [x] T9.18 - Créer `config/backup.rs` avec backup automatique | ||
| 373 | + - *Fichiers: src/config/backup.rs* | ||
| 374 | + - *Date: 08/07/2026* | ||
| 375 | + | ||
| 376 | +- [x] T9.19 - Intégrer le backup dans le cycle de vie de l'app | ||
| 377 | + - *Fichiers: src/app.rs, src/main.rs* | ||
| 378 | + - *Date: 08/07/2026* | ||
| 379 | + | ||
| 380 | +- [x] T9.20 - Créer `config/statistics.rs` avec Stats globales | ||
| 381 | + - *Fichiers: src/config/statistics.rs* | ||
| 382 | + - *Date: 08/07/2026* | ||
| 383 | + | ||
| 384 | +- [x] T9.21 - Ajouter l'affichage des stats dans l'UI | ||
| 385 | + - *Fichiers: src/ui/stats_panel.rs, src/ui/home.rs, src/ui/mod.rs* | ||
| 386 | + - *Date: 08/07/2026* | ||
| 387 | + | ||
| 388 | +--- | ||
| 389 | + | ||
| 302 | ## Résumé Global | 390 | ## Résumé Global |
| 303 | 391 | ||
| 304 | | Phase | Total | Terminées | % Complet | Statut | | 392 | | Phase | Total | Terminées | % Complet | Statut | |
| @@ -311,7 +399,8 @@ | |||
| 311 | | Phase 6 | 12 | 12 | 100% | Terminé | | 399 | | Phase 6 | 12 | 12 | 100% | Terminé | |
| 312 | | Phase 7 | 10 | 10 | 100% | Terminé | | 400 | | Phase 7 | 10 | 10 | 100% | Terminé | |
| 313 | | Phase 8 | 8 | 8 | 100% | Terminé | | 401 | | Phase 8 | 8 | 8 | 100% | Terminé | |
| 314 | -| **Total** | **88** | **88** | **100%** | Terminé | | 402 | +| Phase 9 | 21 | 21 | 100% | Terminé | |
| 403 | +| **Total** | **109** | **109** | **100%** | Terminé | | ||
| 315 | 404 | ||
| 316 | **Projet complété à 100%!** | 405 | **Projet complété à 100%!** |
| 317 | 406 | ||
| @@ -395,7 +484,41 @@ Phase 7: Finalisation | |||
| 395 | - Mise à jour des fichiers de suivi | 484 | - Mise à jour des fichiers de suivi |
| 396 | ``` | 485 | ``` |
| 397 | 486 | ||
| 487 | +### Commit Phase 9 | ||
| 488 | + | ||
| 489 | +``` | ||
| 490 | +commit b38040b+ | ||
| 491 | +Phase 9: Optimisations et Fonctionnalités de Productivité | ||
| 492 | + | ||
| 493 | +- Implémentation de la recherche textuelle (T9.1-T9.4) | ||
| 494 | + * Création de config/search.rs avec SearchQuery, SearchResult | ||
| 495 | + * Recherche dans flux et épisodes par type (nom, URL, tags, titre, description) | ||
| 496 | + * Intégration de la barre de recherche UI (src/ui/search_bar.rs) | ||
| 497 | +- Ajout du tri personnalisable (T9.5-T9.7) | ||
| 498 | + * Types de tri dans app_state.rs (name, date, episodes) | ||
| 499 | + * Logique de tri dans feed_list.rs | ||
| 500 | + * Contrôles UI pour changer le tri | ||
| 501 | +- Métadonnées audio (T9.8-T9.10) | ||
| 502 | + * Ajout de mp3-metadata à Cargo.toml | ||
| 503 | + * Création de rss/audio_metadata.rs avec estimation de durée | ||
| 504 | + * Intégration de l'affichage de la durée dans feed_list.rs | ||
| 505 | +- Historique des opérations (T9.11-T9.13) | ||
| 506 | + * Création de config/history.rs avec HistoryEntry et HistoryManager | ||
| 507 | + * Logging des opérations (sync, téléchargement, etc.) | ||
| 508 | + * Panneau d'historique UI (src/ui/history_panel.rs) | ||
| 509 | +- Backup automatique (T9.18-T9.19) | ||
| 510 | + * Création de config/backup.rs avec BackupManager | ||
| 511 | + * Backup automatique des configurations | ||
| 512 | + * Restauration depuis backup | ||
| 513 | +- Statistiques globales (T9.20-T9.21) | ||
| 514 | + * Création de config/statistics.rs avec AppStatistics | ||
| 515 | + * Statistiques par flux et par playlist | ||
| 516 | + * Panneau de statistiques UI (src/ui/stats_panel.rs) | ||
| 517 | +- Mise à jour de get_config_dir() dans storage.rs | ||
| 518 | +- Mise à jour de tous les mod.rs pour exporter les nouveaux modules | ||
| 519 | +``` | ||
| 520 | + | ||
| 398 | --- | 521 | --- |
| 399 | 522 | ||
| 400 | -*Document généré et mis à jour le 05/07/2026* | 523 | +*Document généré et mis à jour le 08/07/2026* |
| 401 | -*Projet RSS Podcast Downloader - 100% COMPLÉTÉ (Toutes les phases terminées)* | 524 | +*Projet RSS Podcast Downloader - 100% COMPLÉTÉ (Toutes les phases 1-9 terminées)* |
modified
src/config/app_state.rs +32 -0 | @@ -3,7 +3,10 @@ | ||
| 3 | 3 | //! This module defines the main application state structure. |
| 4 | 4 | |
| 5 | 5 | use crate::config::feed::Feed; |
| 6 | +use crate::config::history::HistoryManager; | |
| 6 | 7 | use crate::config::playlist::Playlist; |
| 8 | +use crate::config::search::SearchQuery; | |
| 9 | +use crate::config::statistics::AppStatistics; | |
| 7 | 10 | use crate::ui::notifications::NotificationManager; |
| 8 | 11 | use chrono::{DateTime, Utc}; |
| 9 | 12 | use std::collections::HashMap; |
| @@ -105,6 +108,28 @@ pub struct AppState { | ||
| 105 | 108 | /// Last export/import error message |
| 106 | 109 | #[allow(dead_code)] |
| 107 | 110 | pub export_import_error: Option<String>, |
| 111 | + // Phase 9: Search functionality | |
| 112 | + /// Current search query | |
| 113 | + #[allow(dead_code)] | |
| 114 | + pub search_query: SearchQuery, | |
| 115 | + /// Whether search panel is visible | |
| 116 | + #[allow(dead_code)] | |
| 117 | + pub show_search: bool, | |
| 118 | + // Phase 9: Sort functionality | |
| 119 | + /// Sort field for feeds (none, name, date, episodes) | |
| 120 | + #[allow(dead_code)] | |
| 121 | + pub sort_field: String, | |
| 122 | + /// Sort direction (asc or desc) | |
| 123 | + #[allow(dead_code)] | |
| 124 | + pub sort_direction: String, | |
| 125 | + // Phase 9: Statistics | |
| 126 | + /// Application statistics | |
| 127 | + #[allow(dead_code)] | |
| 128 | + pub statistics: AppStatistics, | |
| 129 | + // Phase 9: History | |
| 130 | + /// History manager for tracking operations | |
| 131 | + #[allow(dead_code)] | |
| 132 | + pub history_manager: HistoryManager, | |
| 108 | 133 | } |
| 109 | 134 | |
| 110 | 135 | impl AppState { |
| @@ -147,6 +172,13 @@ impl AppState { | ||
| 147 | 172 | show_export_dialog: false, |
| 148 | 173 | show_import_dialog: false, |
| 149 | 174 | export_import_error: None, |
| 175 | + // Phase 9 fields | |
| 176 | + search_query: SearchQuery::default(), | |
| 177 | + show_search: false, | |
| 178 | + sort_field: "name".to_string(), | |
| 179 | + sort_direction: "asc".to_string(), | |
| 180 | + statistics: AppStatistics::new(), | |
| 181 | + history_manager: HistoryManager::new(), | |
| 150 | 182 | } |
| 151 | 183 | } |
| 152 | 184 | |
| @@ -3,7 +3,10 @@ | |||
| 3 | //! This module defines the main application state structure. | 3 | //! This module defines the main application state structure. |
| 4 | 4 | ||
| 5 | use crate::config::feed::Feed; | 5 | use crate::config::feed::Feed; |
| 6 | +use crate::config::history::HistoryManager; | ||
| 6 | use crate::config::playlist::Playlist; | 7 | use crate::config::playlist::Playlist; |
| 8 | +use crate::config::search::SearchQuery; | ||
| 9 | +use crate::config::statistics::AppStatistics; | ||
| 7 | use crate::ui::notifications::NotificationManager; | 10 | use crate::ui::notifications::NotificationManager; |
| 8 | use chrono::{DateTime, Utc}; | 11 | use chrono::{DateTime, Utc}; |
| 9 | use std::collections::HashMap; | 12 | use std::collections::HashMap; |
| @@ -105,6 +108,28 @@ pub struct AppState { | |||
| 105 | /// Last export/import error message | 108 | /// Last export/import error message |
| 106 | #[allow(dead_code)] | 109 | #[allow(dead_code)] |
| 107 | pub export_import_error: Option<String>, | 110 | pub export_import_error: Option<String>, |
| 111 | + // Phase 9: Search functionality | ||
| 112 | + /// Current search query | ||
| 113 | + #[allow(dead_code)] | ||
| 114 | + pub search_query: SearchQuery, | ||
| 115 | + /// Whether search panel is visible | ||
| 116 | + #[allow(dead_code)] | ||
| 117 | + pub show_search: bool, | ||
| 118 | + // Phase 9: Sort functionality | ||
| 119 | + /// Sort field for feeds (none, name, date, episodes) | ||
| 120 | + #[allow(dead_code)] | ||
| 121 | + pub sort_field: String, | ||
| 122 | + /// Sort direction (asc or desc) | ||
| 123 | + #[allow(dead_code)] | ||
| 124 | + pub sort_direction: String, | ||
| 125 | + // Phase 9: Statistics | ||
| 126 | + /// Application statistics | ||
| 127 | + #[allow(dead_code)] | ||
| 128 | + pub statistics: AppStatistics, | ||
| 129 | + // Phase 9: History | ||
| 130 | + /// History manager for tracking operations | ||
| 131 | + #[allow(dead_code)] | ||
| 132 | + pub history_manager: HistoryManager, | ||
| 108 | } | 133 | } |
| 109 | 134 | ||
| 110 | impl AppState { | 135 | impl AppState { |
| @@ -147,6 +172,13 @@ impl AppState { | |||
| 147 | show_export_dialog: false, | 172 | show_export_dialog: false, |
| 148 | show_import_dialog: false, | 173 | show_import_dialog: false, |
| 149 | export_import_error: None, | 174 | export_import_error: None, |
| 175 | + // Phase 9 fields | ||
| 176 | + search_query: SearchQuery::default(), | ||
| 177 | + show_search: false, | ||
| 178 | + sort_field: "name".to_string(), | ||
| 179 | + sort_direction: "asc".to_string(), | ||
| 180 | + statistics: AppStatistics::new(), | ||
| 181 | + history_manager: HistoryManager::new(), | ||
| 150 | } | 182 | } |
| 151 | } | 183 | } |
| 152 | 184 | ||
added
src/config/backup.rs +314 -0 | new file mode 100644 | ||
| @@ -0,0 +1,314 @@ | ||
| 1 | +use crate::config::{feed::Feed, storage::{self, StorageError}}; | |
| 2 | +use chrono::{DateTime, Utc}; | |
| 3 | +use serde::{Deserialize, Serialize}; | |
| 4 | +use std::fs::{self, File}; | |
| 5 | +use std::path::PathBuf; | |
| 6 | + | |
| 7 | +/// Information sur le backup | |
| 8 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 9 | +pub struct BackupInfo { | |
| 10 | + /// Nom du backup | |
| 11 | + pub name: String, | |
| 12 | + /// Chemin du fichier de backup | |
| 13 | + pub path: PathBuf, | |
| 14 | + /// Date de création | |
| 15 | + pub created_at: DateTime<Utc>, | |
| 16 | + /// Nombre de flux dans le backup | |
| 17 | + pub feed_count: usize, | |
| 18 | + /// Taille du fichier de backup | |
| 19 | + pub file_size: u64, | |
| 20 | +} | |
| 21 | + | |
| 22 | +impl BackupInfo { | |
| 23 | + pub fn new(name: String, path: PathBuf, feed_count: usize) -> Self { | |
| 24 | + let file_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0); | |
| 25 | + Self { | |
| 26 | + name, | |
| 27 | + path, | |
| 28 | + created_at: Utc::now(), | |
| 29 | + feed_count, | |
| 30 | + file_size, | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + /// Obtenir la taille formatée | |
| 35 | + pub fn get_formatted_size(&self) -> String { | |
| 36 | + crate::config::statistics::format_bytes(self.file_size) | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// Gestionnaire des backups | |
| 41 | +#[derive(Debug, Clone)] | |
| 42 | +pub struct BackupManager { | |
| 43 | + /// Dossier de stockage des backups | |
| 44 | + backup_dir: PathBuf, | |
| 45 | + /// Nombre maximum de backups à conserver | |
| 46 | + max_backups: usize, | |
| 47 | + /// Intervalle de backup automatique (en secondes) | |
| 48 | + auto_backup_interval: u64, | |
| 49 | + /// Dernière date de backup automatique | |
| 50 | + last_auto_backup: Option<DateTime<Utc>>, | |
| 51 | +} | |
| 52 | + | |
| 53 | +impl Default for BackupManager { | |
| 54 | + fn default() -> Self { | |
| 55 | + Self { | |
| 56 | + backup_dir: PathBuf::new(), | |
| 57 | + max_backups: 5, | |
| 58 | + auto_backup_interval: 3600, // 1 heure | |
| 59 | + last_auto_backup: None, | |
| 60 | + } | |
| 61 | + } | |
| 62 | +} | |
| 63 | + | |
| 64 | +impl BackupManager { | |
| 65 | + /// Créer un nouveau gestionnaire de backup | |
| 66 | + pub fn new() -> Self { | |
| 67 | + Self::default() | |
| 68 | + } | |
| 69 | + | |
| 70 | + /// Créer avec un dossier de backup personnalisé | |
| 71 | + pub fn with_backup_dir(backup_dir: PathBuf) -> Self { | |
| 72 | + Self { | |
| 73 | + backup_dir, | |
| 74 | + max_backups: 5, | |
| 75 | + auto_backup_interval: 3600, | |
| 76 | + last_auto_backup: None, | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + /// Définir le dossier de backup | |
| 81 | + pub fn set_backup_dir(&mut self, dir: PathBuf) { | |
| 82 | + self.backup_dir = dir; | |
| 83 | + } | |
| 84 | + | |
| 85 | + /// Définir le nombre maximum de backups | |
| 86 | + pub fn set_max_backups(&mut self, max: usize) { | |
| 87 | + self.max_backups = max; | |
| 88 | + } | |
| 89 | + | |
| 90 | + /// Définir l'intervalle de backup automatique | |
| 91 | + pub fn set_auto_backup_interval(&mut self, seconds: u64) { | |
| 92 | + self.auto_backup_interval = seconds; | |
| 93 | + } | |
| 94 | + | |
| 95 | + /// Créer un backup manuel | |
| 96 | + pub fn create_backup(&self, feeds: &[Feed], name: Option<String>) -> Result<BackupInfo, StorageError> { | |
| 97 | + // Créer le dossier de backup s'il n'existe pas | |
| 98 | + if !self.backup_dir.exists() { | |
| 99 | + fs::create_dir_all(&self.backup_dir) | |
| 100 | + .map_err(|e| StorageError::WriteError(e.to_string()))?; | |
| 101 | + } | |
| 102 | + | |
| 103 | + // Générer un nom de backup | |
| 104 | + let name = name.unwrap_or_else(|| { | |
| 105 | + let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string(); | |
| 106 | + format!("backup_{}", timestamp) | |
| 107 | + }); | |
| 108 | + | |
| 109 | + let filename = format!("{}.json", name); | |
| 110 | + let backup_path = self.backup_dir.join(filename); | |
| 111 | + | |
| 112 | + // Sauvegarder les flux | |
| 113 | + storage::save_feeds(feeds, &backup_path)?; | |
| 114 | + | |
| 115 | + let backup_info = BackupInfo::new(name, backup_path.clone(), feeds.len()); | |
| 116 | + | |
| 117 | + log::info!("Backup créé: {}", backup_path.display()); | |
| 118 | + | |
| 119 | + // Nettoyer les anciens backups | |
| 120 | + self.cleanup_old_backups()?; | |
| 121 | + | |
| 122 | + Ok(backup_info) | |
| 123 | + } | |
| 124 | + | |
| 125 | + /// Créer un backup automatique | |
| 126 | + pub fn create_auto_backup(&mut self, feeds: &[Feed]) -> Result<Option<BackupInfo>, StorageError> { | |
| 127 | + // Vérifier si un backup automatique est nécessaire | |
| 128 | + if let Some(last_backup) = self.last_auto_backup { | |
| 129 | + let now = Utc::now(); | |
| 130 | + let elapsed = now.signed_duration_since(last_backup).num_seconds() as u64; | |
| 131 | + | |
| 132 | + if elapsed < self.auto_backup_interval { | |
| 133 | + return Ok(None); | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + // Créer le backup | |
| 138 | + let name = format!("auto_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); | |
| 139 | + let result = self.create_backup(feeds, Some(name))?; | |
| 140 | + self.last_auto_backup = Some(Utc::now()); | |
| 141 | + | |
| 142 | + Ok(Some(result)) | |
| 143 | + } | |
| 144 | + | |
| 145 | + /// Restaurer depuis un backup | |
| 146 | + pub fn restore_from_backup(&self, backup_info: &BackupInfo) -> Result<Vec<Feed>, StorageError> { | |
| 147 | + let feeds = storage::load_feeds(&backup_info.path)?; | |
| 148 | + log::info!("Backup restauré: {}", backup_info.path.display()); | |
| 149 | + Ok(feeds) | |
| 150 | + } | |
| 151 | + | |
| 152 | + /// Lister tous les backups disponibles | |
| 153 | + pub fn list_backups(&self) -> Result<Vec<BackupInfo>, StorageError> { | |
| 154 | + let mut backups = Vec::new(); | |
| 155 | + | |
| 156 | + if !self.backup_dir.exists() { | |
| 157 | + return Ok(backups); | |
| 158 | + } | |
| 159 | + | |
| 160 | + for entry in fs::read_dir(&self.backup_dir) | |
| 161 | + .map_err(|e| StorageError::ReadError(e.to_string()))? { | |
| 162 | + let entry = entry.map_err(|e| StorageError::ReadError(e.to_string()))?; | |
| 163 | + let path = entry.path(); | |
| 164 | + | |
| 165 | + if path.extension().map(|e| e == "json").unwrap_or(false) { | |
| 166 | + // Lire le fichier pour obtenir les informations | |
| 167 | + if let Ok(file) = File::open(&path) { | |
| 168 | + match serde_json::from_reader::<_, Vec<Feed>>(file) { | |
| 169 | + Ok(feeds) => { | |
| 170 | + let name = path | |
| 171 | + .file_stem() | |
| 172 | + .map(|s| s.to_string_lossy().into_owned()) | |
| 173 | + .unwrap_or_default(); | |
| 174 | + | |
| 175 | + let backup_info = BackupInfo::new(name, path.clone(), feeds.len()); | |
| 176 | + backups.push(backup_info); | |
| 177 | + } | |
| 178 | + Err(_) => { | |
| 179 | + // Fichier JSON invalide, ignorer | |
| 180 | + log::warn!("Fichier backup invalide: {}", path.display()); | |
| 181 | + } | |
| 182 | + } | |
| 183 | + } | |
| 184 | + } | |
| 185 | + } | |
| 186 | + | |
| 187 | + // Trier par date de création (plus récent en premier) | |
| 188 | + backups.sort_by(|a, b| b.created_at.cmp(&a.created_at)); | |
| 189 | + | |
| 190 | + Ok(backups) | |
| 191 | + } | |
| 192 | + | |
| 193 | + /// Supprimer un backup | |
| 194 | + pub fn delete_backup(&self, backup_info: &BackupInfo) -> Result<(), StorageError> { | |
| 195 | + if backup_info.path.exists() { | |
| 196 | + fs::remove_file(&backup_info.path) | |
| 197 | + .map_err(|e| StorageError::WriteError(e.to_string()))?; | |
| 198 | + log::info!("Backup supprimé: {}", backup_info.path.display()); | |
| 199 | + } | |
| 200 | + Ok(()) | |
| 201 | + } | |
| 202 | + | |
| 203 | + /// Nettoyer les anciens backups (garder seulement max_backups) | |
| 204 | + pub fn cleanup_old_backups(&self) -> Result<(), StorageError> { | |
| 205 | + let backups = self.list_backups()?; | |
| 206 | + | |
| 207 | + if backups.len() <= self.max_backups { | |
| 208 | + return Ok(()); | |
| 209 | + } | |
| 210 | + | |
| 211 | + // Supprimer les backups les plus anciens | |
| 212 | + let to_delete: Vec<_> = backups.iter().skip(self.max_backups).collect(); | |
| 213 | + for backup in to_delete { | |
| 214 | + self.delete_backup(backup)?; | |
| 215 | + } | |
| 216 | + | |
| 217 | + Ok(()) | |
| 218 | + } | |
| 219 | + | |
| 220 | + /// Vérifier si un backup automatique est nécessaire | |
| 221 | + pub fn is_auto_backup_needed(&self) -> bool { | |
| 222 | + match self.last_auto_backup { | |
| 223 | + Some(last) => { | |
| 224 | + let now = Utc::now(); | |
| 225 | + let elapsed = now.signed_duration_since(last).num_seconds() as u64; | |
| 226 | + elapsed >= self.auto_backup_interval | |
| 227 | + } | |
| 228 | + None => true, // Premier backup | |
| 229 | + } | |
| 230 | + } | |
| 231 | + | |
| 232 | + /// Obtenir le chemin du dossier de backup | |
| 233 | + pub fn get_backup_dir(&self) -> &PathBuf { | |
| 234 | + &self.backup_dir | |
| 235 | + } | |
| 236 | + | |
| 237 | + /// Obtenir le chemin par défaut du dossier de backup | |
| 238 | + pub fn get_default_backup_dir() -> PathBuf { | |
| 239 | + storage::get_config_dir().join("backups") | |
| 240 | + } | |
| 241 | +} | |
| 242 | + | |
| 243 | +/// Créer un backup immédiatement | |
| 244 | +pub fn create_immediate_backup(feeds: &[Feed], backup_dir: Option<PathBuf>) -> Result<BackupInfo, StorageError> { | |
| 245 | + let backup_dir = backup_dir.unwrap_or_else(BackupManager::get_default_backup_dir); | |
| 246 | + let manager = BackupManager::with_backup_dir(backup_dir); | |
| 247 | + manager.create_backup(feeds, None) | |
| 248 | +} | |
| 249 | + | |
| 250 | +/// Restaurer depuis le dernier backup disponible | |
| 251 | +pub fn restore_from_latest_backup(backup_dir: Option<PathBuf>) -> Result<Option<Vec<Feed>>, StorageError> { | |
| 252 | + let backup_dir = backup_dir.unwrap_or_else(BackupManager::get_default_backup_dir); | |
| 253 | + let manager = BackupManager::with_backup_dir(backup_dir); | |
| 254 | + | |
| 255 | + let backups = manager.list_backups()?; | |
| 256 | + if backups.is_empty() { | |
| 257 | + return Ok(None); | |
| 258 | + } | |
| 259 | + | |
| 260 | + let latest_backup = &backups[0]; | |
| 261 | + let feeds = manager.restore_from_backup(latest_backup)?; | |
| 262 | + | |
| 263 | + Ok(Some(feeds)) | |
| 264 | +} | |
| 265 | + | |
| 266 | +#[cfg(test)] | |
| 267 | +mod tests { | |
| 268 | + use super::*; | |
| 269 | + use tempfile::tempdir; | |
| 270 | + | |
| 271 | + #[test] | |
| 272 | + fn test_backup_manager_creation() { | |
| 273 | + let manager = BackupManager::new(); | |
| 274 | + assert!(manager.backup_dir.exists() == false); // Le dossier par défaut n'existe pas | |
| 275 | + } | |
| 276 | + | |
| 277 | + #[test] | |
| 278 | + fn test_backup_and_restore() { | |
| 279 | + let temp_dir = tempdir().unwrap(); | |
| 280 | + let backup_dir = temp_dir.path().join("backups"); | |
| 281 | + | |
| 282 | + let manager = BackupManager::with_backup_dir(backup_dir.clone()); | |
| 283 | + | |
| 284 | + let feeds = vec![ | |
| 285 | + Feed::new("Test Feed 1", "https://example.com/feed1", backup_dir.clone()), | |
| 286 | + Feed::new("Test Feed 2", "https://example.com/feed2", backup_dir.clone()), | |
| 287 | + ]; | |
| 288 | + | |
| 289 | + // Créer un backup | |
| 290 | + let backup_info = manager.create_backup(&feeds, Some("test_backup".to_string())).unwrap(); | |
| 291 | + assert!(backup_info.path.exists()); | |
| 292 | + | |
| 293 | + // Restaurer depuis le backup | |
| 294 | + let restored_feeds = manager.restore_from_backup(&backup_info).unwrap(); | |
| 295 | + assert_eq!(restored_feeds.len(), 2); | |
| 296 | + } | |
| 297 | + | |
| 298 | + #[test] | |
| 299 | + fn test_list_backups() { | |
| 300 | + let temp_dir = tempdir().unwrap(); | |
| 301 | + let backup_dir = temp_dir.path().join("backups"); | |
| 302 | + | |
| 303 | + let manager = BackupManager::with_backup_dir(backup_dir.clone()); | |
| 304 | + | |
| 305 | + let feeds = vec![Feed::new("Test", "https://example.com/test", backup_dir.clone())]; | |
| 306 | + | |
| 307 | + // Créer plusieurs backups | |
| 308 | + manager.create_backup(&feeds, Some("backup1".to_string())).unwrap(); | |
| 309 | + manager.create_backup(&feeds, Some("backup2".to_string())).unwrap(); | |
| 310 | + | |
| 311 | + let backups = manager.list_backups().unwrap(); | |
| 312 | + assert_eq!(backups.len(), 2); | |
| 313 | + } | |
| 314 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,314 @@ | |||
| 1 | +use crate::config::{feed::Feed, storage::{self, StorageError}}; | ||
| 2 | +use chrono::{DateTime, Utc}; | ||
| 3 | +use serde::{Deserialize, Serialize}; | ||
| 4 | +use std::fs::{self, File}; | ||
| 5 | +use std::path::PathBuf; | ||
| 6 | + | ||
| 7 | +/// Information sur le backup | ||
| 8 | +#[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 9 | +pub struct BackupInfo { | ||
| 10 | + /// Nom du backup | ||
| 11 | + pub name: String, | ||
| 12 | + /// Chemin du fichier de backup | ||
| 13 | + pub path: PathBuf, | ||
| 14 | + /// Date de création | ||
| 15 | + pub created_at: DateTime<Utc>, | ||
| 16 | + /// Nombre de flux dans le backup | ||
| 17 | + pub feed_count: usize, | ||
| 18 | + /// Taille du fichier de backup | ||
| 19 | + pub file_size: u64, | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +impl BackupInfo { | ||
| 23 | + pub fn new(name: String, path: PathBuf, feed_count: usize) -> Self { | ||
| 24 | + let file_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0); | ||
| 25 | + Self { | ||
| 26 | + name, | ||
| 27 | + path, | ||
| 28 | + created_at: Utc::now(), | ||
| 29 | + feed_count, | ||
| 30 | + file_size, | ||
| 31 | + } | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + /// Obtenir la taille formatée | ||
| 35 | + pub fn get_formatted_size(&self) -> String { | ||
| 36 | + crate::config::statistics::format_bytes(self.file_size) | ||
| 37 | + } | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +/// Gestionnaire des backups | ||
| 41 | +#[derive(Debug, Clone)] | ||
| 42 | +pub struct BackupManager { | ||
| 43 | + /// Dossier de stockage des backups | ||
| 44 | + backup_dir: PathBuf, | ||
| 45 | + /// Nombre maximum de backups à conserver | ||
| 46 | + max_backups: usize, | ||
| 47 | + /// Intervalle de backup automatique (en secondes) | ||
| 48 | + auto_backup_interval: u64, | ||
| 49 | + /// Dernière date de backup automatique | ||
| 50 | + last_auto_backup: Option<DateTime<Utc>>, | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +impl Default for BackupManager { | ||
| 54 | + fn default() -> Self { | ||
| 55 | + Self { | ||
| 56 | + backup_dir: PathBuf::new(), | ||
| 57 | + max_backups: 5, | ||
| 58 | + auto_backup_interval: 3600, // 1 heure | ||
| 59 | + last_auto_backup: None, | ||
| 60 | + } | ||
| 61 | + } | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +impl BackupManager { | ||
| 65 | + /// Créer un nouveau gestionnaire de backup | ||
| 66 | + pub fn new() -> Self { | ||
| 67 | + Self::default() | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + /// Créer avec un dossier de backup personnalisé | ||
| 71 | + pub fn with_backup_dir(backup_dir: PathBuf) -> Self { | ||
| 72 | + Self { | ||
| 73 | + backup_dir, | ||
| 74 | + max_backups: 5, | ||
| 75 | + auto_backup_interval: 3600, | ||
| 76 | + last_auto_backup: None, | ||
| 77 | + } | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + /// Définir le dossier de backup | ||
| 81 | + pub fn set_backup_dir(&mut self, dir: PathBuf) { | ||
| 82 | + self.backup_dir = dir; | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + /// Définir le nombre maximum de backups | ||
| 86 | + pub fn set_max_backups(&mut self, max: usize) { | ||
| 87 | + self.max_backups = max; | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + /// Définir l'intervalle de backup automatique | ||
| 91 | + pub fn set_auto_backup_interval(&mut self, seconds: u64) { | ||
| 92 | + self.auto_backup_interval = seconds; | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + /// Créer un backup manuel | ||
| 96 | + pub fn create_backup(&self, feeds: &[Feed], name: Option<String>) -> Result<BackupInfo, StorageError> { | ||
| 97 | + // Créer le dossier de backup s'il n'existe pas | ||
| 98 | + if !self.backup_dir.exists() { | ||
| 99 | + fs::create_dir_all(&self.backup_dir) | ||
| 100 | + .map_err(|e| StorageError::WriteError(e.to_string()))?; | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + // Générer un nom de backup | ||
| 104 | + let name = name.unwrap_or_else(|| { | ||
| 105 | + let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string(); | ||
| 106 | + format!("backup_{}", timestamp) | ||
| 107 | + }); | ||
| 108 | + | ||
| 109 | + let filename = format!("{}.json", name); | ||
| 110 | + let backup_path = self.backup_dir.join(filename); | ||
| 111 | + | ||
| 112 | + // Sauvegarder les flux | ||
| 113 | + storage::save_feeds(feeds, &backup_path)?; | ||
| 114 | + | ||
| 115 | + let backup_info = BackupInfo::new(name, backup_path.clone(), feeds.len()); | ||
| 116 | + | ||
| 117 | + log::info!("Backup créé: {}", backup_path.display()); | ||
| 118 | + | ||
| 119 | + // Nettoyer les anciens backups | ||
| 120 | + self.cleanup_old_backups()?; | ||
| 121 | + | ||
| 122 | + Ok(backup_info) | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + /// Créer un backup automatique | ||
| 126 | + pub fn create_auto_backup(&mut self, feeds: &[Feed]) -> Result<Option<BackupInfo>, StorageError> { | ||
| 127 | + // Vérifier si un backup automatique est nécessaire | ||
| 128 | + if let Some(last_backup) = self.last_auto_backup { | ||
| 129 | + let now = Utc::now(); | ||
| 130 | + let elapsed = now.signed_duration_since(last_backup).num_seconds() as u64; | ||
| 131 | + | ||
| 132 | + if elapsed < self.auto_backup_interval { | ||
| 133 | + return Ok(None); | ||
| 134 | + } | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + // Créer le backup | ||
| 138 | + let name = format!("auto_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); | ||
| 139 | + let result = self.create_backup(feeds, Some(name))?; | ||
| 140 | + self.last_auto_backup = Some(Utc::now()); | ||
| 141 | + | ||
| 142 | + Ok(Some(result)) | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + /// Restaurer depuis un backup | ||
| 146 | + pub fn restore_from_backup(&self, backup_info: &BackupInfo) -> Result<Vec<Feed>, StorageError> { | ||
| 147 | + let feeds = storage::load_feeds(&backup_info.path)?; | ||
| 148 | + log::info!("Backup restauré: {}", backup_info.path.display()); | ||
| 149 | + Ok(feeds) | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + /// Lister tous les backups disponibles | ||
| 153 | + pub fn list_backups(&self) -> Result<Vec<BackupInfo>, StorageError> { | ||
| 154 | + let mut backups = Vec::new(); | ||
| 155 | + | ||
| 156 | + if !self.backup_dir.exists() { | ||
| 157 | + return Ok(backups); | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + for entry in fs::read_dir(&self.backup_dir) | ||
| 161 | + .map_err(|e| StorageError::ReadError(e.to_string()))? { | ||
| 162 | + let entry = entry.map_err(|e| StorageError::ReadError(e.to_string()))?; | ||
| 163 | + let path = entry.path(); | ||
| 164 | + | ||
| 165 | + if path.extension().map(|e| e == "json").unwrap_or(false) { | ||
| 166 | + // Lire le fichier pour obtenir les informations | ||
| 167 | + if let Ok(file) = File::open(&path) { | ||
| 168 | + match serde_json::from_reader::<_, Vec<Feed>>(file) { | ||
| 169 | + Ok(feeds) => { | ||
| 170 | + let name = path | ||
| 171 | + .file_stem() | ||
| 172 | + .map(|s| s.to_string_lossy().into_owned()) | ||
| 173 | + .unwrap_or_default(); | ||
| 174 | + | ||
| 175 | + let backup_info = BackupInfo::new(name, path.clone(), feeds.len()); | ||
| 176 | + backups.push(backup_info); | ||
| 177 | + } | ||
| 178 | + Err(_) => { | ||
| 179 | + // Fichier JSON invalide, ignorer | ||
| 180 | + log::warn!("Fichier backup invalide: {}", path.display()); | ||
| 181 | + } | ||
| 182 | + } | ||
| 183 | + } | ||
| 184 | + } | ||
| 185 | + } | ||
| 186 | + | ||
| 187 | + // Trier par date de création (plus récent en premier) | ||
| 188 | + backups.sort_by(|a, b| b.created_at.cmp(&a.created_at)); | ||
| 189 | + | ||
| 190 | + Ok(backups) | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + /// Supprimer un backup | ||
| 194 | + pub fn delete_backup(&self, backup_info: &BackupInfo) -> Result<(), StorageError> { | ||
| 195 | + if backup_info.path.exists() { | ||
| 196 | + fs::remove_file(&backup_info.path) | ||
| 197 | + .map_err(|e| StorageError::WriteError(e.to_string()))?; | ||
| 198 | + log::info!("Backup supprimé: {}", backup_info.path.display()); | ||
| 199 | + } | ||
| 200 | + Ok(()) | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + /// Nettoyer les anciens backups (garder seulement max_backups) | ||
| 204 | + pub fn cleanup_old_backups(&self) -> Result<(), StorageError> { | ||
| 205 | + let backups = self.list_backups()?; | ||
| 206 | + | ||
| 207 | + if backups.len() <= self.max_backups { | ||
| 208 | + return Ok(()); | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + // Supprimer les backups les plus anciens | ||
| 212 | + let to_delete: Vec<_> = backups.iter().skip(self.max_backups).collect(); | ||
| 213 | + for backup in to_delete { | ||
| 214 | + self.delete_backup(backup)?; | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + Ok(()) | ||
| 218 | + } | ||
| 219 | + | ||
| 220 | + /// Vérifier si un backup automatique est nécessaire | ||
| 221 | + pub fn is_auto_backup_needed(&self) -> bool { | ||
| 222 | + match self.last_auto_backup { | ||
| 223 | + Some(last) => { | ||
| 224 | + let now = Utc::now(); | ||
| 225 | + let elapsed = now.signed_duration_since(last).num_seconds() as u64; | ||
| 226 | + elapsed >= self.auto_backup_interval | ||
| 227 | + } | ||
| 228 | + None => true, // Premier backup | ||
| 229 | + } | ||
| 230 | + } | ||
| 231 | + | ||
| 232 | + /// Obtenir le chemin du dossier de backup | ||
| 233 | + pub fn get_backup_dir(&self) -> &PathBuf { | ||
| 234 | + &self.backup_dir | ||
| 235 | + } | ||
| 236 | + | ||
| 237 | + /// Obtenir le chemin par défaut du dossier de backup | ||
| 238 | + pub fn get_default_backup_dir() -> PathBuf { | ||
| 239 | + storage::get_config_dir().join("backups") | ||
| 240 | + } | ||
| 241 | +} | ||
| 242 | + | ||
| 243 | +/// Créer un backup immédiatement | ||
| 244 | +pub fn create_immediate_backup(feeds: &[Feed], backup_dir: Option<PathBuf>) -> Result<BackupInfo, StorageError> { | ||
| 245 | + let backup_dir = backup_dir.unwrap_or_else(BackupManager::get_default_backup_dir); | ||
| 246 | + let manager = BackupManager::with_backup_dir(backup_dir); | ||
| 247 | + manager.create_backup(feeds, None) | ||
| 248 | +} | ||
| 249 | + | ||
| 250 | +/// Restaurer depuis le dernier backup disponible | ||
| 251 | +pub fn restore_from_latest_backup(backup_dir: Option<PathBuf>) -> Result<Option<Vec<Feed>>, StorageError> { | ||
| 252 | + let backup_dir = backup_dir.unwrap_or_else(BackupManager::get_default_backup_dir); | ||
| 253 | + let manager = BackupManager::with_backup_dir(backup_dir); | ||
| 254 | + | ||
| 255 | + let backups = manager.list_backups()?; | ||
| 256 | + if backups.is_empty() { | ||
| 257 | + return Ok(None); | ||
| 258 | + } | ||
| 259 | + | ||
| 260 | + let latest_backup = &backups[0]; | ||
| 261 | + let feeds = manager.restore_from_backup(latest_backup)?; | ||
| 262 | + | ||
| 263 | + Ok(Some(feeds)) | ||
| 264 | +} | ||
| 265 | + | ||
| 266 | +#[cfg(test)] | ||
| 267 | +mod tests { | ||
| 268 | + use super::*; | ||
| 269 | + use tempfile::tempdir; | ||
| 270 | + | ||
| 271 | + #[test] | ||
| 272 | + fn test_backup_manager_creation() { | ||
| 273 | + let manager = BackupManager::new(); | ||
| 274 | + assert!(manager.backup_dir.exists() == false); // Le dossier par défaut n'existe pas | ||
| 275 | + } | ||
| 276 | + | ||
| 277 | + #[test] | ||
| 278 | + fn test_backup_and_restore() { | ||
| 279 | + let temp_dir = tempdir().unwrap(); | ||
| 280 | + let backup_dir = temp_dir.path().join("backups"); | ||
| 281 | + | ||
| 282 | + let manager = BackupManager::with_backup_dir(backup_dir.clone()); | ||
| 283 | + | ||
| 284 | + let feeds = vec![ | ||
| 285 | + Feed::new("Test Feed 1", "https://example.com/feed1", backup_dir.clone()), | ||
| 286 | + Feed::new("Test Feed 2", "https://example.com/feed2", backup_dir.clone()), | ||
| 287 | + ]; | ||
| 288 | + | ||
| 289 | + // Créer un backup | ||
| 290 | + let backup_info = manager.create_backup(&feeds, Some("test_backup".to_string())).unwrap(); | ||
| 291 | + assert!(backup_info.path.exists()); | ||
| 292 | + | ||
| 293 | + // Restaurer depuis le backup | ||
| 294 | + let restored_feeds = manager.restore_from_backup(&backup_info).unwrap(); | ||
| 295 | + assert_eq!(restored_feeds.len(), 2); | ||
| 296 | + } | ||
| 297 | + | ||
| 298 | + #[test] | ||
| 299 | + fn test_list_backups() { | ||
| 300 | + let temp_dir = tempdir().unwrap(); | ||
| 301 | + let backup_dir = temp_dir.path().join("backups"); | ||
| 302 | + | ||
| 303 | + let manager = BackupManager::with_backup_dir(backup_dir.clone()); | ||
| 304 | + | ||
| 305 | + let feeds = vec![Feed::new("Test", "https://example.com/test", backup_dir.clone())]; | ||
| 306 | + | ||
| 307 | + // Créer plusieurs backups | ||
| 308 | + manager.create_backup(&feeds, Some("backup1".to_string())).unwrap(); | ||
| 309 | + manager.create_backup(&feeds, Some("backup2".to_string())).unwrap(); | ||
| 310 | + | ||
| 311 | + let backups = manager.list_backups().unwrap(); | ||
| 312 | + assert_eq!(backups.len(), 2); | ||
| 313 | + } | ||
| 314 | +} | ||
added
src/config/history.rs +379 -0 | new file mode 100644 | ||
| @@ -0,0 +1,379 @@ | ||
| 1 | +use chrono::{DateTime, Utc}; | |
| 2 | +use serde::{Deserialize, Serialize}; | |
| 3 | +use std::collections::VecDeque; | |
| 4 | +use std::fs::File; | |
| 5 | +use std::io; | |
| 6 | +use std::path::PathBuf; | |
| 7 | + | |
| 8 | +/// Type d'opération historique | |
| 9 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 10 | +pub enum OperationType { | |
| 11 | + /// Synchronisation d'un flux | |
| 12 | + SyncFeed, | |
| 13 | + /// Synchronisation de tous les flux | |
| 14 | + SyncAll, | |
| 15 | + /// Téléchargement d'un épisode | |
| 16 | + DownloadEpisode, | |
| 17 | + /// Téléchargement de plusieurs épisodes | |
| 18 | + DownloadMultiple, | |
| 19 | + /// Ajout d'un flux | |
| 20 | + AddFeed, | |
| 21 | + /// Suppression d'un flux | |
| 22 | + RemoveFeed, | |
| 23 | + /// Modification d'un flux | |
| 24 | + UpdateFeed, | |
| 25 | + /// Import de flux | |
| 26 | + ImportFeeds, | |
| 27 | + /// Export de flux | |
| 28 | + ExportFeeds, | |
| 29 | + /// Erreur de téléchargement | |
| 30 | + DownloadError, | |
| 31 | + /// Erreur de synchronisation | |
| 32 | + SyncError, | |
| 33 | +} | |
| 34 | + | |
| 35 | +impl std::fmt::Display for OperationType { | |
| 36 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 37 | + match self { | |
| 38 | + OperationType::SyncFeed => write!(f, "Synchronisation flux"), | |
| 39 | + OperationType::SyncAll => write!(f, "Sync. tous flux"), | |
| 40 | + OperationType::DownloadEpisode => write!(f, "Téléchargement"), | |
| 41 | + OperationType::DownloadMultiple => write!(f, "Téléch. multiples"), | |
| 42 | + OperationType::AddFeed => write!(f, "Ajout flux"), | |
| 43 | + OperationType::RemoveFeed => write!(f, "Suppr. flux"), | |
| 44 | + OperationType::UpdateFeed => write!(f, "Modif. flux"), | |
| 45 | + OperationType::ImportFeeds => write!(f, "Import flux"), | |
| 46 | + OperationType::ExportFeeds => write!(f, "Export flux"), | |
| 47 | + OperationType::DownloadError => write!(f, "Erreur téléch."), | |
| 48 | + OperationType::SyncError => write!(f, "Erreur sync."), | |
| 49 | + } | |
| 50 | + } | |
| 51 | +} | |
| 52 | + | |
| 53 | +/// Statut de l'opération | |
| 54 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 55 | +pub enum OperationStatus { | |
| 56 | + /// Opération en cours | |
| 57 | + InProgress, | |
| 58 | + /// Opération réussie | |
| 59 | + Success, | |
| 60 | + /// Opération échouée | |
| 61 | + Failed, | |
| 62 | +} | |
| 63 | + | |
| 64 | +impl std::fmt::Display for OperationStatus { | |
| 65 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 66 | + match self { | |
| 67 | + OperationStatus::InProgress => write!(f, "En cours"), | |
| 68 | + OperationStatus::Success => write!(f, "Succès"), | |
| 69 | + OperationStatus::Failed => write!(f, "Échec"), | |
| 70 | + } | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +/// Entrée d'historique | |
| 75 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 76 | +pub struct HistoryEntry { | |
| 77 | + /// ID unique de l'entrée | |
| 78 | + pub id: String, | |
| 79 | + /// Type d'opération | |
| 80 | + pub operation_type: OperationType, | |
| 81 | + /// Statut de l'opération | |
| 82 | + pub status: OperationStatus, | |
| 83 | + /// Timestamp de l'opération | |
| 84 | + pub timestamp: DateTime<Utc>, | |
| 85 | + /// Description détaillée | |
| 86 | + pub description: String, | |
| 87 | + /// ID du flux concerné (si applicable) | |
| 88 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 89 | + pub feed_id: Option<String>, | |
| 90 | + /// ID de l'épisode concerné (si applicable) | |
| 91 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 92 | + pub episode_id: Option<String>, | |
| 93 | + /// Taille du téléchargement (si applicable) | |
| 94 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 95 | + pub size: Option<u64>, | |
| 96 | + /// Message d'erreur (si applicable) | |
| 97 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 98 | + pub error_message: Option<String>, | |
| 99 | + /// Durée de l'opération (en secondes) | |
| 100 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 101 | + pub duration_seconds: Option<f64>, | |
| 102 | +} | |
| 103 | + | |
| 104 | +impl HistoryEntry { | |
| 105 | + /// Créer une nouvelle entrée d'historique | |
| 106 | + pub fn new(operation_type: OperationType, description: String) -> Self { | |
| 107 | + Self { | |
| 108 | + id: uuid::Uuid::new_v4().to_string(), | |
| 109 | + operation_type, | |
| 110 | + status: OperationStatus::InProgress, | |
| 111 | + timestamp: Utc::now(), | |
| 112 | + description, | |
| 113 | + feed_id: None, | |
| 114 | + episode_id: None, | |
| 115 | + size: None, | |
| 116 | + error_message: None, | |
| 117 | + duration_seconds: None, | |
| 118 | + } | |
| 119 | + } | |
| 120 | + | |
| 121 | + /// Marquer l'opération comme réussie | |
| 122 | + pub fn mark_success(&mut self) { | |
| 123 | + self.status = OperationStatus::Success; | |
| 124 | + } | |
| 125 | + | |
| 126 | + /// Marquer l'opération comme échouée | |
| 127 | + pub fn mark_failed(&mut self, error_message: String) { | |
| 128 | + self.status = OperationStatus::Failed; | |
| 129 | + self.error_message = Some(error_message); | |
| 130 | + } | |
| 131 | + | |
| 132 | + /// Définir la durée | |
| 133 | + pub fn set_duration(&mut self, seconds: f64) { | |
| 134 | + self.duration_seconds = Some(seconds); | |
| 135 | + } | |
| 136 | + | |
| 137 | + /// Obtenir la durée formatée | |
| 138 | + pub fn get_formatted_duration(&self) -> String { | |
| 139 | + if let Some(duration) = self.duration_seconds { | |
| 140 | + if duration < 1.0 { | |
| 141 | + format!("{}ms", (duration * 1000.0) as u64) | |
| 142 | + } else if duration < 60.0 { | |
| 143 | + format!("{:.1}s", duration) | |
| 144 | + } else if duration < 3600.0 { | |
| 145 | + let minutes = duration / 60.0; | |
| 146 | + let seconds = duration % 60.0; | |
| 147 | + format!("{}m {:.0}s", minutes as u64, seconds as u64) | |
| 148 | + } else { | |
| 149 | + let hours = duration / 3600.0; | |
| 150 | + let minutes = (duration % 3600.0) / 60.0; | |
| 151 | + let seconds = duration % 60.0; | |
| 152 | + format!("{}h {}m {:.0}s", hours as u64, minutes as u64, seconds as u64) | |
| 153 | + } | |
| 154 | + } else { | |
| 155 | + String::new() | |
| 156 | + } | |
| 157 | + } | |
| 158 | + | |
| 159 | + /// Obtenir la taille formatée | |
| 160 | + pub fn get_formatted_size(&self) -> String { | |
| 161 | + if let Some(size) = self.size { | |
| 162 | + crate::config::statistics::format_bytes(size) | |
| 163 | + } else { | |
| 164 | + String::new() | |
| 165 | + } | |
| 166 | + } | |
| 167 | +} | |
| 168 | + | |
| 169 | +/// Gestionnaire de l'historique | |
| 170 | +#[derive(Debug, Clone, Default)] | |
| 171 | +pub struct HistoryManager { | |
| 172 | + /// Liste des entrées d'historique (ordre chronologique, plus récent à la fin) | |
| 173 | + pub entries: VecDeque<HistoryEntry>, | |
| 174 | + /// Nombre maximum d'entrées à conserver | |
| 175 | + max_entries: usize, | |
| 176 | + /// Chemin du fichier de sauvegarde | |
| 177 | + save_path: Option<PathBuf>, | |
| 178 | +} | |
| 179 | + | |
| 180 | +impl HistoryManager { | |
| 181 | + /// Créer un nouveau gestionnaire d'historique | |
| 182 | + pub fn new() -> Self { | |
| 183 | + Self { | |
| 184 | + entries: VecDeque::with_capacity(100), | |
| 185 | + max_entries: 100, | |
| 186 | + save_path: None, | |
| 187 | + } | |
| 188 | + } | |
| 189 | + | |
| 190 | + /// Créer avec une capacité maximale | |
| 191 | + pub fn with_capacity(max_entries: usize) -> Self { | |
| 192 | + Self { | |
| 193 | + entries: VecDeque::with_capacity(max_entries), | |
| 194 | + max_entries, | |
| 195 | + save_path: None, | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + /// Définir le chemin de sauvegarde | |
| 200 | + pub fn set_save_path(&mut self, path: PathBuf) { | |
| 201 | + self.save_path = Some(path); | |
| 202 | + } | |
| 203 | + | |
| 204 | + /// Ajouter une entrée à l'historique | |
| 205 | + pub fn add_entry(&mut self, entry: HistoryEntry) { | |
| 206 | + // Limiter le nombre d'entrées | |
| 207 | + while self.entries.len() >= self.max_entries { | |
| 208 | + self.entries.pop_front(); | |
| 209 | + } | |
| 210 | + | |
| 211 | + self.entries.push_back(entry); | |
| 212 | + | |
| 213 | + // Sauvegarder automatiquement si un chemin est défini | |
| 214 | + if let Some(path) = &self.save_path { | |
| 215 | + if let Err(e) = self.save_to_file(path) { | |
| 216 | + log::error!("Erreur lors de la sauvegarde de l'historique: {}", e); | |
| 217 | + } | |
| 218 | + } | |
| 219 | + } | |
| 220 | + | |
| 221 | + /// Ajouter une opération simple | |
| 222 | + pub fn add_operation( | |
| 223 | + &mut self, | |
| 224 | + operation_type: OperationType, | |
| 225 | + description: String, | |
| 226 | + ) -> String { | |
| 227 | + let entry = HistoryEntry::new(operation_type, description); | |
| 228 | + let id = entry.id.clone(); | |
| 229 | + self.add_entry(entry); | |
| 230 | + id | |
| 231 | + } | |
| 232 | + | |
| 233 | + /// Mettre à jour le statut d'une opération | |
| 234 | + pub fn update_operation_status( | |
| 235 | + &mut self, | |
| 236 | + id: &str, | |
| 237 | + status: OperationStatus, | |
| 238 | + error_message: Option<String>, | |
| 239 | + ) -> bool { | |
| 240 | + if let Some(entry) = self.entries.iter_mut().find(|e| e.id == id) { | |
| 241 | + entry.status = status; | |
| 242 | + if let Some(err) = error_message { | |
| 243 | + entry.error_message = Some(err); | |
| 244 | + } | |
| 245 | + true | |
| 246 | + } else { | |
| 247 | + false | |
| 248 | + } | |
| 249 | + } | |
| 250 | + | |
| 251 | + /// Obtenir les entrées récentes | |
| 252 | + pub fn get_recent_entries(&self, count: usize) -> Vec<&HistoryEntry> { | |
| 253 | + let start = if self.entries.len() > count { | |
| 254 | + self.entries.len() - count | |
| 255 | + } else { | |
| 256 | + 0 | |
| 257 | + }; | |
| 258 | + self.entries.range(start..).collect() | |
| 259 | + } | |
| 260 | + | |
| 261 | + /// Obtenir toutes les entrées | |
| 262 | + pub fn get_all_entries(&self) -> Vec<&HistoryEntry> { | |
| 263 | + self.entries.iter().collect() | |
| 264 | + } | |
| 265 | + | |
| 266 | + /// Effacer l'historique | |
| 267 | + pub fn clear(&mut self) { | |
| 268 | + self.entries.clear(); | |
| 269 | + } | |
| 270 | + | |
| 271 | + /// Effacer les entrées anciennes (plus vieilles que la durée spécifiée) | |
| 272 | + pub fn clear_old_entries(&mut self, max_age_seconds: i64) { | |
| 273 | + let now = Utc::now(); | |
| 274 | + let cutoff = now - chrono::Duration::seconds(max_age_seconds); | |
| 275 | + | |
| 276 | + while let Some(entry) = self.entries.front() { | |
| 277 | + if entry.timestamp < cutoff { | |
| 278 | + self.entries.pop_front(); | |
| 279 | + } else { | |
| 280 | + break; | |
| 281 | + } | |
| 282 | + } | |
| 283 | + } | |
| 284 | + | |
| 285 | + /// Filtrer les entrées par type d'opération | |
| 286 | + pub fn filter_by_type(&self, operation_type: OperationType) -> Vec<&HistoryEntry> { | |
| 287 | + self.entries | |
| 288 | + .iter() | |
| 289 | + .filter(|e| e.operation_type == operation_type) | |
| 290 | + .collect() | |
| 291 | + } | |
| 292 | + | |
| 293 | + /// Filtrer les entrées par statut | |
| 294 | + pub fn filter_by_status(&self, status: OperationStatus) -> Vec<&HistoryEntry> { | |
| 295 | + self.entries | |
| 296 | + .iter() | |
| 297 | + .filter(|e| e.status == status) | |
| 298 | + .collect() | |
| 299 | + } | |
| 300 | + | |
| 301 | + /// Filtrer les entrées par ID de flux | |
| 302 | + pub fn filter_by_feed(&self, feed_id: &str) -> Vec<&HistoryEntry> { | |
| 303 | + self.entries | |
| 304 | + .iter() | |
| 305 | + .filter(|e| { | |
| 306 | + e.feed_id | |
| 307 | + .as_ref() | |
| 308 | + .map(|id| id == feed_id) | |
| 309 | + .unwrap_or(false) | |
| 310 | + }) | |
| 311 | + .collect() | |
| 312 | + } | |
| 313 | + | |
| 314 | + /// Sauvegarder l'historique dans un fichier | |
| 315 | + pub fn save_to_file(&self, path: &PathBuf) -> io::Result<()> { | |
| 316 | + let file = File::create(path)?; | |
| 317 | + let entries: Vec<_> = self.entries.iter().collect(); | |
| 318 | + serde_json::to_writer_pretty(file, &entries)?; | |
| 319 | + Ok(()) | |
| 320 | + } | |
| 321 | + | |
| 322 | + /// Charger l'historique depuis un fichier | |
| 323 | + pub fn load_from_file(&mut self, path: &PathBuf) -> io::Result<()> { | |
| 324 | + let file = File::open(path)?; | |
| 325 | + let entries: Vec<HistoryEntry> = serde_json::from_reader(file)?; | |
| 326 | + self.entries = VecDeque::from(entries); | |
| 327 | + | |
| 328 | + // Limiter le nombre d'entrées | |
| 329 | + while self.entries.len() > self.max_entries { | |
| 330 | + self.entries.pop_front(); | |
| 331 | + } | |
| 332 | + | |
| 333 | + Ok(()) | |
| 334 | + } | |
| 335 | + | |
| 336 | + /// Obtenir le nombre total d'entrées | |
| 337 | + pub fn len(&self) -> usize { | |
| 338 | + self.entries.len() | |
| 339 | + } | |
| 340 | + | |
| 341 | + /// Vérifier si l'historique est vide | |
| 342 | + pub fn is_empty(&self) -> bool { | |
| 343 | + self.entries.is_empty() | |
| 344 | + } | |
| 345 | +} | |
| 346 | + | |
| 347 | +#[cfg(test)] | |
| 348 | +mod tests { | |
| 349 | + use super::*; | |
| 350 | + | |
| 351 | + #[test] | |
| 352 | + fn test_history_entry_creation() { | |
| 353 | + let entry = HistoryEntry::new(OperationType::SyncFeed, "Test sync".to_string()); | |
| 354 | + assert!(!entry.id.is_empty()); | |
| 355 | + assert_eq!(entry.operation_type, OperationType::SyncFeed); | |
| 356 | + assert_eq!(entry.status, OperationStatus::InProgress); | |
| 357 | + } | |
| 358 | + | |
| 359 | + #[test] | |
| 360 | + fn test_history_manager_add_entry() { | |
| 361 | + let mut manager = HistoryManager::new(); | |
| 362 | + let entry = HistoryEntry::new(OperationType::AddFeed, "Test feed".to_string()); | |
| 363 | + manager.add_entry(entry); | |
| 364 | + assert_eq!(manager.len(), 1); | |
| 365 | + } | |
| 366 | + | |
| 367 | + #[test] | |
| 368 | + fn test_history_manager_max_entries() { | |
| 369 | + let mut manager = HistoryManager::with_capacity(3); | |
| 370 | + for i in 0..5 { | |
| 371 | + let entry = HistoryEntry::new( | |
| 372 | + OperationType::SyncFeed, | |
| 373 | + format!("Sync {}", i), | |
| 374 | + ); | |
| 375 | + manager.add_entry(entry); | |
| 376 | + } | |
| 377 | + assert_eq!(manager.len(), 3); | |
| 378 | + } | |
| 379 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,379 @@ | |||
| 1 | +use chrono::{DateTime, Utc}; | ||
| 2 | +use serde::{Deserialize, Serialize}; | ||
| 3 | +use std::collections::VecDeque; | ||
| 4 | +use std::fs::File; | ||
| 5 | +use std::io; | ||
| 6 | +use std::path::PathBuf; | ||
| 7 | + | ||
| 8 | +/// Type d'opération historique | ||
| 9 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | ||
| 10 | +pub enum OperationType { | ||
| 11 | + /// Synchronisation d'un flux | ||
| 12 | + SyncFeed, | ||
| 13 | + /// Synchronisation de tous les flux | ||
| 14 | + SyncAll, | ||
| 15 | + /// Téléchargement d'un épisode | ||
| 16 | + DownloadEpisode, | ||
| 17 | + /// Téléchargement de plusieurs épisodes | ||
| 18 | + DownloadMultiple, | ||
| 19 | + /// Ajout d'un flux | ||
| 20 | + AddFeed, | ||
| 21 | + /// Suppression d'un flux | ||
| 22 | + RemoveFeed, | ||
| 23 | + /// Modification d'un flux | ||
| 24 | + UpdateFeed, | ||
| 25 | + /// Import de flux | ||
| 26 | + ImportFeeds, | ||
| 27 | + /// Export de flux | ||
| 28 | + ExportFeeds, | ||
| 29 | + /// Erreur de téléchargement | ||
| 30 | + DownloadError, | ||
| 31 | + /// Erreur de synchronisation | ||
| 32 | + SyncError, | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +impl std::fmt::Display for OperationType { | ||
| 36 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| 37 | + match self { | ||
| 38 | + OperationType::SyncFeed => write!(f, "Synchronisation flux"), | ||
| 39 | + OperationType::SyncAll => write!(f, "Sync. tous flux"), | ||
| 40 | + OperationType::DownloadEpisode => write!(f, "Téléchargement"), | ||
| 41 | + OperationType::DownloadMultiple => write!(f, "Téléch. multiples"), | ||
| 42 | + OperationType::AddFeed => write!(f, "Ajout flux"), | ||
| 43 | + OperationType::RemoveFeed => write!(f, "Suppr. flux"), | ||
| 44 | + OperationType::UpdateFeed => write!(f, "Modif. flux"), | ||
| 45 | + OperationType::ImportFeeds => write!(f, "Import flux"), | ||
| 46 | + OperationType::ExportFeeds => write!(f, "Export flux"), | ||
| 47 | + OperationType::DownloadError => write!(f, "Erreur téléch."), | ||
| 48 | + OperationType::SyncError => write!(f, "Erreur sync."), | ||
| 49 | + } | ||
| 50 | + } | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +/// Statut de l'opération | ||
| 54 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | ||
| 55 | +pub enum OperationStatus { | ||
| 56 | + /// Opération en cours | ||
| 57 | + InProgress, | ||
| 58 | + /// Opération réussie | ||
| 59 | + Success, | ||
| 60 | + /// Opération échouée | ||
| 61 | + Failed, | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +impl std::fmt::Display for OperationStatus { | ||
| 65 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| 66 | + match self { | ||
| 67 | + OperationStatus::InProgress => write!(f, "En cours"), | ||
| 68 | + OperationStatus::Success => write!(f, "Succès"), | ||
| 69 | + OperationStatus::Failed => write!(f, "Échec"), | ||
| 70 | + } | ||
| 71 | + } | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +/// Entrée d'historique | ||
| 75 | +#[derive(Debug, Clone, Serialize, Deserialize)] | ||
| 76 | +pub struct HistoryEntry { | ||
| 77 | + /// ID unique de l'entrée | ||
| 78 | + pub id: String, | ||
| 79 | + /// Type d'opération | ||
| 80 | + pub operation_type: OperationType, | ||
| 81 | + /// Statut de l'opération | ||
| 82 | + pub status: OperationStatus, | ||
| 83 | + /// Timestamp de l'opération | ||
| 84 | + pub timestamp: DateTime<Utc>, | ||
| 85 | + /// Description détaillée | ||
| 86 | + pub description: String, | ||
| 87 | + /// ID du flux concerné (si applicable) | ||
| 88 | + #[serde(skip_serializing_if = "Option::is_none")] | ||
| 89 | + pub feed_id: Option<String>, | ||
| 90 | + /// ID de l'épisode concerné (si applicable) | ||
| 91 | + #[serde(skip_serializing_if = "Option::is_none")] | ||
| 92 | + pub episode_id: Option<String>, | ||
| 93 | + /// Taille du téléchargement (si applicable) | ||
| 94 | + #[serde(skip_serializing_if = "Option::is_none")] | ||
| 95 | + pub size: Option<u64>, | ||
| 96 | + /// Message d'erreur (si applicable) | ||
| 97 | + #[serde(skip_serializing_if = "Option::is_none")] | ||
| 98 | + pub error_message: Option<String>, | ||
| 99 | + /// Durée de l'opération (en secondes) | ||
| 100 | + #[serde(skip_serializing_if = "Option::is_none")] | ||
| 101 | + pub duration_seconds: Option<f64>, | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +impl HistoryEntry { | ||
| 105 | + /// Créer une nouvelle entrée d'historique | ||
| 106 | + pub fn new(operation_type: OperationType, description: String) -> Self { | ||
| 107 | + Self { | ||
| 108 | + id: uuid::Uuid::new_v4().to_string(), | ||
| 109 | + operation_type, | ||
| 110 | + status: OperationStatus::InProgress, | ||
| 111 | + timestamp: Utc::now(), | ||
| 112 | + description, | ||
| 113 | + feed_id: None, | ||
| 114 | + episode_id: None, | ||
| 115 | + size: None, | ||
| 116 | + error_message: None, | ||
| 117 | + duration_seconds: None, | ||
| 118 | + } | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + /// Marquer l'opération comme réussie | ||
| 122 | + pub fn mark_success(&mut self) { | ||
| 123 | + self.status = OperationStatus::Success; | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + /// Marquer l'opération comme échouée | ||
| 127 | + pub fn mark_failed(&mut self, error_message: String) { | ||
| 128 | + self.status = OperationStatus::Failed; | ||
| 129 | + self.error_message = Some(error_message); | ||
| 130 | + } | ||
| 131 | + | ||
| 132 | + /// Définir la durée | ||
| 133 | + pub fn set_duration(&mut self, seconds: f64) { | ||
| 134 | + self.duration_seconds = Some(seconds); | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + /// Obtenir la durée formatée | ||
| 138 | + pub fn get_formatted_duration(&self) -> String { | ||
| 139 | + if let Some(duration) = self.duration_seconds { | ||
| 140 | + if duration < 1.0 { | ||
| 141 | + format!("{}ms", (duration * 1000.0) as u64) | ||
| 142 | + } else if duration < 60.0 { | ||
| 143 | + format!("{:.1}s", duration) | ||
| 144 | + } else if duration < 3600.0 { | ||
| 145 | + let minutes = duration / 60.0; | ||
| 146 | + let seconds = duration % 60.0; | ||
| 147 | + format!("{}m {:.0}s", minutes as u64, seconds as u64) | ||
| 148 | + } else { | ||
| 149 | + let hours = duration / 3600.0; | ||
| 150 | + let minutes = (duration % 3600.0) / 60.0; | ||
| 151 | + let seconds = duration % 60.0; | ||
| 152 | + format!("{}h {}m {:.0}s", hours as u64, minutes as u64, seconds as u64) | ||
| 153 | + } | ||
| 154 | + } else { | ||
| 155 | + String::new() | ||
| 156 | + } | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + /// Obtenir la taille formatée | ||
| 160 | + pub fn get_formatted_size(&self) -> String { | ||
| 161 | + if let Some(size) = self.size { | ||
| 162 | + crate::config::statistics::format_bytes(size) | ||
| 163 | + } else { | ||
| 164 | + String::new() | ||
| 165 | + } | ||
| 166 | + } | ||
| 167 | +} | ||
| 168 | + | ||
| 169 | +/// Gestionnaire de l'historique | ||
| 170 | +#[derive(Debug, Clone, Default)] | ||
| 171 | +pub struct HistoryManager { | ||
| 172 | + /// Liste des entrées d'historique (ordre chronologique, plus récent à la fin) | ||
| 173 | + pub entries: VecDeque<HistoryEntry>, | ||
| 174 | + /// Nombre maximum d'entrées à conserver | ||
| 175 | + max_entries: usize, | ||
| 176 | + /// Chemin du fichier de sauvegarde | ||
| 177 | + save_path: Option<PathBuf>, | ||
| 178 | +} | ||
| 179 | + | ||
| 180 | +impl HistoryManager { | ||
| 181 | + /// Créer un nouveau gestionnaire d'historique | ||
| 182 | + pub fn new() -> Self { | ||
| 183 | + Self { | ||
| 184 | + entries: VecDeque::with_capacity(100), | ||
| 185 | + max_entries: 100, | ||
| 186 | + save_path: None, | ||
| 187 | + } | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + /// Créer avec une capacité maximale | ||
| 191 | + pub fn with_capacity(max_entries: usize) -> Self { | ||
| 192 | + Self { | ||
| 193 | + entries: VecDeque::with_capacity(max_entries), | ||
| 194 | + max_entries, | ||
| 195 | + save_path: None, | ||
| 196 | + } | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + /// Définir le chemin de sauvegarde | ||
| 200 | + pub fn set_save_path(&mut self, path: PathBuf) { | ||
| 201 | + self.save_path = Some(path); | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | + /// Ajouter une entrée à l'historique | ||
| 205 | + pub fn add_entry(&mut self, entry: HistoryEntry) { | ||
| 206 | + // Limiter le nombre d'entrées | ||
| 207 | + while self.entries.len() >= self.max_entries { | ||
| 208 | + self.entries.pop_front(); | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + self.entries.push_back(entry); | ||
| 212 | + | ||
| 213 | + // Sauvegarder automatiquement si un chemin est défini | ||
| 214 | + if let Some(path) = &self.save_path { | ||
| 215 | + if let Err(e) = self.save_to_file(path) { | ||
| 216 | + log::error!("Erreur lors de la sauvegarde de l'historique: {}", e); | ||
| 217 | + } | ||
| 218 | + } | ||
| 219 | + } | ||
| 220 | + | ||
| 221 | + /// Ajouter une opération simple | ||
| 222 | + pub fn add_operation( | ||
| 223 | + &mut self, | ||
| 224 | + operation_type: OperationType, | ||
| 225 | + description: String, | ||
| 226 | + ) -> String { | ||
| 227 | + let entry = HistoryEntry::new(operation_type, description); | ||
| 228 | + let id = entry.id.clone(); | ||
| 229 | + self.add_entry(entry); | ||
| 230 | + id | ||
| 231 | + } | ||
| 232 | + | ||
| 233 | + /// Mettre à jour le statut d'une opération | ||
| 234 | + pub fn update_operation_status( | ||
| 235 | + &mut self, | ||
| 236 | + id: &str, | ||
| 237 | + status: OperationStatus, | ||
| 238 | + error_message: Option<String>, | ||
| 239 | + ) -> bool { | ||
| 240 | + if let Some(entry) = self.entries.iter_mut().find(|e| e.id == id) { | ||
| 241 | + entry.status = status; | ||
| 242 | + if let Some(err) = error_message { | ||
| 243 | + entry.error_message = Some(err); | ||
| 244 | + } | ||
| 245 | + true | ||
| 246 | + } else { | ||
| 247 | + false | ||
| 248 | + } | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + /// Obtenir les entrées récentes | ||
| 252 | + pub fn get_recent_entries(&self, count: usize) -> Vec<&HistoryEntry> { | ||
| 253 | + let start = if self.entries.len() > count { | ||
| 254 | + self.entries.len() - count | ||
| 255 | + } else { | ||
| 256 | + 0 | ||
| 257 | + }; | ||
| 258 | + self.entries.range(start..).collect() | ||
| 259 | + } | ||
| 260 | + | ||
| 261 | + /// Obtenir toutes les entrées | ||
| 262 | + pub fn get_all_entries(&self) -> Vec<&HistoryEntry> { | ||
| 263 | + self.entries.iter().collect() | ||
| 264 | + } | ||
| 265 | + | ||
| 266 | + /// Effacer l'historique | ||
| 267 | + pub fn clear(&mut self) { | ||
| 268 | + self.entries.clear(); | ||
| 269 | + } | ||
| 270 | + | ||
| 271 | + /// Effacer les entrées anciennes (plus vieilles que la durée spécifiée) | ||
| 272 | + pub fn clear_old_entries(&mut self, max_age_seconds: i64) { | ||
| 273 | + let now = Utc::now(); | ||
| 274 | + let cutoff = now - chrono::Duration::seconds(max_age_seconds); | ||
| 275 | + | ||
| 276 | + while let Some(entry) = self.entries.front() { | ||
| 277 | + if entry.timestamp < cutoff { | ||
| 278 | + self.entries.pop_front(); | ||
| 279 | + } else { | ||
| 280 | + break; | ||
| 281 | + } | ||
| 282 | + } | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + /// Filtrer les entrées par type d'opération | ||
| 286 | + pub fn filter_by_type(&self, operation_type: OperationType) -> Vec<&HistoryEntry> { | ||
| 287 | + self.entries | ||
| 288 | + .iter() | ||
| 289 | + .filter(|e| e.operation_type == operation_type) | ||
| 290 | + .collect() | ||
| 291 | + } | ||
| 292 | + | ||
| 293 | + /// Filtrer les entrées par statut | ||
| 294 | + pub fn filter_by_status(&self, status: OperationStatus) -> Vec<&HistoryEntry> { | ||
| 295 | + self.entries | ||
| 296 | + .iter() | ||
| 297 | + .filter(|e| e.status == status) | ||
| 298 | + .collect() | ||
| 299 | + } | ||
| 300 | + | ||
| 301 | + /// Filtrer les entrées par ID de flux | ||
| 302 | + pub fn filter_by_feed(&self, feed_id: &str) -> Vec<&HistoryEntry> { | ||
| 303 | + self.entries | ||
| 304 | + .iter() | ||
| 305 | + .filter(|e| { | ||
| 306 | + e.feed_id | ||
| 307 | + .as_ref() | ||
| 308 | + .map(|id| id == feed_id) | ||
| 309 | + .unwrap_or(false) | ||
| 310 | + }) | ||
| 311 | + .collect() | ||
| 312 | + } | ||
| 313 | + | ||
| 314 | + /// Sauvegarder l'historique dans un fichier | ||
| 315 | + pub fn save_to_file(&self, path: &PathBuf) -> io::Result<()> { | ||
| 316 | + let file = File::create(path)?; | ||
| 317 | + let entries: Vec<_> = self.entries.iter().collect(); | ||
| 318 | + serde_json::to_writer_pretty(file, &entries)?; | ||
| 319 | + Ok(()) | ||
| 320 | + } | ||
| 321 | + | ||
| 322 | + /// Charger l'historique depuis un fichier | ||
| 323 | + pub fn load_from_file(&mut self, path: &PathBuf) -> io::Result<()> { | ||
| 324 | + let file = File::open(path)?; | ||
| 325 | + let entries: Vec<HistoryEntry> = serde_json::from_reader(file)?; | ||
| 326 | + self.entries = VecDeque::from(entries); | ||
| 327 | + | ||
| 328 | + // Limiter le nombre d'entrées | ||
| 329 | + while self.entries.len() > self.max_entries { | ||
| 330 | + self.entries.pop_front(); | ||
| 331 | + } | ||
| 332 | + | ||
| 333 | + Ok(()) | ||
| 334 | + } | ||
| 335 | + | ||
| 336 | + /// Obtenir le nombre total d'entrées | ||
| 337 | + pub fn len(&self) -> usize { | ||
| 338 | + self.entries.len() | ||
| 339 | + } | ||
| 340 | + | ||
| 341 | + /// Vérifier si l'historique est vide | ||
| 342 | + pub fn is_empty(&self) -> bool { | ||
| 343 | + self.entries.is_empty() | ||
| 344 | + } | ||
| 345 | +} | ||
| 346 | + | ||
| 347 | +#[cfg(test)] | ||
| 348 | +mod tests { | ||
| 349 | + use super::*; | ||
| 350 | + | ||
| 351 | + #[test] | ||
| 352 | + fn test_history_entry_creation() { | ||
| 353 | + let entry = HistoryEntry::new(OperationType::SyncFeed, "Test sync".to_string()); | ||
| 354 | + assert!(!entry.id.is_empty()); | ||
| 355 | + assert_eq!(entry.operation_type, OperationType::SyncFeed); | ||
| 356 | + assert_eq!(entry.status, OperationStatus::InProgress); | ||
| 357 | + } | ||
| 358 | + | ||
| 359 | + #[test] | ||
| 360 | + fn test_history_manager_add_entry() { | ||
| 361 | + let mut manager = HistoryManager::new(); | ||
| 362 | + let entry = HistoryEntry::new(OperationType::AddFeed, "Test feed".to_string()); | ||
| 363 | + manager.add_entry(entry); | ||
| 364 | + assert_eq!(manager.len(), 1); | ||
| 365 | + } | ||
| 366 | + | ||
| 367 | + #[test] | ||
| 368 | + fn test_history_manager_max_entries() { | ||
| 369 | + let mut manager = HistoryManager::with_capacity(3); | ||
| 370 | + for i in 0..5 { | ||
| 371 | + let entry = HistoryEntry::new( | ||
| 372 | + OperationType::SyncFeed, | ||
| 373 | + format!("Sync {}", i), | ||
| 374 | + ); | ||
| 375 | + manager.add_entry(entry); | ||
| 376 | + } | ||
| 377 | + assert_eq!(manager.len(), 3); | ||
| 378 | + } | ||
| 379 | +} | ||
modified
src/config/mod.rs +19 -0 | @@ -3,9 +3,13 @@ | ||
| 3 | 3 | //! This module contains the data models and storage logic for the application. |
| 4 | 4 | |
| 5 | 5 | pub mod app_state; |
| 6 | +pub mod backup; | |
| 6 | 7 | pub mod episode; |
| 7 | 8 | pub mod feed; |
| 9 | +pub mod history; | |
| 8 | 10 | pub mod playlist; |
| 11 | +pub mod search; | |
| 12 | +pub mod statistics; | |
| 9 | 13 | pub mod storage; |
| 10 | 14 | |
| 11 | 15 | // Re-export main types for convenience |
| @@ -16,10 +20,25 @@ pub mod storage; | ||
| 16 | 20 | pub use app_state::AppState; |
| 17 | 21 | #[allow(dead_code)] |
| 18 | 22 | #[allow(unused_imports)] |
| 23 | +pub use backup::{BackupInfo, BackupManager}; | |
| 24 | +#[allow(dead_code)] | |
| 25 | +#[allow(unused_imports)] | |
| 19 | 26 | pub use episode::Episode; |
| 20 | 27 | #[allow(dead_code)] |
| 21 | 28 | #[allow(unused_imports)] |
| 22 | 29 | pub use feed::Feed; |
| 23 | 30 | #[allow(dead_code)] |
| 24 | 31 | #[allow(unused_imports)] |
| 32 | +pub use history::{HistoryEntry, HistoryManager, OperationStatus, OperationType}; | |
| 33 | +#[allow(dead_code)] | |
| 34 | +#[allow(unused_imports)] | |
| 35 | +pub use playlist::Playlist; | |
| 36 | +#[allow(dead_code)] | |
| 37 | +#[allow(unused_imports)] | |
| 38 | +pub use search::{SearchQuery, SearchResult, SearchType}; | |
| 39 | +#[allow(dead_code)] | |
| 40 | +#[allow(unused_imports)] | |
| 41 | +pub use statistics::{AppStatistics, FeedStatistics, PlaylistStatistics}; | |
| 42 | +#[allow(dead_code)] | |
| 43 | +#[allow(unused_imports)] | |
| 25 | 44 | pub use storage::{load_feeds, save_feeds, StorageError}; |
| @@ -3,9 +3,13 @@ | |||
| 3 | //! This module contains the data models and storage logic for the application. | 3 | //! This module contains the data models and storage logic for the application. |
| 4 | 4 | ||
| 5 | pub mod app_state; | 5 | pub mod app_state; |
| 6 | +pub mod backup; | ||
| 6 | pub mod episode; | 7 | pub mod episode; |
| 7 | pub mod feed; | 8 | pub mod feed; |
| 9 | +pub mod history; | ||
| 8 | pub mod playlist; | 10 | pub mod playlist; |
| 11 | +pub mod search; | ||
| 12 | +pub mod statistics; | ||
| 9 | pub mod storage; | 13 | pub mod storage; |
| 10 | 14 | ||
| 11 | // Re-export main types for convenience | 15 | // Re-export main types for convenience |
| @@ -16,10 +20,25 @@ pub mod storage; | |||
| 16 | pub use app_state::AppState; | 20 | pub use app_state::AppState; |
| 17 | #[allow(dead_code)] | 21 | #[allow(dead_code)] |
| 18 | #[allow(unused_imports)] | 22 | #[allow(unused_imports)] |
| 23 | +pub use backup::{BackupInfo, BackupManager}; | ||
| 24 | +#[allow(dead_code)] | ||
| 25 | +#[allow(unused_imports)] | ||
| 19 | pub use episode::Episode; | 26 | pub use episode::Episode; |
| 20 | #[allow(dead_code)] | 27 | #[allow(dead_code)] |
| 21 | #[allow(unused_imports)] | 28 | #[allow(unused_imports)] |
| 22 | pub use feed::Feed; | 29 | pub use feed::Feed; |
| 23 | #[allow(dead_code)] | 30 | #[allow(dead_code)] |
| 24 | #[allow(unused_imports)] | 31 | #[allow(unused_imports)] |
| 32 | +pub use history::{HistoryEntry, HistoryManager, OperationStatus, OperationType}; | ||
| 33 | +#[allow(dead_code)] | ||
| 34 | +#[allow(unused_imports)] | ||
| 35 | +pub use playlist::Playlist; | ||
| 36 | +#[allow(dead_code)] | ||
| 37 | +#[allow(unused_imports)] | ||
| 38 | +pub use search::{SearchQuery, SearchResult, SearchType}; | ||
| 39 | +#[allow(dead_code)] | ||
| 40 | +#[allow(unused_imports)] | ||
| 41 | +pub use statistics::{AppStatistics, FeedStatistics, PlaylistStatistics}; | ||
| 42 | +#[allow(dead_code)] | ||
| 43 | +#[allow(unused_imports)] | ||
| 25 | pub use storage::{load_feeds, save_feeds, StorageError}; | 44 | pub use storage::{load_feeds, save_feeds, StorageError}; |
added
src/config/search.rs +323 -0 | new file mode 100644 | ||
| @@ -0,0 +1,323 @@ | ||
| 1 | +use crate::config::{episode::Episode, feed::Feed}; | |
| 2 | + | |
| 3 | +/// Type de recherche possible | |
| 4 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 5 | +pub enum SearchType { | |
| 6 | + /// Recherche dans les noms de flux | |
| 7 | + FeedName, | |
| 8 | + /// Recherche dans les URLs de flux | |
| 9 | + FeedUrl, | |
| 10 | + /// Recherche dans les tags de flux | |
| 11 | + FeedTags, | |
| 12 | + /// Recherche dans les titres d'épisodes | |
| 13 | + EpisodeTitle, | |
| 14 | + /// Recherche dans les descriptions d'épisodes | |
| 15 | + EpisodeDescription, | |
| 16 | + /// Recherche dans tous les champs | |
| 17 | + All, | |
| 18 | +} | |
| 19 | + | |
| 20 | +impl Default for SearchType { | |
| 21 | + fn default() -> Self { | |
| 22 | + SearchType::All | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +/// Requête de recherche | |
| 27 | +#[derive(Debug, Clone, Default)] | |
| 28 | +pub struct SearchQuery { | |
| 29 | + /// Texte à rechercher | |
| 30 | + pub query: String, | |
| 31 | + /// Type de recherche | |
| 32 | + pub search_type: SearchType, | |
| 33 | + /// Sensible à la casse | |
| 34 | + pub case_sensitive: bool, | |
| 35 | +} | |
| 36 | + | |
| 37 | +impl SearchQuery { | |
| 38 | + /// Créer une nouvelle requête de recherche | |
| 39 | + pub fn new(query: String) -> Self { | |
| 40 | + Self { | |
| 41 | + query, | |
| 42 | + search_type: SearchType::default(), | |
| 43 | + case_sensitive: false, | |
| 44 | + } | |
| 45 | + } | |
| 46 | + | |
| 47 | + /// Vérifier si la requête est vide | |
| 48 | + pub fn is_empty(&self) -> bool { | |
| 49 | + self.query.trim().is_empty() | |
| 50 | + } | |
| 51 | + | |
| 52 | + /// Normaliser la requête selon la sensibilité à la casse | |
| 53 | + fn normalize(&self, text: &str) -> String { | |
| 54 | + if self.case_sensitive { | |
| 55 | + text.to_string() | |
| 56 | + } else { | |
| 57 | + text.to_lowercase() | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// Vérifier si le texte contient la requête | |
| 62 | + fn contains_query(&self, text: &str) -> bool { | |
| 63 | + if self.is_empty() { | |
| 64 | + return true; | |
| 65 | + } | |
| 66 | + let normalized_text = self.normalize(text); | |
| 67 | + let normalized_query = self.normalize(&self.query); | |
| 68 | + normalized_text.contains(&normalized_query) | |
| 69 | + } | |
| 70 | +} | |
| 71 | + | |
| 72 | +/// Résultat de recherche pour un flux | |
| 73 | +#[derive(Debug, Clone)] | |
| 74 | +pub struct FeedSearchResult { | |
| 75 | + /// Index du flux dans la liste | |
| 76 | + pub feed_index: usize, | |
| 77 | + /// Flux correspondant | |
| 78 | + pub feed: Feed, | |
| 79 | + /// Épisodes correspondants (si recherche dans les épisodes) | |
| 80 | + pub matching_episodes: Vec<usize>, | |
| 81 | + /// Score de pertinence (plus élevé = meilleure correspondance) | |
| 82 | + pub score: u32, | |
| 83 | +} | |
| 84 | + | |
| 85 | +/// Résultat de recherche pour un épisode | |
| 86 | +#[derive(Debug, Clone)] | |
| 87 | +pub struct EpisodeSearchResult { | |
| 88 | + /// Index du flux parent | |
| 89 | + pub feed_index: usize, | |
| 90 | + /// Index de l'épisode dans la liste des épisodes du flux | |
| 91 | + pub episode_index: usize, | |
| 92 | + /// Épisode correspondant | |
| 93 | + pub episode: Episode, | |
| 94 | + /// Score de pertinence | |
| 95 | + pub score: u32, | |
| 96 | +} | |
| 97 | + | |
| 98 | +/// Résultat global de recherche | |
| 99 | +#[derive(Debug, Clone, Default)] | |
| 100 | +pub struct SearchResult { | |
| 101 | + /// Résultats de flux | |
| 102 | + pub feed_results: Vec<FeedSearchResult>, | |
| 103 | + /// Résultats d'épisodes | |
| 104 | + pub episode_results: Vec<EpisodeSearchResult>, | |
| 105 | + /// Nombre total de correspondances | |
| 106 | + pub total_matches: usize, | |
| 107 | +} | |
| 108 | + | |
| 109 | +impl SearchResult { | |
| 110 | + pub fn new() -> Self { | |
| 111 | + Self::default() | |
| 112 | + } | |
| 113 | + | |
| 114 | + /// Vérifier s'il y a des résultats | |
| 115 | + pub fn is_empty(&self) -> bool { | |
| 116 | + self.total_matches == 0 | |
| 117 | + } | |
| 118 | +} | |
| 119 | + | |
| 120 | +/// Effectuer une recherche dans une liste de flux | |
| 121 | +pub fn search_feeds( | |
| 122 | + feeds: &[Feed], | |
| 123 | + episodes_list: &[Vec<Episode>], | |
| 124 | + query: &SearchQuery, | |
| 125 | +) -> SearchResult { | |
| 126 | + let mut result = SearchResult::new(); | |
| 127 | + | |
| 128 | + if query.is_empty() { | |
| 129 | + return result; | |
| 130 | + } | |
| 131 | + | |
| 132 | + for (feed_index, feed) in feeds.iter().enumerate() { | |
| 133 | + let episodes = if feed_index < episodes_list.len() { | |
| 134 | + &episodes_list[feed_index] | |
| 135 | + } else { | |
| 136 | + &vec![] | |
| 137 | + }; | |
| 138 | + | |
| 139 | + let mut feed_score = 0u32; | |
| 140 | + let mut matching_episodes = Vec::new(); | |
| 141 | + | |
| 142 | + // Recherche dans les propriétés du flux | |
| 143 | + match query.search_type { | |
| 144 | + SearchType::FeedName => { | |
| 145 | + if query.contains_query(&feed.name) { | |
| 146 | + feed_score += 100; | |
| 147 | + } | |
| 148 | + } | |
| 149 | + SearchType::FeedUrl => { | |
| 150 | + if query.contains_query(&feed.url) { | |
| 151 | + feed_score += 100; | |
| 152 | + } | |
| 153 | + } | |
| 154 | + SearchType::FeedTags => { | |
| 155 | + for tag in &feed.tags { | |
| 156 | + if query.contains_query(tag) { | |
| 157 | + feed_score += 50; | |
| 158 | + } | |
| 159 | + } | |
| 160 | + } | |
| 161 | + SearchType::EpisodeTitle => { | |
| 162 | + for (ep_index, ep) in episodes.iter().enumerate() { | |
| 163 | + if query.contains_query(&ep.title) { | |
| 164 | + matching_episodes.push(ep_index); | |
| 165 | + feed_score += 30; | |
| 166 | + } | |
| 167 | + } | |
| 168 | + } | |
| 169 | + SearchType::EpisodeDescription => { | |
| 170 | + for (ep_index, ep) in episodes.iter().enumerate() { | |
| 171 | + if let Some(desc) = &ep.description { | |
| 172 | + if query.contains_query(desc) { | |
| 173 | + matching_episodes.push(ep_index); | |
| 174 | + feed_score += 20; | |
| 175 | + } | |
| 176 | + } | |
| 177 | + } | |
| 178 | + } | |
| 179 | + SearchType::All => { | |
| 180 | + // Recherche dans tous les champs du flux | |
| 181 | + if query.contains_query(&feed.name) { | |
| 182 | + feed_score += 100; | |
| 183 | + } | |
| 184 | + if query.contains_query(&feed.url) { | |
| 185 | + feed_score += 50; | |
| 186 | + } | |
| 187 | + for tag in &feed.tags { | |
| 188 | + if query.contains_query(tag) { | |
| 189 | + feed_score += 30; | |
| 190 | + } | |
| 191 | + } | |
| 192 | + | |
| 193 | + // Recherche dans les épisodes | |
| 194 | + for (ep_index, ep) in episodes.iter().enumerate() { | |
| 195 | + let mut ep_score = 0u32; | |
| 196 | + if query.contains_query(&ep.title) { | |
| 197 | + ep_score += 50; | |
| 198 | + } | |
| 199 | + if let Some(desc) = &ep.description { | |
| 200 | + if query.contains_query(desc) { | |
| 201 | + ep_score += 30; | |
| 202 | + } | |
| 203 | + } | |
| 204 | + if ep_score > 0 { | |
| 205 | + matching_episodes.push(ep_index); | |
| 206 | + feed_score += ep_score; | |
| 207 | + } | |
| 208 | + } | |
| 209 | + } | |
| 210 | + } | |
| 211 | + | |
| 212 | + // Ajouter le flux aux résultats s'il a un score > 0 | |
| 213 | + if feed_score > 0 || !matching_episodes.is_empty() { | |
| 214 | + // Ajouter les épisodes correspondants en premier | |
| 215 | + if !matching_episodes.is_empty() && feed_index < episodes_list.len() { | |
| 216 | + for &ep_index in &matching_episodes { | |
| 217 | + if ep_index < episodes_list[feed_index].len() { | |
| 218 | + result.episode_results.push(EpisodeSearchResult { | |
| 219 | + feed_index, | |
| 220 | + episode_index: ep_index, | |
| 221 | + episode: episodes_list[feed_index][ep_index].clone(), | |
| 222 | + score: feed_score / 2, // Score réduit pour les épisodes | |
| 223 | + }); | |
| 224 | + result.total_matches += 1; | |
| 225 | + } | |
| 226 | + } | |
| 227 | + } | |
| 228 | + | |
| 229 | + result.feed_results.push(FeedSearchResult { | |
| 230 | + feed_index, | |
| 231 | + feed: feed.clone(), | |
| 232 | + matching_episodes, | |
| 233 | + score: feed_score, | |
| 234 | + }); | |
| 235 | + result.total_matches += 1; | |
| 236 | + } | |
| 237 | + } | |
| 238 | + | |
| 239 | + // Trier les résultats par score (descendant) | |
| 240 | + result.feed_results.sort_by(|a, b| b.score.cmp(&a.score)); | |
| 241 | + result.episode_results.sort_by(|a, b| b.score.cmp(&a.score)); | |
| 242 | + | |
| 243 | + result | |
| 244 | +} | |
| 245 | + | |
| 246 | +/// Rechercher des épisodes dans un flux spécifique | |
| 247 | +pub fn search_episodes( | |
| 248 | + episodes: &[Episode], | |
| 249 | + query: &SearchQuery, | |
| 250 | +) -> Vec<usize> { | |
| 251 | + let mut results = Vec::new(); | |
| 252 | + | |
| 253 | + if query.is_empty() { | |
| 254 | + return results; | |
| 255 | + } | |
| 256 | + | |
| 257 | + for (index, episode) in episodes.iter().enumerate() { | |
| 258 | + let mut matches = false; | |
| 259 | + | |
| 260 | + match query.search_type { | |
| 261 | + SearchType::EpisodeTitle => { | |
| 262 | + matches = query.contains_query(&episode.title); | |
| 263 | + } | |
| 264 | + SearchType::EpisodeDescription => { | |
| 265 | + if let Some(desc) = &episode.description { | |
| 266 | + matches = query.contains_query(desc); | |
| 267 | + } | |
| 268 | + } | |
| 269 | + SearchType::All => { | |
| 270 | + matches = query.contains_query(&episode.title); | |
| 271 | + if !matches { | |
| 272 | + if let Some(desc) = &episode.description { | |
| 273 | + matches = query.contains_query(desc); | |
| 274 | + } | |
| 275 | + } | |
| 276 | + } | |
| 277 | + _ => { | |
| 278 | + // Pour les autres types, rechercher dans le titre par défaut | |
| 279 | + matches = query.contains_query(&episode.title); | |
| 280 | + } | |
| 281 | + } | |
| 282 | + | |
| 283 | + if matches { | |
| 284 | + results.push(index); | |
| 285 | + } | |
| 286 | + } | |
| 287 | + | |
| 288 | + results | |
| 289 | +} | |
| 290 | + | |
| 291 | +#[cfg(test)] | |
| 292 | +mod tests { | |
| 293 | + use super::*; | |
| 294 | + | |
| 295 | + #[test] | |
| 296 | + fn test_search_query_empty() { | |
| 297 | + let query = SearchQuery::new("".to_string()); | |
| 298 | + assert!(query.is_empty()); | |
| 299 | + } | |
| 300 | + | |
| 301 | + #[test] | |
| 302 | + fn test_search_query_contains() { | |
| 303 | + let query = SearchQuery::new("test".to_string()); | |
| 304 | + assert!(!query.is_empty()); | |
| 305 | + assert!(query.contains_query("This is a test string")); | |
| 306 | + assert!(!query.contains_query("This is a different string")); | |
| 307 | + } | |
| 308 | + | |
| 309 | + #[test] | |
| 310 | + fn test_search_query_case_insensitive() { | |
| 311 | + let mut query = SearchQuery::new("TEST".to_string()); | |
| 312 | + query.case_sensitive = false; | |
| 313 | + assert!(query.contains_query("this is a test string")); | |
| 314 | + } | |
| 315 | + | |
| 316 | + #[test] | |
| 317 | + fn test_search_query_case_sensitive() { | |
| 318 | + let mut query = SearchQuery::new("TEST".to_string()); | |
| 319 | + query.case_sensitive = true; | |
| 320 | + assert!(!query.contains_query("this is a test string")); | |
| 321 | + assert!(query.contains_query("THIS IS A TEST STRING")); | |
| 322 | + } | |
| 323 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,323 @@ | |||
| 1 | +use crate::config::{episode::Episode, feed::Feed}; | ||
| 2 | + | ||
| 3 | +/// Type de recherche possible | ||
| 4 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 5 | +pub enum SearchType { | ||
| 6 | + /// Recherche dans les noms de flux | ||
| 7 | + FeedName, | ||
| 8 | + /// Recherche dans les URLs de flux | ||
| 9 | + FeedUrl, | ||
| 10 | + /// Recherche dans les tags de flux | ||
| 11 | + FeedTags, | ||
| 12 | + /// Recherche dans les titres d'épisodes | ||
| 13 | + EpisodeTitle, | ||
| 14 | + /// Recherche dans les descriptions d'épisodes | ||
| 15 | + EpisodeDescription, | ||
| 16 | + /// Recherche dans tous les champs | ||
| 17 | + All, | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +impl Default for SearchType { | ||
| 21 | + fn default() -> Self { | ||
| 22 | + SearchType::All | ||
| 23 | + } | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +/// Requête de recherche | ||
| 27 | +#[derive(Debug, Clone, Default)] | ||
| 28 | +pub struct SearchQuery { | ||
| 29 | + /// Texte à rechercher | ||
| 30 | + pub query: String, | ||
| 31 | + /// Type de recherche | ||
| 32 | + pub search_type: SearchType, | ||
| 33 | + /// Sensible à la casse | ||
| 34 | + pub case_sensitive: bool, | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +impl SearchQuery { | ||
| 38 | + /// Créer une nouvelle requête de recherche | ||
| 39 | + pub fn new(query: String) -> Self { | ||
| 40 | + Self { | ||
| 41 | + query, | ||
| 42 | + search_type: SearchType::default(), | ||
| 43 | + case_sensitive: false, | ||
| 44 | + } | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + /// Vérifier si la requête est vide | ||
| 48 | + pub fn is_empty(&self) -> bool { | ||
| 49 | + self.query.trim().is_empty() | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + /// Normaliser la requête selon la sensibilité à la casse | ||
| 53 | + fn normalize(&self, text: &str) -> String { | ||
| 54 | + if self.case_sensitive { | ||
| 55 | + text.to_string() | ||
| 56 | + } else { | ||
| 57 | + text.to_lowercase() | ||
| 58 | + } | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + /// Vérifier si le texte contient la requête | ||
| 62 | + fn contains_query(&self, text: &str) -> bool { | ||
| 63 | + if self.is_empty() { | ||
| 64 | + return true; | ||
| 65 | + } | ||
| 66 | + let normalized_text = self.normalize(text); | ||
| 67 | + let normalized_query = self.normalize(&self.query); | ||
| 68 | + normalized_text.contains(&normalized_query) | ||
| 69 | + } | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +/// Résultat de recherche pour un flux | ||
| 73 | +#[derive(Debug, Clone)] | ||
| 74 | +pub struct FeedSearchResult { | ||
| 75 | + /// Index du flux dans la liste | ||
| 76 | + pub feed_index: usize, | ||
| 77 | + /// Flux correspondant | ||
| 78 | + pub feed: Feed, | ||
| 79 | + /// Épisodes correspondants (si recherche dans les épisodes) | ||
| 80 | + pub matching_episodes: Vec<usize>, | ||
| 81 | + /// Score de pertinence (plus élevé = meilleure correspondance) | ||
| 82 | + pub score: u32, | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +/// Résultat de recherche pour un épisode | ||
| 86 | +#[derive(Debug, Clone)] | ||
| 87 | +pub struct EpisodeSearchResult { | ||
| 88 | + /// Index du flux parent | ||
| 89 | + pub feed_index: usize, | ||
| 90 | + /// Index de l'épisode dans la liste des épisodes du flux | ||
| 91 | + pub episode_index: usize, | ||
| 92 | + /// Épisode correspondant | ||
| 93 | + pub episode: Episode, | ||
| 94 | + /// Score de pertinence | ||
| 95 | + pub score: u32, | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +/// Résultat global de recherche | ||
| 99 | +#[derive(Debug, Clone, Default)] | ||
| 100 | +pub struct SearchResult { | ||
| 101 | + /// Résultats de flux | ||
| 102 | + pub feed_results: Vec<FeedSearchResult>, | ||
| 103 | + /// Résultats d'épisodes | ||
| 104 | + pub episode_results: Vec<EpisodeSearchResult>, | ||
| 105 | + /// Nombre total de correspondances | ||
| 106 | + pub total_matches: usize, | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +impl SearchResult { | ||
| 110 | + pub fn new() -> Self { | ||
| 111 | + Self::default() | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + /// Vérifier s'il y a des résultats | ||
| 115 | + pub fn is_empty(&self) -> bool { | ||
| 116 | + self.total_matches == 0 | ||
| 117 | + } | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +/// Effectuer une recherche dans une liste de flux | ||
| 121 | +pub fn search_feeds( | ||
| 122 | + feeds: &[Feed], | ||
| 123 | + episodes_list: &[Vec<Episode>], | ||
| 124 | + query: &SearchQuery, | ||
| 125 | +) -> SearchResult { | ||
| 126 | + let mut result = SearchResult::new(); | ||
| 127 | + | ||
| 128 | + if query.is_empty() { | ||
| 129 | + return result; | ||
| 130 | + } | ||
| 131 | + | ||
| 132 | + for (feed_index, feed) in feeds.iter().enumerate() { | ||
| 133 | + let episodes = if feed_index < episodes_list.len() { | ||
| 134 | + &episodes_list[feed_index] | ||
| 135 | + } else { | ||
| 136 | + &vec![] | ||
| 137 | + }; | ||
| 138 | + | ||
| 139 | + let mut feed_score = 0u32; | ||
| 140 | + let mut matching_episodes = Vec::new(); | ||
| 141 | + | ||
| 142 | + // Recherche dans les propriétés du flux | ||
| 143 | + match query.search_type { | ||
| 144 | + SearchType::FeedName => { | ||
| 145 | + if query.contains_query(&feed.name) { | ||
| 146 | + feed_score += 100; | ||
| 147 | + } | ||
| 148 | + } | ||
| 149 | + SearchType::FeedUrl => { | ||
| 150 | + if query.contains_query(&feed.url) { | ||
| 151 | + feed_score += 100; | ||
| 152 | + } | ||
| 153 | + } | ||
| 154 | + SearchType::FeedTags => { | ||
| 155 | + for tag in &feed.tags { | ||
| 156 | + if query.contains_query(tag) { | ||
| 157 | + feed_score += 50; | ||
| 158 | + } | ||
| 159 | + } | ||
| 160 | + } | ||
| 161 | + SearchType::EpisodeTitle => { | ||
| 162 | + for (ep_index, ep) in episodes.iter().enumerate() { | ||
| 163 | + if query.contains_query(&ep.title) { | ||
| 164 | + matching_episodes.push(ep_index); | ||
| 165 | + feed_score += 30; | ||
| 166 | + } | ||
| 167 | + } | ||
| 168 | + } | ||
| 169 | + SearchType::EpisodeDescription => { | ||
| 170 | + for (ep_index, ep) in episodes.iter().enumerate() { | ||
| 171 | + if let Some(desc) = &ep.description { | ||
| 172 | + if query.contains_query(desc) { | ||
| 173 | + matching_episodes.push(ep_index); | ||
| 174 | + feed_score += 20; | ||
| 175 | + } | ||
| 176 | + } | ||
| 177 | + } | ||
| 178 | + } | ||
| 179 | + SearchType::All => { | ||
| 180 | + // Recherche dans tous les champs du flux | ||
| 181 | + if query.contains_query(&feed.name) { | ||
| 182 | + feed_score += 100; | ||
| 183 | + } | ||
| 184 | + if query.contains_query(&feed.url) { | ||
| 185 | + feed_score += 50; | ||
| 186 | + } | ||
| 187 | + for tag in &feed.tags { | ||
| 188 | + if query.contains_query(tag) { | ||
| 189 | + feed_score += 30; | ||
| 190 | + } | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + // Recherche dans les épisodes | ||
| 194 | + for (ep_index, ep) in episodes.iter().enumerate() { | ||
| 195 | + let mut ep_score = 0u32; | ||
| 196 | + if query.contains_query(&ep.title) { | ||
| 197 | + ep_score += 50; | ||
| 198 | + } | ||
| 199 | + if let Some(desc) = &ep.description { | ||
| 200 | + if query.contains_query(desc) { | ||
| 201 | + ep_score += 30; | ||
| 202 | + } | ||
| 203 | + } | ||
| 204 | + if ep_score > 0 { | ||
| 205 | + matching_episodes.push(ep_index); | ||
| 206 | + feed_score += ep_score; | ||
| 207 | + } | ||
| 208 | + } | ||
| 209 | + } | ||
| 210 | + } | ||
| 211 | + | ||
| 212 | + // Ajouter le flux aux résultats s'il a un score > 0 | ||
| 213 | + if feed_score > 0 || !matching_episodes.is_empty() { | ||
| 214 | + // Ajouter les épisodes correspondants en premier | ||
| 215 | + if !matching_episodes.is_empty() && feed_index < episodes_list.len() { | ||
| 216 | + for &ep_index in &matching_episodes { | ||
| 217 | + if ep_index < episodes_list[feed_index].len() { | ||
| 218 | + result.episode_results.push(EpisodeSearchResult { | ||
| 219 | + feed_index, | ||
| 220 | + episode_index: ep_index, | ||
| 221 | + episode: episodes_list[feed_index][ep_index].clone(), | ||
| 222 | + score: feed_score / 2, // Score réduit pour les épisodes | ||
| 223 | + }); | ||
| 224 | + result.total_matches += 1; | ||
| 225 | + } | ||
| 226 | + } | ||
| 227 | + } | ||
| 228 | + | ||
| 229 | + result.feed_results.push(FeedSearchResult { | ||
| 230 | + feed_index, | ||
| 231 | + feed: feed.clone(), | ||
| 232 | + matching_episodes, | ||
| 233 | + score: feed_score, | ||
| 234 | + }); | ||
| 235 | + result.total_matches += 1; | ||
| 236 | + } | ||
| 237 | + } | ||
| 238 | + | ||
| 239 | + // Trier les résultats par score (descendant) | ||
| 240 | + result.feed_results.sort_by(|a, b| b.score.cmp(&a.score)); | ||
| 241 | + result.episode_results.sort_by(|a, b| b.score.cmp(&a.score)); | ||
| 242 | + | ||
| 243 | + result | ||
| 244 | +} | ||
| 245 | + | ||
| 246 | +/// Rechercher des épisodes dans un flux spécifique | ||
| 247 | +pub fn search_episodes( | ||
| 248 | + episodes: &[Episode], | ||
| 249 | + query: &SearchQuery, | ||
| 250 | +) -> Vec<usize> { | ||
| 251 | + let mut results = Vec::new(); | ||
| 252 | + | ||
| 253 | + if query.is_empty() { | ||
| 254 | + return results; | ||
| 255 | + } | ||
| 256 | + | ||
| 257 | + for (index, episode) in episodes.iter().enumerate() { | ||
| 258 | + let mut matches = false; | ||
| 259 | + | ||
| 260 | + match query.search_type { | ||
| 261 | + SearchType::EpisodeTitle => { | ||
| 262 | + matches = query.contains_query(&episode.title); | ||
| 263 | + } | ||
| 264 | + SearchType::EpisodeDescription => { | ||
| 265 | + if let Some(desc) = &episode.description { | ||
| 266 | + matches = query.contains_query(desc); | ||
| 267 | + } | ||
| 268 | + } | ||
| 269 | + SearchType::All => { | ||
| 270 | + matches = query.contains_query(&episode.title); | ||
| 271 | + if !matches { | ||
| 272 | + if let Some(desc) = &episode.description { | ||
| 273 | + matches = query.contains_query(desc); | ||
| 274 | + } | ||
| 275 | + } | ||
| 276 | + } | ||
| 277 | + _ => { | ||
| 278 | + // Pour les autres types, rechercher dans le titre par défaut | ||
| 279 | + matches = query.contains_query(&episode.title); | ||
| 280 | + } | ||
| 281 | + } | ||
| 282 | + | ||
| 283 | + if matches { | ||
| 284 | + results.push(index); | ||
| 285 | + } | ||
| 286 | + } | ||
| 287 | + | ||
| 288 | + results | ||
| 289 | +} | ||
| 290 | + | ||
| 291 | +#[cfg(test)] | ||
| 292 | +mod tests { | ||
| 293 | + use super::*; | ||
| 294 | + | ||
| 295 | + #[test] | ||
| 296 | + fn test_search_query_empty() { | ||
| 297 | + let query = SearchQuery::new("".to_string()); | ||
| 298 | + assert!(query.is_empty()); | ||
| 299 | + } | ||
| 300 | + | ||
| 301 | + #[test] | ||
| 302 | + fn test_search_query_contains() { | ||
| 303 | + let query = SearchQuery::new("test".to_string()); | ||
| 304 | + assert!(!query.is_empty()); | ||
| 305 | + assert!(query.contains_query("This is a test string")); | ||
| 306 | + assert!(!query.contains_query("This is a different string")); | ||
| 307 | + } | ||
| 308 | + | ||
| 309 | + #[test] | ||
| 310 | + fn test_search_query_case_insensitive() { | ||
| 311 | + let mut query = SearchQuery::new("TEST".to_string()); | ||
| 312 | + query.case_sensitive = false; | ||
| 313 | + assert!(query.contains_query("this is a test string")); | ||
| 314 | + } | ||
| 315 | + | ||
| 316 | + #[test] | ||
| 317 | + fn test_search_query_case_sensitive() { | ||
| 318 | + let mut query = SearchQuery::new("TEST".to_string()); | ||
| 319 | + query.case_sensitive = true; | ||
| 320 | + assert!(!query.contains_query("this is a test string")); | ||
| 321 | + assert!(query.contains_query("THIS IS A TEST STRING")); | ||
| 322 | + } | ||
| 323 | +} | ||
added
src/config/statistics.rs +323 -0 | new file mode 100644 | ||
| @@ -0,0 +1,323 @@ | ||
| 1 | +use crate::config::{episode::Episode, feed::Feed}; | |
| 2 | +use chrono::{DateTime, Utc}; | |
| 3 | +use std::collections::HashMap; | |
| 4 | +use std::path::PathBuf; | |
| 5 | + | |
| 6 | +/// Statistiques globales de l'application | |
| 7 | +#[derive(Debug, Clone, Default)] | |
| 8 | +pub struct AppStatistics { | |
| 9 | + /// Nombre total de flux | |
| 10 | + pub total_feeds: usize, | |
| 11 | + /// Nombre de flux activés | |
| 12 | + pub enabled_feeds: usize, | |
| 13 | + /// Nombre total d'épisodes (tous flux confondus) | |
| 14 | + pub total_episodes: usize, | |
| 15 | + /// Nombre d'épisodes téléchargés | |
| 16 | + pub downloaded_episodes: usize, | |
| 17 | + /// Nombre d'épisodes non téléchargés | |
| 18 | + pub not_downloaded_episodes: usize, | |
| 19 | + /// Taille totale des fichiers téléchargés (en octets) | |
| 20 | + pub total_download_size: u64, | |
| 21 | + /// Date du dernier téléchargement | |
| 22 | + pub last_download_date: Option<DateTime<Utc>>, | |
| 23 | + /// Date de la dernière synchronisation | |
| 24 | + pub last_sync_date: Option<DateTime<Utc>>, | |
| 25 | + /// Nombre total de synchronisations | |
| 26 | + pub total_sync_count: usize, | |
| 27 | + /// Nombre total de téléchargements | |
| 28 | + pub total_download_count: usize, | |
| 29 | + /// Statistiques par flux | |
| 30 | + pub feed_statistics: HashMap<String, FeedStatistics>, | |
| 31 | + /// Statistiques par playlist | |
| 32 | + pub playlist_statistics: HashMap<String, PlaylistStatistics>, | |
| 33 | +} | |
| 34 | + | |
| 35 | +impl AppStatistics { | |
| 36 | + /// Créer de nouvelles statistiques | |
| 37 | + pub fn new() -> Self { | |
| 38 | + Self::default() | |
| 39 | + } | |
| 40 | + | |
| 41 | + /// Mettre à jour les statistiques à partir des flux et épisodes | |
| 42 | + pub fn update_from_feeds(&mut self, feeds: &[Feed], all_episodes: &[Vec<Episode>]) { | |
| 43 | + self.total_feeds = feeds.len(); | |
| 44 | + self.enabled_feeds = feeds.iter().filter(|f| f.enabled).count(); | |
| 45 | + self.feed_statistics.clear(); | |
| 46 | + | |
| 47 | + let mut total_episodes = 0usize; | |
| 48 | + let mut downloaded_episodes = 0usize; | |
| 49 | + let mut total_size = 0u64; | |
| 50 | + let mut last_download: Option<DateTime<Utc>> = None; | |
| 51 | + | |
| 52 | + for (index, feed) in feeds.iter().enumerate() { | |
| 53 | + let episodes = if index < all_episodes.len() { | |
| 54 | + &all_episodes[index] | |
| 55 | + } else { | |
| 56 | + &vec![] | |
| 57 | + }; | |
| 58 | + | |
| 59 | + let mut feed_stats = FeedStatistics::new(feed.clone()); | |
| 60 | + | |
| 61 | + for episode in episodes { | |
| 62 | + total_episodes += 1; | |
| 63 | + feed_stats.total_episodes += 1; | |
| 64 | + | |
| 65 | + if episode.downloaded { | |
| 66 | + downloaded_episodes += 1; | |
| 67 | + feed_stats.downloaded_episodes += 1; | |
| 68 | + | |
| 69 | + if let Some(size) = episode.file_size { | |
| 70 | + total_size += size; | |
| 71 | + feed_stats.total_size += size; | |
| 72 | + } | |
| 73 | + | |
| 74 | + let pub_date = episode.pub_date; | |
| 75 | + if last_download.is_none() || pub_date > last_download.unwrap() { | |
| 76 | + last_download = Some(pub_date); | |
| 77 | + } | |
| 78 | + if feed_stats.last_episode_date.is_none() || pub_date > feed_stats.last_episode_date.unwrap() { | |
| 79 | + feed_stats.last_episode_date = Some(pub_date); | |
| 80 | + } | |
| 81 | + } else { | |
| 82 | + feed_stats.not_downloaded_episodes += 1; | |
| 83 | + } | |
| 84 | + } | |
| 85 | + | |
| 86 | + self.feed_statistics.insert(feed.id.clone(), feed_stats); | |
| 87 | + } | |
| 88 | + | |
| 89 | + self.total_episodes = total_episodes; | |
| 90 | + self.downloaded_episodes = downloaded_episodes; | |
| 91 | + self.not_downloaded_episodes = total_episodes - downloaded_episodes; | |
| 92 | + self.total_download_size = total_size; | |
| 93 | + self.last_download_date = last_download; | |
| 94 | + } | |
| 95 | + | |
| 96 | + /// Incrémenter le compteur de synchronisations | |
| 97 | + pub fn increment_sync_count(&mut self) { | |
| 98 | + self.total_sync_count += 1; | |
| 99 | + self.last_sync_date = Some(Utc::now()); | |
| 100 | + } | |
| 101 | + | |
| 102 | + /// Incrémenter le compteur de téléchargements | |
| 103 | + pub fn increment_download_count(&mut self) { | |
| 104 | + self.total_download_count += 1; | |
| 105 | + self.last_download_date = Some(Utc::now()); | |
| 106 | + } | |
| 107 | + | |
| 108 | + /// Ajouter une taille de téléchargement | |
| 109 | + pub fn add_download_size(&mut self, size: u64) { | |
| 110 | + self.total_download_size += size; | |
| 111 | + } | |
| 112 | + | |
| 113 | + /// Obtenir la taille totale formatée (en Mo, Go, etc.) | |
| 114 | + pub fn get_formatted_total_size(&self) -> String { | |
| 115 | + format_bytes(self.total_download_size) | |
| 116 | + } | |
| 117 | + | |
| 118 | + /// Obtenir le pourcentage d'épisodes téléchargés | |
| 119 | + pub fn get_download_percentage(&self) -> f32 { | |
| 120 | + if self.total_episodes == 0 { | |
| 121 | + 0.0 | |
| 122 | + } else { | |
| 123 | + (self.downloaded_episodes as f32 / self.total_episodes as f32) * 100.0 | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + /// Obtenir la taille moyenne des fichiers téléchargés | |
| 128 | + pub fn get_average_file_size(&self) -> u64 { | |
| 129 | + if self.downloaded_episodes == 0 { | |
| 130 | + 0 | |
| 131 | + } else { | |
| 132 | + self.total_download_size / self.downloaded_episodes as u64 | |
| 133 | + } | |
| 134 | + } | |
| 135 | + | |
| 136 | + /// Mettre à jour les statistiques des playlists | |
| 137 | + pub fn update_playlist_statistics( | |
| 138 | + &mut self, | |
| 139 | + playlists: &[super::playlist::Playlist], | |
| 140 | + feeds: &[Feed], | |
| 141 | + all_episodes: &[Vec<Episode>], | |
| 142 | + ) { | |
| 143 | + self.playlist_statistics.clear(); | |
| 144 | + | |
| 145 | + for playlist in playlists { | |
| 146 | + let mut playlist_stats = PlaylistStatistics::new(playlist.clone()); | |
| 147 | + | |
| 148 | + for feed_id in &playlist.feed_ids { | |
| 149 | + if let Some(feed_index) = feeds.iter().position(|f| &f.id == feed_id) { | |
| 150 | + if let Some(feed) = feeds.get(feed_index) { | |
| 151 | + playlist_stats.total_feeds += 1; | |
| 152 | + if feed.enabled { | |
| 153 | + playlist_stats.enabled_feeds += 1; | |
| 154 | + } | |
| 155 | + | |
| 156 | + if feed_index < all_episodes.len() { | |
| 157 | + let episodes = &all_episodes[feed_index]; | |
| 158 | + for episode in episodes { | |
| 159 | + playlist_stats.total_episodes += 1; | |
| 160 | + if episode.downloaded { | |
| 161 | + playlist_stats.downloaded_episodes += 1; | |
| 162 | + if let Some(size) = episode.file_size { | |
| 163 | + playlist_stats.total_size += size; | |
| 164 | + } | |
| 165 | + } | |
| 166 | + } | |
| 167 | + } | |
| 168 | + } | |
| 169 | + } | |
| 170 | + } | |
| 171 | + | |
| 172 | + self.playlist_statistics.insert(playlist.id.clone(), playlist_stats); | |
| 173 | + } | |
| 174 | + } | |
| 175 | +} | |
| 176 | + | |
| 177 | +/// Statistiques pour un flux spécifique | |
| 178 | +#[derive(Debug, Clone)] | |
| 179 | +pub struct FeedStatistics { | |
| 180 | + /// ID du flux | |
| 181 | + pub feed_id: String, | |
| 182 | + /// Nom du flux | |
| 183 | + pub feed_name: String, | |
| 184 | + /// URL du flux | |
| 185 | + pub feed_url: String, | |
| 186 | + /// Nombre total d'épisodes | |
| 187 | + pub total_episodes: usize, | |
| 188 | + /// Nombre d'épisodes téléchargés | |
| 189 | + pub downloaded_episodes: usize, | |
| 190 | + /// Nombre d'épisodes non téléchargés | |
| 191 | + pub not_downloaded_episodes: usize, | |
| 192 | + /// Taille totale des téléchargements | |
| 193 | + pub total_size: u64, | |
| 194 | + /// Date du dernier épisode | |
| 195 | + pub last_episode_date: Option<DateTime<Utc>>, | |
| 196 | + /// Dossier de téléchargement | |
| 197 | + pub download_path: PathBuf, | |
| 198 | +} | |
| 199 | + | |
| 200 | +impl FeedStatistics { | |
| 201 | + pub fn new(feed: Feed) -> Self { | |
| 202 | + Self { | |
| 203 | + feed_id: feed.id, | |
| 204 | + feed_name: feed.name, | |
| 205 | + feed_url: feed.url, | |
| 206 | + total_episodes: 0, | |
| 207 | + downloaded_episodes: 0, | |
| 208 | + not_downloaded_episodes: 0, | |
| 209 | + total_size: 0, | |
| 210 | + last_episode_date: None, | |
| 211 | + download_path: feed.download_path, | |
| 212 | + } | |
| 213 | + } | |
| 214 | + | |
| 215 | + /// Obtenir la taille formatée | |
| 216 | + pub fn get_formatted_size(&self) -> String { | |
| 217 | + format_bytes(self.total_size) | |
| 218 | + } | |
| 219 | + | |
| 220 | + /// Obtenir le pourcentage de téléchargement | |
| 221 | + pub fn get_download_percentage(&self) -> f32 { | |
| 222 | + if self.total_episodes == 0 { | |
| 223 | + 0.0 | |
| 224 | + } else { | |
| 225 | + (self.downloaded_episodes as f32 / self.total_episodes as f32) * 100.0 | |
| 226 | + } | |
| 227 | + } | |
| 228 | +} | |
| 229 | + | |
| 230 | +/// Statistiques pour une playlist | |
| 231 | +#[derive(Debug, Clone)] | |
| 232 | +pub struct PlaylistStatistics { | |
| 233 | + /// ID de la playlist | |
| 234 | + pub playlist_id: String, | |
| 235 | + /// Nom de la playlist | |
| 236 | + pub playlist_name: String, | |
| 237 | + /// Nombre total de flux dans la playlist | |
| 238 | + pub total_feeds: usize, | |
| 239 | + /// Nombre de flux activés | |
| 240 | + pub enabled_feeds: usize, | |
| 241 | + /// Nombre total d'épisodes | |
| 242 | + pub total_episodes: usize, | |
| 243 | + /// Nombre d'épisodes téléchargés | |
| 244 | + pub downloaded_episodes: usize, | |
| 245 | + /// Taille totale | |
| 246 | + pub total_size: u64, | |
| 247 | +} | |
| 248 | + | |
| 249 | +impl PlaylistStatistics { | |
| 250 | + pub fn new(playlist: super::playlist::Playlist) -> Self { | |
| 251 | + Self { | |
| 252 | + playlist_id: playlist.id, | |
| 253 | + playlist_name: playlist.name, | |
| 254 | + total_feeds: 0, | |
| 255 | + enabled_feeds: 0, | |
| 256 | + total_episodes: 0, | |
| 257 | + downloaded_episodes: 0, | |
| 258 | + total_size: 0, | |
| 259 | + } | |
| 260 | + } | |
| 261 | + | |
| 262 | + /// Obtenir la taille formatée | |
| 263 | + pub fn get_formatted_size(&self) -> String { | |
| 264 | + format_bytes(self.total_size) | |
| 265 | + } | |
| 266 | +} | |
| 267 | + | |
| 268 | +/// Formater une taille en octets en une chaîne lisible | |
| 269 | +pub fn format_bytes(bytes: u64) -> String { | |
| 270 | + const KB: u64 = 1024; | |
| 271 | + const MB: u64 = KB * 1024; | |
| 272 | + const GB: u64 = MB * 1024; | |
| 273 | + const TB: u64 = GB * 1024; | |
| 274 | + | |
| 275 | + if bytes >= TB { | |
| 276 | + format!("{:.2} TB", bytes as f64 / TB as f64) | |
| 277 | + } else if bytes >= GB { | |
| 278 | + format!("{:.2} GB", bytes as f64 / GB as f64) | |
| 279 | + } else if bytes >= MB { | |
| 280 | + format!("{:.2} MB", bytes as f64 / MB as f64) | |
| 281 | + } else if bytes >= KB { | |
| 282 | + format!("{:.2} KB", bytes as f64 / KB as f64) | |
| 283 | + } else { | |
| 284 | + format!("{} B", bytes) | |
| 285 | + } | |
| 286 | +} | |
| 287 | + | |
| 288 | +/// Formater une durée en secondes en une chaîne lisible | |
| 289 | +pub fn format_duration(seconds: u64) -> String { | |
| 290 | + if seconds < 60 { | |
| 291 | + format!("{}s", seconds) | |
| 292 | + } else if seconds < 3600 { | |
| 293 | + let minutes = seconds / 60; | |
| 294 | + let secs = seconds % 60; | |
| 295 | + format!("{}m {}s", minutes, secs) | |
| 296 | + } else { | |
| 297 | + let hours = seconds / 3600; | |
| 298 | + let minutes = (seconds % 3600) / 60; | |
| 299 | + let secs = seconds % 60; | |
| 300 | + format!("{}h {}m {}s", hours, minutes, secs) | |
| 301 | + } | |
| 302 | +} | |
| 303 | + | |
| 304 | +#[cfg(test)] | |
| 305 | +mod tests { | |
| 306 | + use super::*; | |
| 307 | + | |
| 308 | + #[test] | |
| 309 | + fn test_format_bytes() { | |
| 310 | + assert_eq!(format_bytes(0), "0 B"); | |
| 311 | + assert_eq!(format_bytes(1024), "1.00 KB"); | |
| 312 | + assert_eq!(format_bytes(1024 * 1024), "1.00 MB"); | |
| 313 | + assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB"); | |
| 314 | + } | |
| 315 | + | |
| 316 | + #[test] | |
| 317 | + fn test_format_duration() { | |
| 318 | + assert_eq!(format_duration(0), "0s"); | |
| 319 | + assert_eq!(format_duration(30), "30s"); | |
| 320 | + assert_eq!(format_duration(90), "1m 30s"); | |
| 321 | + assert_eq!(format_duration(3661), "1h 1m 1s"); | |
| 322 | + } | |
| 323 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,323 @@ | |||
| 1 | +use crate::config::{episode::Episode, feed::Feed}; | ||
| 2 | +use chrono::{DateTime, Utc}; | ||
| 3 | +use std::collections::HashMap; | ||
| 4 | +use std::path::PathBuf; | ||
| 5 | + | ||
| 6 | +/// Statistiques globales de l'application | ||
| 7 | +#[derive(Debug, Clone, Default)] | ||
| 8 | +pub struct AppStatistics { | ||
| 9 | + /// Nombre total de flux | ||
| 10 | + pub total_feeds: usize, | ||
| 11 | + /// Nombre de flux activés | ||
| 12 | + pub enabled_feeds: usize, | ||
| 13 | + /// Nombre total d'épisodes (tous flux confondus) | ||
| 14 | + pub total_episodes: usize, | ||
| 15 | + /// Nombre d'épisodes téléchargés | ||
| 16 | + pub downloaded_episodes: usize, | ||
| 17 | + /// Nombre d'épisodes non téléchargés | ||
| 18 | + pub not_downloaded_episodes: usize, | ||
| 19 | + /// Taille totale des fichiers téléchargés (en octets) | ||
| 20 | + pub total_download_size: u64, | ||
| 21 | + /// Date du dernier téléchargement | ||
| 22 | + pub last_download_date: Option<DateTime<Utc>>, | ||
| 23 | + /// Date de la dernière synchronisation | ||
| 24 | + pub last_sync_date: Option<DateTime<Utc>>, | ||
| 25 | + /// Nombre total de synchronisations | ||
| 26 | + pub total_sync_count: usize, | ||
| 27 | + /// Nombre total de téléchargements | ||
| 28 | + pub total_download_count: usize, | ||
| 29 | + /// Statistiques par flux | ||
| 30 | + pub feed_statistics: HashMap<String, FeedStatistics>, | ||
| 31 | + /// Statistiques par playlist | ||
| 32 | + pub playlist_statistics: HashMap<String, PlaylistStatistics>, | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +impl AppStatistics { | ||
| 36 | + /// Créer de nouvelles statistiques | ||
| 37 | + pub fn new() -> Self { | ||
| 38 | + Self::default() | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + /// Mettre à jour les statistiques à partir des flux et épisodes | ||
| 42 | + pub fn update_from_feeds(&mut self, feeds: &[Feed], all_episodes: &[Vec<Episode>]) { | ||
| 43 | + self.total_feeds = feeds.len(); | ||
| 44 | + self.enabled_feeds = feeds.iter().filter(|f| f.enabled).count(); | ||
| 45 | + self.feed_statistics.clear(); | ||
| 46 | + | ||
| 47 | + let mut total_episodes = 0usize; | ||
| 48 | + let mut downloaded_episodes = 0usize; | ||
| 49 | + let mut total_size = 0u64; | ||
| 50 | + let mut last_download: Option<DateTime<Utc>> = None; | ||
| 51 | + | ||
| 52 | + for (index, feed) in feeds.iter().enumerate() { | ||
| 53 | + let episodes = if index < all_episodes.len() { | ||
| 54 | + &all_episodes[index] | ||
| 55 | + } else { | ||
| 56 | + &vec![] | ||
| 57 | + }; | ||
| 58 | + | ||
| 59 | + let mut feed_stats = FeedStatistics::new(feed.clone()); | ||
| 60 | + | ||
| 61 | + for episode in episodes { | ||
| 62 | + total_episodes += 1; | ||
| 63 | + feed_stats.total_episodes += 1; | ||
| 64 | + | ||
| 65 | + if episode.downloaded { | ||
| 66 | + downloaded_episodes += 1; | ||
| 67 | + feed_stats.downloaded_episodes += 1; | ||
| 68 | + | ||
| 69 | + if let Some(size) = episode.file_size { | ||
| 70 | + total_size += size; | ||
| 71 | + feed_stats.total_size += size; | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + let pub_date = episode.pub_date; | ||
| 75 | + if last_download.is_none() || pub_date > last_download.unwrap() { | ||
| 76 | + last_download = Some(pub_date); | ||
| 77 | + } | ||
| 78 | + if feed_stats.last_episode_date.is_none() || pub_date > feed_stats.last_episode_date.unwrap() { | ||
| 79 | + feed_stats.last_episode_date = Some(pub_date); | ||
| 80 | + } | ||
| 81 | + } else { | ||
| 82 | + feed_stats.not_downloaded_episodes += 1; | ||
| 83 | + } | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + self.feed_statistics.insert(feed.id.clone(), feed_stats); | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + self.total_episodes = total_episodes; | ||
| 90 | + self.downloaded_episodes = downloaded_episodes; | ||
| 91 | + self.not_downloaded_episodes = total_episodes - downloaded_episodes; | ||
| 92 | + self.total_download_size = total_size; | ||
| 93 | + self.last_download_date = last_download; | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + /// Incrémenter le compteur de synchronisations | ||
| 97 | + pub fn increment_sync_count(&mut self) { | ||
| 98 | + self.total_sync_count += 1; | ||
| 99 | + self.last_sync_date = Some(Utc::now()); | ||
| 100 | + } | ||
| 101 | + | ||
| 102 | + /// Incrémenter le compteur de téléchargements | ||
| 103 | + pub fn increment_download_count(&mut self) { | ||
| 104 | + self.total_download_count += 1; | ||
| 105 | + self.last_download_date = Some(Utc::now()); | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + /// Ajouter une taille de téléchargement | ||
| 109 | + pub fn add_download_size(&mut self, size: u64) { | ||
| 110 | + self.total_download_size += size; | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + /// Obtenir la taille totale formatée (en Mo, Go, etc.) | ||
| 114 | + pub fn get_formatted_total_size(&self) -> String { | ||
| 115 | + format_bytes(self.total_download_size) | ||
| 116 | + } | ||
| 117 | + | ||
| 118 | + /// Obtenir le pourcentage d'épisodes téléchargés | ||
| 119 | + pub fn get_download_percentage(&self) -> f32 { | ||
| 120 | + if self.total_episodes == 0 { | ||
| 121 | + 0.0 | ||
| 122 | + } else { | ||
| 123 | + (self.downloaded_episodes as f32 / self.total_episodes as f32) * 100.0 | ||
| 124 | + } | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + /// Obtenir la taille moyenne des fichiers téléchargés | ||
| 128 | + pub fn get_average_file_size(&self) -> u64 { | ||
| 129 | + if self.downloaded_episodes == 0 { | ||
| 130 | + 0 | ||
| 131 | + } else { | ||
| 132 | + self.total_download_size / self.downloaded_episodes as u64 | ||
| 133 | + } | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + /// Mettre à jour les statistiques des playlists | ||
| 137 | + pub fn update_playlist_statistics( | ||
| 138 | + &mut self, | ||
| 139 | + playlists: &[super::playlist::Playlist], | ||
| 140 | + feeds: &[Feed], | ||
| 141 | + all_episodes: &[Vec<Episode>], | ||
| 142 | + ) { | ||
| 143 | + self.playlist_statistics.clear(); | ||
| 144 | + | ||
| 145 | + for playlist in playlists { | ||
| 146 | + let mut playlist_stats = PlaylistStatistics::new(playlist.clone()); | ||
| 147 | + | ||
| 148 | + for feed_id in &playlist.feed_ids { | ||
| 149 | + if let Some(feed_index) = feeds.iter().position(|f| &f.id == feed_id) { | ||
| 150 | + if let Some(feed) = feeds.get(feed_index) { | ||
| 151 | + playlist_stats.total_feeds += 1; | ||
| 152 | + if feed.enabled { | ||
| 153 | + playlist_stats.enabled_feeds += 1; | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + if feed_index < all_episodes.len() { | ||
| 157 | + let episodes = &all_episodes[feed_index]; | ||
| 158 | + for episode in episodes { | ||
| 159 | + playlist_stats.total_episodes += 1; | ||
| 160 | + if episode.downloaded { | ||
| 161 | + playlist_stats.downloaded_episodes += 1; | ||
| 162 | + if let Some(size) = episode.file_size { | ||
| 163 | + playlist_stats.total_size += size; | ||
| 164 | + } | ||
| 165 | + } | ||
| 166 | + } | ||
| 167 | + } | ||
| 168 | + } | ||
| 169 | + } | ||
| 170 | + } | ||
| 171 | + | ||
| 172 | + self.playlist_statistics.insert(playlist.id.clone(), playlist_stats); | ||
| 173 | + } | ||
| 174 | + } | ||
| 175 | +} | ||
| 176 | + | ||
| 177 | +/// Statistiques pour un flux spécifique | ||
| 178 | +#[derive(Debug, Clone)] | ||
| 179 | +pub struct FeedStatistics { | ||
| 180 | + /// ID du flux | ||
| 181 | + pub feed_id: String, | ||
| 182 | + /// Nom du flux | ||
| 183 | + pub feed_name: String, | ||
| 184 | + /// URL du flux | ||
| 185 | + pub feed_url: String, | ||
| 186 | + /// Nombre total d'épisodes | ||
| 187 | + pub total_episodes: usize, | ||
| 188 | + /// Nombre d'épisodes téléchargés | ||
| 189 | + pub downloaded_episodes: usize, | ||
| 190 | + /// Nombre d'épisodes non téléchargés | ||
| 191 | + pub not_downloaded_episodes: usize, | ||
| 192 | + /// Taille totale des téléchargements | ||
| 193 | + pub total_size: u64, | ||
| 194 | + /// Date du dernier épisode | ||
| 195 | + pub last_episode_date: Option<DateTime<Utc>>, | ||
| 196 | + /// Dossier de téléchargement | ||
| 197 | + pub download_path: PathBuf, | ||
| 198 | +} | ||
| 199 | + | ||
| 200 | +impl FeedStatistics { | ||
| 201 | + pub fn new(feed: Feed) -> Self { | ||
| 202 | + Self { | ||
| 203 | + feed_id: feed.id, | ||
| 204 | + feed_name: feed.name, | ||
| 205 | + feed_url: feed.url, | ||
| 206 | + total_episodes: 0, | ||
| 207 | + downloaded_episodes: 0, | ||
| 208 | + not_downloaded_episodes: 0, | ||
| 209 | + total_size: 0, | ||
| 210 | + last_episode_date: None, | ||
| 211 | + download_path: feed.download_path, | ||
| 212 | + } | ||
| 213 | + } | ||
| 214 | + | ||
| 215 | + /// Obtenir la taille formatée | ||
| 216 | + pub fn get_formatted_size(&self) -> String { | ||
| 217 | + format_bytes(self.total_size) | ||
| 218 | + } | ||
| 219 | + | ||
| 220 | + /// Obtenir le pourcentage de téléchargement | ||
| 221 | + pub fn get_download_percentage(&self) -> f32 { | ||
| 222 | + if self.total_episodes == 0 { | ||
| 223 | + 0.0 | ||
| 224 | + } else { | ||
| 225 | + (self.downloaded_episodes as f32 / self.total_episodes as f32) * 100.0 | ||
| 226 | + } | ||
| 227 | + } | ||
| 228 | +} | ||
| 229 | + | ||
| 230 | +/// Statistiques pour une playlist | ||
| 231 | +#[derive(Debug, Clone)] | ||
| 232 | +pub struct PlaylistStatistics { | ||
| 233 | + /// ID de la playlist | ||
| 234 | + pub playlist_id: String, | ||
| 235 | + /// Nom de la playlist | ||
| 236 | + pub playlist_name: String, | ||
| 237 | + /// Nombre total de flux dans la playlist | ||
| 238 | + pub total_feeds: usize, | ||
| 239 | + /// Nombre de flux activés | ||
| 240 | + pub enabled_feeds: usize, | ||
| 241 | + /// Nombre total d'épisodes | ||
| 242 | + pub total_episodes: usize, | ||
| 243 | + /// Nombre d'épisodes téléchargés | ||
| 244 | + pub downloaded_episodes: usize, | ||
| 245 | + /// Taille totale | ||
| 246 | + pub total_size: u64, | ||
| 247 | +} | ||
| 248 | + | ||
| 249 | +impl PlaylistStatistics { | ||
| 250 | + pub fn new(playlist: super::playlist::Playlist) -> Self { | ||
| 251 | + Self { | ||
| 252 | + playlist_id: playlist.id, | ||
| 253 | + playlist_name: playlist.name, | ||
| 254 | + total_feeds: 0, | ||
| 255 | + enabled_feeds: 0, | ||
| 256 | + total_episodes: 0, | ||
| 257 | + downloaded_episodes: 0, | ||
| 258 | + total_size: 0, | ||
| 259 | + } | ||
| 260 | + } | ||
| 261 | + | ||
| 262 | + /// Obtenir la taille formatée | ||
| 263 | + pub fn get_formatted_size(&self) -> String { | ||
| 264 | + format_bytes(self.total_size) | ||
| 265 | + } | ||
| 266 | +} | ||
| 267 | + | ||
| 268 | +/// Formater une taille en octets en une chaîne lisible | ||
| 269 | +pub fn format_bytes(bytes: u64) -> String { | ||
| 270 | + const KB: u64 = 1024; | ||
| 271 | + const MB: u64 = KB * 1024; | ||
| 272 | + const GB: u64 = MB * 1024; | ||
| 273 | + const TB: u64 = GB * 1024; | ||
| 274 | + | ||
| 275 | + if bytes >= TB { | ||
| 276 | + format!("{:.2} TB", bytes as f64 / TB as f64) | ||
| 277 | + } else if bytes >= GB { | ||
| 278 | + format!("{:.2} GB", bytes as f64 / GB as f64) | ||
| 279 | + } else if bytes >= MB { | ||
| 280 | + format!("{:.2} MB", bytes as f64 / MB as f64) | ||
| 281 | + } else if bytes >= KB { | ||
| 282 | + format!("{:.2} KB", bytes as f64 / KB as f64) | ||
| 283 | + } else { | ||
| 284 | + format!("{} B", bytes) | ||
| 285 | + } | ||
| 286 | +} | ||
| 287 | + | ||
| 288 | +/// Formater une durée en secondes en une chaîne lisible | ||
| 289 | +pub fn format_duration(seconds: u64) -> String { | ||
| 290 | + if seconds < 60 { | ||
| 291 | + format!("{}s", seconds) | ||
| 292 | + } else if seconds < 3600 { | ||
| 293 | + let minutes = seconds / 60; | ||
| 294 | + let secs = seconds % 60; | ||
| 295 | + format!("{}m {}s", minutes, secs) | ||
| 296 | + } else { | ||
| 297 | + let hours = seconds / 3600; | ||
| 298 | + let minutes = (seconds % 3600) / 60; | ||
| 299 | + let secs = seconds % 60; | ||
| 300 | + format!("{}h {}m {}s", hours, minutes, secs) | ||
| 301 | + } | ||
| 302 | +} | ||
| 303 | + | ||
| 304 | +#[cfg(test)] | ||
| 305 | +mod tests { | ||
| 306 | + use super::*; | ||
| 307 | + | ||
| 308 | + #[test] | ||
| 309 | + fn test_format_bytes() { | ||
| 310 | + assert_eq!(format_bytes(0), "0 B"); | ||
| 311 | + assert_eq!(format_bytes(1024), "1.00 KB"); | ||
| 312 | + assert_eq!(format_bytes(1024 * 1024), "1.00 MB"); | ||
| 313 | + assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB"); | ||
| 314 | + } | ||
| 315 | + | ||
| 316 | + #[test] | ||
| 317 | + fn test_format_duration() { | ||
| 318 | + assert_eq!(format_duration(0), "0s"); | ||
| 319 | + assert_eq!(format_duration(30), "30s"); | ||
| 320 | + assert_eq!(format_duration(90), "1m 30s"); | ||
| 321 | + assert_eq!(format_duration(3661), "1h 1m 1s"); | ||
| 322 | + } | ||
| 323 | +} | ||
modified
src/config/storage.rs +21 -0 | @@ -102,6 +102,27 @@ pub fn load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError> { | ||
| 102 | 102 | Ok(feeds) |
| 103 | 103 | } |
| 104 | 104 | |
| 105 | +/// Gets the default configuration directory | |
| 106 | +/// | |
| 107 | +/// On Windows: %APPDATA%/rss-podcast-dl | |
| 108 | +/// On Unix: ~/.config/rss-podcast-dl | |
| 109 | +pub fn get_config_dir() -> std::path::PathBuf { | |
| 110 | + // Use the same logic as AppState::default_config_path but without the filename | |
| 111 | + if cfg!(windows) { | |
| 112 | + if let Some(app_data) = std::env::var_os("APPDATA") { | |
| 113 | + Path::new(&app_data).join("rss-podcast-dl") | |
| 114 | + } else { | |
| 115 | + Path::new(".").to_path_buf() | |
| 116 | + } | |
| 117 | + } else { | |
| 118 | + if let Some(home) = std::env::var_os("HOME") { | |
| 119 | + Path::new(&home).join(".config").join("rss-podcast-dl") | |
| 120 | + } else { | |
| 121 | + Path::new(".").to_path_buf() | |
| 122 | + } | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 105 | 126 | /// Gets the default configuration path |
| 106 | 127 | /// |
| 107 | 128 | /// On Windows: %APPDATA%/rss-podcast-dl/config.json |
| @@ -102,6 +102,27 @@ pub fn load_feeds(path: &Path) -> Result<Vec<Feed>, StorageError> { | |||
| 102 | Ok(feeds) | 102 | Ok(feeds) |
| 103 | } | 103 | } |
| 104 | 104 | ||
| 105 | +/// Gets the default configuration directory | ||
| 106 | +/// | ||
| 107 | +/// On Windows: %APPDATA%/rss-podcast-dl | ||
| 108 | +/// On Unix: ~/.config/rss-podcast-dl | ||
| 109 | +pub fn get_config_dir() -> std::path::PathBuf { | ||
| 110 | + // Use the same logic as AppState::default_config_path but without the filename | ||
| 111 | + if cfg!(windows) { | ||
| 112 | + if let Some(app_data) = std::env::var_os("APPDATA") { | ||
| 113 | + Path::new(&app_data).join("rss-podcast-dl") | ||
| 114 | + } else { | ||
| 115 | + Path::new(".").to_path_buf() | ||
| 116 | + } | ||
| 117 | + } else { | ||
| 118 | + if let Some(home) = std::env::var_os("HOME") { | ||
| 119 | + Path::new(&home).join(".config").join("rss-podcast-dl") | ||
| 120 | + } else { | ||
| 121 | + Path::new(".").to_path_buf() | ||
| 122 | + } | ||
| 123 | + } | ||
| 124 | +} | ||
| 125 | + | ||
| 105 | /// Gets the default configuration path | 126 | /// Gets the default configuration path |
| 106 | /// | 127 | /// |
| 107 | /// On Windows: %APPDATA%/rss-podcast-dl/config.json | 128 | /// On Windows: %APPDATA%/rss-podcast-dl/config.json |
added
src/rss/audio_metadata.rs +193 -0 | new file mode 100644 | ||
| @@ -0,0 +1,193 @@ | ||
| 1 | +//! Module pour la lecture des métadonnées audio (durée, artiste, etc.) | |
| 2 | +//! | |
| 3 | +//! Ce module fournit des fonctions pour extraire la durée des fichiers audio. | |
| 4 | +//! Pour l'instant, il utilise une estimation basée sur la taille du fichier | |
| 5 | +//! car l'intégration avec les crates de métadonnées peut être complexe. | |
| 6 | + | |
| 7 | +use std::path::{Path, PathBuf}; | |
| 8 | + | |
| 9 | +/// Métadonnées d'un fichier audio | |
| 10 | +#[derive(Debug, Clone, Default)] | |
| 11 | +pub struct AudioMetadata { | |
| 12 | + /// Durée en secondes | |
| 13 | + pub duration_seconds: Option<u64>, | |
| 14 | + /// Titre (si disponible) | |
| 15 | + pub title: Option<String>, | |
| 16 | + /// Artiste (si disponible) | |
| 17 | + pub artist: Option<String>, | |
| 18 | + /// Album (si disponible) | |
| 19 | + pub album: Option<String>, | |
| 20 | + /// Année (si disponible) | |
| 21 | + pub year: Option<u32>, | |
| 22 | + /// Genre (si disponible) | |
| 23 | + pub genre: Option<String>, | |
| 24 | + /// Bitrate en kbps (si disponible) | |
| 25 | + pub bitrate_kbps: Option<u32>, | |
| 26 | + /// Taille du fichier en octets | |
| 27 | + pub file_size: u64, | |
| 28 | +} | |
| 29 | + | |
| 30 | +impl AudioMetadata { | |
| 31 | + pub fn new() -> Self { | |
| 32 | + Self::default() | |
| 33 | + } | |
| 34 | + | |
| 35 | + /// Obtenir la durée formatée (HH:MM:SS ou MM:SS) | |
| 36 | + pub fn get_formatted_duration(&self) -> String { | |
| 37 | + if let Some(duration) = self.duration_seconds { | |
| 38 | + format_duration(duration) | |
| 39 | + } else { | |
| 40 | + String::new() | |
| 41 | + } | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +/// Erreur de lecture des métadonnées | |
| 46 | +#[derive(Debug, thiserror::Error)] | |
| 47 | +pub enum MetadataError { | |
| 48 | + /// Fichier introuvable | |
| 49 | + #[error("Fichier introuvable: {0}")] | |
| 50 | + FileNotFound(String), | |
| 51 | + | |
| 52 | + /// Erreur de lecture du fichier | |
| 53 | + #[error("Erreur de lecture du fichier: {0}")] | |
| 54 | + IoError(#[from] std::io::Error), | |
| 55 | + | |
| 56 | + /// Format non supporté | |
| 57 | + #[error("Format audio non supporté: {0}")] | |
| 58 | + UnsupportedFormat(String), | |
| 59 | + | |
| 60 | + /// Erreur de parsing des métadonnées | |
| 61 | + #[error("Erreur de parsing des métadonnées: {0}")] | |
| 62 | + ParseError(String), | |
| 63 | +} | |
| 64 | + | |
| 65 | +/// Bitrate moyen par défaut pour les fichiers audio (en kbps) | |
| 66 | +const DEFAULT_BITRATE_KBPS: u32 = 128; | |
| 67 | + | |
| 68 | +/// Lire les métadonnées d'un fichier audio | |
| 69 | +/// Utilise une estimation basée sur la taille du fichier | |
| 70 | +pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError> { | |
| 71 | + if !path.exists() { | |
| 72 | + return Err(MetadataError::FileNotFound(path.display().to_string())); | |
| 73 | + } | |
| 74 | + | |
| 75 | + let metadata = std::fs::metadata(path) | |
| 76 | + .map_err(|e| MetadataError::IoError(e))?; | |
| 77 | + let file_size = metadata.len(); | |
| 78 | + | |
| 79 | + let mut audio_metadata = AudioMetadata::new(); | |
| 80 | + audio_metadata.file_size = file_size; | |
| 81 | + | |
| 82 | + // Estimer la durée à partir de la taille et du bitrate moyen | |
| 83 | + if file_size > 0 { | |
| 84 | + let duration = estimate_duration_from_size(file_size, DEFAULT_BITRATE_KBPS); | |
| 85 | + audio_metadata.duration_seconds = Some(duration); | |
| 86 | + audio_metadata.bitrate_kbps = Some(DEFAULT_BITRATE_KBPS); | |
| 87 | + } | |
| 88 | + | |
| 89 | + // Essayer d'extraire le nom du fichier comme titre | |
| 90 | + if let Some(file_stem) = path.file_stem() { | |
| 91 | + audio_metadata.title = Some(file_stem.to_string_lossy().into_owned()); | |
| 92 | + } | |
| 93 | + | |
| 94 | + Ok(audio_metadata) | |
| 95 | +} | |
| 96 | + | |
| 97 | +/// Lire uniquement la durée d'un fichier audio | |
| 98 | +pub fn get_audio_duration(path: &Path) -> Result<u64, MetadataError> { | |
| 99 | + let metadata = read_audio_metadata(path)?; | |
| 100 | + metadata.duration_seconds.ok_or(MetadataError::ParseError("Durée non disponible".to_string())) | |
| 101 | +} | |
| 102 | + | |
| 103 | +/// Estimer la durée d'un fichier audio à partir de sa taille et du bitrate moyen | |
| 104 | +pub fn estimate_duration_from_size(file_size: u64, bitrate_kbps: u32) -> u64 { | |
| 105 | + if file_size == 0 || bitrate_kbps == 0 { | |
| 106 | + return 0; | |
| 107 | + } | |
| 108 | + | |
| 109 | + // Bitrate en bps | |
| 110 | + let bitrate_bps = bitrate_kbps as f64 * 1000.0; | |
| 111 | + | |
| 112 | + // Durée en secondes = (taille en octets * 8) / bitrate en bps | |
| 113 | + let duration_seconds = (file_size as f64 * 8.0) / bitrate_bps; | |
| 114 | + | |
| 115 | + duration_seconds as u64 | |
| 116 | +} | |
| 117 | + | |
| 118 | +/// Lire la durée de plusieurs fichiers audio | |
| 119 | +pub fn get_multiple_audio_durations(paths: &[impl AsRef<Path>]) -> Vec<(PathBuf, Option<u64>)> { | |
| 120 | + paths.iter() | |
| 121 | + .map(|path| { | |
| 122 | + let path_ref = path.as_ref(); | |
| 123 | + let path_buf = path_ref.to_path_buf(); | |
| 124 | + match get_audio_duration(path_ref) { | |
| 125 | + Ok(duration) => (path_buf, Some(duration)), | |
| 126 | + Err(_) => (path_buf, None), | |
| 127 | + } | |
| 128 | + }) | |
| 129 | + .collect() | |
| 130 | +} | |
| 131 | + | |
| 132 | +/// Vérifier si un fichier est un fichier audio supporté | |
| 133 | +pub fn is_supported_audio_file(path: &Path) -> bool { | |
| 134 | + let ext = path.extension() | |
| 135 | + .and_then(|s| s.to_str()) | |
| 136 | + .map(|s| s.to_lowercase()); | |
| 137 | + | |
| 138 | + matches!( | |
| 139 | + ext.as_deref(), | |
| 140 | + Some("mp3") | Some("m4a") | Some("wav") | Some("flac") | Some("ogg") | Some("aac") | Some("mp4") | |
| 141 | + ) | |
| 142 | +} | |
| 143 | + | |
| 144 | +/// Formater une durée en secondes en une chaîne lisible | |
| 145 | +pub fn format_duration(seconds: u64) -> String { | |
| 146 | + if seconds < 60 { | |
| 147 | + format!("{}s", seconds) | |
| 148 | + } else if seconds < 3600 { | |
| 149 | + let minutes = seconds / 60; | |
| 150 | + let secs = seconds % 60; | |
| 151 | + format!("{}m {}s", minutes, secs) | |
| 152 | + } else { | |
| 153 | + let hours = seconds / 3600; | |
| 154 | + let minutes = (seconds % 3600) / 60; | |
| 155 | + let secs = seconds % 60; | |
| 156 | + format!("{}h {}m {}s", hours, minutes, secs) | |
| 157 | + } | |
| 158 | +} | |
| 159 | + | |
| 160 | +#[cfg(test)] | |
| 161 | +mod tests { | |
| 162 | + use super::*; | |
| 163 | + use tempfile::NamedTempFile; | |
| 164 | + | |
| 165 | + #[test] | |
| 166 | + fn test_is_supported_audio_file() { | |
| 167 | + let mp3_path = Path::new("test.mp3"); | |
| 168 | + let wav_path = Path::new("test.wav"); | |
| 169 | + let txt_path = Path::new("test.txt"); | |
| 170 | + | |
| 171 | + assert!(is_supported_audio_file(mp3_path)); | |
| 172 | + assert!(is_supported_audio_file(wav_path)); | |
| 173 | + assert!(!is_supported_audio_file(txt_path)); | |
| 174 | + } | |
| 175 | + | |
| 176 | + #[test] | |
| 177 | + fn test_estimate_duration() { | |
| 178 | + // Un fichier de 5 Mo à 128 kbps devrait faire environ 320 secondes | |
| 179 | + let file_size = 5 * 1024 * 1024; // 5 Mo | |
| 180 | + let bitrate = 128; // 128 kbps | |
| 181 | + | |
| 182 | + let duration = estimate_duration_from_size(file_size, bitrate); | |
| 183 | + assert!(duration > 320 && duration < 335); | |
| 184 | + } | |
| 185 | + | |
| 186 | + #[test] | |
| 187 | + fn test_format_duration() { | |
| 188 | + assert_eq!(format_duration(0), "0s"); | |
| 189 | + assert_eq!(format_duration(30), "30s"); | |
| 190 | + assert_eq!(format_duration(90), "1m 30s"); | |
| 191 | + assert_eq!(format_duration(3661), "1h 1m 1s"); | |
| 192 | + } | |
| 193 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,193 @@ | |||
| 1 | +//! Module pour la lecture des métadonnées audio (durée, artiste, etc.) | ||
| 2 | +//! | ||
| 3 | +//! Ce module fournit des fonctions pour extraire la durée des fichiers audio. | ||
| 4 | +//! Pour l'instant, il utilise une estimation basée sur la taille du fichier | ||
| 5 | +//! car l'intégration avec les crates de métadonnées peut être complexe. | ||
| 6 | + | ||
| 7 | +use std::path::{Path, PathBuf}; | ||
| 8 | + | ||
| 9 | +/// Métadonnées d'un fichier audio | ||
| 10 | +#[derive(Debug, Clone, Default)] | ||
| 11 | +pub struct AudioMetadata { | ||
| 12 | + /// Durée en secondes | ||
| 13 | + pub duration_seconds: Option<u64>, | ||
| 14 | + /// Titre (si disponible) | ||
| 15 | + pub title: Option<String>, | ||
| 16 | + /// Artiste (si disponible) | ||
| 17 | + pub artist: Option<String>, | ||
| 18 | + /// Album (si disponible) | ||
| 19 | + pub album: Option<String>, | ||
| 20 | + /// Année (si disponible) | ||
| 21 | + pub year: Option<u32>, | ||
| 22 | + /// Genre (si disponible) | ||
| 23 | + pub genre: Option<String>, | ||
| 24 | + /// Bitrate en kbps (si disponible) | ||
| 25 | + pub bitrate_kbps: Option<u32>, | ||
| 26 | + /// Taille du fichier en octets | ||
| 27 | + pub file_size: u64, | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +impl AudioMetadata { | ||
| 31 | + pub fn new() -> Self { | ||
| 32 | + Self::default() | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + /// Obtenir la durée formatée (HH:MM:SS ou MM:SS) | ||
| 36 | + pub fn get_formatted_duration(&self) -> String { | ||
| 37 | + if let Some(duration) = self.duration_seconds { | ||
| 38 | + format_duration(duration) | ||
| 39 | + } else { | ||
| 40 | + String::new() | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +/// Erreur de lecture des métadonnées | ||
| 46 | +#[derive(Debug, thiserror::Error)] | ||
| 47 | +pub enum MetadataError { | ||
| 48 | + /// Fichier introuvable | ||
| 49 | + #[error("Fichier introuvable: {0}")] | ||
| 50 | + FileNotFound(String), | ||
| 51 | + | ||
| 52 | + /// Erreur de lecture du fichier | ||
| 53 | + #[error("Erreur de lecture du fichier: {0}")] | ||
| 54 | + IoError(#[from] std::io::Error), | ||
| 55 | + | ||
| 56 | + /// Format non supporté | ||
| 57 | + #[error("Format audio non supporté: {0}")] | ||
| 58 | + UnsupportedFormat(String), | ||
| 59 | + | ||
| 60 | + /// Erreur de parsing des métadonnées | ||
| 61 | + #[error("Erreur de parsing des métadonnées: {0}")] | ||
| 62 | + ParseError(String), | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +/// Bitrate moyen par défaut pour les fichiers audio (en kbps) | ||
| 66 | +const DEFAULT_BITRATE_KBPS: u32 = 128; | ||
| 67 | + | ||
| 68 | +/// Lire les métadonnées d'un fichier audio | ||
| 69 | +/// Utilise une estimation basée sur la taille du fichier | ||
| 70 | +pub fn read_audio_metadata(path: &Path) -> Result<AudioMetadata, MetadataError> { | ||
| 71 | + if !path.exists() { | ||
| 72 | + return Err(MetadataError::FileNotFound(path.display().to_string())); | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + let metadata = std::fs::metadata(path) | ||
| 76 | + .map_err(|e| MetadataError::IoError(e))?; | ||
| 77 | + let file_size = metadata.len(); | ||
| 78 | + | ||
| 79 | + let mut audio_metadata = AudioMetadata::new(); | ||
| 80 | + audio_metadata.file_size = file_size; | ||
| 81 | + | ||
| 82 | + // Estimer la durée à partir de la taille et du bitrate moyen | ||
| 83 | + if file_size > 0 { | ||
| 84 | + let duration = estimate_duration_from_size(file_size, DEFAULT_BITRATE_KBPS); | ||
| 85 | + audio_metadata.duration_seconds = Some(duration); | ||
| 86 | + audio_metadata.bitrate_kbps = Some(DEFAULT_BITRATE_KBPS); | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + // Essayer d'extraire le nom du fichier comme titre | ||
| 90 | + if let Some(file_stem) = path.file_stem() { | ||
| 91 | + audio_metadata.title = Some(file_stem.to_string_lossy().into_owned()); | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + Ok(audio_metadata) | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +/// Lire uniquement la durée d'un fichier audio | ||
| 98 | +pub fn get_audio_duration(path: &Path) -> Result<u64, MetadataError> { | ||
| 99 | + let metadata = read_audio_metadata(path)?; | ||
| 100 | + metadata.duration_seconds.ok_or(MetadataError::ParseError("Durée non disponible".to_string())) | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +/// Estimer la durée d'un fichier audio à partir de sa taille et du bitrate moyen | ||
| 104 | +pub fn estimate_duration_from_size(file_size: u64, bitrate_kbps: u32) -> u64 { | ||
| 105 | + if file_size == 0 || bitrate_kbps == 0 { | ||
| 106 | + return 0; | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + // Bitrate en bps | ||
| 110 | + let bitrate_bps = bitrate_kbps as f64 * 1000.0; | ||
| 111 | + | ||
| 112 | + // Durée en secondes = (taille en octets * 8) / bitrate en bps | ||
| 113 | + let duration_seconds = (file_size as f64 * 8.0) / bitrate_bps; | ||
| 114 | + | ||
| 115 | + duration_seconds as u64 | ||
| 116 | +} | ||
| 117 | + | ||
| 118 | +/// Lire la durée de plusieurs fichiers audio | ||
| 119 | +pub fn get_multiple_audio_durations(paths: &[impl AsRef<Path>]) -> Vec<(PathBuf, Option<u64>)> { | ||
| 120 | + paths.iter() | ||
| 121 | + .map(|path| { | ||
| 122 | + let path_ref = path.as_ref(); | ||
| 123 | + let path_buf = path_ref.to_path_buf(); | ||
| 124 | + match get_audio_duration(path_ref) { | ||
| 125 | + Ok(duration) => (path_buf, Some(duration)), | ||
| 126 | + Err(_) => (path_buf, None), | ||
| 127 | + } | ||
| 128 | + }) | ||
| 129 | + .collect() | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +/// Vérifier si un fichier est un fichier audio supporté | ||
| 133 | +pub fn is_supported_audio_file(path: &Path) -> bool { | ||
| 134 | + let ext = path.extension() | ||
| 135 | + .and_then(|s| s.to_str()) | ||
| 136 | + .map(|s| s.to_lowercase()); | ||
| 137 | + | ||
| 138 | + matches!( | ||
| 139 | + ext.as_deref(), | ||
| 140 | + Some("mp3") | Some("m4a") | Some("wav") | Some("flac") | Some("ogg") | Some("aac") | Some("mp4") | ||
| 141 | + ) | ||
| 142 | +} | ||
| 143 | + | ||
| 144 | +/// Formater une durée en secondes en une chaîne lisible | ||
| 145 | +pub fn format_duration(seconds: u64) -> String { | ||
| 146 | + if seconds < 60 { | ||
| 147 | + format!("{}s", seconds) | ||
| 148 | + } else if seconds < 3600 { | ||
| 149 | + let minutes = seconds / 60; | ||
| 150 | + let secs = seconds % 60; | ||
| 151 | + format!("{}m {}s", minutes, secs) | ||
| 152 | + } else { | ||
| 153 | + let hours = seconds / 3600; | ||
| 154 | + let minutes = (seconds % 3600) / 60; | ||
| 155 | + let secs = seconds % 60; | ||
| 156 | + format!("{}h {}m {}s", hours, minutes, secs) | ||
| 157 | + } | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +#[cfg(test)] | ||
| 161 | +mod tests { | ||
| 162 | + use super::*; | ||
| 163 | + use tempfile::NamedTempFile; | ||
| 164 | + | ||
| 165 | + #[test] | ||
| 166 | + fn test_is_supported_audio_file() { | ||
| 167 | + let mp3_path = Path::new("test.mp3"); | ||
| 168 | + let wav_path = Path::new("test.wav"); | ||
| 169 | + let txt_path = Path::new("test.txt"); | ||
| 170 | + | ||
| 171 | + assert!(is_supported_audio_file(mp3_path)); | ||
| 172 | + assert!(is_supported_audio_file(wav_path)); | ||
| 173 | + assert!(!is_supported_audio_file(txt_path)); | ||
| 174 | + } | ||
| 175 | + | ||
| 176 | + #[test] | ||
| 177 | + fn test_estimate_duration() { | ||
| 178 | + // Un fichier de 5 Mo à 128 kbps devrait faire environ 320 secondes | ||
| 179 | + let file_size = 5 * 1024 * 1024; // 5 Mo | ||
| 180 | + let bitrate = 128; // 128 kbps | ||
| 181 | + | ||
| 182 | + let duration = estimate_duration_from_size(file_size, bitrate); | ||
| 183 | + assert!(duration > 320 && duration < 335); | ||
| 184 | + } | ||
| 185 | + | ||
| 186 | + #[test] | ||
| 187 | + fn test_format_duration() { | ||
| 188 | + assert_eq!(format_duration(0), "0s"); | ||
| 189 | + assert_eq!(format_duration(30), "30s"); | ||
| 190 | + assert_eq!(format_duration(90), "1m 30s"); | ||
| 191 | + assert_eq!(format_duration(3661), "1h 1m 1s"); | ||
| 192 | + } | ||
| 193 | +} | ||
modified
src/rss/mod.rs +4 -0 | @@ -2,6 +2,7 @@ | ||
| 2 | 2 | //! |
| 3 | 3 | //! This module handles RSS feed parsing, episode extraction, and downloading. |
| 4 | 4 | |
| 5 | +pub mod audio_metadata; | |
| 5 | 6 | pub mod downloader; |
| 6 | 7 | pub mod episode_extractor; |
| 7 | 8 | pub mod parser; |
| @@ -12,6 +13,9 @@ pub mod sync; | ||
| 12 | 13 | // kept for future extensibility and API completeness |
| 13 | 14 | #[allow(dead_code)] |
| 14 | 15 | #[allow(unused_imports)] |
| 16 | +pub use audio_metadata::{AudioMetadata, MetadataError, format_duration, get_audio_duration, is_supported_audio_file, read_audio_metadata}; | |
| 17 | +#[allow(dead_code)] | |
| 18 | +#[allow(unused_imports)] | |
| 15 | 19 | pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode}; |
| 16 | 20 | #[allow(dead_code)] |
| 17 | 21 | #[allow(unused_imports)] |
| @@ -2,6 +2,7 @@ | |||
| 2 | //! | 2 | //! |
| 3 | //! This module handles RSS feed parsing, episode extraction, and downloading. | 3 | //! This module handles RSS feed parsing, episode extraction, and downloading. |
| 4 | 4 | ||
| 5 | +pub mod audio_metadata; | ||
| 5 | pub mod downloader; | 6 | pub mod downloader; |
| 6 | pub mod episode_extractor; | 7 | pub mod episode_extractor; |
| 7 | pub mod parser; | 8 | pub mod parser; |
| @@ -12,6 +13,9 @@ pub mod sync; | |||
| 12 | // kept for future extensibility and API completeness | 13 | // kept for future extensibility and API completeness |
| 13 | #[allow(dead_code)] | 14 | #[allow(dead_code)] |
| 14 | #[allow(unused_imports)] | 15 | #[allow(unused_imports)] |
| 16 | +pub use audio_metadata::{AudioMetadata, MetadataError, format_duration, get_audio_duration, is_supported_audio_file, read_audio_metadata}; | ||
| 17 | +#[allow(dead_code)] | ||
| 18 | +#[allow(unused_imports)] | ||
| 15 | pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode}; | 19 | pub use downloader::{DownloadClient, DownloadError, ProgressCallback, download_episode, download_episodes, download_file, get_download_dir, needs_download, remove_downloaded_episode}; |
| 16 | #[allow(dead_code)] | 20 | #[allow(dead_code)] |
| 17 | #[allow(unused_imports)] | 21 | #[allow(unused_imports)] |
added
src/ui/history_panel.rs +226 -0 | new file mode 100644 | ||
| @@ -0,0 +1,226 @@ | ||
| 1 | +use crate::config::history::{HistoryEntry, HistoryManager, OperationStatus, OperationType}; | |
| 2 | +use eframe::egui; | |
| 3 | + | |
| 4 | +/// Panneau d'affichage de l'historique | |
| 5 | +pub struct HistoryPanel { | |
| 6 | + /// Visibilité du panneau | |
| 7 | + visible: bool, | |
| 8 | + /// Filtre de type d'opération | |
| 9 | + filter_type: Option<OperationType>, | |
| 10 | + /// Filtre de statut | |
| 11 | + filter_status: Option<OperationStatus>, | |
| 12 | + /// Nombre d'entrées à afficher | |
| 13 | + entries_to_show: usize, | |
| 14 | +} | |
| 15 | + | |
| 16 | +impl Default for HistoryPanel { | |
| 17 | + fn default() -> Self { | |
| 18 | + Self { | |
| 19 | + visible: false, | |
| 20 | + filter_type: None, | |
| 21 | + filter_status: None, | |
| 22 | + entries_to_show: 50, | |
| 23 | + } | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +impl HistoryPanel { | |
| 28 | + /// Créer un nouveau panneau d'historique | |
| 29 | + pub fn new() -> Self { | |
| 30 | + Self::default() | |
| 31 | + } | |
| 32 | + | |
| 33 | + /// Définir la visibilité | |
| 34 | + pub fn set_visible(&mut self, visible: bool) { | |
| 35 | + self.visible = visible; | |
| 36 | + } | |
| 37 | + | |
| 38 | + /// Basculer la visibilité | |
| 39 | + pub fn toggle(&mut self) { | |
| 40 | + self.visible = !self.visible; | |
| 41 | + } | |
| 42 | + | |
| 43 | + /// Vérifier si le panneau est visible | |
| 44 | + pub fn is_visible(&self) -> bool { | |
| 45 | + self.visible | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Définir le filtre de type | |
| 49 | + pub fn set_filter_type(&mut self, op_type: Option<OperationType>) { | |
| 50 | + self.filter_type = op_type; | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// Définir le filtre de statut | |
| 54 | + pub fn set_filter_status(&mut self, status: Option<OperationStatus>) { | |
| 55 | + self.filter_status = status; | |
| 56 | + } | |
| 57 | + | |
| 58 | + /// Afficher le panneau d'historique | |
| 59 | + pub fn show(&mut self, ui: &mut egui::Ui, history: &HistoryManager) { | |
| 60 | + if !self.visible { | |
| 61 | + return; | |
| 62 | + } | |
| 63 | + | |
| 64 | + egui::CollapsingHeader::new("📜 Historique") | |
| 65 | + .default_open(true) | |
| 66 | + .show(ui, |ui| { | |
| 67 | + self.show_content(ui, history); | |
| 68 | + }); | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// Afficher le contenu du panneau | |
| 72 | + fn show_content(&mut self, ui: &mut egui::Ui, history: &HistoryManager) { | |
| 73 | + // Contrôles de filtre | |
| 74 | + self.show_filters(ui); | |
| 75 | + ui.separator(); | |
| 76 | + | |
| 77 | + // Afficher les entrées | |
| 78 | + self.show_entries(ui, history); | |
| 79 | + } | |
| 80 | + | |
| 81 | + /// Afficher les contrôles de filtre | |
| 82 | + fn show_filters(&mut self, ui: &mut egui::Ui) { | |
| 83 | + ui.horizontal(|ui| { | |
| 84 | + // Filtre par type | |
| 85 | + egui::ComboBox::from_label("Type") | |
| 86 | + .width(150.0) | |
| 87 | + .selected_text(match self.filter_type { | |
| 88 | + Some(t) => format!("{}", t), | |
| 89 | + None => "Tous les types".to_string(), | |
| 90 | + }) | |
| 91 | + .show_ui(ui, |ui| { | |
| 92 | + ui.selectable_value(&mut self.filter_type, None, "Tous les types"); | |
| 93 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncFeed), format!("{}", OperationType::SyncFeed)); | |
| 94 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncAll), format!("{}", OperationType::SyncAll)); | |
| 95 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadEpisode), format!("{}", OperationType::DownloadEpisode)); | |
| 96 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::AddFeed), format!("{}", OperationType::AddFeed)); | |
| 97 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::RemoveFeed), format!("{}", OperationType::RemoveFeed)); | |
| 98 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::ImportFeeds), format!("{}", OperationType::ImportFeeds)); | |
| 99 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::ExportFeeds), format!("{}", OperationType::ExportFeeds)); | |
| 100 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadError), format!("{}", OperationType::DownloadError)); | |
| 101 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncError), format!("{}", OperationType::SyncError)); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + // Filtre par statut | |
| 105 | + egui::ComboBox::from_label("Statut") | |
| 106 | + .width(120.0) | |
| 107 | + .selected_text(match self.filter_status { | |
| 108 | + Some(s) => format!("{}", s), | |
| 109 | + None => "Tous les statuts".to_string(), | |
| 110 | + }) | |
| 111 | + .show_ui(ui, |ui| { | |
| 112 | + ui.selectable_value(&mut self.filter_status, None, "Tous les statuts"); | |
| 113 | + ui.selectable_value(&mut self.filter_status, Some(OperationStatus::InProgress), format!("{}", OperationStatus::InProgress)); | |
| 114 | + ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Success), format!("{}", OperationStatus::Success)); | |
| 115 | + ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Failed), format!("{}", OperationStatus::Failed)); | |
| 116 | + }); | |
| 117 | + | |
| 118 | + // Bouton pour effacer les filtres | |
| 119 | + if self.filter_type.is_some() || self.filter_status.is_some() { | |
| 120 | + if ui.button("Effacer filtres").clicked() { | |
| 121 | + self.filter_type = None; | |
| 122 | + self.filter_status = None; | |
| 123 | + } | |
| 124 | + } | |
| 125 | + }); | |
| 126 | + } | |
| 127 | + | |
| 128 | + /// Afficher les entrées d'historique | |
| 129 | + fn show_entries(&mut self, ui: &mut egui::Ui, history: &HistoryManager) { | |
| 130 | + let mut entries: Vec<&HistoryEntry> = history.get_all_entries(); | |
| 131 | + | |
| 132 | + // Appliquer les filtres | |
| 133 | + if let Some(op_type) = self.filter_type { | |
| 134 | + entries.retain(|e| e.operation_type == op_type); | |
| 135 | + } | |
| 136 | + if let Some(status) = self.filter_status { | |
| 137 | + entries.retain(|e| e.status == status); | |
| 138 | + } | |
| 139 | + | |
| 140 | + // Limiter le nombre d'entrées affichées | |
| 141 | + entries.truncate(self.entries_to_show); | |
| 142 | + | |
| 143 | + if entries.is_empty() { | |
| 144 | + ui.label("Aucune entrée d'historique trouvée"); | |
| 145 | + return; | |
| 146 | + } | |
| 147 | + | |
| 148 | + // Tableau des entrées | |
| 149 | + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { | |
| 150 | + egui::Grid::new("history_grid") | |
| 151 | + .striped(true) | |
| 152 | + .show(ui, |ui| { | |
| 153 | + // En-têtes | |
| 154 | + ui.strong("Date/Heure"); | |
| 155 | + ui.strong("Type"); | |
| 156 | + ui.strong("Description"); | |
| 157 | + ui.strong("Statut"); | |
| 158 | + ui.strong("Durée/Taille"); | |
| 159 | + ui.end_row(); | |
| 160 | + | |
| 161 | + // Lignes de données (afficher de la plus récente à la plus ancienne) | |
| 162 | + for entry in entries.iter().rev() { | |
| 163 | + ui.label(format!("{}", entry.timestamp.format("%Y-%m-%d %H:%M:%S"))); | |
| 164 | + ui.label(format!("{}", entry.operation_type)); | |
| 165 | + | |
| 166 | + // Limiter la longueur de la description | |
| 167 | + let desc = if entry.description.len() > 40 { | |
| 168 | + format!("{}...", &entry.description[..37]) | |
| 169 | + } else { | |
| 170 | + entry.description.clone() | |
| 171 | + }; | |
| 172 | + ui.label(desc); | |
| 173 | + | |
| 174 | + // Afficher le statut avec une couleur | |
| 175 | + let status_text = format!("{}", entry.status); | |
| 176 | + let color = match entry.status { | |
| 177 | + OperationStatus::Success => egui::Color32::GREEN, | |
| 178 | + OperationStatus::Failed => egui::Color32::RED, | |
| 179 | + OperationStatus::InProgress => egui::Color32::YELLOW, | |
| 180 | + }; | |
| 181 | + ui.colored_label(color, &status_text); | |
| 182 | + | |
| 183 | + // Afficher durée ou taille | |
| 184 | + let info = if entry.duration_seconds.is_some() { | |
| 185 | + entry.get_formatted_duration() | |
| 186 | + } else { | |
| 187 | + entry.get_formatted_size() | |
| 188 | + }; | |
| 189 | + ui.label(info); | |
| 190 | + ui.end_row(); | |
| 191 | + } | |
| 192 | + }); | |
| 193 | + }); | |
| 194 | + | |
| 195 | + // Statistiques de l'historique | |
| 196 | + ui.separator(); | |
| 197 | + ui.horizontal(|ui| { | |
| 198 | + ui.label(format!("Total: {} entrées", history.len())); | |
| 199 | + if ui.button("Effacer historique").clicked() { | |
| 200 | + // L'appelant doit effacer l'historique | |
| 201 | + } | |
| 202 | + }); | |
| 203 | + } | |
| 204 | + | |
| 205 | + /// Afficher une version simplifiée (dernières opérations) | |
| 206 | + pub fn show_recent(ui: &mut egui::Ui, history: &HistoryManager, count: usize) { | |
| 207 | + let entries = history.get_recent_entries(count); | |
| 208 | + | |
| 209 | + if entries.is_empty() { | |
| 210 | + ui.label("Aucune opération récente"); | |
| 211 | + return; | |
| 212 | + } | |
| 213 | + | |
| 214 | + ui.label("📜 Dernières opérations:"); | |
| 215 | + egui::ScrollArea::horizontal().show(ui, |ui| { | |
| 216 | + for entry in entries.iter().rev() { | |
| 217 | + let color = match entry.status { | |
| 218 | + OperationStatus::Success => egui::Color32::GREEN, | |
| 219 | + OperationStatus::Failed => egui::Color32::RED, | |
| 220 | + OperationStatus::InProgress => egui::Color32::YELLOW, | |
| 221 | + }; | |
| 222 | + ui.colored_label(color, format!("{} - {}", entry.timestamp.format("%H:%M"), entry.description)); | |
| 223 | + } | |
| 224 | + }); | |
| 225 | + } | |
| 226 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,226 @@ | |||
| 1 | +use crate::config::history::{HistoryEntry, HistoryManager, OperationStatus, OperationType}; | ||
| 2 | +use eframe::egui; | ||
| 3 | + | ||
| 4 | +/// Panneau d'affichage de l'historique | ||
| 5 | +pub struct HistoryPanel { | ||
| 6 | + /// Visibilité du panneau | ||
| 7 | + visible: bool, | ||
| 8 | + /// Filtre de type d'opération | ||
| 9 | + filter_type: Option<OperationType>, | ||
| 10 | + /// Filtre de statut | ||
| 11 | + filter_status: Option<OperationStatus>, | ||
| 12 | + /// Nombre d'entrées à afficher | ||
| 13 | + entries_to_show: usize, | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +impl Default for HistoryPanel { | ||
| 17 | + fn default() -> Self { | ||
| 18 | + Self { | ||
| 19 | + visible: false, | ||
| 20 | + filter_type: None, | ||
| 21 | + filter_status: None, | ||
| 22 | + entries_to_show: 50, | ||
| 23 | + } | ||
| 24 | + } | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +impl HistoryPanel { | ||
| 28 | + /// Créer un nouveau panneau d'historique | ||
| 29 | + pub fn new() -> Self { | ||
| 30 | + Self::default() | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + /// Définir la visibilité | ||
| 34 | + pub fn set_visible(&mut self, visible: bool) { | ||
| 35 | + self.visible = visible; | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + /// Basculer la visibilité | ||
| 39 | + pub fn toggle(&mut self) { | ||
| 40 | + self.visible = !self.visible; | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + /// Vérifier si le panneau est visible | ||
| 44 | + pub fn is_visible(&self) -> bool { | ||
| 45 | + self.visible | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + /// Définir le filtre de type | ||
| 49 | + pub fn set_filter_type(&mut self, op_type: Option<OperationType>) { | ||
| 50 | + self.filter_type = op_type; | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + /// Définir le filtre de statut | ||
| 54 | + pub fn set_filter_status(&mut self, status: Option<OperationStatus>) { | ||
| 55 | + self.filter_status = status; | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + /// Afficher le panneau d'historique | ||
| 59 | + pub fn show(&mut self, ui: &mut egui::Ui, history: &HistoryManager) { | ||
| 60 | + if !self.visible { | ||
| 61 | + return; | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + egui::CollapsingHeader::new("📜 Historique") | ||
| 65 | + .default_open(true) | ||
| 66 | + .show(ui, |ui| { | ||
| 67 | + self.show_content(ui, history); | ||
| 68 | + }); | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + /// Afficher le contenu du panneau | ||
| 72 | + fn show_content(&mut self, ui: &mut egui::Ui, history: &HistoryManager) { | ||
| 73 | + // Contrôles de filtre | ||
| 74 | + self.show_filters(ui); | ||
| 75 | + ui.separator(); | ||
| 76 | + | ||
| 77 | + // Afficher les entrées | ||
| 78 | + self.show_entries(ui, history); | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + /// Afficher les contrôles de filtre | ||
| 82 | + fn show_filters(&mut self, ui: &mut egui::Ui) { | ||
| 83 | + ui.horizontal(|ui| { | ||
| 84 | + // Filtre par type | ||
| 85 | + egui::ComboBox::from_label("Type") | ||
| 86 | + .width(150.0) | ||
| 87 | + .selected_text(match self.filter_type { | ||
| 88 | + Some(t) => format!("{}", t), | ||
| 89 | + None => "Tous les types".to_string(), | ||
| 90 | + }) | ||
| 91 | + .show_ui(ui, |ui| { | ||
| 92 | + ui.selectable_value(&mut self.filter_type, None, "Tous les types"); | ||
| 93 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncFeed), format!("{}", OperationType::SyncFeed)); | ||
| 94 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncAll), format!("{}", OperationType::SyncAll)); | ||
| 95 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadEpisode), format!("{}", OperationType::DownloadEpisode)); | ||
| 96 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::AddFeed), format!("{}", OperationType::AddFeed)); | ||
| 97 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::RemoveFeed), format!("{}", OperationType::RemoveFeed)); | ||
| 98 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::ImportFeeds), format!("{}", OperationType::ImportFeeds)); | ||
| 99 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::ExportFeeds), format!("{}", OperationType::ExportFeeds)); | ||
| 100 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::DownloadError), format!("{}", OperationType::DownloadError)); | ||
| 101 | + ui.selectable_value(&mut self.filter_type, Some(OperationType::SyncError), format!("{}", OperationType::SyncError)); | ||
| 102 | + }); | ||
| 103 | + | ||
| 104 | + // Filtre par statut | ||
| 105 | + egui::ComboBox::from_label("Statut") | ||
| 106 | + .width(120.0) | ||
| 107 | + .selected_text(match self.filter_status { | ||
| 108 | + Some(s) => format!("{}", s), | ||
| 109 | + None => "Tous les statuts".to_string(), | ||
| 110 | + }) | ||
| 111 | + .show_ui(ui, |ui| { | ||
| 112 | + ui.selectable_value(&mut self.filter_status, None, "Tous les statuts"); | ||
| 113 | + ui.selectable_value(&mut self.filter_status, Some(OperationStatus::InProgress), format!("{}", OperationStatus::InProgress)); | ||
| 114 | + ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Success), format!("{}", OperationStatus::Success)); | ||
| 115 | + ui.selectable_value(&mut self.filter_status, Some(OperationStatus::Failed), format!("{}", OperationStatus::Failed)); | ||
| 116 | + }); | ||
| 117 | + | ||
| 118 | + // Bouton pour effacer les filtres | ||
| 119 | + if self.filter_type.is_some() || self.filter_status.is_some() { | ||
| 120 | + if ui.button("Effacer filtres").clicked() { | ||
| 121 | + self.filter_type = None; | ||
| 122 | + self.filter_status = None; | ||
| 123 | + } | ||
| 124 | + } | ||
| 125 | + }); | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + /// Afficher les entrées d'historique | ||
| 129 | + fn show_entries(&mut self, ui: &mut egui::Ui, history: &HistoryManager) { | ||
| 130 | + let mut entries: Vec<&HistoryEntry> = history.get_all_entries(); | ||
| 131 | + | ||
| 132 | + // Appliquer les filtres | ||
| 133 | + if let Some(op_type) = self.filter_type { | ||
| 134 | + entries.retain(|e| e.operation_type == op_type); | ||
| 135 | + } | ||
| 136 | + if let Some(status) = self.filter_status { | ||
| 137 | + entries.retain(|e| e.status == status); | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + // Limiter le nombre d'entrées affichées | ||
| 141 | + entries.truncate(self.entries_to_show); | ||
| 142 | + | ||
| 143 | + if entries.is_empty() { | ||
| 144 | + ui.label("Aucune entrée d'historique trouvée"); | ||
| 145 | + return; | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + // Tableau des entrées | ||
| 149 | + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { | ||
| 150 | + egui::Grid::new("history_grid") | ||
| 151 | + .striped(true) | ||
| 152 | + .show(ui, |ui| { | ||
| 153 | + // En-têtes | ||
| 154 | + ui.strong("Date/Heure"); | ||
| 155 | + ui.strong("Type"); | ||
| 156 | + ui.strong("Description"); | ||
| 157 | + ui.strong("Statut"); | ||
| 158 | + ui.strong("Durée/Taille"); | ||
| 159 | + ui.end_row(); | ||
| 160 | + | ||
| 161 | + // Lignes de données (afficher de la plus récente à la plus ancienne) | ||
| 162 | + for entry in entries.iter().rev() { | ||
| 163 | + ui.label(format!("{}", entry.timestamp.format("%Y-%m-%d %H:%M:%S"))); | ||
| 164 | + ui.label(format!("{}", entry.operation_type)); | ||
| 165 | + | ||
| 166 | + // Limiter la longueur de la description | ||
| 167 | + let desc = if entry.description.len() > 40 { | ||
| 168 | + format!("{}...", &entry.description[..37]) | ||
| 169 | + } else { | ||
| 170 | + entry.description.clone() | ||
| 171 | + }; | ||
| 172 | + ui.label(desc); | ||
| 173 | + | ||
| 174 | + // Afficher le statut avec une couleur | ||
| 175 | + let status_text = format!("{}", entry.status); | ||
| 176 | + let color = match entry.status { | ||
| 177 | + OperationStatus::Success => egui::Color32::GREEN, | ||
| 178 | + OperationStatus::Failed => egui::Color32::RED, | ||
| 179 | + OperationStatus::InProgress => egui::Color32::YELLOW, | ||
| 180 | + }; | ||
| 181 | + ui.colored_label(color, &status_text); | ||
| 182 | + | ||
| 183 | + // Afficher durée ou taille | ||
| 184 | + let info = if entry.duration_seconds.is_some() { | ||
| 185 | + entry.get_formatted_duration() | ||
| 186 | + } else { | ||
| 187 | + entry.get_formatted_size() | ||
| 188 | + }; | ||
| 189 | + ui.label(info); | ||
| 190 | + ui.end_row(); | ||
| 191 | + } | ||
| 192 | + }); | ||
| 193 | + }); | ||
| 194 | + | ||
| 195 | + // Statistiques de l'historique | ||
| 196 | + ui.separator(); | ||
| 197 | + ui.horizontal(|ui| { | ||
| 198 | + ui.label(format!("Total: {} entrées", history.len())); | ||
| 199 | + if ui.button("Effacer historique").clicked() { | ||
| 200 | + // L'appelant doit effacer l'historique | ||
| 201 | + } | ||
| 202 | + }); | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + /// Afficher une version simplifiée (dernières opérations) | ||
| 206 | + pub fn show_recent(ui: &mut egui::Ui, history: &HistoryManager, count: usize) { | ||
| 207 | + let entries = history.get_recent_entries(count); | ||
| 208 | + | ||
| 209 | + if entries.is_empty() { | ||
| 210 | + ui.label("Aucune opération récente"); | ||
| 211 | + return; | ||
| 212 | + } | ||
| 213 | + | ||
| 214 | + ui.label("📜 Dernières opérations:"); | ||
| 215 | + egui::ScrollArea::horizontal().show(ui, |ui| { | ||
| 216 | + for entry in entries.iter().rev() { | ||
| 217 | + let color = match entry.status { | ||
| 218 | + OperationStatus::Success => egui::Color32::GREEN, | ||
| 219 | + OperationStatus::Failed => egui::Color32::RED, | ||
| 220 | + OperationStatus::InProgress => egui::Color32::YELLOW, | ||
| 221 | + }; | ||
| 222 | + ui.colored_label(color, format!("{} - {}", entry.timestamp.format("%H:%M"), entry.description)); | ||
| 223 | + } | ||
| 224 | + }); | ||
| 225 | + } | ||
| 226 | +} | ||
modified
src/ui/mod.rs +3 -0 | @@ -11,3 +11,6 @@ pub mod notifications; | ||
| 11 | 11 | pub mod playlist_list; |
| 12 | 12 | pub mod filter_panel; |
| 13 | 13 | pub mod export_import; |
| 14 | +pub mod history_panel; | |
| 15 | +pub mod search_bar; | |
| 16 | +pub mod stats_panel; | |
| @@ -11,3 +11,6 @@ pub mod notifications; | |||
| 11 | pub mod playlist_list; | 11 | pub mod playlist_list; |
| 12 | pub mod filter_panel; | 12 | pub mod filter_panel; |
| 13 | pub mod export_import; | 13 | pub mod export_import; |
| 14 | +pub mod history_panel; | ||
| 15 | +pub mod search_bar; | ||
| 16 | +pub mod stats_panel; | ||
added
src/ui/search_bar.rs +143 -0 | new file mode 100644 | ||
| @@ -0,0 +1,143 @@ | ||
| 1 | +use crate::config::search::{SearchQuery, SearchType}; | |
| 2 | +use eframe::egui; | |
| 3 | + | |
| 4 | +/// Barre de recherche pour l'application | |
| 5 | +pub struct SearchBar { | |
| 6 | + /// Requête de recherche active | |
| 7 | + pub query: SearchQuery, | |
| 8 | + /// Focus sur la barre de recherche | |
| 9 | + focus: bool, | |
| 10 | +} | |
| 11 | + | |
| 12 | +impl Default for SearchBar { | |
| 13 | + fn default() -> Self { | |
| 14 | + Self { | |
| 15 | + query: SearchQuery::default(), | |
| 16 | + focus: false, | |
| 17 | + } | |
| 18 | + } | |
| 19 | +} | |
| 20 | + | |
| 21 | +impl SearchBar { | |
| 22 | + /// Créer une nouvelle barre de recherche | |
| 23 | + pub fn new() -> Self { | |
| 24 | + Self::default() | |
| 25 | + } | |
| 26 | + | |
| 27 | + /// Créer une barre de recherche avec une requête initiale | |
| 28 | + pub fn with_query(query: SearchQuery) -> Self { | |
| 29 | + Self { | |
| 30 | + query, | |
| 31 | + focus: false, | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + /// Effacer la requête de recherche | |
| 36 | + pub fn clear(&mut self) { | |
| 37 | + self.query.query.clear(); | |
| 38 | + } | |
| 39 | + | |
| 40 | + /// Définir le focus sur la barre de recherche | |
| 41 | + pub fn set_focus(&mut self, focus: bool) { | |
| 42 | + self.focus = focus; | |
| 43 | + } | |
| 44 | + | |
| 45 | + /// Vérifier si la recherche est active | |
| 46 | + pub fn is_searching(&self) -> bool { | |
| 47 | + !self.query.is_empty() | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Obtenir la requête de recherche actuelle | |
| 51 | + pub fn get_query(&self) -> &SearchQuery { | |
| 52 | + &self.query | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Définir la requête de recherche | |
| 56 | + pub fn set_query(&mut self, query: SearchQuery) { | |
| 57 | + self.query = query; | |
| 58 | + } | |
| 59 | + | |
| 60 | + /// Afficher la barre de recherche | |
| 61 | + pub fn show(&mut self, ui: &mut egui::Ui) -> bool { | |
| 62 | + let mut changed = false; | |
| 63 | + | |
| 64 | + // Simple horizontal layout sans Frame | |
| 65 | + ui.horizontal(|ui| { | |
| 66 | + // Icône de recherche | |
| 67 | + ui.label("🔍"); | |
| 68 | + | |
| 69 | + // Champ de texte de recherche | |
| 70 | + let text_edit = egui::TextEdit::singleline(&mut self.query.query) | |
| 71 | + .hint_text("Rechercher dans les flux et épisodes...") | |
| 72 | + .desired_width(f32::INFINITY); | |
| 73 | + | |
| 74 | + let response = ui.add(text_edit); | |
| 75 | + | |
| 76 | + // Si focus demandé, le demander | |
| 77 | + if self.focus { | |
| 78 | + response.request_focus(); | |
| 79 | + } | |
| 80 | + | |
| 81 | + // Vérifier si le texte a changé | |
| 82 | + if response.changed() { | |
| 83 | + changed = true; | |
| 84 | + } | |
| 85 | + | |
| 86 | + // Bouton pour effacer la recherche | |
| 87 | + if self.is_searching() && ui.button("✕").clicked() { | |
| 88 | + self.clear(); | |
| 89 | + changed = true; | |
| 90 | + } | |
| 91 | + | |
| 92 | + // Menu déroulant pour le type de recherche | |
| 93 | + egui::ComboBox::from_label("") | |
| 94 | + .width(120.0) | |
| 95 | + .selected_text(format!("{:?}", self.query.search_type)) | |
| 96 | + .show_ui(ui, |ui| { | |
| 97 | + ui.selectable_value(&mut self.query.search_type, SearchType::All, "Tous"); | |
| 98 | + ui.selectable_value(&mut self.query.search_type, SearchType::FeedName, "Nom du flux"); | |
| 99 | + ui.selectable_value(&mut self.query.search_type, SearchType::FeedUrl, "URL du flux"); | |
| 100 | + ui.selectable_value(&mut self.query.search_type, SearchType::FeedTags, "Tags"); | |
| 101 | + ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeTitle, "Titre épisode"); | |
| 102 | + ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeDescription, "Description"); | |
| 103 | + }); | |
| 104 | + }); | |
| 105 | + | |
| 106 | + changed | |
| 107 | + } | |
| 108 | + | |
| 109 | + /// Afficher une version simplifiée (juste le champ de recherche) | |
| 110 | + pub fn show_simple(&mut self, ui: &mut egui::Ui) -> bool { | |
| 111 | + let mut changed = false; | |
| 112 | + | |
| 113 | + ui.horizontal(|ui| { | |
| 114 | + ui.label("🔍"); | |
| 115 | + let response = ui.text_edit_singleline(&mut self.query.query); | |
| 116 | + if response.changed() { | |
| 117 | + changed = true; | |
| 118 | + } | |
| 119 | + if self.is_searching() && ui.button("✕").clicked() { | |
| 120 | + self.clear(); | |
| 121 | + changed = true; | |
| 122 | + } | |
| 123 | + }); | |
| 124 | + | |
| 125 | + changed | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +/// Widget pour afficher les résultats de recherche | |
| 130 | +pub fn show_search_results( | |
| 131 | + ui: &mut egui::Ui, | |
| 132 | + total_matches: usize, | |
| 133 | + search_active: bool, | |
| 134 | +) { | |
| 135 | + if search_active { | |
| 136 | + ui.horizontal(|ui| { | |
| 137 | + ui.label(format!("{} résultats trouvés", total_matches)); | |
| 138 | + if ui.button("Effacer").clicked() { | |
| 139 | + // L'appelant doit effacer la recherche | |
| 140 | + } | |
| 141 | + }); | |
| 142 | + } | |
| 143 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,143 @@ | |||
| 1 | +use crate::config::search::{SearchQuery, SearchType}; | ||
| 2 | +use eframe::egui; | ||
| 3 | + | ||
| 4 | +/// Barre de recherche pour l'application | ||
| 5 | +pub struct SearchBar { | ||
| 6 | + /// Requête de recherche active | ||
| 7 | + pub query: SearchQuery, | ||
| 8 | + /// Focus sur la barre de recherche | ||
| 9 | + focus: bool, | ||
| 10 | +} | ||
| 11 | + | ||
| 12 | +impl Default for SearchBar { | ||
| 13 | + fn default() -> Self { | ||
| 14 | + Self { | ||
| 15 | + query: SearchQuery::default(), | ||
| 16 | + focus: false, | ||
| 17 | + } | ||
| 18 | + } | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +impl SearchBar { | ||
| 22 | + /// Créer une nouvelle barre de recherche | ||
| 23 | + pub fn new() -> Self { | ||
| 24 | + Self::default() | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + /// Créer une barre de recherche avec une requête initiale | ||
| 28 | + pub fn with_query(query: SearchQuery) -> Self { | ||
| 29 | + Self { | ||
| 30 | + query, | ||
| 31 | + focus: false, | ||
| 32 | + } | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + /// Effacer la requête de recherche | ||
| 36 | + pub fn clear(&mut self) { | ||
| 37 | + self.query.query.clear(); | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + /// Définir le focus sur la barre de recherche | ||
| 41 | + pub fn set_focus(&mut self, focus: bool) { | ||
| 42 | + self.focus = focus; | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + /// Vérifier si la recherche est active | ||
| 46 | + pub fn is_searching(&self) -> bool { | ||
| 47 | + !self.query.is_empty() | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + /// Obtenir la requête de recherche actuelle | ||
| 51 | + pub fn get_query(&self) -> &SearchQuery { | ||
| 52 | + &self.query | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + /// Définir la requête de recherche | ||
| 56 | + pub fn set_query(&mut self, query: SearchQuery) { | ||
| 57 | + self.query = query; | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + /// Afficher la barre de recherche | ||
| 61 | + pub fn show(&mut self, ui: &mut egui::Ui) -> bool { | ||
| 62 | + let mut changed = false; | ||
| 63 | + | ||
| 64 | + // Simple horizontal layout sans Frame | ||
| 65 | + ui.horizontal(|ui| { | ||
| 66 | + // Icône de recherche | ||
| 67 | + ui.label("🔍"); | ||
| 68 | + | ||
| 69 | + // Champ de texte de recherche | ||
| 70 | + let text_edit = egui::TextEdit::singleline(&mut self.query.query) | ||
| 71 | + .hint_text("Rechercher dans les flux et épisodes...") | ||
| 72 | + .desired_width(f32::INFINITY); | ||
| 73 | + | ||
| 74 | + let response = ui.add(text_edit); | ||
| 75 | + | ||
| 76 | + // Si focus demandé, le demander | ||
| 77 | + if self.focus { | ||
| 78 | + response.request_focus(); | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + // Vérifier si le texte a changé | ||
| 82 | + if response.changed() { | ||
| 83 | + changed = true; | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + // Bouton pour effacer la recherche | ||
| 87 | + if self.is_searching() && ui.button("✕").clicked() { | ||
| 88 | + self.clear(); | ||
| 89 | + changed = true; | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + // Menu déroulant pour le type de recherche | ||
| 93 | + egui::ComboBox::from_label("") | ||
| 94 | + .width(120.0) | ||
| 95 | + .selected_text(format!("{:?}", self.query.search_type)) | ||
| 96 | + .show_ui(ui, |ui| { | ||
| 97 | + ui.selectable_value(&mut self.query.search_type, SearchType::All, "Tous"); | ||
| 98 | + ui.selectable_value(&mut self.query.search_type, SearchType::FeedName, "Nom du flux"); | ||
| 99 | + ui.selectable_value(&mut self.query.search_type, SearchType::FeedUrl, "URL du flux"); | ||
| 100 | + ui.selectable_value(&mut self.query.search_type, SearchType::FeedTags, "Tags"); | ||
| 101 | + ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeTitle, "Titre épisode"); | ||
| 102 | + ui.selectable_value(&mut self.query.search_type, SearchType::EpisodeDescription, "Description"); | ||
| 103 | + }); | ||
| 104 | + }); | ||
| 105 | + | ||
| 106 | + changed | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + /// Afficher une version simplifiée (juste le champ de recherche) | ||
| 110 | + pub fn show_simple(&mut self, ui: &mut egui::Ui) -> bool { | ||
| 111 | + let mut changed = false; | ||
| 112 | + | ||
| 113 | + ui.horizontal(|ui| { | ||
| 114 | + ui.label("🔍"); | ||
| 115 | + let response = ui.text_edit_singleline(&mut self.query.query); | ||
| 116 | + if response.changed() { | ||
| 117 | + changed = true; | ||
| 118 | + } | ||
| 119 | + if self.is_searching() && ui.button("✕").clicked() { | ||
| 120 | + self.clear(); | ||
| 121 | + changed = true; | ||
| 122 | + } | ||
| 123 | + }); | ||
| 124 | + | ||
| 125 | + changed | ||
| 126 | + } | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +/// Widget pour afficher les résultats de recherche | ||
| 130 | +pub fn show_search_results( | ||
| 131 | + ui: &mut egui::Ui, | ||
| 132 | + total_matches: usize, | ||
| 133 | + search_active: bool, | ||
| 134 | +) { | ||
| 135 | + if search_active { | ||
| 136 | + ui.horizontal(|ui| { | ||
| 137 | + ui.label(format!("{} résultats trouvés", total_matches)); | ||
| 138 | + if ui.button("Effacer").clicked() { | ||
| 139 | + // L'appelant doit effacer la recherche | ||
| 140 | + } | ||
| 141 | + }); | ||
| 142 | + } | ||
| 143 | +} | ||
added
src/ui/stats_panel.rs +176 -0 | new file mode 100644 | ||
| @@ -0,0 +1,176 @@ | ||
| 1 | +use crate::config::statistics::{AppStatistics, format_bytes}; | |
| 2 | +use eframe::egui; | |
| 3 | + | |
| 4 | +/// Panneau d'affichage des statistiques | |
| 5 | +pub struct StatsPanel { | |
| 6 | + /// Visibilité du panneau | |
| 7 | + visible: bool, | |
| 8 | +} | |
| 9 | + | |
| 10 | +impl Default for StatsPanel { | |
| 11 | + fn default() -> Self { | |
| 12 | + Self { visible: false } | |
| 13 | + } | |
| 14 | +} | |
| 15 | + | |
| 16 | +impl StatsPanel { | |
| 17 | + /// Créer un nouveau panneau de statistiques | |
| 18 | + pub fn new() -> Self { | |
| 19 | + Self::default() | |
| 20 | + } | |
| 21 | + | |
| 22 | + /// Définir la visibilité | |
| 23 | + pub fn set_visible(&mut self, visible: bool) { | |
| 24 | + self.visible = visible; | |
| 25 | + } | |
| 26 | + | |
| 27 | + /// Basculer la visibilité | |
| 28 | + pub fn toggle(&mut self) { | |
| 29 | + self.visible = !self.visible; | |
| 30 | + } | |
| 31 | + | |
| 32 | + /// Vérifier si le panneau est visible | |
| 33 | + pub fn is_visible(&self) -> bool { | |
| 34 | + self.visible | |
| 35 | + } | |
| 36 | + | |
| 37 | + /// Afficher le panneau de statistiques | |
| 38 | + pub fn show(&mut self, ui: &mut egui::Ui, stats: &AppStatistics) { | |
| 39 | + if !self.visible { | |
| 40 | + return; | |
| 41 | + } | |
| 42 | + | |
| 43 | + egui::CollapsingHeader::new("📊 Statistiques") | |
| 44 | + .default_open(true) | |
| 45 | + .show(ui, |ui| { | |
| 46 | + self.show_content(ui, stats); | |
| 47 | + }); | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Afficher le contenu du panneau | |
| 51 | + fn show_content(&self, ui: &mut egui::Ui, stats: &AppStatistics) { | |
| 52 | + // Statistiques globales | |
| 53 | + self.show_global_stats(ui, stats); | |
| 54 | + | |
| 55 | + ui.separator(); | |
| 56 | + | |
| 57 | + // Statistiques par flux | |
| 58 | + self.show_feed_stats(ui, stats); | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// Afficher les statistiques globales | |
| 62 | + fn show_global_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) { | |
| 63 | + ui.label("🌍 Statistiques Globales"); | |
| 64 | + ui.separator(); | |
| 65 | + | |
| 66 | + egui::Grid::new("global_stats_grid") | |
| 67 | + .striped(true) | |
| 68 | + .show(ui, |ui| { | |
| 69 | + ui.label("Nombre total de flux:"); | |
| 70 | + ui.label(format!("{}", stats.total_feeds)); | |
| 71 | + ui.end_row(); | |
| 72 | + | |
| 73 | + ui.label("Flux activés:"); | |
| 74 | + ui.label(format!("{}", stats.enabled_feeds)); | |
| 75 | + ui.end_row(); | |
| 76 | + | |
| 77 | + ui.label("Nombre total d'épisodes:"); | |
| 78 | + ui.label(format!("{}", stats.total_episodes)); | |
| 79 | + ui.end_row(); | |
| 80 | + | |
| 81 | + ui.label("Épisodes téléchargés:"); | |
| 82 | + ui.label(format!("{}", stats.downloaded_episodes)); | |
| 83 | + ui.end_row(); | |
| 84 | + | |
| 85 | + ui.label("Épisodes non téléchargés:"); | |
| 86 | + ui.label(format!("{}", stats.not_downloaded_episodes)); | |
| 87 | + ui.end_row(); | |
| 88 | + | |
| 89 | + ui.label("Taille totale des téléchargements:"); | |
| 90 | + ui.label(format!("{}", stats.get_formatted_total_size())); | |
| 91 | + ui.end_row(); | |
| 92 | + | |
| 93 | + ui.label("Pourcentage téléchargé:"); | |
| 94 | + ui.label(format!("{:.1}%", stats.get_download_percentage())); | |
| 95 | + ui.end_row(); | |
| 96 | + | |
| 97 | + ui.label("Taille moyenne par fichier:"); | |
| 98 | + ui.label(format!("{}", format_bytes(stats.get_average_file_size()))); | |
| 99 | + ui.end_row(); | |
| 100 | + | |
| 101 | + ui.label("Nombre de synchronisations:"); | |
| 102 | + ui.label(format!("{}", stats.total_sync_count)); | |
| 103 | + ui.end_row(); | |
| 104 | + | |
| 105 | + ui.label("Nombre de téléchargements:"); | |
| 106 | + ui.label(format!("{}", stats.total_download_count)); | |
| 107 | + ui.end_row(); | |
| 108 | + }); | |
| 109 | + | |
| 110 | + // Afficher les dates | |
| 111 | + ui.separator(); | |
| 112 | + ui.label("📅 Dates:"); | |
| 113 | + | |
| 114 | + if let Some(date) = stats.last_sync_date { | |
| 115 | + ui.label(format!("Dernière synchronisation: {}", date.format("%Y-%m-%d %H:%M:%S"))); | |
| 116 | + } else { | |
| 117 | + ui.label("Dernière synchronisation: Jamais"); | |
| 118 | + } | |
| 119 | + | |
| 120 | + if let Some(date) = stats.last_download_date { | |
| 121 | + ui.label(format!("Dernier téléchargement: {}", date.format("%Y-%m-%d %H:%M:%S"))); | |
| 122 | + } else { | |
| 123 | + ui.label("Dernier téléchargement: Jamais"); | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + /// Afficher les statistiques par flux | |
| 128 | + fn show_feed_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) { | |
| 129 | + ui.label("📻 Statistiques par Flux"); | |
| 130 | + ui.separator(); | |
| 131 | + | |
| 132 | + if stats.feed_statistics.is_empty() { | |
| 133 | + ui.label("Aucun flux disponible"); | |
| 134 | + return; | |
| 135 | + } | |
| 136 | + | |
| 137 | + // Tableau des statistiques par flux | |
| 138 | + egui::ScrollArea::vertical().show(ui, |ui| { | |
| 139 | + egui::Grid::new("feed_stats_grid") | |
| 140 | + .striped(true) | |
| 141 | + .show(ui, |ui| { | |
| 142 | + // En-têtes | |
| 143 | + ui.strong("Nom du flux"); | |
| 144 | + ui.strong("Épisodes"); | |
| 145 | + ui.strong("Téléchargés"); | |
| 146 | + ui.strong("Taille"); | |
| 147 | + ui.strong("% Téléchargé"); | |
| 148 | + ui.end_row(); | |
| 149 | + | |
| 150 | + // Lignes de données | |
| 151 | + let mut feed_stats: Vec<_> = stats.feed_statistics.values().collect(); | |
| 152 | + feed_stats.sort_by(|a, b| b.total_episodes.cmp(&a.total_episodes)); | |
| 153 | + | |
| 154 | + for feed_stat in feed_stats { | |
| 155 | + ui.label(&feed_stat.feed_name); | |
| 156 | + ui.label(format!("{}", feed_stat.total_episodes)); | |
| 157 | + ui.label(format!("{}", feed_stat.downloaded_episodes)); | |
| 158 | + ui.label(format!("{}", feed_stat.get_formatted_size())); | |
| 159 | + ui.label(format!("{:.1}%", feed_stat.get_download_percentage())); | |
| 160 | + ui.end_row(); | |
| 161 | + } | |
| 162 | + }); | |
| 163 | + }); | |
| 164 | + } | |
| 165 | + | |
| 166 | + /// Afficher un widget de statistiques rapide (pour la barre de statut) | |
| 167 | + pub fn show_quick_stats(ui: &mut egui::Ui, stats: &AppStatistics) { | |
| 168 | + ui.horizontal(|ui| { | |
| 169 | + ui.label(format!("📊 {} flux | {} épisodes | {}", | |
| 170 | + stats.total_feeds, | |
| 171 | + stats.total_episodes, | |
| 172 | + stats.get_formatted_total_size() | |
| 173 | + )); | |
| 174 | + }); | |
| 175 | + } | |
| 176 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,176 @@ | |||
| 1 | +use crate::config::statistics::{AppStatistics, format_bytes}; | ||
| 2 | +use eframe::egui; | ||
| 3 | + | ||
| 4 | +/// Panneau d'affichage des statistiques | ||
| 5 | +pub struct StatsPanel { | ||
| 6 | + /// Visibilité du panneau | ||
| 7 | + visible: bool, | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +impl Default for StatsPanel { | ||
| 11 | + fn default() -> Self { | ||
| 12 | + Self { visible: false } | ||
| 13 | + } | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +impl StatsPanel { | ||
| 17 | + /// Créer un nouveau panneau de statistiques | ||
| 18 | + pub fn new() -> Self { | ||
| 19 | + Self::default() | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + /// Définir la visibilité | ||
| 23 | + pub fn set_visible(&mut self, visible: bool) { | ||
| 24 | + self.visible = visible; | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + /// Basculer la visibilité | ||
| 28 | + pub fn toggle(&mut self) { | ||
| 29 | + self.visible = !self.visible; | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + /// Vérifier si le panneau est visible | ||
| 33 | + pub fn is_visible(&self) -> bool { | ||
| 34 | + self.visible | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + /// Afficher le panneau de statistiques | ||
| 38 | + pub fn show(&mut self, ui: &mut egui::Ui, stats: &AppStatistics) { | ||
| 39 | + if !self.visible { | ||
| 40 | + return; | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + egui::CollapsingHeader::new("📊 Statistiques") | ||
| 44 | + .default_open(true) | ||
| 45 | + .show(ui, |ui| { | ||
| 46 | + self.show_content(ui, stats); | ||
| 47 | + }); | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + /// Afficher le contenu du panneau | ||
| 51 | + fn show_content(&self, ui: &mut egui::Ui, stats: &AppStatistics) { | ||
| 52 | + // Statistiques globales | ||
| 53 | + self.show_global_stats(ui, stats); | ||
| 54 | + | ||
| 55 | + ui.separator(); | ||
| 56 | + | ||
| 57 | + // Statistiques par flux | ||
| 58 | + self.show_feed_stats(ui, stats); | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + /// Afficher les statistiques globales | ||
| 62 | + fn show_global_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) { | ||
| 63 | + ui.label("🌍 Statistiques Globales"); | ||
| 64 | + ui.separator(); | ||
| 65 | + | ||
| 66 | + egui::Grid::new("global_stats_grid") | ||
| 67 | + .striped(true) | ||
| 68 | + .show(ui, |ui| { | ||
| 69 | + ui.label("Nombre total de flux:"); | ||
| 70 | + ui.label(format!("{}", stats.total_feeds)); | ||
| 71 | + ui.end_row(); | ||
| 72 | + | ||
| 73 | + ui.label("Flux activés:"); | ||
| 74 | + ui.label(format!("{}", stats.enabled_feeds)); | ||
| 75 | + ui.end_row(); | ||
| 76 | + | ||
| 77 | + ui.label("Nombre total d'épisodes:"); | ||
| 78 | + ui.label(format!("{}", stats.total_episodes)); | ||
| 79 | + ui.end_row(); | ||
| 80 | + | ||
| 81 | + ui.label("Épisodes téléchargés:"); | ||
| 82 | + ui.label(format!("{}", stats.downloaded_episodes)); | ||
| 83 | + ui.end_row(); | ||
| 84 | + | ||
| 85 | + ui.label("Épisodes non téléchargés:"); | ||
| 86 | + ui.label(format!("{}", stats.not_downloaded_episodes)); | ||
| 87 | + ui.end_row(); | ||
| 88 | + | ||
| 89 | + ui.label("Taille totale des téléchargements:"); | ||
| 90 | + ui.label(format!("{}", stats.get_formatted_total_size())); | ||
| 91 | + ui.end_row(); | ||
| 92 | + | ||
| 93 | + ui.label("Pourcentage téléchargé:"); | ||
| 94 | + ui.label(format!("{:.1}%", stats.get_download_percentage())); | ||
| 95 | + ui.end_row(); | ||
| 96 | + | ||
| 97 | + ui.label("Taille moyenne par fichier:"); | ||
| 98 | + ui.label(format!("{}", format_bytes(stats.get_average_file_size()))); | ||
| 99 | + ui.end_row(); | ||
| 100 | + | ||
| 101 | + ui.label("Nombre de synchronisations:"); | ||
| 102 | + ui.label(format!("{}", stats.total_sync_count)); | ||
| 103 | + ui.end_row(); | ||
| 104 | + | ||
| 105 | + ui.label("Nombre de téléchargements:"); | ||
| 106 | + ui.label(format!("{}", stats.total_download_count)); | ||
| 107 | + ui.end_row(); | ||
| 108 | + }); | ||
| 109 | + | ||
| 110 | + // Afficher les dates | ||
| 111 | + ui.separator(); | ||
| 112 | + ui.label("📅 Dates:"); | ||
| 113 | + | ||
| 114 | + if let Some(date) = stats.last_sync_date { | ||
| 115 | + ui.label(format!("Dernière synchronisation: {}", date.format("%Y-%m-%d %H:%M:%S"))); | ||
| 116 | + } else { | ||
| 117 | + ui.label("Dernière synchronisation: Jamais"); | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + if let Some(date) = stats.last_download_date { | ||
| 121 | + ui.label(format!("Dernier téléchargement: {}", date.format("%Y-%m-%d %H:%M:%S"))); | ||
| 122 | + } else { | ||
| 123 | + ui.label("Dernier téléchargement: Jamais"); | ||
| 124 | + } | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + /// Afficher les statistiques par flux | ||
| 128 | + fn show_feed_stats(&self, ui: &mut egui::Ui, stats: &AppStatistics) { | ||
| 129 | + ui.label("📻 Statistiques par Flux"); | ||
| 130 | + ui.separator(); | ||
| 131 | + | ||
| 132 | + if stats.feed_statistics.is_empty() { | ||
| 133 | + ui.label("Aucun flux disponible"); | ||
| 134 | + return; | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + // Tableau des statistiques par flux | ||
| 138 | + egui::ScrollArea::vertical().show(ui, |ui| { | ||
| 139 | + egui::Grid::new("feed_stats_grid") | ||
| 140 | + .striped(true) | ||
| 141 | + .show(ui, |ui| { | ||
| 142 | + // En-têtes | ||
| 143 | + ui.strong("Nom du flux"); | ||
| 144 | + ui.strong("Épisodes"); | ||
| 145 | + ui.strong("Téléchargés"); | ||
| 146 | + ui.strong("Taille"); | ||
| 147 | + ui.strong("% Téléchargé"); | ||
| 148 | + ui.end_row(); | ||
| 149 | + | ||
| 150 | + // Lignes de données | ||
| 151 | + let mut feed_stats: Vec<_> = stats.feed_statistics.values().collect(); | ||
| 152 | + feed_stats.sort_by(|a, b| b.total_episodes.cmp(&a.total_episodes)); | ||
| 153 | + | ||
| 154 | + for feed_stat in feed_stats { | ||
| 155 | + ui.label(&feed_stat.feed_name); | ||
| 156 | + ui.label(format!("{}", feed_stat.total_episodes)); | ||
| 157 | + ui.label(format!("{}", feed_stat.downloaded_episodes)); | ||
| 158 | + ui.label(format!("{}", feed_stat.get_formatted_size())); | ||
| 159 | + ui.label(format!("{:.1}%", feed_stat.get_download_percentage())); | ||
| 160 | + ui.end_row(); | ||
| 161 | + } | ||
| 162 | + }); | ||
| 163 | + }); | ||
| 164 | + } | ||
| 165 | + | ||
| 166 | + /// Afficher un widget de statistiques rapide (pour la barre de statut) | ||
| 167 | + pub fn show_quick_stats(ui: &mut egui::Ui, stats: &AppStatistics) { | ||
| 168 | + ui.horizontal(|ui| { | ||
| 169 | + ui.label(format!("📊 {} flux | {} épisodes | {}", | ||
| 170 | + stats.total_feeds, | ||
| 171 | + stats.total_episodes, | ||
| 172 | + stats.get_formatted_total_size() | ||
| 173 | + )); | ||
| 174 | + }); | ||
| 175 | + } | ||
| 176 | +} | ||