Phase 8: Playlists, Tags et Filtrage (T8.3-T8.6)
ed3f0cc parent: a614bb5 modified
spec/init-plan.md +1 -1 | @@ -322,7 +322,7 @@ pub async fn download_episode( | ||
| 322 | 322 | | Phase 5 | 2 jours | J11-J12 | ✅ | |
| 323 | 323 | | Phase 6 | 2 jours | J13-J14 | ✅ | |
| 324 | 324 | | Phase 7 | 1 jour | J15 | ✅ | |
| 325 | -| Phase 8 | 3 jours | J16-J18 | 🟡 25% (2/8 tâches) | | |
| 325 | +| Phase 8 | 3 jours | J16-J18 | 🟡 75% (6/8 tâches) | | |
| 326 | 326 | | **Total** | **18 jours** | | | |
| 327 | 327 | |
| 328 | 328 | --- |
| @@ -322,7 +322,7 @@ pub async fn download_episode( | |||
| 322 | | Phase 5 | 2 jours | J11-J12 | ✅ | | 322 | | Phase 5 | 2 jours | J11-J12 | ✅ | |
| 323 | | Phase 6 | 2 jours | J13-J14 | ✅ | | 323 | | Phase 6 | 2 jours | J13-J14 | ✅ | |
| 324 | | Phase 7 | 1 jour | J15 | ✅ | | 324 | | Phase 7 | 1 jour | J15 | ✅ | |
| 325 | -| Phase 8 | 3 jours | J16-J18 | 🟡 25% (2/8 tâches) | | 325 | +| Phase 8 | 3 jours | J16-J18 | 🟡 75% (6/8 tâches) | |
| 326 | | **Total** | **18 jours** | | | | 326 | | **Total** | **18 jours** | | | |
| 327 | 327 | ||
| 328 | --- | 328 | --- |
modified
spec/init-tasks.md +6 -6 | @@ -182,10 +182,10 @@ | ||
| 182 | 182 | |----|-------|----------|-------|---------|-------------|--------| |
| 183 | 183 | | T8.1 | Implémenter la détection automatique des nouveaux épisodes | H | 3h | `src/rss/sync.rs`, `src/config/feed.rs` | T5.2, T5.4 | ✅ | |
| 184 | 184 | | T8.2 | Implémenter les notifications de téléchargement (popups) | H | 4h | `src/ui/notifications.rs`, `src/ui/mod.rs`, `src/app.rs`, `src/config/app_state.rs`, `src/ui/home.rs`, `src/ui/feed_list.rs` | T4.13, T5.3 | ✅ | |
| 185 | -| T8.3 | Implémenter le support des playlists (groupement de flux) | M | 3h | `src/config/playlist.rs`, `src/ui/playlist_list.rs` | T2.4, T4.7 | ⬜ | | |
| 186 | -| T8.4 | Implémenter le filtrage par date | M | 2h | `src/ui/filter_panel.rs`, `src/rss/sync.rs` | T5.2 | ⬜ | | |
| 187 | -| T8.5 | Implémenter le filtrage par numéro d'épisode | M | 2h | `src/ui/filter_panel.rs`, `src/config/episode.rs` | T2.3, T8.4 | ⬜ | | |
| 188 | -| T8.6 | Ajouter un système de tags pour les flux | L | 2h | `src/config/feed.rs`, `src/ui/feed_edit_dialog.rs` | T2.1 | ⬜ | | |
| 185 | +| T8.3 | Implémenter le support des playlists (groupement de flux) | M | 3h | `src/config/playlist.rs`, `src/ui/playlist_list.rs`, `src/config/mod.rs`, `src/config/app_state.rs`, `src/config/storage.rs`, `src/app.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T2.4, T4.7 | ✅ | | |
| 186 | +| T8.4 | Implémenter le filtrage par date | M | 2h | `src/ui/filter_panel.rs`, `src/config/app_state.rs`, `src/ui/feed_list.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T5.2 | ✅ | | |
| 187 | +| T8.5 | Implémenter le filtrage par numéro d'épisode | M | 2h | `src/ui/filter_panel.rs`, `src/config/episode.rs`, `src/config/app_state.rs` | T2.3, T8.4 | ✅ | | |
| 188 | +| T8.6 | Ajouter un système de tags pour les flux | L | 2h | `src/config/feed.rs`, `src/ui/feed_edit_dialog.rs`, `src/ui/feed_list.rs`, `src/config/app_state.rs` | T2.1 | ✅ | | |
| 189 | 189 | | T8.7 | Implémenter l'export des flux (fichier JSON personnalisé) | L | 2h | `src/config/storage.rs`, `src/ui/home.rs` | T2.8 | ⬜ | |
| 190 | 190 | | T8.8 | Implémenter l'import des flux | L | 2h | `src/config/storage.rs`, `src/ui/home.rs` | T8.7 | ⬜ | |
| 191 | 191 | |
| @@ -204,8 +204,8 @@ | ||
| 204 | 204 | | Phase 5 | 10 tâches | 16h | ✅ Terminé | |
| 205 | 205 | | Phase 6 | 12 tâches | 24h | ✅ Terminé | |
| 206 | 206 | | Phase 7 | 10 tâches | 11h | ✅ Terminé | |
| 207 | -| Phase 8 | 8 tâches | 20h | 🟡 25% complet (2/8) | | |
| 208 | -| **Total** | **88 tâches** | **145 heures** (~18 jours) | 🟡 93.2% complet | | |
| 207 | +| Phase 8 | 8 tâches | 20h | 🟡 75% complet (6/8) | | |
| 208 | +| **Total** | **88 tâches** | **145 heures** (~18 jours) | 🟡 97.7% complet | | |
| 209 | 209 | |
| 210 | 210 | --- |
| 211 | 211 | |
| @@ -182,10 +182,10 @@ | |||
| 182 | |----|-------|----------|-------|---------|-------------|--------| | 182 | |----|-------|----------|-------|---------|-------------|--------| |
| 183 | | T8.1 | Implémenter la détection automatique des nouveaux épisodes | H | 3h | `src/rss/sync.rs`, `src/config/feed.rs` | T5.2, T5.4 | ✅ | | 183 | | T8.1 | Implémenter la détection automatique des nouveaux épisodes | H | 3h | `src/rss/sync.rs`, `src/config/feed.rs` | T5.2, T5.4 | ✅ | |
| 184 | | T8.2 | Implémenter les notifications de téléchargement (popups) | H | 4h | `src/ui/notifications.rs`, `src/ui/mod.rs`, `src/app.rs`, `src/config/app_state.rs`, `src/ui/home.rs`, `src/ui/feed_list.rs` | T4.13, T5.3 | ✅ | | 184 | | T8.2 | Implémenter les notifications de téléchargement (popups) | H | 4h | `src/ui/notifications.rs`, `src/ui/mod.rs`, `src/app.rs`, `src/config/app_state.rs`, `src/ui/home.rs`, `src/ui/feed_list.rs` | T4.13, T5.3 | ✅ | |
| 185 | -| T8.3 | Implémenter le support des playlists (groupement de flux) | M | 3h | `src/config/playlist.rs`, `src/ui/playlist_list.rs` | T2.4, T4.7 | ⬜ | | 185 | +| T8.3 | Implémenter le support des playlists (groupement de flux) | M | 3h | `src/config/playlist.rs`, `src/ui/playlist_list.rs`, `src/config/mod.rs`, `src/config/app_state.rs`, `src/config/storage.rs`, `src/app.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T2.4, T4.7 | ✅ | |
| 186 | -| T8.4 | Implémenter le filtrage par date | M | 2h | `src/ui/filter_panel.rs`, `src/rss/sync.rs` | T5.2 | ⬜ | | 186 | +| T8.4 | Implémenter le filtrage par date | M | 2h | `src/ui/filter_panel.rs`, `src/config/app_state.rs`, `src/ui/feed_list.rs`, `src/ui/home.rs`, `src/ui/mod.rs` | T5.2 | ✅ | |
| 187 | -| T8.5 | Implémenter le filtrage par numéro d'épisode | M | 2h | `src/ui/filter_panel.rs`, `src/config/episode.rs` | T2.3, T8.4 | ⬜ | | 187 | +| T8.5 | Implémenter le filtrage par numéro d'épisode | M | 2h | `src/ui/filter_panel.rs`, `src/config/episode.rs`, `src/config/app_state.rs` | T2.3, T8.4 | ✅ | |
| 188 | -| T8.6 | Ajouter un système de tags pour les flux | L | 2h | `src/config/feed.rs`, `src/ui/feed_edit_dialog.rs` | T2.1 | ⬜ | | 188 | +| T8.6 | Ajouter un système de tags pour les flux | L | 2h | `src/config/feed.rs`, `src/ui/feed_edit_dialog.rs`, `src/ui/feed_list.rs`, `src/config/app_state.rs` | T2.1 | ✅ | |
| 189 | | T8.7 | Implémenter l'export des flux (fichier JSON personnalisé) | L | 2h | `src/config/storage.rs`, `src/ui/home.rs` | T2.8 | ⬜ | | 189 | | T8.7 | Implémenter l'export des flux (fichier JSON personnalisé) | L | 2h | `src/config/storage.rs`, `src/ui/home.rs` | T2.8 | ⬜ | |
| 190 | | T8.8 | Implémenter l'import des flux | L | 2h | `src/config/storage.rs`, `src/ui/home.rs` | T8.7 | ⬜ | | 190 | | T8.8 | Implémenter l'import des flux | L | 2h | `src/config/storage.rs`, `src/ui/home.rs` | T8.7 | ⬜ | |
| 191 | 191 | ||
| @@ -204,8 +204,8 @@ | |||
| 204 | | Phase 5 | 10 tâches | 16h | ✅ Terminé | | 204 | | Phase 5 | 10 tâches | 16h | ✅ Terminé | |
| 205 | | Phase 6 | 12 tâches | 24h | ✅ Terminé | | 205 | | Phase 6 | 12 tâches | 24h | ✅ Terminé | |
| 206 | | Phase 7 | 10 tâches | 11h | ✅ Terminé | | 206 | | Phase 7 | 10 tâches | 11h | ✅ Terminé | |
| 207 | -| Phase 8 | 8 tâches | 20h | 🟡 25% complet (2/8) | | 207 | +| Phase 8 | 8 tâches | 20h | 🟡 75% complet (6/8) | |
| 208 | -| **Total** | **88 tâches** | **145 heures** (~18 jours) | 🟡 93.2% complet | | 208 | +| **Total** | **88 tâches** | **145 heures** (~18 jours) | 🟡 97.7% complet | |
| 209 | 209 | ||
| 210 | --- | 210 | --- |
| 211 | 211 | ||
modified
spec/progress.md +23 -23 | @@ -1,7 +1,7 @@ | ||
| 1 | 1 | # RSS Podcast Downloader - Suivi des Progrès |
| 2 | 2 | |
| 3 | 3 | *Dernière mise à jour: 05/07/2026* |
| 4 | -*Version: 2.1* | |
| 4 | +*Version: 2.2* | |
| 5 | 5 | |
| 6 | 6 | --- |
| 7 | 7 | |
| @@ -255,7 +255,7 @@ | ||
| 255 | 255 | |
| 256 | 256 | --- |
| 257 | 257 | |
| 258 | -## Phase 8 : Fonctionnalités Avancées (2/8 tâches - 25%) | |
| 258 | +## Phase 8 : Fonctionnalités Avancées (6/8 tâches - 75%) | |
| 259 | 259 | |
| 260 | 260 | - [x] T8.1 - Implémenter la détection automatique des nouveaux épisodes |
| 261 | 261 | - *Fichiers: src/rss/sync.rs, src/rss/downloader.rs* |
| @@ -267,21 +267,25 @@ | ||
| 267 | 267 | - *Contenu: Module NotificationManager complet avec affichage dans UI, notifications pour synchronisation et suppression* |
| 268 | 268 | - *Date: 05/07/2026* |
| 269 | 269 | |
| 270 | -- [ ] T8.3 - Implémenter le support des playlists (groupement de flux) | |
| 271 | - - *Fichiers: src/config/playlist.rs, src/ui/playlist_list.rs* | |
| 272 | - - *Statut: Non commencé* | |
| 270 | +- [x] T8.3 - Implémenter le support des playlists (groupement de flux) | |
| 271 | + - *Fichiers: src/config/playlist.rs, src/ui/playlist_list.rs, src/config/mod.rs, src/config/app_state.rs, src/config/storage.rs, src/app.rs, src/ui/home.rs, src/ui/mod.rs* | |
| 272 | + - *Contenu: Structure Playlist avec feed_ids, UI pour créer/éditer/supprimer des playlists, persistance des playlists* | |
| 273 | + - *Date: 05/07/2026* | |
| 273 | 274 | |
| 274 | -- [ ] T8.4 - Implémenter le filtrage par date | |
| 275 | - - *Fichiers: src/ui/filter_panel.rs, src/rss/sync.rs* | |
| 276 | - - *Statut: Non commencé* | |
| 275 | +- [x] T8.4 - Implémenter le filtrage par date | |
| 276 | + - *Fichiers: src/ui/filter_panel.rs, src/config/app_state.rs, src/ui/feed_list.rs, src/ui/home.rs, src/ui/mod.rs* | |
| 277 | + - *Contenu: Panneau de filtrage avec sélection de dates (depuis/jusqu'à), boutons rapides (7j, 30j)* | |
| 278 | + - *Date: 05/07/2026* | |
| 277 | 279 | |
| 278 | -- [ ] T8.5 - Implémenter le filtrage par numéro d'épisode | |
| 279 | - - *Fichiers: src/ui/filter_panel.rs, src/config/episode.rs* | |
| 280 | - - *Statut: Non commencé* | |
| 280 | +- [x] T8.5 - Implémenter le filtrage par numéro d'épisode | |
| 281 | + - *Fichiers: src/ui/filter_panel.rs, src/config/episode.rs, src/config/app_state.rs* | |
| 282 | + - *Contenu: Filtrage par numéro dans le titre de l'épisode, extraction automatique du numéro* | |
| 283 | + - *Date: 05/07/2026* | |
| 281 | 284 | |
| 282 | -- [ ] T8.6 - Ajouter un système de tags pour les flux | |
| 283 | - - *Fichiers: src/config/feed.rs, src/ui/feed_edit_dialog.rs* | |
| 284 | - - *Statut: Non commencé* | |
| 285 | +- [x] T8.6 - Ajouter un système de tags pour les flux | |
| 286 | + - *Fichiers: src/config/feed.rs, src/ui/feed_edit_dialog.rs, src/ui/feed_list.rs, src/config/app_state.rs* | |
| 287 | + - *Contenu: Champ tags dans Feed, édition des tags dans le dialogue d'édition, affichage des tags dans la liste* | |
| 288 | + - *Date: 05/07/2026* | |
| 285 | 289 | |
| 286 | 290 | - [ ] T8.7 - Implémenter l'export des flux (fichier JSON personnalisé) |
| 287 | 291 | - *Fichiers: src/config/storage.rs, src/ui/home.rs* |
| @@ -304,20 +308,16 @@ | ||
| 304 | 308 | | Phase 5 | 10 | 10 | 100% | Terminé | |
| 305 | 309 | | Phase 6 | 12 | 12 | 100% | Terminé | |
| 306 | 310 | | Phase 7 | 10 | 10 | 100% | Terminé | |
| 307 | -| Phase 8 | 8 | 2 | 25% | En cours | | |
| 308 | -| **Total** | **88** | **82** | **93.2%** | En cours | | |
| 311 | +| Phase 8 | 8 | 6 | 75% | En cours | | |
| 312 | +| **Total** | **88** | **86** | **97.7%** | En cours | | |
| 309 | 313 | |
| 310 | -**Projet complété à 93.2%** | |
| 314 | +**Projet complété à 97.7%** | |
| 311 | 315 | |
| 312 | 316 | --- |
| 313 | 317 | |
| 314 | 318 | ## Prochaines Étapes |
| 315 | 319 | |
| 316 | -### Phase 8 en cours (2/8 tâches) | |
| 317 | -- Continuer avec T8.3 - Implémenter le support des playlists | |
| 318 | -- Continuer avec T8.4 - Implémenter le filtrage par date | |
| 319 | -- Continuer avec T8.5 - Implémenter le filtrage par numéro d'épisode | |
| 320 | -- Continuer avec T8.6 - Ajouter un système de tags pour les flux | |
| 320 | +### Phase 8 en cours (6/8 tâches - 75% complet) | |
| 321 | 321 | - Continuer avec T8.7 - Implémenter l'export des flux |
| 322 | 322 | - Continuer avec T8.8 - Implémenter l'import des flux |
| 323 | 323 | |
| @@ -397,4 +397,4 @@ Phase 7: Finalisation | ||
| 397 | 397 | --- |
| 398 | 398 | |
| 399 | 399 | *Document généré et mis à jour le 05/07/2026* |
| 400 | -*Projet RSS Podcast Downloader - 93.2% complet (Phase 8 en cours)* | |
| 400 | +*Projet RSS Podcast Downloader - 97.7% complet (Phase 8 en cours - 6/8 tâches)* | |
| @@ -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: 05/07/2026* |
| 4 | -*Version: 2.1* | 4 | +*Version: 2.2* |
| 5 | 5 | ||
| 6 | --- | 6 | --- |
| 7 | 7 | ||
| @@ -255,7 +255,7 @@ | |||
| 255 | 255 | ||
| 256 | --- | 256 | --- |
| 257 | 257 | ||
| 258 | -## Phase 8 : Fonctionnalités Avancées (2/8 tâches - 25%) | 258 | +## Phase 8 : Fonctionnalités Avancées (6/8 tâches - 75%) |
| 259 | 259 | ||
| 260 | - [x] T8.1 - Implémenter la détection automatique des nouveaux épisodes | 260 | - [x] T8.1 - Implémenter la détection automatique des nouveaux épisodes |
| 261 | - *Fichiers: src/rss/sync.rs, src/rss/downloader.rs* | 261 | - *Fichiers: src/rss/sync.rs, src/rss/downloader.rs* |
| @@ -267,21 +267,25 @@ | |||
| 267 | - *Contenu: Module NotificationManager complet avec affichage dans UI, notifications pour synchronisation et suppression* | 267 | - *Contenu: Module NotificationManager complet avec affichage dans UI, notifications pour synchronisation et suppression* |
| 268 | - *Date: 05/07/2026* | 268 | - *Date: 05/07/2026* |
| 269 | 269 | ||
| 270 | -- [ ] T8.3 - Implémenter le support des playlists (groupement de flux) | 270 | +- [x] T8.3 - Implémenter le support des playlists (groupement de flux) |
| 271 | - - *Fichiers: src/config/playlist.rs, src/ui/playlist_list.rs* | 271 | + - *Fichiers: src/config/playlist.rs, src/ui/playlist_list.rs, src/config/mod.rs, src/config/app_state.rs, src/config/storage.rs, src/app.rs, src/ui/home.rs, src/ui/mod.rs* |
| 272 | - - *Statut: Non commencé* | 272 | + - *Contenu: Structure Playlist avec feed_ids, UI pour créer/éditer/supprimer des playlists, persistance des playlists* |
| 273 | + - *Date: 05/07/2026* | ||
| 273 | 274 | ||
| 274 | -- [ ] T8.4 - Implémenter le filtrage par date | 275 | +- [x] T8.4 - Implémenter le filtrage par date |
| 275 | - - *Fichiers: src/ui/filter_panel.rs, src/rss/sync.rs* | 276 | + - *Fichiers: src/ui/filter_panel.rs, src/config/app_state.rs, src/ui/feed_list.rs, src/ui/home.rs, src/ui/mod.rs* |
| 276 | - - *Statut: Non commencé* | 277 | + - *Contenu: Panneau de filtrage avec sélection de dates (depuis/jusqu'à), boutons rapides (7j, 30j)* |
| 278 | + - *Date: 05/07/2026* | ||
| 277 | 279 | ||
| 278 | -- [ ] T8.5 - Implémenter le filtrage par numéro d'épisode | 280 | +- [x] T8.5 - Implémenter le filtrage par numéro d'épisode |
| 279 | - - *Fichiers: src/ui/filter_panel.rs, src/config/episode.rs* | 281 | + - *Fichiers: src/ui/filter_panel.rs, src/config/episode.rs, src/config/app_state.rs* |
| 280 | - - *Statut: Non commencé* | 282 | + - *Contenu: Filtrage par numéro dans le titre de l'épisode, extraction automatique du numéro* |
| 283 | + - *Date: 05/07/2026* | ||
| 281 | 284 | ||
| 282 | -- [ ] T8.6 - Ajouter un système de tags pour les flux | 285 | +- [x] T8.6 - Ajouter un système de tags pour les flux |
| 283 | - - *Fichiers: src/config/feed.rs, src/ui/feed_edit_dialog.rs* | 286 | + - *Fichiers: src/config/feed.rs, src/ui/feed_edit_dialog.rs, src/ui/feed_list.rs, src/config/app_state.rs* |
| 284 | - - *Statut: Non commencé* | 287 | + - *Contenu: Champ tags dans Feed, édition des tags dans le dialogue d'édition, affichage des tags dans la liste* |
| 288 | + - *Date: 05/07/2026* | ||
| 285 | 289 | ||
| 286 | - [ ] T8.7 - Implémenter l'export des flux (fichier JSON personnalisé) | 290 | - [ ] T8.7 - Implémenter l'export des flux (fichier JSON personnalisé) |
| 287 | - *Fichiers: src/config/storage.rs, src/ui/home.rs* | 291 | - *Fichiers: src/config/storage.rs, src/ui/home.rs* |
| @@ -304,20 +308,16 @@ | |||
| 304 | | Phase 5 | 10 | 10 | 100% | Terminé | | 308 | | Phase 5 | 10 | 10 | 100% | Terminé | |
| 305 | | Phase 6 | 12 | 12 | 100% | Terminé | | 309 | | Phase 6 | 12 | 12 | 100% | Terminé | |
| 306 | | Phase 7 | 10 | 10 | 100% | Terminé | | 310 | | Phase 7 | 10 | 10 | 100% | Terminé | |
| 307 | -| Phase 8 | 8 | 2 | 25% | En cours | | 311 | +| Phase 8 | 8 | 6 | 75% | En cours | |
| 308 | -| **Total** | **88** | **82** | **93.2%** | En cours | | 312 | +| **Total** | **88** | **86** | **97.7%** | En cours | |
| 309 | 313 | ||
| 310 | -**Projet complété à 93.2%** | 314 | +**Projet complété à 97.7%** |
| 311 | 315 | ||
| 312 | --- | 316 | --- |
| 313 | 317 | ||
| 314 | ## Prochaines Étapes | 318 | ## Prochaines Étapes |
| 315 | 319 | ||
| 316 | -### Phase 8 en cours (2/8 tâches) | 320 | +### Phase 8 en cours (6/8 tâches - 75% complet) |
| 317 | -- Continuer avec T8.3 - Implémenter le support des playlists | ||
| 318 | -- Continuer avec T8.4 - Implémenter le filtrage par date | ||
| 319 | -- Continuer avec T8.5 - Implémenter le filtrage par numéro d'épisode | ||
| 320 | -- Continuer avec T8.6 - Ajouter un système de tags pour les flux | ||
| 321 | - Continuer avec T8.7 - Implémenter l'export des flux | 321 | - Continuer avec T8.7 - Implémenter l'export des flux |
| 322 | - Continuer avec T8.8 - Implémenter l'import des flux | 322 | - Continuer avec T8.8 - Implémenter l'import des flux |
| 323 | 323 | ||
| @@ -397,4 +397,4 @@ Phase 7: Finalisation | |||
| 397 | --- | 397 | --- |
| 398 | 398 | ||
| 399 | *Document généré et mis à jour le 05/07/2026* | 399 | *Document généré et mis à jour le 05/07/2026* |
| 400 | -*Projet RSS Podcast Downloader - 93.2% complet (Phase 8 en cours)* | 400 | +*Projet RSS Podcast Downloader - 97.7% complet (Phase 8 en cours - 6/8 tâches)* |
modified
src/app.rs +11 -0 | @@ -143,6 +143,17 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { | ||
| 143 | 143 | } |
| 144 | 144 | } |
| 145 | 145 | |
| 146 | + // Load playlists | |
| 147 | + match crate::config::storage::load_playlists_default() { | |
| 148 | + Ok(playlists) => { | |
| 149 | + state.playlists = playlists; | |
| 150 | + info!("Loaded {} playlists from configuration", state.playlists.len()); | |
| 151 | + } | |
| 152 | + Err(e) => { | |
| 153 | + info!("No existing playlists found or error loading: {}", e); | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 146 | 157 | let app = RssPodcastApp::new(state); |
| 147 | 158 | |
| 148 | 159 | if let Err(e) = eframe::run_native( |
| @@ -143,6 +143,17 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { | |||
| 143 | } | 143 | } |
| 144 | } | 144 | } |
| 145 | 145 | ||
| 146 | + // Load playlists | ||
| 147 | + match crate::config::storage::load_playlists_default() { | ||
| 148 | + Ok(playlists) => { | ||
| 149 | + state.playlists = playlists; | ||
| 150 | + info!("Loaded {} playlists from configuration", state.playlists.len()); | ||
| 151 | + } | ||
| 152 | + Err(e) => { | ||
| 153 | + info!("No existing playlists found or error loading: {}", e); | ||
| 154 | + } | ||
| 155 | + } | ||
| 156 | + | ||
| 146 | let app = RssPodcastApp::new(state); | 157 | let app = RssPodcastApp::new(state); |
| 147 | 158 | ||
| 148 | if let Err(e) = eframe::run_native( | 159 | if let Err(e) = eframe::run_native( |
modified
src/config/app_state.rs +164 -0 | @@ -3,7 +3,9 @@ | ||
| 3 | 3 | //! This module defines the main application state structure. |
| 4 | 4 | |
| 5 | 5 | use crate::config::feed::Feed; |
| 6 | +use crate::config::playlist::Playlist; | |
| 6 | 7 | use crate::ui::notifications::NotificationManager; |
| 8 | +use chrono::{DateTime, Utc}; | |
| 7 | 9 | use std::collections::HashMap; |
| 8 | 10 | use std::path::PathBuf; |
| 9 | 11 | |
| @@ -34,8 +36,31 @@ pub struct AppState { | ||
| 34 | 36 | pub edit_feed_download_path: String, |
| 35 | 37 | /// Temporary storage for edit feed enabled state |
| 36 | 38 | pub edit_feed_enabled: bool, |
| 39 | + /// Temporary storage for edit feed tags (comma-separated) | |
| 40 | + pub edit_feed_tags: String, | |
| 37 | 41 | /// Index of the feed being edited (if any) |
| 38 | 42 | pub editing_feed_index: Option<usize>, |
| 43 | + /// Whether the add playlist dialog is visible | |
| 44 | + #[allow(dead_code)] | |
| 45 | + pub show_add_playlist_dialog: bool, | |
| 46 | + /// Temporary storage for add playlist name | |
| 47 | + #[allow(dead_code)] | |
| 48 | + pub add_playlist_name: String, | |
| 49 | + /// Temporary storage for add playlist description | |
| 50 | + #[allow(dead_code)] | |
| 51 | + pub add_playlist_description: String, | |
| 52 | + /// Whether the edit playlist dialog is visible | |
| 53 | + #[allow(dead_code)] | |
| 54 | + pub show_edit_playlist_dialog: bool, | |
| 55 | + /// Temporary storage for edit playlist name | |
| 56 | + #[allow(dead_code)] | |
| 57 | + pub edit_playlist_name: String, | |
| 58 | + /// Temporary storage for edit playlist description | |
| 59 | + #[allow(dead_code)] | |
| 60 | + pub edit_playlist_description: String, | |
| 61 | + /// Index of the playlist being edited (if any) | |
| 62 | + #[allow(dead_code)] | |
| 63 | + pub editing_playlist_index: Option<usize>, | |
| 39 | 64 | /// Whether a synchronization is currently in progress |
| 40 | 65 | pub sync_in_progress: bool, |
| 41 | 66 | /// Download progress for each feed (feed_id -> progress 0.0-1.0) |
| @@ -53,6 +78,24 @@ pub struct AppState { | ||
| 53 | 78 | pub config_path: PathBuf, |
| 54 | 79 | /// Manager for displaying notifications to the user |
| 55 | 80 | pub notification_manager: NotificationManager, |
| 81 | + /// List of playlists for grouping feeds | |
| 82 | + pub playlists: Vec<Playlist>, | |
| 83 | + // Filter state | |
| 84 | + /// Filter: date from (inclusive) | |
| 85 | + #[allow(dead_code)] | |
| 86 | + pub filter_date_from: Option<DateTime<Utc>>, | |
| 87 | + /// Filter: date to (inclusive) | |
| 88 | + #[allow(dead_code)] | |
| 89 | + pub filter_date_to: Option<DateTime<Utc>>, | |
| 90 | + /// Filter: episode number | |
| 91 | + #[allow(dead_code)] | |
| 92 | + pub filter_episode_number: String, | |
| 93 | + /// Whether to show the date picker for 'from' date | |
| 94 | + #[allow(dead_code)] | |
| 95 | + pub show_date_picker_from: bool, | |
| 96 | + /// Whether to show the date picker for 'to' date | |
| 97 | + #[allow(dead_code)] | |
| 98 | + pub show_date_picker_to: bool, | |
| 56 | 99 | } |
| 57 | 100 | |
| 58 | 101 | impl AppState { |
| @@ -70,7 +113,15 @@ impl AppState { | ||
| 70 | 113 | edit_feed_url: String::new(), |
| 71 | 114 | edit_feed_download_path: String::new(), |
| 72 | 115 | edit_feed_enabled: true, |
| 116 | + edit_feed_tags: String::new(), | |
| 73 | 117 | editing_feed_index: None, |
| 118 | + show_add_playlist_dialog: false, | |
| 119 | + add_playlist_name: String::new(), | |
| 120 | + add_playlist_description: String::new(), | |
| 121 | + show_edit_playlist_dialog: false, | |
| 122 | + edit_playlist_name: String::new(), | |
| 123 | + edit_playlist_description: String::new(), | |
| 124 | + editing_playlist_index: None, | |
| 74 | 125 | sync_in_progress: false, |
| 75 | 126 | download_progress: HashMap::new(), |
| 76 | 127 | error_message: None, |
| @@ -78,6 +129,12 @@ impl AppState { | ||
| 78 | 129 | show_settings: false, |
| 79 | 130 | config_path: config_path.into(), |
| 80 | 131 | notification_manager: NotificationManager::new(), |
| 132 | + playlists: Vec::new(), | |
| 133 | + filter_date_from: None, | |
| 134 | + filter_date_to: None, | |
| 135 | + filter_episode_number: String::new(), | |
| 136 | + show_date_picker_from: false, | |
| 137 | + show_date_picker_to: false, | |
| 81 | 138 | } |
| 82 | 139 | } |
| 83 | 140 | |
| @@ -246,6 +303,7 @@ impl AppState { | ||
| 246 | 303 | self.edit_feed_url = feed.url.clone(); |
| 247 | 304 | self.edit_feed_download_path = feed.download_path.to_string_lossy().to_string(); |
| 248 | 305 | self.edit_feed_enabled = feed.enabled; |
| 306 | + self.edit_feed_tags = feed.tags_string(); | |
| 249 | 307 | } |
| 250 | 308 | } |
| 251 | 309 | |
| @@ -255,8 +313,114 @@ impl AppState { | ||
| 255 | 313 | self.edit_feed_url.clear(); |
| 256 | 314 | self.edit_feed_download_path.clear(); |
| 257 | 315 | self.edit_feed_enabled = true; |
| 316 | + self.edit_feed_tags.clear(); | |
| 258 | 317 | self.editing_feed_index = None; |
| 259 | 318 | } |
| 319 | + | |
| 320 | + // Playlist methods | |
| 321 | + | |
| 322 | + /// Adds a new playlist | |
| 323 | + #[allow(dead_code)] | |
| 324 | + pub fn add_playlist(&mut self, playlist: Playlist) { | |
| 325 | + self.playlists.push(playlist); | |
| 326 | + self.notification_manager.success("Playlist ajoutée"); | |
| 327 | + } | |
| 328 | + | |
| 329 | + /// Removes a playlist by index | |
| 330 | + #[allow(dead_code)] | |
| 331 | + pub fn remove_playlist(&mut self, index: usize) -> Option<Playlist> { | |
| 332 | + if index < self.playlists.len() { | |
| 333 | + let playlist = self.playlists.remove(index); | |
| 334 | + self.notification_manager.success("Playlist supprimée"); | |
| 335 | + Some(playlist) | |
| 336 | + } else { | |
| 337 | + None | |
| 338 | + } | |
| 339 | + } | |
| 340 | + | |
| 341 | + /// Gets a playlist by index | |
| 342 | + #[allow(dead_code)] | |
| 343 | + pub fn get_playlist(&self, index: usize) -> Option<&Playlist> { | |
| 344 | + self.playlists.get(index) | |
| 345 | + } | |
| 346 | + | |
| 347 | + /// Gets a mutable playlist by index | |
| 348 | + #[allow(dead_code)] | |
| 349 | + pub fn get_playlist_mut(&mut self, index: usize) -> Option<&mut Playlist> { | |
| 350 | + self.playlists.get_mut(index) | |
| 351 | + } | |
| 352 | + | |
| 353 | + /// Returns the number of playlists | |
| 354 | + #[allow(dead_code)] | |
| 355 | + pub fn playlist_count(&self) -> usize { | |
| 356 | + self.playlists.len() | |
| 357 | + } | |
| 358 | + | |
| 359 | + /// Returns true if there are any playlists | |
| 360 | + #[allow(dead_code)] | |
| 361 | + pub fn has_playlists(&self) -> bool { | |
| 362 | + !self.playlists.is_empty() | |
| 363 | + } | |
| 364 | + | |
| 365 | + /// Adds a feed to a playlist by index | |
| 366 | + #[allow(dead_code)] | |
| 367 | + pub fn add_feed_to_playlist(&mut self, playlist_index: usize, feed_id: &str) { | |
| 368 | + if let Some(playlist) = self.playlists.get_mut(playlist_index) { | |
| 369 | + playlist.add_feed(feed_id); | |
| 370 | + } | |
| 371 | + } | |
| 372 | + | |
| 373 | + /// Removes a feed from a playlist by index | |
| 374 | + #[allow(dead_code)] | |
| 375 | + pub fn remove_feed_from_playlist(&mut self, playlist_index: usize, feed_id: &str) { | |
| 376 | + if let Some(playlist) = self.playlists.get_mut(playlist_index) { | |
| 377 | + playlist.remove_feed(feed_id); | |
| 378 | + } | |
| 379 | + } | |
| 380 | + | |
| 381 | + /// Gets all playlists that contain a specific feed | |
| 382 | + #[allow(dead_code)] | |
| 383 | + pub fn get_playlists_containing_feed(&self, feed_id: &str) -> Vec<usize> { | |
| 384 | + self.playlists | |
| 385 | + .iter() | |
| 386 | + .enumerate() | |
| 387 | + .filter(|(_, p)| p.contains_feed(feed_id)) | |
| 388 | + .map(|(i, _)| i) | |
| 389 | + .collect() | |
| 390 | + } | |
| 391 | + | |
| 392 | + /// Clears the add playlist form fields | |
| 393 | + #[allow(dead_code)] | |
| 394 | + pub fn clear_add_playlist_form(&mut self) { | |
| 395 | + self.add_playlist_name.clear(); | |
| 396 | + self.add_playlist_description.clear(); | |
| 397 | + } | |
| 398 | + | |
| 399 | + /// Clears the edit playlist form fields | |
| 400 | + #[allow(dead_code)] | |
| 401 | + pub fn clear_edit_playlist_form(&mut self) { | |
| 402 | + self.edit_playlist_name.clear(); | |
| 403 | + self.edit_playlist_description.clear(); | |
| 404 | + self.editing_playlist_index = None; | |
| 405 | + } | |
| 406 | + | |
| 407 | + /// Loads playlist data into edit form fields | |
| 408 | + #[allow(dead_code)] | |
| 409 | + pub fn load_edit_playlist_form(&mut self, index: usize) { | |
| 410 | + if let Some(playlist) = self.playlists.get(index) { | |
| 411 | + self.edit_playlist_name = playlist.name.clone(); | |
| 412 | + self.edit_playlist_description = playlist.description.clone().unwrap_or_default(); | |
| 413 | + } | |
| 414 | + } | |
| 415 | + | |
| 416 | + /// Clears all filter criteria | |
| 417 | + #[allow(dead_code)] | |
| 418 | + pub fn clear_filters(&mut self) { | |
| 419 | + self.filter_date_from = None; | |
| 420 | + self.filter_date_to = None; | |
| 421 | + self.filter_episode_number.clear(); | |
| 422 | + self.notification_manager.info("Filtres effacés"); | |
| 423 | + } | |
| 260 | 424 | } |
| 261 | 425 | |
| 262 | 426 | #[cfg(test)] |
| @@ -3,7 +3,9 @@ | |||
| 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::playlist::Playlist; | ||
| 6 | use crate::ui::notifications::NotificationManager; | 7 | use crate::ui::notifications::NotificationManager; |
| 8 | +use chrono::{DateTime, Utc}; | ||
| 7 | use std::collections::HashMap; | 9 | use std::collections::HashMap; |
| 8 | use std::path::PathBuf; | 10 | use std::path::PathBuf; |
| 9 | 11 | ||
| @@ -34,8 +36,31 @@ pub struct AppState { | |||
| 34 | pub edit_feed_download_path: String, | 36 | pub edit_feed_download_path: String, |
| 35 | /// Temporary storage for edit feed enabled state | 37 | /// Temporary storage for edit feed enabled state |
| 36 | pub edit_feed_enabled: bool, | 38 | pub edit_feed_enabled: bool, |
| 39 | + /// Temporary storage for edit feed tags (comma-separated) | ||
| 40 | + pub edit_feed_tags: String, | ||
| 37 | /// Index of the feed being edited (if any) | 41 | /// Index of the feed being edited (if any) |
| 38 | pub editing_feed_index: Option<usize>, | 42 | pub editing_feed_index: Option<usize>, |
| 43 | + /// Whether the add playlist dialog is visible | ||
| 44 | + #[allow(dead_code)] | ||
| 45 | + pub show_add_playlist_dialog: bool, | ||
| 46 | + /// Temporary storage for add playlist name | ||
| 47 | + #[allow(dead_code)] | ||
| 48 | + pub add_playlist_name: String, | ||
| 49 | + /// Temporary storage for add playlist description | ||
| 50 | + #[allow(dead_code)] | ||
| 51 | + pub add_playlist_description: String, | ||
| 52 | + /// Whether the edit playlist dialog is visible | ||
| 53 | + #[allow(dead_code)] | ||
| 54 | + pub show_edit_playlist_dialog: bool, | ||
| 55 | + /// Temporary storage for edit playlist name | ||
| 56 | + #[allow(dead_code)] | ||
| 57 | + pub edit_playlist_name: String, | ||
| 58 | + /// Temporary storage for edit playlist description | ||
| 59 | + #[allow(dead_code)] | ||
| 60 | + pub edit_playlist_description: String, | ||
| 61 | + /// Index of the playlist being edited (if any) | ||
| 62 | + #[allow(dead_code)] | ||
| 63 | + pub editing_playlist_index: Option<usize>, | ||
| 39 | /// Whether a synchronization is currently in progress | 64 | /// Whether a synchronization is currently in progress |
| 40 | pub sync_in_progress: bool, | 65 | pub sync_in_progress: bool, |
| 41 | /// Download progress for each feed (feed_id -> progress 0.0-1.0) | 66 | /// Download progress for each feed (feed_id -> progress 0.0-1.0) |
| @@ -53,6 +78,24 @@ pub struct AppState { | |||
| 53 | pub config_path: PathBuf, | 78 | pub config_path: PathBuf, |
| 54 | /// Manager for displaying notifications to the user | 79 | /// Manager for displaying notifications to the user |
| 55 | pub notification_manager: NotificationManager, | 80 | pub notification_manager: NotificationManager, |
| 81 | + /// List of playlists for grouping feeds | ||
| 82 | + pub playlists: Vec<Playlist>, | ||
| 83 | + // Filter state | ||
| 84 | + /// Filter: date from (inclusive) | ||
| 85 | + #[allow(dead_code)] | ||
| 86 | + pub filter_date_from: Option<DateTime<Utc>>, | ||
| 87 | + /// Filter: date to (inclusive) | ||
| 88 | + #[allow(dead_code)] | ||
| 89 | + pub filter_date_to: Option<DateTime<Utc>>, | ||
| 90 | + /// Filter: episode number | ||
| 91 | + #[allow(dead_code)] | ||
| 92 | + pub filter_episode_number: String, | ||
| 93 | + /// Whether to show the date picker for 'from' date | ||
| 94 | + #[allow(dead_code)] | ||
| 95 | + pub show_date_picker_from: bool, | ||
| 96 | + /// Whether to show the date picker for 'to' date | ||
| 97 | + #[allow(dead_code)] | ||
| 98 | + pub show_date_picker_to: bool, | ||
| 56 | } | 99 | } |
| 57 | 100 | ||
| 58 | impl AppState { | 101 | impl AppState { |
| @@ -70,7 +113,15 @@ impl AppState { | |||
| 70 | edit_feed_url: String::new(), | 113 | edit_feed_url: String::new(), |
| 71 | edit_feed_download_path: String::new(), | 114 | edit_feed_download_path: String::new(), |
| 72 | edit_feed_enabled: true, | 115 | edit_feed_enabled: true, |
| 116 | + edit_feed_tags: String::new(), | ||
| 73 | editing_feed_index: None, | 117 | editing_feed_index: None, |
| 118 | + show_add_playlist_dialog: false, | ||
| 119 | + add_playlist_name: String::new(), | ||
| 120 | + add_playlist_description: String::new(), | ||
| 121 | + show_edit_playlist_dialog: false, | ||
| 122 | + edit_playlist_name: String::new(), | ||
| 123 | + edit_playlist_description: String::new(), | ||
| 124 | + editing_playlist_index: None, | ||
| 74 | sync_in_progress: false, | 125 | sync_in_progress: false, |
| 75 | download_progress: HashMap::new(), | 126 | download_progress: HashMap::new(), |
| 76 | error_message: None, | 127 | error_message: None, |
| @@ -78,6 +129,12 @@ impl AppState { | |||
| 78 | show_settings: false, | 129 | show_settings: false, |
| 79 | config_path: config_path.into(), | 130 | config_path: config_path.into(), |
| 80 | notification_manager: NotificationManager::new(), | 131 | notification_manager: NotificationManager::new(), |
| 132 | + playlists: Vec::new(), | ||
| 133 | + filter_date_from: None, | ||
| 134 | + filter_date_to: None, | ||
| 135 | + filter_episode_number: String::new(), | ||
| 136 | + show_date_picker_from: false, | ||
| 137 | + show_date_picker_to: false, | ||
| 81 | } | 138 | } |
| 82 | } | 139 | } |
| 83 | 140 | ||
| @@ -246,6 +303,7 @@ impl AppState { | |||
| 246 | self.edit_feed_url = feed.url.clone(); | 303 | self.edit_feed_url = feed.url.clone(); |
| 247 | self.edit_feed_download_path = feed.download_path.to_string_lossy().to_string(); | 304 | self.edit_feed_download_path = feed.download_path.to_string_lossy().to_string(); |
| 248 | self.edit_feed_enabled = feed.enabled; | 305 | self.edit_feed_enabled = feed.enabled; |
| 306 | + self.edit_feed_tags = feed.tags_string(); | ||
| 249 | } | 307 | } |
| 250 | } | 308 | } |
| 251 | 309 | ||
| @@ -255,8 +313,114 @@ impl AppState { | |||
| 255 | self.edit_feed_url.clear(); | 313 | self.edit_feed_url.clear(); |
| 256 | self.edit_feed_download_path.clear(); | 314 | self.edit_feed_download_path.clear(); |
| 257 | self.edit_feed_enabled = true; | 315 | self.edit_feed_enabled = true; |
| 316 | + self.edit_feed_tags.clear(); | ||
| 258 | self.editing_feed_index = None; | 317 | self.editing_feed_index = None; |
| 259 | } | 318 | } |
| 319 | + | ||
| 320 | + // Playlist methods | ||
| 321 | + | ||
| 322 | + /// Adds a new playlist | ||
| 323 | + #[allow(dead_code)] | ||
| 324 | + pub fn add_playlist(&mut self, playlist: Playlist) { | ||
| 325 | + self.playlists.push(playlist); | ||
| 326 | + self.notification_manager.success("Playlist ajoutée"); | ||
| 327 | + } | ||
| 328 | + | ||
| 329 | + /// Removes a playlist by index | ||
| 330 | + #[allow(dead_code)] | ||
| 331 | + pub fn remove_playlist(&mut self, index: usize) -> Option<Playlist> { | ||
| 332 | + if index < self.playlists.len() { | ||
| 333 | + let playlist = self.playlists.remove(index); | ||
| 334 | + self.notification_manager.success("Playlist supprimée"); | ||
| 335 | + Some(playlist) | ||
| 336 | + } else { | ||
| 337 | + None | ||
| 338 | + } | ||
| 339 | + } | ||
| 340 | + | ||
| 341 | + /// Gets a playlist by index | ||
| 342 | + #[allow(dead_code)] | ||
| 343 | + pub fn get_playlist(&self, index: usize) -> Option<&Playlist> { | ||
| 344 | + self.playlists.get(index) | ||
| 345 | + } | ||
| 346 | + | ||
| 347 | + /// Gets a mutable playlist by index | ||
| 348 | + #[allow(dead_code)] | ||
| 349 | + pub fn get_playlist_mut(&mut self, index: usize) -> Option<&mut Playlist> { | ||
| 350 | + self.playlists.get_mut(index) | ||
| 351 | + } | ||
| 352 | + | ||
| 353 | + /// Returns the number of playlists | ||
| 354 | + #[allow(dead_code)] | ||
| 355 | + pub fn playlist_count(&self) -> usize { | ||
| 356 | + self.playlists.len() | ||
| 357 | + } | ||
| 358 | + | ||
| 359 | + /// Returns true if there are any playlists | ||
| 360 | + #[allow(dead_code)] | ||
| 361 | + pub fn has_playlists(&self) -> bool { | ||
| 362 | + !self.playlists.is_empty() | ||
| 363 | + } | ||
| 364 | + | ||
| 365 | + /// Adds a feed to a playlist by index | ||
| 366 | + #[allow(dead_code)] | ||
| 367 | + pub fn add_feed_to_playlist(&mut self, playlist_index: usize, feed_id: &str) { | ||
| 368 | + if let Some(playlist) = self.playlists.get_mut(playlist_index) { | ||
| 369 | + playlist.add_feed(feed_id); | ||
| 370 | + } | ||
| 371 | + } | ||
| 372 | + | ||
| 373 | + /// Removes a feed from a playlist by index | ||
| 374 | + #[allow(dead_code)] | ||
| 375 | + pub fn remove_feed_from_playlist(&mut self, playlist_index: usize, feed_id: &str) { | ||
| 376 | + if let Some(playlist) = self.playlists.get_mut(playlist_index) { | ||
| 377 | + playlist.remove_feed(feed_id); | ||
| 378 | + } | ||
| 379 | + } | ||
| 380 | + | ||
| 381 | + /// Gets all playlists that contain a specific feed | ||
| 382 | + #[allow(dead_code)] | ||
| 383 | + pub fn get_playlists_containing_feed(&self, feed_id: &str) -> Vec<usize> { | ||
| 384 | + self.playlists | ||
| 385 | + .iter() | ||
| 386 | + .enumerate() | ||
| 387 | + .filter(|(_, p)| p.contains_feed(feed_id)) | ||
| 388 | + .map(|(i, _)| i) | ||
| 389 | + .collect() | ||
| 390 | + } | ||
| 391 | + | ||
| 392 | + /// Clears the add playlist form fields | ||
| 393 | + #[allow(dead_code)] | ||
| 394 | + pub fn clear_add_playlist_form(&mut self) { | ||
| 395 | + self.add_playlist_name.clear(); | ||
| 396 | + self.add_playlist_description.clear(); | ||
| 397 | + } | ||
| 398 | + | ||
| 399 | + /// Clears the edit playlist form fields | ||
| 400 | + #[allow(dead_code)] | ||
| 401 | + pub fn clear_edit_playlist_form(&mut self) { | ||
| 402 | + self.edit_playlist_name.clear(); | ||
| 403 | + self.edit_playlist_description.clear(); | ||
| 404 | + self.editing_playlist_index = None; | ||
| 405 | + } | ||
| 406 | + | ||
| 407 | + /// Loads playlist data into edit form fields | ||
| 408 | + #[allow(dead_code)] | ||
| 409 | + pub fn load_edit_playlist_form(&mut self, index: usize) { | ||
| 410 | + if let Some(playlist) = self.playlists.get(index) { | ||
| 411 | + self.edit_playlist_name = playlist.name.clone(); | ||
| 412 | + self.edit_playlist_description = playlist.description.clone().unwrap_or_default(); | ||
| 413 | + } | ||
| 414 | + } | ||
| 415 | + | ||
| 416 | + /// Clears all filter criteria | ||
| 417 | + #[allow(dead_code)] | ||
| 418 | + pub fn clear_filters(&mut self) { | ||
| 419 | + self.filter_date_from = None; | ||
| 420 | + self.filter_date_to = None; | ||
| 421 | + self.filter_episode_number.clear(); | ||
| 422 | + self.notification_manager.info("Filtres effacés"); | ||
| 423 | + } | ||
| 260 | } | 424 | } |
| 261 | 425 | ||
| 262 | #[cfg(test)] | 426 | #[cfg(test)] |
modified
src/config/feed.rs +31 -0 | @@ -24,6 +24,8 @@ pub struct Feed { | ||
| 24 | 24 | pub last_episode_date: Option<DateTime<Utc>>, |
| 25 | 25 | /// Whether the feed is enabled for automatic synchronization |
| 26 | 26 | pub enabled: bool, |
| 27 | + /// Tags for categorizing the feed (e.g., "Tech", "News", "French") | |
| 28 | + pub tags: Vec<String>, | |
| 27 | 29 | } |
| 28 | 30 | |
| 29 | 31 | impl Default for Feed { |
| @@ -36,6 +38,7 @@ impl Default for Feed { | ||
| 36 | 38 | last_checked: None, |
| 37 | 39 | last_episode_date: None, |
| 38 | 40 | enabled: true, |
| 41 | + tags: Vec::new(), | |
| 39 | 42 | } |
| 40 | 43 | } |
| 41 | 44 | } |
| @@ -51,6 +54,7 @@ impl Feed { | ||
| 51 | 54 | last_checked: None, |
| 52 | 55 | last_episode_date: None, |
| 53 | 56 | enabled: true, |
| 57 | + tags: Vec::new(), | |
| 54 | 58 | } |
| 55 | 59 | } |
| 56 | 60 | |
| @@ -92,6 +96,33 @@ impl Feed { | ||
| 92 | 96 | pub fn is_valid(&self) -> bool { |
| 93 | 97 | self.validate().is_ok() |
| 94 | 98 | } |
| 99 | + | |
| 100 | + /// Adds a tag to the feed if not already present | |
| 101 | + #[allow(dead_code)] | |
| 102 | + pub fn add_tag(&mut self, tag: impl Into<String>) { | |
| 103 | + let tag = tag.into(); | |
| 104 | + if !self.tags.contains(&tag) { | |
| 105 | + self.tags.push(tag); | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + /// Removes a tag from the feed | |
| 110 | + #[allow(dead_code)] | |
| 111 | + pub fn remove_tag(&mut self, tag: &str) { | |
| 112 | + self.tags.retain(|t| t != tag); | |
| 113 | + } | |
| 114 | + | |
| 115 | + /// Returns true if the feed has the given tag | |
| 116 | + #[allow(dead_code)] | |
| 117 | + pub fn has_tag(&self, tag: &str) -> bool { | |
| 118 | + self.tags.contains(&tag.to_string()) | |
| 119 | + } | |
| 120 | + | |
| 121 | + /// Returns the tags as a comma-separated string | |
| 122 | + #[allow(dead_code)] | |
| 123 | + pub fn tags_string(&self) -> String { | |
| 124 | + self.tags.join(", ") | |
| 125 | + } | |
| 95 | 126 | } |
| 96 | 127 | |
| 97 | 128 | #[cfg(test)] |
| @@ -24,6 +24,8 @@ pub struct Feed { | |||
| 24 | pub last_episode_date: Option<DateTime<Utc>>, | 24 | pub last_episode_date: Option<DateTime<Utc>>, |
| 25 | /// Whether the feed is enabled for automatic synchronization | 25 | /// Whether the feed is enabled for automatic synchronization |
| 26 | pub enabled: bool, | 26 | pub enabled: bool, |
| 27 | + /// Tags for categorizing the feed (e.g., "Tech", "News", "French") | ||
| 28 | + pub tags: Vec<String>, | ||
| 27 | } | 29 | } |
| 28 | 30 | ||
| 29 | impl Default for Feed { | 31 | impl Default for Feed { |
| @@ -36,6 +38,7 @@ impl Default for Feed { | |||
| 36 | last_checked: None, | 38 | last_checked: None, |
| 37 | last_episode_date: None, | 39 | last_episode_date: None, |
| 38 | enabled: true, | 40 | enabled: true, |
| 41 | + tags: Vec::new(), | ||
| 39 | } | 42 | } |
| 40 | } | 43 | } |
| 41 | } | 44 | } |
| @@ -51,6 +54,7 @@ impl Feed { | |||
| 51 | last_checked: None, | 54 | last_checked: None, |
| 52 | last_episode_date: None, | 55 | last_episode_date: None, |
| 53 | enabled: true, | 56 | enabled: true, |
| 57 | + tags: Vec::new(), | ||
| 54 | } | 58 | } |
| 55 | } | 59 | } |
| 56 | 60 | ||
| @@ -92,6 +96,33 @@ impl Feed { | |||
| 92 | pub fn is_valid(&self) -> bool { | 96 | pub fn is_valid(&self) -> bool { |
| 93 | self.validate().is_ok() | 97 | self.validate().is_ok() |
| 94 | } | 98 | } |
| 99 | + | ||
| 100 | + /// Adds a tag to the feed if not already present | ||
| 101 | + #[allow(dead_code)] | ||
| 102 | + pub fn add_tag(&mut self, tag: impl Into<String>) { | ||
| 103 | + let tag = tag.into(); | ||
| 104 | + if !self.tags.contains(&tag) { | ||
| 105 | + self.tags.push(tag); | ||
| 106 | + } | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + /// Removes a tag from the feed | ||
| 110 | + #[allow(dead_code)] | ||
| 111 | + pub fn remove_tag(&mut self, tag: &str) { | ||
| 112 | + self.tags.retain(|t| t != tag); | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + /// Returns true if the feed has the given tag | ||
| 116 | + #[allow(dead_code)] | ||
| 117 | + pub fn has_tag(&self, tag: &str) -> bool { | ||
| 118 | + self.tags.contains(&tag.to_string()) | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + /// Returns the tags as a comma-separated string | ||
| 122 | + #[allow(dead_code)] | ||
| 123 | + pub fn tags_string(&self) -> String { | ||
| 124 | + self.tags.join(", ") | ||
| 125 | + } | ||
| 95 | } | 126 | } |
| 96 | 127 | ||
| 97 | #[cfg(test)] | 128 | #[cfg(test)] |
modified
src/config/mod.rs +1 -0 | @@ -5,6 +5,7 @@ | ||
| 5 | 5 | pub mod app_state; |
| 6 | 6 | pub mod episode; |
| 7 | 7 | pub mod feed; |
| 8 | +pub mod playlist; | |
| 8 | 9 | pub mod storage; |
| 9 | 10 | |
| 10 | 11 | // Re-export main types for convenience |
| @@ -5,6 +5,7 @@ | |||
| 5 | pub mod app_state; | 5 | pub mod app_state; |
| 6 | pub mod episode; | 6 | pub mod episode; |
| 7 | pub mod feed; | 7 | pub mod feed; |
| 8 | +pub mod playlist; | ||
| 8 | pub mod storage; | 9 | pub mod storage; |
| 9 | 10 | ||
| 10 | // Re-export main types for convenience | 11 | // Re-export main types for convenience |
added
src/config/playlist.rs +154 -0 | new file mode 100644 | ||
| @@ -0,0 +1,154 @@ | ||
| 1 | +//! Playlist module - Podcast feed playlist model | |
| 2 | +//! | |
| 3 | +//! This module defines the Playlist structure for grouping RSS feeds. | |
| 4 | + | |
| 5 | +use serde::{Deserialize, Serialize}; | |
| 6 | +use uuid::Uuid; | |
| 7 | + | |
| 8 | +/// Represents a playlist that groups multiple RSS feeds | |
| 9 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | |
| 10 | +pub struct Playlist { | |
| 11 | + /// Unique identifier for the playlist | |
| 12 | + pub id: String, | |
| 13 | + /// Display name of the playlist | |
| 14 | + pub name: String, | |
| 15 | + /// List of feed IDs that belong to this playlist | |
| 16 | + pub feed_ids: Vec<String>, | |
| 17 | + /// Description of the playlist (optional) | |
| 18 | + pub description: Option<String>, | |
| 19 | +} | |
| 20 | + | |
| 21 | +impl Default for Playlist { | |
| 22 | + fn default() -> Self { | |
| 23 | + Self { | |
| 24 | + id: Uuid::new_v4().to_string(), | |
| 25 | + name: String::new(), | |
| 26 | + feed_ids: Vec::new(), | |
| 27 | + description: None, | |
| 28 | + } | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +impl Playlist { | |
| 33 | + /// Creates a new empty playlist with the given name | |
| 34 | + pub fn new(name: impl Into<String>) -> Self { | |
| 35 | + Self { | |
| 36 | + id: Uuid::new_v4().to_string(), | |
| 37 | + name: name.into(), | |
| 38 | + feed_ids: Vec::new(), | |
| 39 | + description: None, | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + /// Creates a new playlist with the given name and feed IDs | |
| 44 | + pub fn with_feeds(name: impl Into<String>, feed_ids: Vec<String>) -> Self { | |
| 45 | + Self { | |
| 46 | + id: Uuid::new_v4().to_string(), | |
| 47 | + name: name.into(), | |
| 48 | + feed_ids, | |
| 49 | + description: None, | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// Adds a feed ID to the playlist if not already present | |
| 54 | + pub fn add_feed(&mut self, feed_id: impl Into<String>) { | |
| 55 | + let feed_id = feed_id.into(); | |
| 56 | + if !self.feed_ids.contains(&feed_id) { | |
| 57 | + self.feed_ids.push(feed_id); | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// Removes a feed ID from the playlist | |
| 62 | + pub fn remove_feed(&mut self, feed_id: &str) { | |
| 63 | + self.feed_ids.retain(|id| id != feed_id); | |
| 64 | + } | |
| 65 | + | |
| 66 | + /// Returns true if the playlist contains the given feed ID | |
| 67 | + pub fn contains_feed(&self, feed_id: &str) -> bool { | |
| 68 | + self.feed_ids.contains(&feed_id.to_string()) | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// Returns the number of feeds in the playlist | |
| 72 | + pub fn feed_count(&self) -> usize { | |
| 73 | + self.feed_ids.len() | |
| 74 | + } | |
| 75 | + | |
| 76 | + /// Sets the description of the playlist | |
| 77 | + pub fn set_description(&mut self, description: impl Into<String>) { | |
| 78 | + self.description = Some(description.into()); | |
| 79 | + } | |
| 80 | + | |
| 81 | + /// Clears the description of the playlist | |
| 82 | + pub fn clear_description(&mut self) { | |
| 83 | + self.description = None; | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +#[cfg(test)] | |
| 88 | +mod tests { | |
| 89 | + use super::*; | |
| 90 | + | |
| 91 | + #[test] | |
| 92 | + fn test_playlist_default() { | |
| 93 | + let playlist = Playlist::default(); | |
| 94 | + assert!(!playlist.id.is_empty()); | |
| 95 | + assert!(playlist.name.is_empty()); | |
| 96 | + assert!(playlist.feed_ids.is_empty()); | |
| 97 | + assert!(playlist.description.is_none()); | |
| 98 | + } | |
| 99 | + | |
| 100 | + #[test] | |
| 101 | + fn test_playlist_new() { | |
| 102 | + let playlist = Playlist::new("My Playlist"); | |
| 103 | + assert_eq!(playlist.name, "My Playlist"); | |
| 104 | + assert!(playlist.feed_ids.is_empty()); | |
| 105 | + } | |
| 106 | + | |
| 107 | + #[test] | |
| 108 | + fn test_playlist_with_feeds() { | |
| 109 | + let feed_ids = vec!["feed-1".to_string(), "feed-2".to_string()]; | |
| 110 | + let playlist = Playlist::with_feeds("Test Playlist", feed_ids.clone()); | |
| 111 | + assert_eq!(playlist.name, "Test Playlist"); | |
| 112 | + assert_eq!(playlist.feed_ids, feed_ids); | |
| 113 | + } | |
| 114 | + | |
| 115 | + #[test] | |
| 116 | + fn test_add_feed() { | |
| 117 | + let mut playlist = Playlist::new("Test"); | |
| 118 | + playlist.add_feed("feed-1"); | |
| 119 | + playlist.add_feed("feed-2"); | |
| 120 | + playlist.add_feed("feed-1"); // Duplicate - should not be added | |
| 121 | + | |
| 122 | + assert_eq!(playlist.feed_ids.len(), 2); | |
| 123 | + assert!(playlist.contains_feed("feed-1")); | |
| 124 | + assert!(playlist.contains_feed("feed-2")); | |
| 125 | + } | |
| 126 | + | |
| 127 | + #[test] | |
| 128 | + fn test_remove_feed() { | |
| 129 | + let mut playlist = Playlist::with_feeds("Test", vec!["feed-1".to_string(), "feed-2".to_string()]); | |
| 130 | + playlist.remove_feed("feed-1"); | |
| 131 | + | |
| 132 | + assert_eq!(playlist.feed_ids.len(), 1); | |
| 133 | + assert!(!playlist.contains_feed("feed-1")); | |
| 134 | + assert!(playlist.contains_feed("feed-2")); | |
| 135 | + } | |
| 136 | + | |
| 137 | + #[test] | |
| 138 | + fn test_feed_count() { | |
| 139 | + let playlist = Playlist::with_feeds("Test", vec!["feed-1".to_string(), "feed-2".to_string(), "feed-3".to_string()]); | |
| 140 | + assert_eq!(playlist.feed_count(), 3); | |
| 141 | + } | |
| 142 | + | |
| 143 | + #[test] | |
| 144 | + fn test_description() { | |
| 145 | + let mut playlist = Playlist::new("Test"); | |
| 146 | + assert!(playlist.description.is_none()); | |
| 147 | + | |
| 148 | + playlist.set_description("A test playlist"); | |
| 149 | + assert_eq!(playlist.description, Some("A test playlist".to_string())); | |
| 150 | + | |
| 151 | + playlist.clear_description(); | |
| 152 | + assert!(playlist.description.is_none()); | |
| 153 | + } | |
| 154 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,154 @@ | |||
| 1 | +//! Playlist module - Podcast feed playlist model | ||
| 2 | +//! | ||
| 3 | +//! This module defines the Playlist structure for grouping RSS feeds. | ||
| 4 | + | ||
| 5 | +use serde::{Deserialize, Serialize}; | ||
| 6 | +use uuid::Uuid; | ||
| 7 | + | ||
| 8 | +/// Represents a playlist that groups multiple RSS feeds | ||
| 9 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||
| 10 | +pub struct Playlist { | ||
| 11 | + /// Unique identifier for the playlist | ||
| 12 | + pub id: String, | ||
| 13 | + /// Display name of the playlist | ||
| 14 | + pub name: String, | ||
| 15 | + /// List of feed IDs that belong to this playlist | ||
| 16 | + pub feed_ids: Vec<String>, | ||
| 17 | + /// Description of the playlist (optional) | ||
| 18 | + pub description: Option<String>, | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +impl Default for Playlist { | ||
| 22 | + fn default() -> Self { | ||
| 23 | + Self { | ||
| 24 | + id: Uuid::new_v4().to_string(), | ||
| 25 | + name: String::new(), | ||
| 26 | + feed_ids: Vec::new(), | ||
| 27 | + description: None, | ||
| 28 | + } | ||
| 29 | + } | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +impl Playlist { | ||
| 33 | + /// Creates a new empty playlist with the given name | ||
| 34 | + pub fn new(name: impl Into<String>) -> Self { | ||
| 35 | + Self { | ||
| 36 | + id: Uuid::new_v4().to_string(), | ||
| 37 | + name: name.into(), | ||
| 38 | + feed_ids: Vec::new(), | ||
| 39 | + description: None, | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + /// Creates a new playlist with the given name and feed IDs | ||
| 44 | + pub fn with_feeds(name: impl Into<String>, feed_ids: Vec<String>) -> Self { | ||
| 45 | + Self { | ||
| 46 | + id: Uuid::new_v4().to_string(), | ||
| 47 | + name: name.into(), | ||
| 48 | + feed_ids, | ||
| 49 | + description: None, | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + /// Adds a feed ID to the playlist if not already present | ||
| 54 | + pub fn add_feed(&mut self, feed_id: impl Into<String>) { | ||
| 55 | + let feed_id = feed_id.into(); | ||
| 56 | + if !self.feed_ids.contains(&feed_id) { | ||
| 57 | + self.feed_ids.push(feed_id); | ||
| 58 | + } | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + /// Removes a feed ID from the playlist | ||
| 62 | + pub fn remove_feed(&mut self, feed_id: &str) { | ||
| 63 | + self.feed_ids.retain(|id| id != feed_id); | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + /// Returns true if the playlist contains the given feed ID | ||
| 67 | + pub fn contains_feed(&self, feed_id: &str) -> bool { | ||
| 68 | + self.feed_ids.contains(&feed_id.to_string()) | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + /// Returns the number of feeds in the playlist | ||
| 72 | + pub fn feed_count(&self) -> usize { | ||
| 73 | + self.feed_ids.len() | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + /// Sets the description of the playlist | ||
| 77 | + pub fn set_description(&mut self, description: impl Into<String>) { | ||
| 78 | + self.description = Some(description.into()); | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + /// Clears the description of the playlist | ||
| 82 | + pub fn clear_description(&mut self) { | ||
| 83 | + self.description = None; | ||
| 84 | + } | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +#[cfg(test)] | ||
| 88 | +mod tests { | ||
| 89 | + use super::*; | ||
| 90 | + | ||
| 91 | + #[test] | ||
| 92 | + fn test_playlist_default() { | ||
| 93 | + let playlist = Playlist::default(); | ||
| 94 | + assert!(!playlist.id.is_empty()); | ||
| 95 | + assert!(playlist.name.is_empty()); | ||
| 96 | + assert!(playlist.feed_ids.is_empty()); | ||
| 97 | + assert!(playlist.description.is_none()); | ||
| 98 | + } | ||
| 99 | + | ||
| 100 | + #[test] | ||
| 101 | + fn test_playlist_new() { | ||
| 102 | + let playlist = Playlist::new("My Playlist"); | ||
| 103 | + assert_eq!(playlist.name, "My Playlist"); | ||
| 104 | + assert!(playlist.feed_ids.is_empty()); | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + #[test] | ||
| 108 | + fn test_playlist_with_feeds() { | ||
| 109 | + let feed_ids = vec!["feed-1".to_string(), "feed-2".to_string()]; | ||
| 110 | + let playlist = Playlist::with_feeds("Test Playlist", feed_ids.clone()); | ||
| 111 | + assert_eq!(playlist.name, "Test Playlist"); | ||
| 112 | + assert_eq!(playlist.feed_ids, feed_ids); | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + #[test] | ||
| 116 | + fn test_add_feed() { | ||
| 117 | + let mut playlist = Playlist::new("Test"); | ||
| 118 | + playlist.add_feed("feed-1"); | ||
| 119 | + playlist.add_feed("feed-2"); | ||
| 120 | + playlist.add_feed("feed-1"); // Duplicate - should not be added | ||
| 121 | + | ||
| 122 | + assert_eq!(playlist.feed_ids.len(), 2); | ||
| 123 | + assert!(playlist.contains_feed("feed-1")); | ||
| 124 | + assert!(playlist.contains_feed("feed-2")); | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + #[test] | ||
| 128 | + fn test_remove_feed() { | ||
| 129 | + let mut playlist = Playlist::with_feeds("Test", vec!["feed-1".to_string(), "feed-2".to_string()]); | ||
| 130 | + playlist.remove_feed("feed-1"); | ||
| 131 | + | ||
| 132 | + assert_eq!(playlist.feed_ids.len(), 1); | ||
| 133 | + assert!(!playlist.contains_feed("feed-1")); | ||
| 134 | + assert!(playlist.contains_feed("feed-2")); | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + #[test] | ||
| 138 | + fn test_feed_count() { | ||
| 139 | + let playlist = Playlist::with_feeds("Test", vec!["feed-1".to_string(), "feed-2".to_string(), "feed-3".to_string()]); | ||
| 140 | + assert_eq!(playlist.feed_count(), 3); | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + #[test] | ||
| 144 | + fn test_description() { | ||
| 145 | + let mut playlist = Playlist::new("Test"); | ||
| 146 | + assert!(playlist.description.is_none()); | ||
| 147 | + | ||
| 148 | + playlist.set_description("A test playlist"); | ||
| 149 | + assert_eq!(playlist.description, Some("A test playlist".to_string())); | ||
| 150 | + | ||
| 151 | + playlist.clear_description(); | ||
| 152 | + assert!(playlist.description.is_none()); | ||
| 153 | + } | ||
| 154 | +} | ||
modified
src/config/storage.rs +68 -0 | @@ -3,6 +3,7 @@ | ||
| 3 | 3 | //! This module handles saving and loading feed configurations to/from JSON files. |
| 4 | 4 | |
| 5 | 5 | use crate::config::feed::Feed; |
| 6 | +use crate::config::playlist::Playlist; | |
| 6 | 7 | use log::{info, warn}; |
| 7 | 8 | #[allow(unused_imports)] |
| 8 | 9 | use serde::{Deserialize, Serialize}; |
| @@ -132,6 +133,73 @@ pub fn load_feeds_default() -> Result<Vec<Feed>, StorageError> { | ||
| 132 | 133 | load_feeds(&get_config_path()) |
| 133 | 134 | } |
| 134 | 135 | |
| 136 | +/// Gets the default playlists configuration path | |
| 137 | +pub fn get_playlists_path() -> std::path::PathBuf { | |
| 138 | + let mut path = get_config_path(); | |
| 139 | + let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string(); | |
| 140 | + path.set_file_name(format!("{}_playlists", filename)); | |
| 141 | + path | |
| 142 | +} | |
| 143 | + | |
| 144 | +/// Saves the list of playlists to a JSON file | |
| 145 | +pub fn save_playlists(playlists: &[Playlist], path: &Path) -> Result<(), StorageError> { | |
| 146 | + info!("Saving {} playlists to {}", playlists.len(), path.display()); | |
| 147 | + | |
| 148 | + // Create parent directories if they don't exist | |
| 149 | + if let Some(parent) = path.parent() { | |
| 150 | + if !parent.exists() { | |
| 151 | + info!("Creating directory: {}", parent.display()); | |
| 152 | + fs::create_dir_all(parent) | |
| 153 | + .map_err(|e| StorageError::InvalidDirectory(format!("{}: {}", parent.display(), e)))?; | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + // Serialize playlists to JSON | |
| 158 | + let json = serde_json::to_string_pretty(playlists) | |
| 159 | + .map_err(|e| StorageError::SerializeError(e.to_string()))?; | |
| 160 | + | |
| 161 | + // Write to file | |
| 162 | + fs::write(path, json) | |
| 163 | + .map_err(|e| StorageError::WriteError(format!("{}: {}", path.display(), e)))?; | |
| 164 | + | |
| 165 | + info!("Successfully saved {} playlists", playlists.len()); | |
| 166 | + Ok(()) | |
| 167 | +} | |
| 168 | + | |
| 169 | +/// Loads the list of playlists from a JSON file | |
| 170 | +pub fn load_playlists(path: &Path) -> Result<Vec<Playlist>, StorageError> { | |
| 171 | + info!("Loading playlists from {}", path.display()); | |
| 172 | + | |
| 173 | + // Check if file exists | |
| 174 | + if !path.exists() { | |
| 175 | + warn!("Playlists file not found: {}", path.display()); | |
| 176 | + return Ok(Vec::new()); // Return empty list if file doesn't exist | |
| 177 | + } | |
| 178 | + | |
| 179 | + // Read file content | |
| 180 | + let content = fs::read_to_string(path) | |
| 181 | + .map_err(|e| StorageError::ReadError(format!("{}: {}", path.display(), e)))?; | |
| 182 | + | |
| 183 | + // Parse JSON | |
| 184 | + let playlists: Vec<Playlist> = serde_json::from_str(&content) | |
| 185 | + .map_err(|e| StorageError::ParseError(format!("{}: {}", path.display(), e)))?; | |
| 186 | + | |
| 187 | + info!("Successfully loaded {} playlists", playlists.len()); | |
| 188 | + Ok(playlists) | |
| 189 | +} | |
| 190 | + | |
| 191 | +/// Saves playlists to the default configuration path | |
| 192 | +#[allow(dead_code)] | |
| 193 | +pub fn save_playlists_default(playlists: &[Playlist]) -> Result<(), StorageError> { | |
| 194 | + save_playlists(playlists, &get_playlists_path()) | |
| 195 | +} | |
| 196 | + | |
| 197 | +/// Loads playlists from the default configuration path | |
| 198 | +#[allow(dead_code)] | |
| 199 | +pub fn load_playlists_default() -> Result<Vec<Playlist>, StorageError> { | |
| 200 | + load_playlists(&get_playlists_path()) | |
| 201 | +} | |
| 202 | + | |
| 135 | 203 | #[cfg(test)] |
| 136 | 204 | mod tests { |
| 137 | 205 | use super::*; |
| @@ -3,6 +3,7 @@ | |||
| 3 | //! This module handles saving and loading feed configurations to/from JSON files. | 3 | //! This module handles saving and loading feed configurations to/from JSON files. |
| 4 | 4 | ||
| 5 | use crate::config::feed::Feed; | 5 | use crate::config::feed::Feed; |
| 6 | +use crate::config::playlist::Playlist; | ||
| 6 | use log::{info, warn}; | 7 | use log::{info, warn}; |
| 7 | #[allow(unused_imports)] | 8 | #[allow(unused_imports)] |
| 8 | use serde::{Deserialize, Serialize}; | 9 | use serde::{Deserialize, Serialize}; |
| @@ -132,6 +133,73 @@ pub fn load_feeds_default() -> Result<Vec<Feed>, StorageError> { | |||
| 132 | load_feeds(&get_config_path()) | 133 | load_feeds(&get_config_path()) |
| 133 | } | 134 | } |
| 134 | 135 | ||
| 136 | +/// Gets the default playlists configuration path | ||
| 137 | +pub fn get_playlists_path() -> std::path::PathBuf { | ||
| 138 | + let mut path = get_config_path(); | ||
| 139 | + let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string(); | ||
| 140 | + path.set_file_name(format!("{}_playlists", filename)); | ||
| 141 | + path | ||
| 142 | +} | ||
| 143 | + | ||
| 144 | +/// Saves the list of playlists to a JSON file | ||
| 145 | +pub fn save_playlists(playlists: &[Playlist], path: &Path) -> Result<(), StorageError> { | ||
| 146 | + info!("Saving {} playlists to {}", playlists.len(), path.display()); | ||
| 147 | + | ||
| 148 | + // Create parent directories if they don't exist | ||
| 149 | + if let Some(parent) = path.parent() { | ||
| 150 | + if !parent.exists() { | ||
| 151 | + info!("Creating directory: {}", parent.display()); | ||
| 152 | + fs::create_dir_all(parent) | ||
| 153 | + .map_err(|e| StorageError::InvalidDirectory(format!("{}: {}", parent.display(), e)))?; | ||
| 154 | + } | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + // Serialize playlists to JSON | ||
| 158 | + let json = serde_json::to_string_pretty(playlists) | ||
| 159 | + .map_err(|e| StorageError::SerializeError(e.to_string()))?; | ||
| 160 | + | ||
| 161 | + // Write to file | ||
| 162 | + fs::write(path, json) | ||
| 163 | + .map_err(|e| StorageError::WriteError(format!("{}: {}", path.display(), e)))?; | ||
| 164 | + | ||
| 165 | + info!("Successfully saved {} playlists", playlists.len()); | ||
| 166 | + Ok(()) | ||
| 167 | +} | ||
| 168 | + | ||
| 169 | +/// Loads the list of playlists from a JSON file | ||
| 170 | +pub fn load_playlists(path: &Path) -> Result<Vec<Playlist>, StorageError> { | ||
| 171 | + info!("Loading playlists from {}", path.display()); | ||
| 172 | + | ||
| 173 | + // Check if file exists | ||
| 174 | + if !path.exists() { | ||
| 175 | + warn!("Playlists file not found: {}", path.display()); | ||
| 176 | + return Ok(Vec::new()); // Return empty list if file doesn't exist | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + // Read file content | ||
| 180 | + let content = fs::read_to_string(path) | ||
| 181 | + .map_err(|e| StorageError::ReadError(format!("{}: {}", path.display(), e)))?; | ||
| 182 | + | ||
| 183 | + // Parse JSON | ||
| 184 | + let playlists: Vec<Playlist> = serde_json::from_str(&content) | ||
| 185 | + .map_err(|e| StorageError::ParseError(format!("{}: {}", path.display(), e)))?; | ||
| 186 | + | ||
| 187 | + info!("Successfully loaded {} playlists", playlists.len()); | ||
| 188 | + Ok(playlists) | ||
| 189 | +} | ||
| 190 | + | ||
| 191 | +/// Saves playlists to the default configuration path | ||
| 192 | +#[allow(dead_code)] | ||
| 193 | +pub fn save_playlists_default(playlists: &[Playlist]) -> Result<(), StorageError> { | ||
| 194 | + save_playlists(playlists, &get_playlists_path()) | ||
| 195 | +} | ||
| 196 | + | ||
| 197 | +/// Loads playlists from the default configuration path | ||
| 198 | +#[allow(dead_code)] | ||
| 199 | +pub fn load_playlists_default() -> Result<Vec<Playlist>, StorageError> { | ||
| 200 | + load_playlists(&get_playlists_path()) | ||
| 201 | +} | ||
| 202 | + | ||
| 135 | #[cfg(test)] | 203 | #[cfg(test)] |
| 136 | mod tests { | 204 | mod tests { |
| 137 | use super::*; | 205 | use super::*; |
modified
src/ui/feed_edit_dialog.rs +12 -1 | @@ -41,6 +41,10 @@ pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) { | ||
| 41 | 41 | |
| 42 | 42 | ui.checkbox(&mut state.edit_feed_enabled, "Activé"); |
| 43 | 43 | |
| 44 | + ui.label("Tags (séparés par des virgules)"); | |
| 45 | + ui.text_edit_singleline(&mut state.edit_feed_tags); | |
| 46 | + ui.label("Exemple: Tech,Podcast,Français"); | |
| 47 | + | |
| 44 | 48 | ui.separator(); |
| 45 | 49 | |
| 46 | 50 | ui.horizontal(|ui| { |
| @@ -51,9 +55,16 @@ pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) { | ||
| 51 | 55 | state.feeds[editing_index].download_path = PathBuf::from(state.edit_feed_download_path.clone()); |
| 52 | 56 | state.feeds[editing_index].enabled = state.edit_feed_enabled; |
| 53 | 57 | |
| 58 | + // Parse tags from comma-separated string | |
| 59 | + state.feeds[editing_index].tags = state.edit_feed_tags | |
| 60 | + .split(',') | |
| 61 | + .map(|s| s.trim().to_string()) | |
| 62 | + .filter(|s| !s.is_empty()) | |
| 63 | + .collect(); | |
| 64 | + | |
| 54 | 65 | super::super::config::storage::save_feeds_default(&state.feeds).ok(); |
| 55 | 66 | log::info!("Feed updated successfully"); |
| 56 | - state.set_info("Flux modifié".to_string()); | |
| 67 | + state.notification_manager.success("Flux modifié"); | |
| 57 | 68 | state.clear_edit_feed_form(); |
| 58 | 69 | state.show_edit_feed_dialog = false; |
| 59 | 70 | } |
| @@ -41,6 +41,10 @@ pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) { | |||
| 41 | 41 | ||
| 42 | ui.checkbox(&mut state.edit_feed_enabled, "Activé"); | 42 | ui.checkbox(&mut state.edit_feed_enabled, "Activé"); |
| 43 | 43 | ||
| 44 | + ui.label("Tags (séparés par des virgules)"); | ||
| 45 | + ui.text_edit_singleline(&mut state.edit_feed_tags); | ||
| 46 | + ui.label("Exemple: Tech,Podcast,Français"); | ||
| 47 | + | ||
| 44 | ui.separator(); | 48 | ui.separator(); |
| 45 | 49 | ||
| 46 | ui.horizontal(|ui| { | 50 | ui.horizontal(|ui| { |
| @@ -51,9 +55,16 @@ pub fn draw_edit_feed_dialog(state: &mut AppState, ui: &mut Ui) { | |||
| 51 | state.feeds[editing_index].download_path = PathBuf::from(state.edit_feed_download_path.clone()); | 55 | state.feeds[editing_index].download_path = PathBuf::from(state.edit_feed_download_path.clone()); |
| 52 | state.feeds[editing_index].enabled = state.edit_feed_enabled; | 56 | state.feeds[editing_index].enabled = state.edit_feed_enabled; |
| 53 | 57 | ||
| 58 | + // Parse tags from comma-separated string | ||
| 59 | + state.feeds[editing_index].tags = state.edit_feed_tags | ||
| 60 | + .split(',') | ||
| 61 | + .map(|s| s.trim().to_string()) | ||
| 62 | + .filter(|s| !s.is_empty()) | ||
| 63 | + .collect(); | ||
| 64 | + | ||
| 54 | super::super::config::storage::save_feeds_default(&state.feeds).ok(); | 65 | super::super::config::storage::save_feeds_default(&state.feeds).ok(); |
| 55 | log::info!("Feed updated successfully"); | 66 | log::info!("Feed updated successfully"); |
| 56 | - state.set_info("Flux modifié".to_string()); | 67 | + state.notification_manager.success("Flux modifié"); |
| 57 | state.clear_edit_feed_form(); | 68 | state.clear_edit_feed_form(); |
| 58 | state.show_edit_feed_dialog = false; | 69 | state.show_edit_feed_dialog = false; |
| 59 | } | 70 | } |
modified
src/ui/feed_list.rs +15 -3 | @@ -7,11 +7,17 @@ use egui::{Align, Layout, ScrollArea, Ui}; | ||
| 7 | 7 | |
| 8 | 8 | /// Draws the feed list |
| 9 | 9 | pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) { |
| 10 | + // Apply filters | |
| 11 | + let filtered_indices = crate::ui::filter_panel::filter_feeds(&state.feeds, state); | |
| 12 | + | |
| 13 | + if filtered_indices.is_empty() { | |
| 14 | + ui.label("Aucun flux correspondant aux filtres."); | |
| 15 | + return; | |
| 16 | + } | |
| 17 | + | |
| 10 | 18 | ScrollArea::vertical().show(ui, |ui| { |
| 11 | - let mut index = 0; | |
| 12 | - while index < state.feeds.len() { | |
| 19 | + for &index in &filtered_indices { | |
| 13 | 20 | draw_feed_item(state, ui, index); |
| 14 | - index += 1; | |
| 15 | 21 | } |
| 16 | 22 | }); |
| 17 | 23 | } |
| @@ -50,6 +56,12 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | ||
| 50 | 56 | super::super::config::storage::save_feeds_default(&state.feeds).ok(); |
| 51 | 57 | } |
| 52 | 58 | |
| 59 | + // Tags | |
| 60 | + let feed = &state.feeds[index]; | |
| 61 | + if !feed.tags.is_empty() { | |
| 62 | + ui.label(format!("Tags: {}", feed.tags_string())); | |
| 63 | + } | |
| 64 | + | |
| 53 | 65 | // Last checked |
| 54 | 66 | if let Some(date) = feed_last_checked { |
| 55 | 67 | ui.label(format!("Vérifié: {}", date.format("%Y-%m-%d %H:%M"))); |
| @@ -7,11 +7,17 @@ use egui::{Align, Layout, ScrollArea, Ui}; | |||
| 7 | 7 | ||
| 8 | /// Draws the feed list | 8 | /// Draws the feed list |
| 9 | pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) { | 9 | pub fn draw_feed_list(state: &mut AppState, ui: &mut Ui) { |
| 10 | + // Apply filters | ||
| 11 | + let filtered_indices = crate::ui::filter_panel::filter_feeds(&state.feeds, state); | ||
| 12 | + | ||
| 13 | + if filtered_indices.is_empty() { | ||
| 14 | + ui.label("Aucun flux correspondant aux filtres."); | ||
| 15 | + return; | ||
| 16 | + } | ||
| 17 | + | ||
| 10 | ScrollArea::vertical().show(ui, |ui| { | 18 | ScrollArea::vertical().show(ui, |ui| { |
| 11 | - let mut index = 0; | 19 | + for &index in &filtered_indices { |
| 12 | - while index < state.feeds.len() { | ||
| 13 | draw_feed_item(state, ui, index); | 20 | draw_feed_item(state, ui, index); |
| 14 | - index += 1; | ||
| 15 | } | 21 | } |
| 16 | }); | 22 | }); |
| 17 | } | 23 | } |
| @@ -50,6 +56,12 @@ fn draw_feed_item(state: &mut AppState, ui: &mut Ui, index: usize) { | |||
| 50 | super::super::config::storage::save_feeds_default(&state.feeds).ok(); | 56 | super::super::config::storage::save_feeds_default(&state.feeds).ok(); |
| 51 | } | 57 | } |
| 52 | 58 | ||
| 59 | + // Tags | ||
| 60 | + let feed = &state.feeds[index]; | ||
| 61 | + if !feed.tags.is_empty() { | ||
| 62 | + ui.label(format!("Tags: {}", feed.tags_string())); | ||
| 63 | + } | ||
| 64 | + | ||
| 53 | // Last checked | 65 | // Last checked |
| 54 | if let Some(date) = feed_last_checked { | 66 | if let Some(date) = feed_last_checked { |
| 55 | ui.label(format!("Vérifié: {}", date.format("%Y-%m-%d %H:%M"))); | 67 | ui.label(format!("Vérifié: {}", date.format("%Y-%m-%d %H:%M"))); |
added
src/ui/filter_panel.rs +239 -0 | new file mode 100644 | ||
| @@ -0,0 +1,239 @@ | ||
| 1 | +//! Filter Panel UI component | |
| 2 | +//! | |
| 3 | +//! This module contains the UI for filtering feeds and episodes. | |
| 4 | + | |
| 5 | +use crate::config::AppState; | |
| 6 | +use chrono::{DateTime, Utc}; | |
| 7 | +use egui::Ui; | |
| 8 | + | |
| 9 | +/// Draws the filter panel for feeds | |
| 10 | +pub fn draw_filter_panel(state: &mut AppState, ui: &mut Ui) { | |
| 11 | + ui.horizontal(|ui| { | |
| 12 | + ui.label("Filtres:"); | |
| 13 | + | |
| 14 | + // Date filter | |
| 15 | + ui.label("Date de publication:"); | |
| 16 | + | |
| 17 | + // Date from filter | |
| 18 | + if let Some(date_from) = state.filter_date_from { | |
| 19 | + if ui.button(format!("Depuis: {}", date_from.format("%Y-%m-%d"))).clicked() { | |
| 20 | + state.show_date_picker_from = true; | |
| 21 | + } | |
| 22 | + } else { | |
| 23 | + if ui.button("Depuis...").clicked() { | |
| 24 | + state.show_date_picker_from = true; | |
| 25 | + } | |
| 26 | + } | |
| 27 | + | |
| 28 | + ui.add_space(5.0); | |
| 29 | + | |
| 30 | + // Date to filter | |
| 31 | + if let Some(date_to) = state.filter_date_to { | |
| 32 | + if ui.button(format!("Jusqu'à: {}", date_to.format("%Y-%m-%d"))).clicked() { | |
| 33 | + state.show_date_picker_to = true; | |
| 34 | + } | |
| 35 | + } else { | |
| 36 | + if ui.button("Jusqu'à...").clicked() { | |
| 37 | + state.show_date_picker_to = true; | |
| 38 | + } | |
| 39 | + } | |
| 40 | + | |
| 41 | + ui.add_space(10.0); | |
| 42 | + | |
| 43 | + // Episode number filter | |
| 44 | + ui.label("Numéro d'épisode:"); | |
| 45 | + ui.add(egui::TextEdit::singleline(&mut state.filter_episode_number) | |
| 46 | + .hint_text("ex: 10")); | |
| 47 | + | |
| 48 | + ui.add_space(10.0); | |
| 49 | + | |
| 50 | + // Clear filters button | |
| 51 | + if ui.button("Effacer les filtres").clicked() { | |
| 52 | + state.clear_filters(); | |
| 53 | + } | |
| 54 | + }); | |
| 55 | + | |
| 56 | + ui.separator(); | |
| 57 | + | |
| 58 | + // Draw date pickers | |
| 59 | + draw_date_picker_from(state, ui); | |
| 60 | + draw_date_picker_to(state, ui); | |
| 61 | +} | |
| 62 | + | |
| 63 | +/// Draws the date picker for 'from' date | |
| 64 | +fn draw_date_picker_from(state: &mut AppState, ui: &mut Ui) { | |
| 65 | + if !state.show_date_picker_from { | |
| 66 | + return; | |
| 67 | + } | |
| 68 | + | |
| 69 | + egui::Window::new("Sélectionner la date de début") | |
| 70 | + .resizable(false) | |
| 71 | + .show(ui.ctx(), |ui| { | |
| 72 | + ui.vertical(|ui| { | |
| 73 | + ui.label("Date de début (yyyy-mm-dd):"); | |
| 74 | + | |
| 75 | + let mut date_str = state.filter_date_from | |
| 76 | + .map(|d| d.format("%Y-%m-%d").to_string()) | |
| 77 | + .unwrap_or_default(); | |
| 78 | + | |
| 79 | + if ui.text_edit_singleline(&mut date_str).changed() { | |
| 80 | + if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { | |
| 81 | + state.filter_date_from = Some(DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(0, 0, 0).unwrap(), Utc)); | |
| 82 | + state.show_date_picker_from = false; | |
| 83 | + } | |
| 84 | + } | |
| 85 | + | |
| 86 | + ui.separator(); | |
| 87 | + | |
| 88 | + ui.horizontal(|ui| { | |
| 89 | + // Quick date buttons | |
| 90 | + if ui.button("Aujourd'hui").clicked() { | |
| 91 | + state.filter_date_from = Some(Utc::now()); | |
| 92 | + state.show_date_picker_from = false; | |
| 93 | + } | |
| 94 | + | |
| 95 | + if ui.button("7 derniers jours").clicked() { | |
| 96 | + state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7)); | |
| 97 | + state.show_date_picker_from = false; | |
| 98 | + } | |
| 99 | + | |
| 100 | + if ui.button("30 derniers jours").clicked() { | |
| 101 | + state.filter_date_from = Some(Utc::now() - chrono::Duration::days(30)); | |
| 102 | + state.show_date_picker_from = false; | |
| 103 | + } | |
| 104 | + | |
| 105 | + if ui.button("Annuler").clicked() { | |
| 106 | + state.show_date_picker_from = false; | |
| 107 | + } | |
| 108 | + }); | |
| 109 | + }); | |
| 110 | + }); | |
| 111 | +} | |
| 112 | + | |
| 113 | +/// Draws the date picker for 'to' date | |
| 114 | +fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) { | |
| 115 | + if !state.show_date_picker_to { | |
| 116 | + return; | |
| 117 | + } | |
| 118 | + | |
| 119 | + egui::Window::new("Sélectionner la date de fin") | |
| 120 | + .resizable(false) | |
| 121 | + .show(ui.ctx(), |ui| { | |
| 122 | + ui.vertical(|ui| { | |
| 123 | + ui.label("Date de fin (yyyy-mm-dd):"); | |
| 124 | + | |
| 125 | + let mut date_str = state.filter_date_to | |
| 126 | + .map(|d| d.format("%Y-%m-%d").to_string()) | |
| 127 | + .unwrap_or_default(); | |
| 128 | + | |
| 129 | + if ui.text_edit_singleline(&mut date_str).changed() { | |
| 130 | + if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { | |
| 131 | + state.filter_date_to = Some(DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(23, 59, 59).unwrap(), Utc)); | |
| 132 | + state.show_date_picker_to = false; | |
| 133 | + } | |
| 134 | + } | |
| 135 | + | |
| 136 | + ui.separator(); | |
| 137 | + | |
| 138 | + ui.horizontal(|ui| { | |
| 139 | + // Quick date buttons | |
| 140 | + if ui.button("Aujourd'hui").clicked() { | |
| 141 | + state.filter_date_to = Some(Utc::now()); | |
| 142 | + state.show_date_picker_to = false; | |
| 143 | + } | |
| 144 | + | |
| 145 | + if ui.button("7 derniers jours").clicked() { | |
| 146 | + state.filter_date_to = Some(Utc::now()); | |
| 147 | + state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7)); | |
| 148 | + state.show_date_picker_to = false; | |
| 149 | + } | |
| 150 | + | |
| 151 | + if ui.button("Annuler").clicked() { | |
| 152 | + state.show_date_picker_to = false; | |
| 153 | + } | |
| 154 | + }); | |
| 155 | + }); | |
| 156 | + }); | |
| 157 | +} | |
| 158 | + | |
| 159 | +/// Filters feeds based on current filter criteria | |
| 160 | +pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Vec<usize> { | |
| 161 | + feeds.iter() | |
| 162 | + .enumerate() | |
| 163 | + .filter(|(_, feed)| { | |
| 164 | + // Check date filter | |
| 165 | + if let Some(date_from) = state.filter_date_from { | |
| 166 | + if let Some(last_episode) = feed.last_episode_date { | |
| 167 | + if last_episode < date_from { | |
| 168 | + return false; | |
| 169 | + } | |
| 170 | + } else { | |
| 171 | + // No episodes, don't show if filtering by date | |
| 172 | + return false; | |
| 173 | + } | |
| 174 | + } | |
| 175 | + | |
| 176 | + if let Some(date_to) = state.filter_date_to { | |
| 177 | + if let Some(last_episode) = feed.last_episode_date { | |
| 178 | + if last_episode > date_to { | |
| 179 | + return false; | |
| 180 | + } | |
| 181 | + } else { | |
| 182 | + // No episodes, don't show if filtering by date | |
| 183 | + return false; | |
| 184 | + } | |
| 185 | + } | |
| 186 | + | |
| 187 | + true | |
| 188 | + }) | |
| 189 | + .map(|(i, _)| i) | |
| 190 | + .collect() | |
| 191 | +} | |
| 192 | + | |
| 193 | +/// Filters episodes based on current filter criteria | |
| 194 | +#[allow(dead_code)] | |
| 195 | +pub fn filter_episodes(episodes: &[crate::config::episode::Episode], state: &AppState) -> Vec<usize> { | |
| 196 | + episodes.iter() | |
| 197 | + .enumerate() | |
| 198 | + .filter(|(_, episode)| { | |
| 199 | + // Check date filter | |
| 200 | + if let Some(date_from) = state.filter_date_from { | |
| 201 | + if episode.pub_date < date_from { | |
| 202 | + return false; | |
| 203 | + } | |
| 204 | + } | |
| 205 | + | |
| 206 | + if let Some(date_to) = state.filter_date_to { | |
| 207 | + if episode.pub_date > date_to { | |
| 208 | + return false; | |
| 209 | + } | |
| 210 | + } | |
| 211 | + | |
| 212 | + // Check episode number filter | |
| 213 | + if !state.filter_episode_number.is_empty() { | |
| 214 | + if let Ok(ep_num) = state.filter_episode_number.parse::<u32>() { | |
| 215 | + // Try to extract episode number from title (e.g., "Episode 10" or "#10") | |
| 216 | + let title_lower = episode.title.to_lowercase(); | |
| 217 | + if title_lower.contains("episode") || title_lower.contains("ep") || title_lower.contains("#") { | |
| 218 | + // Simple extraction - look for numbers in title | |
| 219 | + let found_number = episode.title | |
| 220 | + .chars() | |
| 221 | + .filter(|c| c.is_ascii_digit()) | |
| 222 | + .collect::<String>() | |
| 223 | + .parse::<u32>() | |
| 224 | + .ok(); | |
| 225 | + | |
| 226 | + if let Some(found_num) = found_number { | |
| 227 | + if found_num != ep_num { | |
| 228 | + return false; | |
| 229 | + } | |
| 230 | + } | |
| 231 | + } | |
| 232 | + } | |
| 233 | + } | |
| 234 | + | |
| 235 | + true | |
| 236 | + }) | |
| 237 | + .map(|(i, _)| i) | |
| 238 | + .collect() | |
| 239 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,239 @@ | |||
| 1 | +//! Filter Panel UI component | ||
| 2 | +//! | ||
| 3 | +//! This module contains the UI for filtering feeds and episodes. | ||
| 4 | + | ||
| 5 | +use crate::config::AppState; | ||
| 6 | +use chrono::{DateTime, Utc}; | ||
| 7 | +use egui::Ui; | ||
| 8 | + | ||
| 9 | +/// Draws the filter panel for feeds | ||
| 10 | +pub fn draw_filter_panel(state: &mut AppState, ui: &mut Ui) { | ||
| 11 | + ui.horizontal(|ui| { | ||
| 12 | + ui.label("Filtres:"); | ||
| 13 | + | ||
| 14 | + // Date filter | ||
| 15 | + ui.label("Date de publication:"); | ||
| 16 | + | ||
| 17 | + // Date from filter | ||
| 18 | + if let Some(date_from) = state.filter_date_from { | ||
| 19 | + if ui.button(format!("Depuis: {}", date_from.format("%Y-%m-%d"))).clicked() { | ||
| 20 | + state.show_date_picker_from = true; | ||
| 21 | + } | ||
| 22 | + } else { | ||
| 23 | + if ui.button("Depuis...").clicked() { | ||
| 24 | + state.show_date_picker_from = true; | ||
| 25 | + } | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + ui.add_space(5.0); | ||
| 29 | + | ||
| 30 | + // Date to filter | ||
| 31 | + if let Some(date_to) = state.filter_date_to { | ||
| 32 | + if ui.button(format!("Jusqu'à: {}", date_to.format("%Y-%m-%d"))).clicked() { | ||
| 33 | + state.show_date_picker_to = true; | ||
| 34 | + } | ||
| 35 | + } else { | ||
| 36 | + if ui.button("Jusqu'à...").clicked() { | ||
| 37 | + state.show_date_picker_to = true; | ||
| 38 | + } | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + ui.add_space(10.0); | ||
| 42 | + | ||
| 43 | + // Episode number filter | ||
| 44 | + ui.label("Numéro d'épisode:"); | ||
| 45 | + ui.add(egui::TextEdit::singleline(&mut state.filter_episode_number) | ||
| 46 | + .hint_text("ex: 10")); | ||
| 47 | + | ||
| 48 | + ui.add_space(10.0); | ||
| 49 | + | ||
| 50 | + // Clear filters button | ||
| 51 | + if ui.button("Effacer les filtres").clicked() { | ||
| 52 | + state.clear_filters(); | ||
| 53 | + } | ||
| 54 | + }); | ||
| 55 | + | ||
| 56 | + ui.separator(); | ||
| 57 | + | ||
| 58 | + // Draw date pickers | ||
| 59 | + draw_date_picker_from(state, ui); | ||
| 60 | + draw_date_picker_to(state, ui); | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +/// Draws the date picker for 'from' date | ||
| 64 | +fn draw_date_picker_from(state: &mut AppState, ui: &mut Ui) { | ||
| 65 | + if !state.show_date_picker_from { | ||
| 66 | + return; | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + egui::Window::new("Sélectionner la date de début") | ||
| 70 | + .resizable(false) | ||
| 71 | + .show(ui.ctx(), |ui| { | ||
| 72 | + ui.vertical(|ui| { | ||
| 73 | + ui.label("Date de début (yyyy-mm-dd):"); | ||
| 74 | + | ||
| 75 | + let mut date_str = state.filter_date_from | ||
| 76 | + .map(|d| d.format("%Y-%m-%d").to_string()) | ||
| 77 | + .unwrap_or_default(); | ||
| 78 | + | ||
| 79 | + if ui.text_edit_singleline(&mut date_str).changed() { | ||
| 80 | + if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { | ||
| 81 | + state.filter_date_from = Some(DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(0, 0, 0).unwrap(), Utc)); | ||
| 82 | + state.show_date_picker_from = false; | ||
| 83 | + } | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + ui.separator(); | ||
| 87 | + | ||
| 88 | + ui.horizontal(|ui| { | ||
| 89 | + // Quick date buttons | ||
| 90 | + if ui.button("Aujourd'hui").clicked() { | ||
| 91 | + state.filter_date_from = Some(Utc::now()); | ||
| 92 | + state.show_date_picker_from = false; | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + if ui.button("7 derniers jours").clicked() { | ||
| 96 | + state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7)); | ||
| 97 | + state.show_date_picker_from = false; | ||
| 98 | + } | ||
| 99 | + | ||
| 100 | + if ui.button("30 derniers jours").clicked() { | ||
| 101 | + state.filter_date_from = Some(Utc::now() - chrono::Duration::days(30)); | ||
| 102 | + state.show_date_picker_from = false; | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + if ui.button("Annuler").clicked() { | ||
| 106 | + state.show_date_picker_from = false; | ||
| 107 | + } | ||
| 108 | + }); | ||
| 109 | + }); | ||
| 110 | + }); | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +/// Draws the date picker for 'to' date | ||
| 114 | +fn draw_date_picker_to(state: &mut AppState, ui: &mut Ui) { | ||
| 115 | + if !state.show_date_picker_to { | ||
| 116 | + return; | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + egui::Window::new("Sélectionner la date de fin") | ||
| 120 | + .resizable(false) | ||
| 121 | + .show(ui.ctx(), |ui| { | ||
| 122 | + ui.vertical(|ui| { | ||
| 123 | + ui.label("Date de fin (yyyy-mm-dd):"); | ||
| 124 | + | ||
| 125 | + let mut date_str = state.filter_date_to | ||
| 126 | + .map(|d| d.format("%Y-%m-%d").to_string()) | ||
| 127 | + .unwrap_or_default(); | ||
| 128 | + | ||
| 129 | + if ui.text_edit_singleline(&mut date_str).changed() { | ||
| 130 | + if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { | ||
| 131 | + state.filter_date_to = Some(DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(23, 59, 59).unwrap(), Utc)); | ||
| 132 | + state.show_date_picker_to = false; | ||
| 133 | + } | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + ui.separator(); | ||
| 137 | + | ||
| 138 | + ui.horizontal(|ui| { | ||
| 139 | + // Quick date buttons | ||
| 140 | + if ui.button("Aujourd'hui").clicked() { | ||
| 141 | + state.filter_date_to = Some(Utc::now()); | ||
| 142 | + state.show_date_picker_to = false; | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + if ui.button("7 derniers jours").clicked() { | ||
| 146 | + state.filter_date_to = Some(Utc::now()); | ||
| 147 | + state.filter_date_from = Some(Utc::now() - chrono::Duration::days(7)); | ||
| 148 | + state.show_date_picker_to = false; | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | + if ui.button("Annuler").clicked() { | ||
| 152 | + state.show_date_picker_to = false; | ||
| 153 | + } | ||
| 154 | + }); | ||
| 155 | + }); | ||
| 156 | + }); | ||
| 157 | +} | ||
| 158 | + | ||
| 159 | +/// Filters feeds based on current filter criteria | ||
| 160 | +pub fn filter_feeds(feeds: &[crate::config::feed::Feed], state: &AppState) -> Vec<usize> { | ||
| 161 | + feeds.iter() | ||
| 162 | + .enumerate() | ||
| 163 | + .filter(|(_, feed)| { | ||
| 164 | + // Check date filter | ||
| 165 | + if let Some(date_from) = state.filter_date_from { | ||
| 166 | + if let Some(last_episode) = feed.last_episode_date { | ||
| 167 | + if last_episode < date_from { | ||
| 168 | + return false; | ||
| 169 | + } | ||
| 170 | + } else { | ||
| 171 | + // No episodes, don't show if filtering by date | ||
| 172 | + return false; | ||
| 173 | + } | ||
| 174 | + } | ||
| 175 | + | ||
| 176 | + if let Some(date_to) = state.filter_date_to { | ||
| 177 | + if let Some(last_episode) = feed.last_episode_date { | ||
| 178 | + if last_episode > date_to { | ||
| 179 | + return false; | ||
| 180 | + } | ||
| 181 | + } else { | ||
| 182 | + // No episodes, don't show if filtering by date | ||
| 183 | + return false; | ||
| 184 | + } | ||
| 185 | + } | ||
| 186 | + | ||
| 187 | + true | ||
| 188 | + }) | ||
| 189 | + .map(|(i, _)| i) | ||
| 190 | + .collect() | ||
| 191 | +} | ||
| 192 | + | ||
| 193 | +/// Filters episodes based on current filter criteria | ||
| 194 | +#[allow(dead_code)] | ||
| 195 | +pub fn filter_episodes(episodes: &[crate::config::episode::Episode], state: &AppState) -> Vec<usize> { | ||
| 196 | + episodes.iter() | ||
| 197 | + .enumerate() | ||
| 198 | + .filter(|(_, episode)| { | ||
| 199 | + // Check date filter | ||
| 200 | + if let Some(date_from) = state.filter_date_from { | ||
| 201 | + if episode.pub_date < date_from { | ||
| 202 | + return false; | ||
| 203 | + } | ||
| 204 | + } | ||
| 205 | + | ||
| 206 | + if let Some(date_to) = state.filter_date_to { | ||
| 207 | + if episode.pub_date > date_to { | ||
| 208 | + return false; | ||
| 209 | + } | ||
| 210 | + } | ||
| 211 | + | ||
| 212 | + // Check episode number filter | ||
| 213 | + if !state.filter_episode_number.is_empty() { | ||
| 214 | + if let Ok(ep_num) = state.filter_episode_number.parse::<u32>() { | ||
| 215 | + // Try to extract episode number from title (e.g., "Episode 10" or "#10") | ||
| 216 | + let title_lower = episode.title.to_lowercase(); | ||
| 217 | + if title_lower.contains("episode") || title_lower.contains("ep") || title_lower.contains("#") { | ||
| 218 | + // Simple extraction - look for numbers in title | ||
| 219 | + let found_number = episode.title | ||
| 220 | + .chars() | ||
| 221 | + .filter(|c| c.is_ascii_digit()) | ||
| 222 | + .collect::<String>() | ||
| 223 | + .parse::<u32>() | ||
| 224 | + .ok(); | ||
| 225 | + | ||
| 226 | + if let Some(found_num) = found_number { | ||
| 227 | + if found_num != ep_num { | ||
| 228 | + return false; | ||
| 229 | + } | ||
| 230 | + } | ||
| 231 | + } | ||
| 232 | + } | ||
| 233 | + } | ||
| 234 | + | ||
| 235 | + true | ||
| 236 | + }) | ||
| 237 | + .map(|(i, _)| i) | ||
| 238 | + .collect() | ||
| 239 | +} | ||
modified
src/ui/home.rs +14 -0 | @@ -14,6 +14,9 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | ||
| 14 | 14 | |
| 15 | 15 | ui.separator(); |
| 16 | 16 | |
| 17 | + // Filter panel | |
| 18 | + crate::ui::filter_panel::draw_filter_panel(state, ui); | |
| 19 | + | |
| 17 | 20 | // Action buttons |
| 18 | 21 | draw_action_buttons(state, ui); |
| 19 | 22 | |
| @@ -26,11 +29,22 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | ||
| 26 | 29 | draw_empty_state(ui); |
| 27 | 30 | } |
| 28 | 31 | |
| 32 | + ui.separator(); | |
| 33 | + | |
| 34 | + // Playlist list | |
| 35 | + crate::ui::playlist_list::draw_playlist_list(state, ui); | |
| 36 | + | |
| 29 | 37 | // Draw add feed dialog |
| 30 | 38 | crate::ui::add_feed_dialog::draw_add_feed_dialog(state, ui); |
| 31 | 39 | |
| 32 | 40 | // Draw edit feed dialog |
| 33 | 41 | crate::ui::feed_edit_dialog::draw_edit_feed_dialog(state, ui); |
| 42 | + | |
| 43 | + // Draw add playlist dialog | |
| 44 | + crate::ui::playlist_list::draw_add_playlist_dialog(state, ui); | |
| 45 | + | |
| 46 | + // Draw edit playlist dialog | |
| 47 | + crate::ui::playlist_list::draw_edit_playlist_dialog(state, ui); | |
| 34 | 48 | } |
| 35 | 49 | |
| 36 | 50 | /// Draws the action buttons |
| @@ -14,6 +14,9 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | |||
| 14 | 14 | ||
| 15 | ui.separator(); | 15 | ui.separator(); |
| 16 | 16 | ||
| 17 | + // Filter panel | ||
| 18 | + crate::ui::filter_panel::draw_filter_panel(state, ui); | ||
| 19 | + | ||
| 17 | // Action buttons | 20 | // Action buttons |
| 18 | draw_action_buttons(state, ui); | 21 | draw_action_buttons(state, ui); |
| 19 | 22 | ||
| @@ -26,11 +29,22 @@ pub fn draw_home(state: &mut AppState, ui: &mut Ui) { | |||
| 26 | draw_empty_state(ui); | 29 | draw_empty_state(ui); |
| 27 | } | 30 | } |
| 28 | 31 | ||
| 32 | + ui.separator(); | ||
| 33 | + | ||
| 34 | + // Playlist list | ||
| 35 | + crate::ui::playlist_list::draw_playlist_list(state, ui); | ||
| 36 | + | ||
| 29 | // Draw add feed dialog | 37 | // Draw add feed dialog |
| 30 | crate::ui::add_feed_dialog::draw_add_feed_dialog(state, ui); | 38 | crate::ui::add_feed_dialog::draw_add_feed_dialog(state, ui); |
| 31 | 39 | ||
| 32 | // Draw edit feed dialog | 40 | // Draw edit feed dialog |
| 33 | crate::ui::feed_edit_dialog::draw_edit_feed_dialog(state, ui); | 41 | crate::ui::feed_edit_dialog::draw_edit_feed_dialog(state, ui); |
| 42 | + | ||
| 43 | + // Draw add playlist dialog | ||
| 44 | + crate::ui::playlist_list::draw_add_playlist_dialog(state, ui); | ||
| 45 | + | ||
| 46 | + // Draw edit playlist dialog | ||
| 47 | + crate::ui::playlist_list::draw_edit_playlist_dialog(state, ui); | ||
| 34 | } | 48 | } |
| 35 | 49 | ||
| 36 | /// Draws the action buttons | 50 | /// Draws the action buttons |
modified
src/ui/mod.rs +2 -0 | @@ -8,3 +8,5 @@ pub mod add_feed_dialog; | ||
| 8 | 8 | pub mod feed_edit_dialog; |
| 9 | 9 | pub mod status_bar; |
| 10 | 10 | pub mod notifications; |
| 11 | +pub mod playlist_list; | |
| 12 | +pub mod filter_panel; | |
| @@ -8,3 +8,5 @@ pub mod add_feed_dialog; | |||
| 8 | pub mod feed_edit_dialog; | 8 | pub mod feed_edit_dialog; |
| 9 | pub mod status_bar; | 9 | pub mod status_bar; |
| 10 | pub mod notifications; | 10 | pub mod notifications; |
| 11 | +pub mod playlist_list; | ||
| 12 | +pub mod filter_panel; | ||
added
src/ui/playlist_list.rs +166 -0 | new file mode 100644 | ||
| @@ -0,0 +1,166 @@ | ||
| 1 | +//! Playlist List UI component | |
| 2 | +//! | |
| 3 | +//! This module contains the UI for displaying and managing playlists. | |
| 4 | + | |
| 5 | +use crate::config::playlist::Playlist; | |
| 6 | +use crate::config::AppState; | |
| 7 | +use egui::{Align, Layout, ScrollArea, Ui}; | |
| 8 | + | |
| 9 | +/// Draws the playlist list | |
| 10 | +pub fn draw_playlist_list(state: &mut AppState, ui: &mut Ui) { | |
| 11 | + ui.horizontal(|ui| { | |
| 12 | + ui.label("Playlists:"); | |
| 13 | + if ui.button("➕ Ajouter").clicked() { | |
| 14 | + state.show_add_playlist_dialog = true; | |
| 15 | + } | |
| 16 | + }); | |
| 17 | + | |
| 18 | + ui.separator(); | |
| 19 | + | |
| 20 | + if state.playlists.is_empty() { | |
| 21 | + ui.label("Aucune playlist. Cliquez sur 'Ajouter' pour créer une playlist."); | |
| 22 | + return; | |
| 23 | + } | |
| 24 | + | |
| 25 | + ScrollArea::vertical().show(ui, |ui| { | |
| 26 | + let playlist_count = state.playlists.len(); | |
| 27 | + for index in 0..playlist_count { | |
| 28 | + draw_playlist_item(state, ui, index); | |
| 29 | + } | |
| 30 | + }); | |
| 31 | +} | |
| 32 | + | |
| 33 | +/// Draws a single playlist item | |
| 34 | +fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) { | |
| 35 | + let playlist = &state.playlists[index]; | |
| 36 | + let playlist_name = playlist.name.clone(); | |
| 37 | + let feed_count = playlist.feed_count(); | |
| 38 | + | |
| 39 | + ui.horizontal(|ui| { | |
| 40 | + // Playlist name and info | |
| 41 | + ui.vertical(|ui| { | |
| 42 | + ui.label(&playlist_name); | |
| 43 | + ui.label(format!("{} flux", feed_count)); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + // Actions | |
| 47 | + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | |
| 48 | + // Edit button | |
| 49 | + if ui.button("✏️").on_hover_text("Éditer").clicked() { | |
| 50 | + state.editing_playlist_index = Some(index); | |
| 51 | + state.edit_playlist_name = playlist_name; | |
| 52 | + state.show_edit_playlist_dialog = true; | |
| 53 | + } | |
| 54 | + | |
| 55 | + // Delete button | |
| 56 | + if ui.button("🗑️").on_hover_text("Supprimer").clicked() { | |
| 57 | + if let Some(playlist) = state.remove_playlist(index) { | |
| 58 | + log::info!("Deleted playlist: {}", playlist.name); | |
| 59 | + super::super::config::storage::save_playlists_default(&state.playlists).ok(); | |
| 60 | + } | |
| 61 | + } | |
| 62 | + }); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + ui.separator(); | |
| 66 | +} | |
| 67 | + | |
| 68 | +/// Draws the add playlist dialog | |
| 69 | +pub fn draw_add_playlist_dialog(state: &mut AppState, ui: &mut Ui) { | |
| 70 | + if !state.show_add_playlist_dialog { | |
| 71 | + return; | |
| 72 | + } | |
| 73 | + | |
| 74 | + egui::Window::new("Ajouter une playlist") | |
| 75 | + .resizable(false) | |
| 76 | + .show(ui.ctx(), |ui| { | |
| 77 | + ui.vertical(|ui| { | |
| 78 | + ui.label("Nouvelle playlist"); | |
| 79 | + ui.separator(); | |
| 80 | + | |
| 81 | + ui.label("Nom de la playlist"); | |
| 82 | + ui.text_edit_singleline(&mut state.add_playlist_name); | |
| 83 | + | |
| 84 | + ui.label("Description (optionnel)"); | |
| 85 | + ui.text_edit_multiline(&mut state.add_playlist_description); | |
| 86 | + | |
| 87 | + ui.separator(); | |
| 88 | + | |
| 89 | + ui.horizontal(|ui| { | |
| 90 | + if !state.add_playlist_name.is_empty() && ui.button("Ajouter").clicked() { | |
| 91 | + log::info!("Adding new playlist: {}", state.add_playlist_name); | |
| 92 | + let mut playlist = Playlist::new(state.add_playlist_name.clone()); | |
| 93 | + if !state.add_playlist_description.is_empty() { | |
| 94 | + playlist.set_description(state.add_playlist_description.clone()); | |
| 95 | + } | |
| 96 | + state.add_playlist(playlist); | |
| 97 | + super::super::config::storage::save_playlists_default(&state.playlists).ok(); | |
| 98 | + log::info!("Playlist added successfully"); | |
| 99 | + state.clear_add_playlist_form(); | |
| 100 | + state.show_add_playlist_dialog = false; | |
| 101 | + } else { | |
| 102 | + ui.label("Veuillez entrer un nom"); | |
| 103 | + } | |
| 104 | + | |
| 105 | + if ui.button("Annuler").clicked() { | |
| 106 | + state.clear_add_playlist_form(); | |
| 107 | + state.show_add_playlist_dialog = false; | |
| 108 | + } | |
| 109 | + }); | |
| 110 | + }); | |
| 111 | + }); | |
| 112 | +} | |
| 113 | + | |
| 114 | +/// Draws the edit playlist dialog | |
| 115 | +pub fn draw_edit_playlist_dialog(state: &mut AppState, ui: &mut Ui) { | |
| 116 | + let editing_index = match state.editing_playlist_index { | |
| 117 | + Some(idx) => idx, | |
| 118 | + None => return, | |
| 119 | + }; | |
| 120 | + | |
| 121 | + if editing_index >= state.playlists.len() { | |
| 122 | + state.clear_edit_playlist_form(); | |
| 123 | + return; | |
| 124 | + } | |
| 125 | + | |
| 126 | + egui::Window::new("Éditer la playlist") | |
| 127 | + .resizable(false) | |
| 128 | + .show(ui.ctx(), |ui| { | |
| 129 | + ui.vertical(|ui| { | |
| 130 | + ui.label(format!("Éditer: {}", state.edit_playlist_name)); | |
| 131 | + ui.separator(); | |
| 132 | + | |
| 133 | + ui.label("Nom de la playlist"); | |
| 134 | + ui.text_edit_singleline(&mut state.edit_playlist_name); | |
| 135 | + | |
| 136 | + ui.label("Description (optionnel)"); | |
| 137 | + ui.text_edit_multiline(&mut state.edit_playlist_description); | |
| 138 | + | |
| 139 | + ui.separator(); | |
| 140 | + | |
| 141 | + ui.horizontal(|ui| { | |
| 142 | + if !state.edit_playlist_name.is_empty() && ui.button("Sauvegarder").clicked() { | |
| 143 | + log::info!("Updating playlist at index {}", editing_index); | |
| 144 | + state.playlists[editing_index].name = state.edit_playlist_name.clone(); | |
| 145 | + if !state.edit_playlist_description.is_empty() { | |
| 146 | + state.playlists[editing_index].set_description(state.edit_playlist_description.clone()); | |
| 147 | + } else { | |
| 148 | + state.playlists[editing_index].clear_description(); | |
| 149 | + } | |
| 150 | + super::super::config::storage::save_playlists_default(&state.playlists).ok(); | |
| 151 | + log::info!("Playlist updated successfully"); | |
| 152 | + state.notification_manager.success("Playlist modifiée"); | |
| 153 | + state.clear_edit_playlist_form(); | |
| 154 | + state.show_edit_playlist_dialog = false; | |
| 155 | + } else { | |
| 156 | + ui.label("Veuillez entrer un nom"); | |
| 157 | + } | |
| 158 | + | |
| 159 | + if ui.button("Annuler").clicked() { | |
| 160 | + state.clear_edit_playlist_form(); | |
| 161 | + state.show_edit_playlist_dialog = false; | |
| 162 | + } | |
| 163 | + }); | |
| 164 | + }); | |
| 165 | + }); | |
| 166 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,166 @@ | |||
| 1 | +//! Playlist List UI component | ||
| 2 | +//! | ||
| 3 | +//! This module contains the UI for displaying and managing playlists. | ||
| 4 | + | ||
| 5 | +use crate::config::playlist::Playlist; | ||
| 6 | +use crate::config::AppState; | ||
| 7 | +use egui::{Align, Layout, ScrollArea, Ui}; | ||
| 8 | + | ||
| 9 | +/// Draws the playlist list | ||
| 10 | +pub fn draw_playlist_list(state: &mut AppState, ui: &mut Ui) { | ||
| 11 | + ui.horizontal(|ui| { | ||
| 12 | + ui.label("Playlists:"); | ||
| 13 | + if ui.button("➕ Ajouter").clicked() { | ||
| 14 | + state.show_add_playlist_dialog = true; | ||
| 15 | + } | ||
| 16 | + }); | ||
| 17 | + | ||
| 18 | + ui.separator(); | ||
| 19 | + | ||
| 20 | + if state.playlists.is_empty() { | ||
| 21 | + ui.label("Aucune playlist. Cliquez sur 'Ajouter' pour créer une playlist."); | ||
| 22 | + return; | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + ScrollArea::vertical().show(ui, |ui| { | ||
| 26 | + let playlist_count = state.playlists.len(); | ||
| 27 | + for index in 0..playlist_count { | ||
| 28 | + draw_playlist_item(state, ui, index); | ||
| 29 | + } | ||
| 30 | + }); | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +/// Draws a single playlist item | ||
| 34 | +fn draw_playlist_item(state: &mut AppState, ui: &mut Ui, index: usize) { | ||
| 35 | + let playlist = &state.playlists[index]; | ||
| 36 | + let playlist_name = playlist.name.clone(); | ||
| 37 | + let feed_count = playlist.feed_count(); | ||
| 38 | + | ||
| 39 | + ui.horizontal(|ui| { | ||
| 40 | + // Playlist name and info | ||
| 41 | + ui.vertical(|ui| { | ||
| 42 | + ui.label(&playlist_name); | ||
| 43 | + ui.label(format!("{} flux", feed_count)); | ||
| 44 | + }); | ||
| 45 | + | ||
| 46 | + // Actions | ||
| 47 | + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 48 | + // Edit button | ||
| 49 | + if ui.button("✏️").on_hover_text("Éditer").clicked() { | ||
| 50 | + state.editing_playlist_index = Some(index); | ||
| 51 | + state.edit_playlist_name = playlist_name; | ||
| 52 | + state.show_edit_playlist_dialog = true; | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + // Delete button | ||
| 56 | + if ui.button("🗑️").on_hover_text("Supprimer").clicked() { | ||
| 57 | + if let Some(playlist) = state.remove_playlist(index) { | ||
| 58 | + log::info!("Deleted playlist: {}", playlist.name); | ||
| 59 | + super::super::config::storage::save_playlists_default(&state.playlists).ok(); | ||
| 60 | + } | ||
| 61 | + } | ||
| 62 | + }); | ||
| 63 | + }); | ||
| 64 | + | ||
| 65 | + ui.separator(); | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +/// Draws the add playlist dialog | ||
| 69 | +pub fn draw_add_playlist_dialog(state: &mut AppState, ui: &mut Ui) { | ||
| 70 | + if !state.show_add_playlist_dialog { | ||
| 71 | + return; | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + egui::Window::new("Ajouter une playlist") | ||
| 75 | + .resizable(false) | ||
| 76 | + .show(ui.ctx(), |ui| { | ||
| 77 | + ui.vertical(|ui| { | ||
| 78 | + ui.label("Nouvelle playlist"); | ||
| 79 | + ui.separator(); | ||
| 80 | + | ||
| 81 | + ui.label("Nom de la playlist"); | ||
| 82 | + ui.text_edit_singleline(&mut state.add_playlist_name); | ||
| 83 | + | ||
| 84 | + ui.label("Description (optionnel)"); | ||
| 85 | + ui.text_edit_multiline(&mut state.add_playlist_description); | ||
| 86 | + | ||
| 87 | + ui.separator(); | ||
| 88 | + | ||
| 89 | + ui.horizontal(|ui| { | ||
| 90 | + if !state.add_playlist_name.is_empty() && ui.button("Ajouter").clicked() { | ||
| 91 | + log::info!("Adding new playlist: {}", state.add_playlist_name); | ||
| 92 | + let mut playlist = Playlist::new(state.add_playlist_name.clone()); | ||
| 93 | + if !state.add_playlist_description.is_empty() { | ||
| 94 | + playlist.set_description(state.add_playlist_description.clone()); | ||
| 95 | + } | ||
| 96 | + state.add_playlist(playlist); | ||
| 97 | + super::super::config::storage::save_playlists_default(&state.playlists).ok(); | ||
| 98 | + log::info!("Playlist added successfully"); | ||
| 99 | + state.clear_add_playlist_form(); | ||
| 100 | + state.show_add_playlist_dialog = false; | ||
| 101 | + } else { | ||
| 102 | + ui.label("Veuillez entrer un nom"); | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + if ui.button("Annuler").clicked() { | ||
| 106 | + state.clear_add_playlist_form(); | ||
| 107 | + state.show_add_playlist_dialog = false; | ||
| 108 | + } | ||
| 109 | + }); | ||
| 110 | + }); | ||
| 111 | + }); | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +/// Draws the edit playlist dialog | ||
| 115 | +pub fn draw_edit_playlist_dialog(state: &mut AppState, ui: &mut Ui) { | ||
| 116 | + let editing_index = match state.editing_playlist_index { | ||
| 117 | + Some(idx) => idx, | ||
| 118 | + None => return, | ||
| 119 | + }; | ||
| 120 | + | ||
| 121 | + if editing_index >= state.playlists.len() { | ||
| 122 | + state.clear_edit_playlist_form(); | ||
| 123 | + return; | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + egui::Window::new("Éditer la playlist") | ||
| 127 | + .resizable(false) | ||
| 128 | + .show(ui.ctx(), |ui| { | ||
| 129 | + ui.vertical(|ui| { | ||
| 130 | + ui.label(format!("Éditer: {}", state.edit_playlist_name)); | ||
| 131 | + ui.separator(); | ||
| 132 | + | ||
| 133 | + ui.label("Nom de la playlist"); | ||
| 134 | + ui.text_edit_singleline(&mut state.edit_playlist_name); | ||
| 135 | + | ||
| 136 | + ui.label("Description (optionnel)"); | ||
| 137 | + ui.text_edit_multiline(&mut state.edit_playlist_description); | ||
| 138 | + | ||
| 139 | + ui.separator(); | ||
| 140 | + | ||
| 141 | + ui.horizontal(|ui| { | ||
| 142 | + if !state.edit_playlist_name.is_empty() && ui.button("Sauvegarder").clicked() { | ||
| 143 | + log::info!("Updating playlist at index {}", editing_index); | ||
| 144 | + state.playlists[editing_index].name = state.edit_playlist_name.clone(); | ||
| 145 | + if !state.edit_playlist_description.is_empty() { | ||
| 146 | + state.playlists[editing_index].set_description(state.edit_playlist_description.clone()); | ||
| 147 | + } else { | ||
| 148 | + state.playlists[editing_index].clear_description(); | ||
| 149 | + } | ||
| 150 | + super::super::config::storage::save_playlists_default(&state.playlists).ok(); | ||
| 151 | + log::info!("Playlist updated successfully"); | ||
| 152 | + state.notification_manager.success("Playlist modifiée"); | ||
| 153 | + state.clear_edit_playlist_form(); | ||
| 154 | + state.show_edit_playlist_dialog = false; | ||
| 155 | + } else { | ||
| 156 | + ui.label("Veuillez entrer un nom"); | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + if ui.button("Annuler").clicked() { | ||
| 160 | + state.clear_edit_playlist_form(); | ||
| 161 | + state.show_edit_playlist_dialog = false; | ||
| 162 | + } | ||
| 163 | + }); | ||
| 164 | + }); | ||
| 165 | + }); | ||
| 166 | +} | ||