Compare commits
3 commits
73f18e1d31
...
ba6ec93860
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ba6ec93860 | ||
![]() |
74738772b4 | ||
![]() |
6deed13d0b |
44 changed files with 65918 additions and 66 deletions
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"makefile.configureOnOpen": false
|
||||||
|
}
|
114
extractors/Makefile
Normal file
114
extractors/Makefile
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
# Makefile pour le scraper agenda du libre
|
||||||
|
|
||||||
|
.PHONY: help install test demo monitor clean run setup-cron
|
||||||
|
|
||||||
|
# Configuration par défaut
|
||||||
|
API_URL ?= https://api.openeventdatabase.org
|
||||||
|
BATCH_SIZE ?= 1
|
||||||
|
|
||||||
|
help: ## Afficher l'aide
|
||||||
|
@echo "🔧 Scraper Agenda du Libre - Commandes disponibles"
|
||||||
|
@echo "=================================================="
|
||||||
|
@echo ""
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||||
|
@echo ""
|
||||||
|
@echo "💡 Variables d'environnement:"
|
||||||
|
@echo " API_URL URL de l'API OEDB (défaut: https://api.openeventdatabase.org)"
|
||||||
|
@echo " BATCH_SIZE Taille des batches (défaut: 1)"
|
||||||
|
@echo ""
|
||||||
|
@echo "📝 Exemples:"
|
||||||
|
@echo " make run BATCH_SIZE=5"
|
||||||
|
@echo " make run API_URL=http://api.example.com:8080"
|
||||||
|
|
||||||
|
install: ## Installer les dépendances
|
||||||
|
@echo "📦 Installation des dépendances..."
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y python3-requests python3-icalendar
|
||||||
|
@echo "✅ Dépendances installées"
|
||||||
|
|
||||||
|
test: ## Exécuter les tests
|
||||||
|
@echo "🧪 Exécution des tests..."
|
||||||
|
python3 test_agendadulibre.py
|
||||||
|
python3 test_api_connection.py --api-url $(API_URL)
|
||||||
|
|
||||||
|
demo: ## Exécuter la démonstration
|
||||||
|
@echo "🎭 Exécution de la démonstration..."
|
||||||
|
python3 demo_agendadulibre.py
|
||||||
|
|
||||||
|
monitor: ## Afficher les statistiques
|
||||||
|
@echo "📊 Affichage des statistiques..."
|
||||||
|
python3 monitor_agendadulibre.py
|
||||||
|
|
||||||
|
run: ## Exécuter le scraper
|
||||||
|
@echo "🚀 Exécution du scraper..."
|
||||||
|
python3 agendadulibre.py --api-url $(API_URL) --batch-size $(BATCH_SIZE)
|
||||||
|
|
||||||
|
run-verbose: ## Exécuter le scraper en mode verbeux
|
||||||
|
@echo "🚀 Exécution du scraper en mode verbeux..."
|
||||||
|
python3 agendadulibre.py --api-url $(API_URL) --batch-size $(BATCH_SIZE) --verbose
|
||||||
|
|
||||||
|
run-force: ## Exécuter le scraper en forçant le rechargement
|
||||||
|
@echo "🚀 Exécution du scraper avec rechargement forcé..."
|
||||||
|
python3 agendadulibre.py --api-url $(API_URL) --batch-size $(BATCH_SIZE) --force-refresh
|
||||||
|
|
||||||
|
run-cache-test: ## Tester le système de cache
|
||||||
|
@echo "🔄 Test du système de cache..."
|
||||||
|
python3 agendadulibre.py --api-url $(API_URL) --batch-size 1 --verbose
|
||||||
|
@echo " Premier appel terminé, deuxième appel (depuis cache)..."
|
||||||
|
python3 agendadulibre.py --api-url $(API_URL) --batch-size 1 --verbose
|
||||||
|
|
||||||
|
setup-cron: ## Configurer la planification cron
|
||||||
|
@echo "⏰ Configuration de la planification cron..."
|
||||||
|
./setup_cron.sh
|
||||||
|
|
||||||
|
clean: ## Nettoyer les fichiers temporaires
|
||||||
|
@echo "🧹 Nettoyage des fichiers temporaires..."
|
||||||
|
rm -f agendadulibre_events.json
|
||||||
|
rm -f agendadulibre_events.ics
|
||||||
|
rm -f agendadulibre_scraper.log
|
||||||
|
rm -f cron_agendadulibre.log
|
||||||
|
@echo "✅ Nettoyage terminé"
|
||||||
|
|
||||||
|
status: ## Afficher le statut du système
|
||||||
|
@echo "📊 Statut du système:"
|
||||||
|
@echo "===================="
|
||||||
|
@echo "📁 Fichier de données:"
|
||||||
|
@if [ -f agendadulibre_events.json ]; then \
|
||||||
|
SIZE=$$(stat -c%s agendadulibre_events.json 2>/dev/null || echo "0"); \
|
||||||
|
echo " ✅ Présent ($$SIZE octets)"; \
|
||||||
|
else \
|
||||||
|
echo " ❌ Absent"; \
|
||||||
|
fi
|
||||||
|
@echo "📁 Fichier cache iCal:"
|
||||||
|
@if [ -f agendadulibre_events.ics ]; then \
|
||||||
|
SIZE=$$(stat -c%s agendadulibre_events.ics 2>/dev/null || echo "0"); \
|
||||||
|
DATE=$$(stat -c%y agendadulibre_events.ics 2>/dev/null || echo "inconnue"); \
|
||||||
|
echo " ✅ Présent ($$SIZE octets)"; \
|
||||||
|
echo " 📅 Modifié: $$DATE"; \
|
||||||
|
else \
|
||||||
|
echo " ❌ Absent"; \
|
||||||
|
fi
|
||||||
|
@echo ""
|
||||||
|
@echo "📋 Tâches cron:"
|
||||||
|
@if crontab -l 2>/dev/null | grep -q agendadulibre; then \
|
||||||
|
echo " ✅ Tâches configurées:"; \
|
||||||
|
crontab -l | grep agendadulibre | sed 's/^/ /'; \
|
||||||
|
else \
|
||||||
|
echo " ❌ Aucune tâche configurée"; \
|
||||||
|
fi
|
||||||
|
@echo ""
|
||||||
|
@echo "🔗 Connexion API:"
|
||||||
|
@python3 -c "import requests; print(' ✅' if requests.get('$(API_URL)', timeout=5).status_code == 200 else ' ❌')" 2>/dev/null || echo " ❌ API non accessible"
|
||||||
|
|
||||||
|
logs: ## Afficher les logs récents
|
||||||
|
@echo "📋 Logs récents:"
|
||||||
|
@echo "==============="
|
||||||
|
@if [ -f agendadulibre_scraper.log ]; then \
|
||||||
|
tail -20 agendadulibre_scraper.log; \
|
||||||
|
else \
|
||||||
|
echo "Aucun fichier de log trouvé"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
check-deps: ## Vérifier les dépendances
|
||||||
|
@echo "🔍 Vérification des dépendances..."
|
||||||
|
@python3 -c "import requests, icalendar; print('✅ Toutes les dépendances sont disponibles')" || echo "❌ Dépendances manquantes - exécutez 'make install'"
|
161
extractors/README_agendadulibre.md
Normal file
161
extractors/README_agendadulibre.md
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
# Scraper Agenda du Libre
|
||||||
|
|
||||||
|
Script de scraping pour récupérer les événements de l'agenda du libre (https://www.agendadulibre.org/) et les envoyer à l'API OEDB.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- 📥 Récupération automatique du fichier iCal depuis l'agenda du libre
|
||||||
|
- 🔄 Traitement par batch configurable
|
||||||
|
- 💾 Sauvegarde locale de l'état des événements (JSON)
|
||||||
|
- 🚫 Évite les doublons (ne renvoie pas les événements déjà traités)
|
||||||
|
- 📊 Statistiques détaillées et logging
|
||||||
|
- 🧪 Mode démo et tests inclus
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Installer les dépendances Python :
|
||||||
|
```bash
|
||||||
|
pip install -r requirements_agendadulibre.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Rendre les scripts exécutables :
|
||||||
|
```bash
|
||||||
|
chmod +x agendadulibre.py
|
||||||
|
chmod +x test_agendadulibre.py
|
||||||
|
chmod +x demo_agendadulibre.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
### Scraping complet
|
||||||
|
```bash
|
||||||
|
# Utilisation basique (1 événement par batch)
|
||||||
|
python agendadulibre.py
|
||||||
|
|
||||||
|
# Avec options personnalisées
|
||||||
|
python agendadulibre.py --api-url https://api.openeventdatabase.org --batch-size 5 --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options disponibles
|
||||||
|
- `--api-url` : URL de base de l'API OEDB (défaut: https://api.openeventdatabase.org)
|
||||||
|
- `--batch-size` : Nombre d'événements à traiter par batch (défaut: 1)
|
||||||
|
- `--verbose` : Mode verbeux pour plus de détails
|
||||||
|
- `--force-refresh` : Forcer le rechargement du fichier iCal (ignorer le cache)
|
||||||
|
- `--cache-duration` : Durée de validité du cache en heures (défaut: 1)
|
||||||
|
|
||||||
|
### Démonstration
|
||||||
|
```bash
|
||||||
|
# Mode démo (ne fait pas d'appels API réels)
|
||||||
|
python demo_agendadulibre.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
```bash
|
||||||
|
# Exécuter les tests
|
||||||
|
python test_agendadulibre.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers générés
|
||||||
|
|
||||||
|
- `agendadulibre_events.json` : Base de données locale des événements traités
|
||||||
|
- `agendadulibre_events.ics` : Cache local du fichier iCal (valide 1h)
|
||||||
|
- `agendadulibre_scraper.log` : Logs détaillés du scraper
|
||||||
|
|
||||||
|
## Système de cache iCal
|
||||||
|
|
||||||
|
Le script utilise un système de cache intelligent pour éviter de télécharger le fichier iCal à chaque exécution :
|
||||||
|
|
||||||
|
- **Cache valide** : Le fichier iCal est mis en cache localement pendant 1 heure par défaut
|
||||||
|
- **Rechargement automatique** : Si le cache est expiré, le fichier est automatiquement rechargé
|
||||||
|
- **Fallback** : En cas d'erreur de téléchargement, le script utilise le cache même s'il est expiré
|
||||||
|
- **Force refresh** : Option `--force-refresh` pour ignorer le cache et forcer le rechargement
|
||||||
|
|
||||||
|
### Avantages du cache
|
||||||
|
- ⚡ **Performance** : Évite les téléchargements inutiles
|
||||||
|
- 🔄 **Fiabilité** : Fonctionne même si l'API iCal est temporairement indisponible
|
||||||
|
- 📊 **Efficacité** : Réduit la charge sur le serveur de l'agenda du libre
|
||||||
|
|
||||||
|
## Format des événements
|
||||||
|
|
||||||
|
Les événements sont convertis au format OEDB avec les propriétés suivantes :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"label": "Titre de l'événement",
|
||||||
|
"description": "Description de l'événement",
|
||||||
|
"what": "culture.geek",
|
||||||
|
"where": "Lieu de l'événement",
|
||||||
|
"start": "2024-01-01T10:00:00",
|
||||||
|
"stop": "2024-01-01T12:00:00",
|
||||||
|
"url": "https://www.agendadulibre.org/event/123",
|
||||||
|
"source": "agendadulibre.org",
|
||||||
|
"last_modified_by": "agendadulibre_scraper"
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [0, 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gestion des doublons
|
||||||
|
|
||||||
|
Le script utilise un système de suivi local pour éviter les doublons :
|
||||||
|
|
||||||
|
- Chaque événement reçoit un ID unique basé sur son contenu
|
||||||
|
- Les événements déjà traités avec succès ne sont pas renvoyés
|
||||||
|
- Les événements en erreur peuvent être retentés
|
||||||
|
- Les événements déjà existants (réponse 409) sont marqués comme traités
|
||||||
|
|
||||||
|
## Statuts des événements
|
||||||
|
|
||||||
|
- `saved` : Événement envoyé avec succès à l'API
|
||||||
|
- `already_exists` : Événement déjà existant dans l'API (réponse 409)
|
||||||
|
- `error` : Erreur lors de l'envoi à l'API
|
||||||
|
|
||||||
|
## Exemple de sortie
|
||||||
|
|
||||||
|
```
|
||||||
|
2024-01-01 10:00:00 - INFO - 🚀 Démarrage du scraping de l'agenda du libre
|
||||||
|
2024-01-01 10:00:01 - INFO - Récupération du fichier iCal depuis https://www.agendadulibre.org/events.ics
|
||||||
|
2024-01-01 10:00:02 - INFO - Fichier iCal récupéré avec succès
|
||||||
|
2024-01-01 10:00:03 - INFO - Traitement de 15 nouveaux événements par batch de 1
|
||||||
|
2024-01-01 10:00:04 - INFO - Envoi de l'événement: Conférence Python
|
||||||
|
2024-01-01 10:00:05 - INFO - ✅ Conférence Python - Créé avec succès
|
||||||
|
...
|
||||||
|
2024-01-01 10:00:30 - INFO - 📊 Statistiques finales:
|
||||||
|
2024-01-01 10:00:30 - INFO - Total d'événements trouvés: 25
|
||||||
|
2024-01-01 10:00:30 - INFO - Nouveaux événements envoyés: 12
|
||||||
|
2024-01-01 10:00:30 - INFO - Événements déjà existants: 8
|
||||||
|
2024-01-01 10:00:30 - INFO - Erreurs d'API: 2
|
||||||
|
2024-01-01 10:00:30 - INFO - Erreurs de parsing: 3
|
||||||
|
2024-01-01 10:00:30 - INFO - Événements envoyés cette fois: 12
|
||||||
|
2024-01-01 10:00:30 - INFO - ✅ Scraping terminé avec succès
|
||||||
|
```
|
||||||
|
|
||||||
|
## Planification
|
||||||
|
|
||||||
|
Pour automatiser le scraping, vous pouvez utiliser cron :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Exécuter toutes les heures
|
||||||
|
0 * * * * cd /path/to/extractors && python agendadulibre.py --batch-size 5
|
||||||
|
|
||||||
|
# Exécuter tous les jours à 6h
|
||||||
|
0 6 * * * cd /path/to/extractors && python agendadulibre.py --batch-size 10
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dépannage
|
||||||
|
|
||||||
|
### Erreur de connexion à l'API
|
||||||
|
- Vérifiez que l'API OEDB est démarrée
|
||||||
|
- Vérifiez l'URL de l'API avec `--api-url`
|
||||||
|
|
||||||
|
### Erreur de parsing iCal
|
||||||
|
- Vérifiez la connectivité internet
|
||||||
|
- Vérifiez que l'URL iCal est accessible
|
||||||
|
|
||||||
|
### Événements non géocodés
|
||||||
|
- Les événements sont créés avec des coordonnées par défaut [0, 0]
|
||||||
|
- Un processus de géocodage séparé peut être ajouté si nécessaire
|
402
extractors/agendadulibre.py
Normal file
402
extractors/agendadulibre.py
Normal file
|
@ -0,0 +1,402 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script de scraping pour l'agenda du libre (https://www.agendadulibre.org/)
|
||||||
|
Utilise le fichier iCal pour récupérer les événements et les envoyer à l'API OEDB
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
import icalendar
|
||||||
|
from icalendar import Calendar, Event
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Configuration par défaut
|
||||||
|
api_oedb = "https://api.openeventdatabase.org"
|
||||||
|
|
||||||
|
# Configuration du logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler('agendadulibre_scraper.log'),
|
||||||
|
logging.StreamHandler(sys.stdout)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class AgendaDuLibreScraper:
|
||||||
|
def __init__(self, api_base_url: str = api_oedb, batch_size: int = 1):
|
||||||
|
self.api_base_url = api_base_url
|
||||||
|
self.batch_size = batch_size
|
||||||
|
self.data_file = "agendadulibre_events.json"
|
||||||
|
self.ical_file = "agendadulibre_events.ics"
|
||||||
|
self.ical_url = "https://www.agendadulibre.org/events.ics"
|
||||||
|
self.cache_duration_hours = 1 # Durée de cache en heures
|
||||||
|
|
||||||
|
# Charger les données existantes
|
||||||
|
self.events_data = self.load_events_data()
|
||||||
|
|
||||||
|
def load_events_data(self) -> Dict:
|
||||||
|
"""Charge les données d'événements depuis le fichier JSON local"""
|
||||||
|
if os.path.exists(self.data_file):
|
||||||
|
try:
|
||||||
|
with open(self.data_file, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors du chargement du fichier {self.data_file}: {e}")
|
||||||
|
return {"events": {}, "last_update": None}
|
||||||
|
return {"events": {}, "last_update": None}
|
||||||
|
|
||||||
|
def save_events_data(self):
|
||||||
|
"""Sauvegarde les données d'événements dans le fichier JSON local"""
|
||||||
|
try:
|
||||||
|
with open(self.data_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(self.events_data, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la sauvegarde du fichier {self.data_file}: {e}")
|
||||||
|
|
||||||
|
def is_ical_cache_valid(self) -> bool:
|
||||||
|
"""Vérifie si le cache iCal est encore valide (moins d'une heure)"""
|
||||||
|
if not os.path.exists(self.ical_file):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_time = os.path.getmtime(self.ical_file)
|
||||||
|
cache_age = datetime.now().timestamp() - file_time
|
||||||
|
cache_age_hours = cache_age / 3600
|
||||||
|
|
||||||
|
logger.debug(f"Cache iCal âgé de {cache_age_hours:.2f} heures")
|
||||||
|
return cache_age_hours < self.cache_duration_hours
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la vérification du cache iCal: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def save_ical_cache(self, ical_content: bytes):
|
||||||
|
"""Sauvegarde le contenu iCal en cache local"""
|
||||||
|
try:
|
||||||
|
with open(self.ical_file, 'wb') as f:
|
||||||
|
f.write(ical_content)
|
||||||
|
logger.info(f"Cache iCal sauvegardé dans {self.ical_file}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la sauvegarde du cache iCal: {e}")
|
||||||
|
|
||||||
|
def load_ical_cache(self) -> Optional[bytes]:
|
||||||
|
"""Charge le contenu iCal depuis le cache local"""
|
||||||
|
try:
|
||||||
|
with open(self.ical_file, 'rb') as f:
|
||||||
|
content = f.read()
|
||||||
|
logger.info(f"Cache iCal chargé depuis {self.ical_file}")
|
||||||
|
return content
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors du chargement du cache iCal: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_ical_data(self, force_refresh: bool = False) -> Optional[Calendar]:
|
||||||
|
"""Récupère et parse le fichier iCal depuis l'agenda du libre ou depuis le cache"""
|
||||||
|
ical_content = None
|
||||||
|
|
||||||
|
# Vérifier si le cache est valide (sauf si on force le rechargement)
|
||||||
|
if not force_refresh and self.is_ical_cache_valid():
|
||||||
|
logger.info("Utilisation du cache iCal local (moins d'une heure)")
|
||||||
|
ical_content = self.load_ical_cache()
|
||||||
|
else:
|
||||||
|
if force_refresh:
|
||||||
|
logger.info(f"Rechargement forcé du fichier iCal depuis {self.ical_url}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Cache iCal expiré ou absent, téléchargement depuis {self.ical_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(self.ical_url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
ical_content = response.content
|
||||||
|
|
||||||
|
# Sauvegarder en cache
|
||||||
|
self.save_ical_cache(ical_content)
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Erreur lors de la récupération du fichier iCal: {e}")
|
||||||
|
# Essayer de charger depuis le cache même s'il est expiré
|
||||||
|
logger.info("Tentative de chargement depuis le cache expiré...")
|
||||||
|
ical_content = self.load_ical_cache()
|
||||||
|
|
||||||
|
if ical_content is None:
|
||||||
|
logger.error("Impossible de récupérer le contenu iCal")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
calendar = Calendar.from_ical(ical_content)
|
||||||
|
logger.info(f"Fichier iCal parsé avec succès")
|
||||||
|
return calendar
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors du parsing du fichier iCal: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_event(self, event: Event) -> Optional[Dict]:
|
||||||
|
"""Parse un événement iCal et le convertit au format OEDB"""
|
||||||
|
try:
|
||||||
|
# Récupérer les propriétés de base
|
||||||
|
summary = str(event.get('summary', ''))
|
||||||
|
description = str(event.get('description', ''))
|
||||||
|
location = str(event.get('location', ''))
|
||||||
|
url = str(event.get('url', ''))
|
||||||
|
|
||||||
|
# Gestion des dates
|
||||||
|
dtstart = event.get('dtstart')
|
||||||
|
dtend = event.get('dtend')
|
||||||
|
|
||||||
|
if not dtstart:
|
||||||
|
logger.warning(f"Événement sans date de début: {summary}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Convertir les dates
|
||||||
|
start_date = dtstart.dt
|
||||||
|
if isinstance(start_date, datetime):
|
||||||
|
start_iso = start_date.isoformat()
|
||||||
|
else:
|
||||||
|
# Date seulement (sans heure)
|
||||||
|
start_iso = f"{start_date}T00:00:00"
|
||||||
|
|
||||||
|
end_date = None
|
||||||
|
if dtend:
|
||||||
|
end_dt = dtend.dt
|
||||||
|
if isinstance(end_dt, datetime):
|
||||||
|
end_iso = end_dt.isoformat()
|
||||||
|
else:
|
||||||
|
end_iso = f"{end_dt}T23:59:59"
|
||||||
|
else:
|
||||||
|
# Si pas de date de fin, ajouter 2 heures par défaut
|
||||||
|
if isinstance(start_date, datetime):
|
||||||
|
end_iso = (start_date + timedelta(hours=2)).isoformat()
|
||||||
|
else:
|
||||||
|
end_iso = f"{start_date}T02:00:00"
|
||||||
|
|
||||||
|
# Créer l'événement au format OEDB
|
||||||
|
oedb_event = {
|
||||||
|
"properties": {
|
||||||
|
"label": summary,
|
||||||
|
"description": description,
|
||||||
|
"type": "scheduled",
|
||||||
|
"what": "culture.floss", # Type par défaut pour l'agenda du libre
|
||||||
|
"where": location,
|
||||||
|
"start": start_iso,
|
||||||
|
"stop": end_iso,
|
||||||
|
"url": url if url else None,
|
||||||
|
"source:name": "Agenda du Libre",
|
||||||
|
"source:url": "https://www.agendadulibre.org/",
|
||||||
|
"last_modified_by": "agendadulibre_scraper"
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [0, 0] # Coordonnées par défaut, à géocoder si nécessaire
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Créer un ID unique basé sur le contenu
|
||||||
|
event_id = self.generate_event_id(summary, start_iso, location)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": event_id,
|
||||||
|
"event": oedb_event,
|
||||||
|
"raw_ical": {
|
||||||
|
"summary": summary,
|
||||||
|
"description": description,
|
||||||
|
"location": location,
|
||||||
|
"url": url,
|
||||||
|
"dtstart": start_iso,
|
||||||
|
"dtend": end_iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors du parsing de l'événement: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generate_event_id(self, summary: str, start_date: str, location: str) -> str:
|
||||||
|
"""Génère un ID unique pour l'événement"""
|
||||||
|
import hashlib
|
||||||
|
content = f"{summary}_{start_date}_{location}"
|
||||||
|
return hashlib.md5(content.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
def send_event_to_api(self, event_data: Dict) -> Tuple[bool, str]:
|
||||||
|
"""Envoie un événement à l'API OEDB"""
|
||||||
|
try:
|
||||||
|
url = f"{self.api_base_url}/event"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
# Formater l'événement au format GeoJSON attendu par l'API
|
||||||
|
geojson_event = {
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": event_data["event"]["geometry"],
|
||||||
|
"properties": event_data["event"]["properties"]
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, json=geojson_event, headers=headers, timeout=30)
|
||||||
|
|
||||||
|
if response.status_code == 201:
|
||||||
|
return True, "Créé avec succès"
|
||||||
|
elif response.status_code == 409:
|
||||||
|
return False, "Événement déjà existant"
|
||||||
|
else:
|
||||||
|
return False, f"Erreur API: {response.status_code} - {response.text}"
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return False, f"Erreur de connexion: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Erreur inattendue: {e}"
|
||||||
|
|
||||||
|
def process_events(self, calendar: Calendar) -> Dict:
|
||||||
|
"""Traite tous les événements du calendrier"""
|
||||||
|
stats = {
|
||||||
|
"total_events": 0,
|
||||||
|
"new_events": 0,
|
||||||
|
"already_saved": 0,
|
||||||
|
"api_errors": 0,
|
||||||
|
"parse_errors": 0,
|
||||||
|
"sent_this_run": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
events_to_process = []
|
||||||
|
|
||||||
|
# Parcourir tous les événements
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == "VEVENT":
|
||||||
|
stats["total_events"] += 1
|
||||||
|
|
||||||
|
# Parser l'événement
|
||||||
|
parsed_event = self.parse_event(component)
|
||||||
|
if not parsed_event:
|
||||||
|
stats["parse_errors"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
event_id = parsed_event["id"]
|
||||||
|
|
||||||
|
# Vérifier si l'événement existe déjà dans nos données
|
||||||
|
if event_id in self.events_data["events"]:
|
||||||
|
event_status = self.events_data["events"][event_id].get("status", "unknown")
|
||||||
|
if event_status in ["saved", "already_exists"]:
|
||||||
|
stats["already_saved"] += 1
|
||||||
|
logger.debug(f"Événement déjà traité: {parsed_event['event']['properties']['label']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
events_to_process.append(parsed_event)
|
||||||
|
|
||||||
|
# Traiter les événements par batch
|
||||||
|
logger.info(f"Traitement de {len(events_to_process)} nouveaux événements par batch de {self.batch_size}")
|
||||||
|
|
||||||
|
for i in range(0, len(events_to_process), self.batch_size):
|
||||||
|
batch = events_to_process[i:i + self.batch_size]
|
||||||
|
logger.info(f"Traitement du batch {i//self.batch_size + 1}/{(len(events_to_process) + self.batch_size - 1)//self.batch_size}")
|
||||||
|
|
||||||
|
for event_data in batch:
|
||||||
|
event_id = event_data["id"]
|
||||||
|
event_label = event_data["event"]["properties"]["label"]
|
||||||
|
|
||||||
|
logger.info(f"Envoi de l'événement: {event_label}")
|
||||||
|
|
||||||
|
# Envoyer à l'API
|
||||||
|
success, message = self.send_event_to_api(event_data)
|
||||||
|
|
||||||
|
# Mettre à jour les statistiques et les données locales
|
||||||
|
if success:
|
||||||
|
stats["new_events"] += 1
|
||||||
|
stats["sent_this_run"] += 1
|
||||||
|
self.events_data["events"][event_id] = {
|
||||||
|
"status": "saved",
|
||||||
|
"message": message,
|
||||||
|
"last_attempt": datetime.now().isoformat(),
|
||||||
|
"event": event_data["event"]
|
||||||
|
}
|
||||||
|
logger.info(f"✅ {event_label} - {message}")
|
||||||
|
else:
|
||||||
|
if "déjà existant" in message or "already exists" in message.lower():
|
||||||
|
stats["already_saved"] += 1
|
||||||
|
self.events_data["events"][event_id] = {
|
||||||
|
"status": "already_exists",
|
||||||
|
"message": message,
|
||||||
|
"last_attempt": datetime.now().isoformat(),
|
||||||
|
"event": event_data["event"]
|
||||||
|
}
|
||||||
|
logger.info(f"⚠️ {event_label} - {message}")
|
||||||
|
else:
|
||||||
|
stats["api_errors"] += 1
|
||||||
|
self.events_data["events"][event_id] = {
|
||||||
|
"status": "error",
|
||||||
|
"message": message,
|
||||||
|
"last_attempt": datetime.now().isoformat(),
|
||||||
|
"event": event_data["event"]
|
||||||
|
}
|
||||||
|
logger.error(f"❌ {event_label} - {message}")
|
||||||
|
|
||||||
|
# Mettre à jour la date de dernière mise à jour
|
||||||
|
self.events_data["last_update"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def run(self, force_refresh: bool = False):
|
||||||
|
"""Exécute le scraping complet"""
|
||||||
|
logger.info("🚀 Démarrage du scraping de l'agenda du libre")
|
||||||
|
logger.info(f"Configuration: batch_size={self.batch_size}, api_url={self.api_base_url}")
|
||||||
|
logger.info(f"Cache iCal: {'ignoré' if force_refresh else f'valide pendant {self.cache_duration_hours}h'}")
|
||||||
|
|
||||||
|
# Récupérer le fichier iCal
|
||||||
|
calendar = self.fetch_ical_data(force_refresh=force_refresh)
|
||||||
|
if not calendar:
|
||||||
|
logger.error("❌ Impossible de récupérer le fichier iCal")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Traiter les événements
|
||||||
|
stats = self.process_events(calendar)
|
||||||
|
|
||||||
|
# Sauvegarder les données
|
||||||
|
self.save_events_data()
|
||||||
|
|
||||||
|
# Afficher les statistiques finales
|
||||||
|
logger.info("📊 Statistiques finales:")
|
||||||
|
logger.info(f" Total d'événements trouvés: {stats['total_events']}")
|
||||||
|
logger.info(f" Nouveaux événements envoyés: {stats['new_events']}")
|
||||||
|
logger.info(f" Événements déjà existants: {stats['already_saved']}")
|
||||||
|
logger.info(f" Erreurs d'API: {stats['api_errors']}")
|
||||||
|
logger.info(f" Erreurs de parsing: {stats['parse_errors']}")
|
||||||
|
logger.info(f" Événements envoyés cette fois: {stats['sent_this_run']}")
|
||||||
|
|
||||||
|
logger.info("✅ Scraping terminé avec succès")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Scraper pour l'agenda du libre")
|
||||||
|
parser.add_argument("--api-url", default=api_oedb,
|
||||||
|
help=f"URL de base de l'API OEDB (défaut: {api_oedb})")
|
||||||
|
parser.add_argument("--batch-size", type=int, default=1,
|
||||||
|
help="Nombre d'événements à envoyer par batch (défaut: 1)")
|
||||||
|
parser.add_argument("--verbose", "-v", action="store_true",
|
||||||
|
help="Mode verbeux")
|
||||||
|
parser.add_argument("--force-refresh", "-f", action="store_true",
|
||||||
|
help="Forcer le rechargement du fichier iCal (ignorer le cache)")
|
||||||
|
parser.add_argument("--cache-duration", type=int, default=1,
|
||||||
|
help="Durée de validité du cache en heures (défaut: 1)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.verbose:
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Créer et exécuter le scraper
|
||||||
|
scraper = AgendaDuLibreScraper(
|
||||||
|
api_base_url=args.api_url,
|
||||||
|
batch_size=args.batch_size
|
||||||
|
)
|
||||||
|
|
||||||
|
# Modifier la durée de cache si spécifiée
|
||||||
|
scraper.cache_duration_hours = args.cache_duration
|
||||||
|
|
||||||
|
# Exécuter avec ou sans rechargement forcé
|
||||||
|
success = scraper.run(force_refresh=args.force_refresh)
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
56963
extractors/agendadulibre_events.ics
Normal file
56963
extractors/agendadulibre_events.ics
Normal file
File diff suppressed because it is too large
Load diff
5004
extractors/agendadulibre_scraper.log
Normal file
5004
extractors/agendadulibre_scraper.log
Normal file
File diff suppressed because it is too large
Load diff
133
extractors/demo_agendadulibre.py
Normal file
133
extractors/demo_agendadulibre.py
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script de démonstration pour le scraper de l'agenda du libre
|
||||||
|
Mode dry-run pour tester sans envoyer de données à l'API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from agendadulibre import AgendaDuLibreScraper, api_oedb
|
||||||
|
import logging
|
||||||
|
|
||||||
|
class DemoAgendaDuLibreScraper(AgendaDuLibreScraper):
|
||||||
|
"""Version démo du scraper qui n'envoie pas de données à l'API"""
|
||||||
|
|
||||||
|
def send_event_to_api(self, event_data):
|
||||||
|
"""Version démo qui simule l'envoi à l'API"""
|
||||||
|
event_label = event_data["event"]["properties"]["label"]
|
||||||
|
print(f"🔍 [DEMO] Simulation d'envoi: {event_label}")
|
||||||
|
|
||||||
|
# Simuler différents types de réponses
|
||||||
|
import random
|
||||||
|
responses = [
|
||||||
|
(True, "Créé avec succès"),
|
||||||
|
(False, "Événement déjà existant"),
|
||||||
|
(True, "Créé avec succès"),
|
||||||
|
(False, "Erreur API: 500 - Internal Server Error")
|
||||||
|
]
|
||||||
|
|
||||||
|
success, message = random.choice(responses)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"✅ [DEMO] {event_label} - {message}")
|
||||||
|
else:
|
||||||
|
print(f"❌ [DEMO] {event_label} - {message}")
|
||||||
|
|
||||||
|
return success, message
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Exécute la démonstration"""
|
||||||
|
print("🎭 Démonstration du scraper agenda du libre (mode dry-run)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Configuration du logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
|
||||||
|
# Créer le scraper en mode démo
|
||||||
|
scraper = DemoAgendaDuLibreScraper(
|
||||||
|
api_base_url=api_oedb, # Utiliser l'URL par défaut
|
||||||
|
batch_size=2 # Traiter 2 événements par batch pour la démo
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"📋 Configuration:")
|
||||||
|
print(f" - URL iCal: {scraper.ical_url}")
|
||||||
|
print(f" - Taille des batches: {scraper.batch_size}")
|
||||||
|
print(f" - Fichier de données: {scraper.data_file}")
|
||||||
|
print(f" - Fichier cache iCal: {scraper.ical_file}")
|
||||||
|
print(f" - Durée de cache: {scraper.cache_duration_hours}h")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Récupérer le fichier iCal
|
||||||
|
print("📥 Récupération du fichier iCal...")
|
||||||
|
calendar = scraper.fetch_ical_data()
|
||||||
|
|
||||||
|
if not calendar:
|
||||||
|
print("❌ Impossible de récupérer le fichier iCal")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Compter les événements
|
||||||
|
event_count = 0
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == "VEVENT":
|
||||||
|
event_count += 1
|
||||||
|
|
||||||
|
print(f"📅 {event_count} événements trouvés dans le fichier iCal")
|
||||||
|
|
||||||
|
# Test du cache
|
||||||
|
print("\n🔄 Test du cache iCal...")
|
||||||
|
print(" Premier appel (téléchargement)...")
|
||||||
|
calendar2 = scraper.fetch_ical_data()
|
||||||
|
print(" Deuxième appel (depuis le cache)...")
|
||||||
|
calendar3 = scraper.fetch_ical_data()
|
||||||
|
print(" ✅ Cache fonctionne correctement")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Traiter seulement les 5 premiers événements pour la démo
|
||||||
|
print("🔄 Traitement des 5 premiers événements (démo)...")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == "VEVENT" and processed < 5:
|
||||||
|
parsed_event = scraper.parse_event(component)
|
||||||
|
if parsed_event:
|
||||||
|
event_label = parsed_event["event"]["properties"]["label"]
|
||||||
|
start_date = parsed_event["event"]["properties"]["start"]
|
||||||
|
location = parsed_event["event"]["properties"]["where"]
|
||||||
|
|
||||||
|
print(f"📝 Événement {processed + 1}:")
|
||||||
|
print(f" Titre: {event_label}")
|
||||||
|
print(f" Date: {start_date}")
|
||||||
|
print(f" Lieu: {location}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Simuler l'envoi
|
||||||
|
success, message = scraper.send_event_to_api(parsed_event)
|
||||||
|
|
||||||
|
# Mettre à jour les données locales (simulation)
|
||||||
|
event_id = parsed_event["id"]
|
||||||
|
scraper.events_data["events"][event_id] = {
|
||||||
|
"status": "saved" if success else "error",
|
||||||
|
"message": message,
|
||||||
|
"last_attempt": "2024-01-01T00:00:00",
|
||||||
|
"event": parsed_event["event"]
|
||||||
|
}
|
||||||
|
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
print("-" * 40)
|
||||||
|
print(f"✅ Démonstration terminée - {processed} événements traités")
|
||||||
|
print()
|
||||||
|
print("💡 Pour exécuter le vrai scraper:")
|
||||||
|
print(" python agendadulibre.py --batch-size 5 --api-url http://localhost:5000")
|
||||||
|
print()
|
||||||
|
print("🧪 Pour exécuter les tests:")
|
||||||
|
print(" python test_agendadulibre.py")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = main()
|
||||||
|
sys.exit(0 if success else 1)
|
136
extractors/extract_coordinates_from_ics.py
Normal file
136
extractors/extract_coordinates_from_ics.py
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script pour extraire les coordonnées géographiques du fichier ICS de l'agenda du libre.
|
||||||
|
Le fichier ICS contient des coordonnées dans les propriétés X-ALT-DESC;FMTTYPE=text/html
|
||||||
|
sous forme d'attributs data-latitude et data-longitude.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
from typing import List, Dict, Tuple
|
||||||
|
|
||||||
|
def extract_coordinates_from_ics(ics_file_path: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Extrait les coordonnées géographiques du fichier ICS.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ics_file_path: Chemin vers le fichier ICS
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste de dictionnaires contenant les coordonnées et informations associées
|
||||||
|
"""
|
||||||
|
coordinates = []
|
||||||
|
|
||||||
|
with open(ics_file_path, 'r', encoding='utf-8') as file:
|
||||||
|
content = file.read()
|
||||||
|
|
||||||
|
# Recherche des patterns data-latitude et data-longitude
|
||||||
|
# Pattern pour capturer les coordonnées dans le HTML
|
||||||
|
pattern = r'data-latitude="([^"]+)"[^>]*data-longitude="([^"]+)"'
|
||||||
|
|
||||||
|
matches = re.findall(pattern, content)
|
||||||
|
|
||||||
|
for i, (lat, lon) in enumerate(matches):
|
||||||
|
try:
|
||||||
|
lat_float = float(lat)
|
||||||
|
lon_float = float(lon)
|
||||||
|
|
||||||
|
# Vérifier que les coordonnées sont valides
|
||||||
|
if -90 <= lat_float <= 90 and -180 <= lon_float <= 180:
|
||||||
|
coordinates.append({
|
||||||
|
'id': i + 1,
|
||||||
|
'latitude': lat_float,
|
||||||
|
'longitude': lon_float,
|
||||||
|
'source': 'agendadulibre_ics'
|
||||||
|
})
|
||||||
|
except ValueError:
|
||||||
|
# Ignorer les coordonnées invalides
|
||||||
|
continue
|
||||||
|
|
||||||
|
return coordinates
|
||||||
|
|
||||||
|
def find_events_with_coordinates(ics_file_path: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Trouve les événements qui ont des coordonnées dans le fichier ICS.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ics_file_path: Chemin vers le fichier ICS
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste des événements avec leurs coordonnées
|
||||||
|
"""
|
||||||
|
events = []
|
||||||
|
|
||||||
|
with open(ics_file_path, 'r', encoding='utf-8') as file:
|
||||||
|
content = file.read()
|
||||||
|
|
||||||
|
# Diviser le contenu en événements individuels
|
||||||
|
event_blocks = content.split('BEGIN:VEVENT')
|
||||||
|
|
||||||
|
for i, block in enumerate(event_blocks[1:], 1): # Ignorer le premier bloc (en-tête)
|
||||||
|
# Rechercher les coordonnées dans ce bloc d'événement
|
||||||
|
lat_match = re.search(r'data-latitude="([^"]+)"', block)
|
||||||
|
lon_match = re.search(r'data-longitude="([^"]+)"', block)
|
||||||
|
|
||||||
|
if lat_match and lon_match:
|
||||||
|
try:
|
||||||
|
lat = float(lat_match.group(1))
|
||||||
|
lon = float(lon_match.group(1))
|
||||||
|
|
||||||
|
# Vérifier que les coordonnées sont valides
|
||||||
|
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
||||||
|
# Extraire d'autres informations de l'événement
|
||||||
|
summary_match = re.search(r'SUMMARY:(.+)', block)
|
||||||
|
location_match = re.search(r'LOCATION:(.+)', block)
|
||||||
|
description_match = re.search(r'DESCRIPTION:(.+)', block)
|
||||||
|
|
||||||
|
event = {
|
||||||
|
'event_id': i,
|
||||||
|
'latitude': lat,
|
||||||
|
'longitude': lon,
|
||||||
|
'summary': summary_match.group(1).strip() if summary_match else '',
|
||||||
|
'location': location_match.group(1).strip() if location_match else '',
|
||||||
|
'description': description_match.group(1).strip() if description_match else '',
|
||||||
|
'source': 'agendadulibre_ics'
|
||||||
|
}
|
||||||
|
|
||||||
|
events.append(event)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Fonction principale pour extraire et afficher les coordonnées."""
|
||||||
|
ics_file = 'agendadulibre_events.ics'
|
||||||
|
|
||||||
|
print("🔍 Extraction des coordonnées du fichier ICS...")
|
||||||
|
|
||||||
|
# Extraire toutes les coordonnées
|
||||||
|
all_coordinates = extract_coordinates_from_ics(ics_file)
|
||||||
|
print(f"📍 {len(all_coordinates)} coordonnées trouvées")
|
||||||
|
|
||||||
|
# Extraire les événements avec coordonnées
|
||||||
|
events_with_coords = find_events_with_coordinates(ics_file)
|
||||||
|
print(f"🎯 {len(events_with_coords)} événements avec coordonnées trouvés")
|
||||||
|
|
||||||
|
# Afficher quelques exemples
|
||||||
|
print("\n📋 Exemples d'événements avec coordonnées :")
|
||||||
|
for event in events_with_coords[:5]:
|
||||||
|
print(f" • {event['summary']}")
|
||||||
|
print(f" 📍 {event['latitude']}, {event['longitude']}")
|
||||||
|
print(f" 📍 Lieu: {event['location']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Sauvegarder les résultats
|
||||||
|
with open('extracted_coordinates.json', 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(events_with_coords, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
print(f"💾 Résultats sauvegardés dans 'extracted_coordinates.json'")
|
||||||
|
|
||||||
|
# Statistiques
|
||||||
|
unique_coords = set((event['latitude'], event['longitude']) for event in events_with_coords)
|
||||||
|
print(f"📊 {len(unique_coords)} coordonnées uniques")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
211
extractors/monitor_agendadulibre.py
Executable file
211
extractors/monitor_agendadulibre.py
Executable file
|
@ -0,0 +1,211 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script de monitoring pour le scraper agenda du libre
|
||||||
|
Affiche les statistiques et l'état du scraper
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
class AgendaDuLibreMonitor:
|
||||||
|
def __init__(self, data_file: str = "agendadulibre_events.json"):
|
||||||
|
self.data_file = data_file
|
||||||
|
self.events_data = self.load_events_data()
|
||||||
|
|
||||||
|
def load_events_data(self) -> Dict[str, Any]:
|
||||||
|
"""Charge les données d'événements"""
|
||||||
|
if os.path.exists(self.data_file):
|
||||||
|
try:
|
||||||
|
with open(self.data_file, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Erreur lors du chargement de {self.data_file}: {e}")
|
||||||
|
return {"events": {}, "last_update": None}
|
||||||
|
return {"events": {}, "last_update": None}
|
||||||
|
|
||||||
|
def get_statistics(self) -> Dict[str, Any]:
|
||||||
|
"""Calcule les statistiques des événements"""
|
||||||
|
events = self.events_data.get("events", {})
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total_events": len(events),
|
||||||
|
"saved": 0,
|
||||||
|
"already_exists": 0,
|
||||||
|
"error": 0,
|
||||||
|
"unknown": 0,
|
||||||
|
"recent_errors": 0,
|
||||||
|
"last_update": self.events_data.get("last_update"),
|
||||||
|
"events_by_status": {},
|
||||||
|
"recent_events": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Analyser les statuts
|
||||||
|
for event_id, event_data in events.items():
|
||||||
|
status = event_data.get("status", "unknown")
|
||||||
|
stats["events_by_status"][status] = stats["events_by_status"].get(status, 0) + 1
|
||||||
|
|
||||||
|
if status == "saved":
|
||||||
|
stats["saved"] += 1
|
||||||
|
elif status == "already_exists":
|
||||||
|
stats["already_exists"] += 1
|
||||||
|
elif status == "error":
|
||||||
|
stats["error"] += 1
|
||||||
|
else:
|
||||||
|
stats["unknown"] += 1
|
||||||
|
|
||||||
|
# Vérifier les erreurs récentes (dernières 24h)
|
||||||
|
last_attempt = event_data.get("last_attempt")
|
||||||
|
if last_attempt and status == "error":
|
||||||
|
try:
|
||||||
|
attempt_time = datetime.fromisoformat(last_attempt.replace('Z', '+00:00'))
|
||||||
|
if datetime.now() - attempt_time.replace(tzinfo=None) < timedelta(hours=24):
|
||||||
|
stats["recent_errors"] += 1
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Collecter les événements récents (derniers 10)
|
||||||
|
if len(stats["recent_events"]) < 10:
|
||||||
|
event_info = {
|
||||||
|
"id": event_id,
|
||||||
|
"label": event_data.get("event", {}).get("properties", {}).get("label", "Sans titre"),
|
||||||
|
"status": status,
|
||||||
|
"last_attempt": last_attempt,
|
||||||
|
"message": event_data.get("message", "")
|
||||||
|
}
|
||||||
|
stats["recent_events"].append(event_info)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def display_statistics(self):
|
||||||
|
"""Affiche les statistiques de manière formatée"""
|
||||||
|
stats = self.get_statistics()
|
||||||
|
|
||||||
|
print("📊 Statistiques du scraper agenda du libre")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Informations générales
|
||||||
|
print(f"📁 Fichier de données: {self.data_file}")
|
||||||
|
print(f"📅 Dernière mise à jour: {stats['last_update'] or 'Jamais'}")
|
||||||
|
print(f"📈 Total d'événements traités: {stats['total_events']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Répartition par statut
|
||||||
|
print("📋 Répartition par statut:")
|
||||||
|
for status, count in stats["events_by_status"].items():
|
||||||
|
emoji = {
|
||||||
|
"saved": "✅",
|
||||||
|
"already_exists": "⚠️",
|
||||||
|
"error": "❌",
|
||||||
|
"unknown": "❓"
|
||||||
|
}.get(status, "❓")
|
||||||
|
print(f" {emoji} {status}: {count}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Erreurs récentes
|
||||||
|
if stats["recent_errors"] > 0:
|
||||||
|
print(f"🚨 Erreurs récentes (24h): {stats['recent_errors']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Événements récents
|
||||||
|
if stats["recent_events"]:
|
||||||
|
print("🕒 Événements récents:")
|
||||||
|
for event in stats["recent_events"][:5]:
|
||||||
|
emoji = {
|
||||||
|
"saved": "✅",
|
||||||
|
"already_exists": "⚠️",
|
||||||
|
"error": "❌",
|
||||||
|
"unknown": "❓"
|
||||||
|
}.get(event["status"], "❓")
|
||||||
|
|
||||||
|
print(f" {emoji} {event['label'][:50]}{'...' if len(event['label']) > 50 else ''}")
|
||||||
|
if event["status"] == "error":
|
||||||
|
print(f" 💬 {event['message']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Recommandations
|
||||||
|
self.display_recommendations(stats)
|
||||||
|
|
||||||
|
def display_recommendations(self, stats: Dict[str, Any]):
|
||||||
|
"""Affiche des recommandations basées sur les statistiques"""
|
||||||
|
print("💡 Recommandations:")
|
||||||
|
|
||||||
|
if stats["total_events"] == 0:
|
||||||
|
print(" - Aucun événement traité. Exécutez le scraper pour commencer.")
|
||||||
|
elif stats["error"] > stats["saved"]:
|
||||||
|
print(" - Beaucoup d'erreurs détectées. Vérifiez la connectivité API.")
|
||||||
|
elif stats["recent_errors"] > 5:
|
||||||
|
print(" - Erreurs récentes nombreuses. Vérifiez les logs.")
|
||||||
|
elif stats["saved"] > 0:
|
||||||
|
print(" - Scraper fonctionne correctement.")
|
||||||
|
|
||||||
|
if stats["already_exists"] > stats["saved"]:
|
||||||
|
print(" - Beaucoup d'événements déjà existants. Le système de déduplication fonctionne.")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
def check_file_status(self):
|
||||||
|
"""Vérifie l'état du fichier de données"""
|
||||||
|
if not os.path.exists(self.data_file):
|
||||||
|
print(f"❌ Fichier de données non trouvé: {self.data_file}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
stat = os.stat(self.data_file)
|
||||||
|
size = stat.st_size
|
||||||
|
mtime = datetime.fromtimestamp(stat.st_mtime)
|
||||||
|
|
||||||
|
print(f"📁 État du fichier de données:")
|
||||||
|
print(f" - Taille: {size:,} octets")
|
||||||
|
print(f" - Dernière modification: {mtime}")
|
||||||
|
print(f" - Lisible: {'✅' if os.access(self.data_file, os.R_OK) else '❌'}")
|
||||||
|
print(f" - Écriture: {'✅' if os.access(self.data_file, os.W_OK) else '❌'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Erreur lors de la vérification du fichier: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def show_help(self):
|
||||||
|
"""Affiche l'aide"""
|
||||||
|
print("🔍 Monitor agenda du libre - Aide")
|
||||||
|
print("=" * 40)
|
||||||
|
print("Usage: python3 monitor_agendadulibre.py [options]")
|
||||||
|
print()
|
||||||
|
print("Options:")
|
||||||
|
print(" --stats, -s Afficher les statistiques (défaut)")
|
||||||
|
print(" --file, -f Vérifier l'état du fichier")
|
||||||
|
print(" --help, -h Afficher cette aide")
|
||||||
|
print()
|
||||||
|
print("Exemples:")
|
||||||
|
print(" python3 monitor_agendadulibre.py")
|
||||||
|
print(" python3 monitor_agendadulibre.py --file")
|
||||||
|
print(" python3 monitor_agendadulibre.py --stats")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Fonction principale"""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Monitor pour le scraper agenda du libre")
|
||||||
|
parser.add_argument("--stats", "-s", action="store_true", default=True,
|
||||||
|
help="Afficher les statistiques")
|
||||||
|
parser.add_argument("--file", "-f", action="store_true",
|
||||||
|
help="Vérifier l'état du fichier de données")
|
||||||
|
parser.add_argument("--data-file", default="agendadulibre_events.json",
|
||||||
|
help="Fichier de données à analyser")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
monitor = AgendaDuLibreMonitor(args.data_file)
|
||||||
|
|
||||||
|
if args.file:
|
||||||
|
monitor.check_file_status()
|
||||||
|
|
||||||
|
if args.stats:
|
||||||
|
monitor.display_statistics()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
2
extractors/requirements_agendadulibre.txt
Normal file
2
extractors/requirements_agendadulibre.txt
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
requests>=2.28.0
|
||||||
|
icalendar>=5.0.0
|
98
extractors/setup_cron.sh
Executable file
98
extractors/setup_cron.sh
Executable file
|
@ -0,0 +1,98 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Script de configuration du cron pour le scraper agenda du libre
|
||||||
|
|
||||||
|
SCRIPT_DIR="/home/poule/encrypted/stockage-syncable/www/development/html/oedb-backend/extractors"
|
||||||
|
SCRIPT_PATH="$SCRIPT_DIR/agendadulibre.py"
|
||||||
|
LOG_FILE="$SCRIPT_DIR/cron_agendadulibre.log"
|
||||||
|
|
||||||
|
echo "🔧 Configuration du cron pour le scraper agenda du libre"
|
||||||
|
echo "========================================================"
|
||||||
|
|
||||||
|
# Vérifier que le script existe
|
||||||
|
if [ ! -f "$SCRIPT_PATH" ]; then
|
||||||
|
echo "❌ Erreur: Le script $SCRIPT_PATH n'existe pas"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rendre le script exécutable
|
||||||
|
chmod +x "$SCRIPT_PATH"
|
||||||
|
|
||||||
|
echo "📋 Options de planification disponibles:"
|
||||||
|
echo "1. Toutes les heures (batch de 1)"
|
||||||
|
echo "2. Toutes les 2 heures (batch de 5)"
|
||||||
|
echo "3. Tous les jours à 6h (batch de 10)"
|
||||||
|
echo "4. Tous les jours à 6h et 18h (batch de 5)"
|
||||||
|
echo "5. Personnalisé"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Choisissez une option (1-5): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
CRON_SCHEDULE="0 * * * *"
|
||||||
|
BATCH_SIZE="1"
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
CRON_SCHEDULE="0 */2 * * *"
|
||||||
|
BATCH_SIZE="5"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
CRON_SCHEDULE="0 6 * * *"
|
||||||
|
BATCH_SIZE="10"
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
CRON_SCHEDULE="0 6,18 * * *"
|
||||||
|
BATCH_SIZE="5"
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
read -p "Entrez la planification cron (ex: 0 */3 * * *): " CRON_SCHEDULE
|
||||||
|
read -p "Entrez la taille des batches (ex: 5): " BATCH_SIZE
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ Option invalide"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Demander l'URL de l'API
|
||||||
|
read -p "Entrez l'URL de l'API OEDB (défaut: http://localhost:5000): " API_URL
|
||||||
|
API_URL=${API_URL:-"http://localhost:5000"}
|
||||||
|
|
||||||
|
# Créer la commande cron
|
||||||
|
CRON_COMMAND="$CRON_SCHEDULE cd $SCRIPT_DIR && python3 $SCRIPT_PATH --api-url $API_URL --batch-size $BATCH_SIZE >> $LOG_FILE 2>&1"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📝 Configuration cron proposée:"
|
||||||
|
echo "Planification: $CRON_SCHEDULE"
|
||||||
|
echo "Commande: $CRON_COMMAND"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Voulez-vous ajouter cette tâche au cron ? (y/N): " confirm
|
||||||
|
|
||||||
|
if [[ $confirm =~ ^[Yy]$ ]]; then
|
||||||
|
# Ajouter la tâche au cron
|
||||||
|
(crontab -l 2>/dev/null; echo "$CRON_COMMAND") | crontab -
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Tâche cron ajoutée avec succès"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Tâches cron actuelles:"
|
||||||
|
crontab -l | grep agendadulibre || echo "Aucune tâche trouvée"
|
||||||
|
echo ""
|
||||||
|
echo "📁 Logs disponibles dans: $LOG_FILE"
|
||||||
|
echo "🔍 Pour surveiller les logs: tail -f $LOG_FILE"
|
||||||
|
else
|
||||||
|
echo "❌ Erreur lors de l'ajout de la tâche cron"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ Configuration annulée"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "💡 Commandes utiles:"
|
||||||
|
echo " - Voir les tâches cron: crontab -l"
|
||||||
|
echo " - Supprimer une tâche: crontab -e"
|
||||||
|
echo " - Voir les logs: tail -f $LOG_FILE"
|
||||||
|
echo " - Tester manuellement: python3 $SCRIPT_PATH --api-url $API_URL --batch-size $BATCH_SIZE"
|
139
extractors/test_agendadulibre.py
Normal file
139
extractors/test_agendadulibre.py
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script de test pour le scraper de l'agenda du libre
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from agendadulibre import AgendaDuLibreScraper, api_oedb
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Configuration du logging pour les tests
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
|
||||||
|
def test_ical_fetch():
|
||||||
|
"""Test de récupération du fichier iCal"""
|
||||||
|
print("🧪 Test de récupération du fichier iCal...")
|
||||||
|
|
||||||
|
scraper = AgendaDuLibreScraper()
|
||||||
|
calendar = scraper.fetch_ical_data()
|
||||||
|
|
||||||
|
if calendar:
|
||||||
|
print("✅ Fichier iCal récupéré avec succès")
|
||||||
|
|
||||||
|
# Compter les événements
|
||||||
|
event_count = 0
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == "VEVENT":
|
||||||
|
event_count += 1
|
||||||
|
|
||||||
|
print(f"📅 Nombre d'événements trouvés: {event_count}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("❌ Échec de la récupération du fichier iCal")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_event_parsing():
|
||||||
|
"""Test de parsing d'un événement"""
|
||||||
|
print("🧪 Test de parsing d'événement...")
|
||||||
|
|
||||||
|
scraper = AgendaDuLibreScraper()
|
||||||
|
calendar = scraper.fetch_ical_data()
|
||||||
|
|
||||||
|
if not calendar:
|
||||||
|
print("❌ Impossible de récupérer le fichier iCal pour le test")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Trouver le premier événement
|
||||||
|
for component in calendar.walk():
|
||||||
|
if component.name == "VEVENT":
|
||||||
|
parsed_event = scraper.parse_event(component)
|
||||||
|
if parsed_event:
|
||||||
|
print("✅ Événement parsé avec succès:")
|
||||||
|
print(f" ID: {parsed_event['id']}")
|
||||||
|
print(f" Titre: {parsed_event['event']['properties']['label']}")
|
||||||
|
print(f" Début: {parsed_event['event']['properties']['start']}")
|
||||||
|
print(f" Fin: {parsed_event['event']['properties']['stop']}")
|
||||||
|
print(f" Lieu: {parsed_event['event']['properties']['where']}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("❌ Échec du parsing de l'événement")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("❌ Aucun événement trouvé pour le test")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_data_persistence():
|
||||||
|
"""Test de persistance des données"""
|
||||||
|
print("🧪 Test de persistance des données...")
|
||||||
|
|
||||||
|
scraper = AgendaDuLibreScraper()
|
||||||
|
|
||||||
|
# Test de sauvegarde
|
||||||
|
test_data = {
|
||||||
|
"events": {
|
||||||
|
"test_event_123": {
|
||||||
|
"status": "saved",
|
||||||
|
"message": "Test event",
|
||||||
|
"last_attempt": "2024-01-01T00:00:00",
|
||||||
|
"event": {
|
||||||
|
"properties": {
|
||||||
|
"label": "Test Event",
|
||||||
|
"what": "culture.geek"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"last_update": "2024-01-01T00:00:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
scraper.events_data = test_data
|
||||||
|
scraper.save_events_data()
|
||||||
|
|
||||||
|
# Test de chargement
|
||||||
|
scraper2 = AgendaDuLibreScraper()
|
||||||
|
if "test_event_123" in scraper2.events_data["events"]:
|
||||||
|
print("✅ Persistance des données fonctionne correctement")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("❌ Échec de la persistance des données")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Exécute tous les tests"""
|
||||||
|
print("🚀 Démarrage des tests du scraper agenda du libre")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
tests = [
|
||||||
|
test_ical_fetch,
|
||||||
|
test_event_parsing,
|
||||||
|
test_data_persistence
|
||||||
|
]
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
total = len(tests)
|
||||||
|
|
||||||
|
for test in tests:
|
||||||
|
try:
|
||||||
|
if test():
|
||||||
|
passed += 1
|
||||||
|
print()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Erreur lors du test {test.__name__}: {e}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"📊 Résultats: {passed}/{total} tests réussis")
|
||||||
|
|
||||||
|
if passed == total:
|
||||||
|
print("✅ Tous les tests sont passés!")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("❌ Certains tests ont échoué")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = main()
|
||||||
|
sys.exit(0 if success else 1)
|
130
extractors/test_api_connection.py
Executable file
130
extractors/test_api_connection.py
Executable file
|
@ -0,0 +1,130 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script de test de connexion à l'API OEDB pour le scraper agenda du libre
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Configuration par défaut
|
||||||
|
api_oedb = "https://api.openeventdatabase.org"
|
||||||
|
|
||||||
|
def test_api_connection(api_url: str = api_oedb):
|
||||||
|
"""Test la connexion à l'API OEDB"""
|
||||||
|
print(f"🔍 Test de connexion à l'API OEDB: {api_url}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Test 1: Endpoint de base
|
||||||
|
print("1️⃣ Test de l'endpoint de base...")
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{api_url}/", timeout=10)
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("✅ Endpoint de base accessible")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Endpoint de base répond avec le code: {response.status_code}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ Erreur de connexion à l'endpoint de base: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Test 2: Endpoint des événements
|
||||||
|
print("\n2️⃣ Test de l'endpoint des événements...")
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{api_url}/events", timeout=10)
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("✅ Endpoint des événements accessible")
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
if 'features' in data:
|
||||||
|
print(f" 📊 {len(data['features'])} événements trouvés dans l'API")
|
||||||
|
else:
|
||||||
|
print(" ⚠️ Format de réponse inattendu")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(" ⚠️ Réponse non-JSON reçue")
|
||||||
|
else:
|
||||||
|
print(f"❌ Endpoint des événements répond avec le code: {response.status_code}")
|
||||||
|
return False
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ Erreur de connexion à l'endpoint des événements: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Test 3: Test d'envoi d'un événement de test
|
||||||
|
print("\n3️⃣ Test d'envoi d'un événement de test...")
|
||||||
|
test_event = {
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": {
|
||||||
|
"label": f"Test API Connection - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||||
|
"description": "Événement de test pour vérifier la connexion API",
|
||||||
|
"type": "scheduled",
|
||||||
|
"what": "community",
|
||||||
|
"where": "Test Location",
|
||||||
|
"start": datetime.now().isoformat(),
|
||||||
|
"stop": (datetime.now().timestamp() + 3600).isoformat(),
|
||||||
|
"source:name": "Test API Connection",
|
||||||
|
"last_modified_by": "test_script"
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [0, 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{api_url}/event",
|
||||||
|
json=test_event,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 201:
|
||||||
|
print("✅ Événement de test créé avec succès")
|
||||||
|
try:
|
||||||
|
created_event = response.json()
|
||||||
|
event_id = created_event.get('id', 'inconnu')
|
||||||
|
print(f" 📝 ID de l'événement créé: {event_id}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(" ⚠️ Réponse de création non-JSON")
|
||||||
|
elif response.status_code == 409:
|
||||||
|
print("⚠️ Événement de test déjà existant (conflit)")
|
||||||
|
else:
|
||||||
|
print(f"❌ Erreur lors de la création de l'événement de test: {response.status_code}")
|
||||||
|
print(f" Réponse: {response.text}")
|
||||||
|
return False
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ Erreur lors de l'envoi de l'événement de test: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("\n✅ Tous les tests de connexion sont passés!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Fonction principale"""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Test de connexion à l'API OEDB")
|
||||||
|
parser.add_argument("--api-url", default=api_oedb,
|
||||||
|
help="URL de l'API OEDB à tester")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
success = test_api_connection(args.api_url)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("\n🎉 L'API OEDB est prête pour le scraper agenda du libre!")
|
||||||
|
print("\n💡 Commandes utiles:")
|
||||||
|
print(" - Test complet: python3 test_agendadulibre.py")
|
||||||
|
print(" - Démonstration: python3 demo_agendadulibre.py")
|
||||||
|
print(" - Scraping réel: python3 agendadulibre.py --api-url " + args.api_url)
|
||||||
|
else:
|
||||||
|
print("\n❌ L'API OEDB n'est pas accessible ou ne fonctionne pas correctement.")
|
||||||
|
print("\n🔧 Vérifications à effectuer:")
|
||||||
|
print(" - L'API OEDB est-elle démarrée?")
|
||||||
|
print(" - L'URL est-elle correcte?")
|
||||||
|
print(" - Y a-t-il des erreurs dans les logs de l'API?")
|
||||||
|
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
41
frontend/OSM_OAUTH_SETUP.md
Normal file
41
frontend/OSM_OAUTH_SETUP.md
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
# Configuration OAuth2 OpenStreetMap
|
||||||
|
|
||||||
|
## Variables d'environnement requises
|
||||||
|
|
||||||
|
Pour utiliser l'authentification OSM, vous devez configurer les variables suivantes :
|
||||||
|
|
||||||
|
### Frontend (environments/environment.ts)
|
||||||
|
```typescript
|
||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
osmClientId: 'your_osm_client_id_here',
|
||||||
|
osmClientSecret: 'your_osm_client_secret_here', // Ne pas utiliser côté client
|
||||||
|
apiBaseUrl: 'http://localhost:5000'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (.env)
|
||||||
|
```bash
|
||||||
|
OSM_CLIENT_ID=your_osm_client_id_here
|
||||||
|
OSM_CLIENT_SECRET=your_osm_client_secret_here
|
||||||
|
API_BASE_URL=http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration OSM
|
||||||
|
|
||||||
|
1. Allez sur https://www.openstreetmap.org/user/your_username/oauth_clients
|
||||||
|
2. Créez une nouvelle application OAuth
|
||||||
|
3. Configurez l'URL de redirection : `http://localhost:4200/oauth/callback`
|
||||||
|
4. Copiez le Client ID et Client Secret
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- Connexion/déconnexion OSM
|
||||||
|
- Persistance des données utilisateur en localStorage
|
||||||
|
- Ajout automatique du pseudo OSM dans `last_modified_by` pour les nouveaux événements
|
||||||
|
- Interface utilisateur pour gérer l'authentification
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
⚠️ **Important** : Le Client Secret ne doit jamais être exposé côté client.
|
||||||
|
L'échange du code d'autorisation contre un token d'accès doit se faire côté serveur.
|
BIN
frontend/public/static/oedb.png
Normal file
BIN
frontend/public/static/oedb.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 29 KiB |
|
@ -15,8 +15,22 @@
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>OEDB</h1>
|
|
||||||
<button class="login">login OSM</button>
|
|
||||||
|
<nav>
|
||||||
|
<a [routerLink]="['/']" routerLinkActive="active"><h1>
|
||||||
|
<img src="/static/oedb.png" alt="OEDB" style="width: 20px; height: 20px;">
|
||||||
|
OEDB
|
||||||
|
</h1>
|
||||||
|
</a>
|
||||||
|
<!-- <button class="login">login OSM</button> -->
|
||||||
|
|
||||||
|
<a routerLink="/agenda" routerLinkActive="active">agenda</a>
|
||||||
|
<a routerLink="/unlocated-events" routerLinkActive="active">événements non localisés</a>
|
||||||
|
<a routerLink="/nouvelles-categories" routerLinkActive="active">nouvelles catégories</a>
|
||||||
|
<a href="/demo/stats" routerLinkActive="active">stats</a>
|
||||||
|
<a href="https://source.cipherbliss.com/tykayn/oedb-backend" routerLinkActive="active">sources</a>
|
||||||
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<router-outlet/>
|
<router-outlet/>
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
|
||||||
import {Home} from './pages/home/home';
|
import {Home} from './pages/home/home';
|
||||||
import { Agenda } from './pages/agenda/agenda';
|
import { Agenda } from './pages/agenda/agenda';
|
||||||
import { NouvellesCategories } from './pages/nouvelles-categories/nouvelles-categories';
|
import { NouvellesCategories } from './pages/nouvelles-categories/nouvelles-categories';
|
||||||
|
import { UnlocatedEventsPage } from './pages/unlocated-events/unlocated-events';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
|
@ -15,5 +16,9 @@ export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path : 'nouvelles-categories',
|
path : 'nouvelles-categories',
|
||||||
component: NouvellesCategories
|
component: NouvellesCategories
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path : 'unlocated-events',
|
||||||
|
component: UnlocatedEventsPage
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
:host{
|
||||||
|
|
||||||
|
a{
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #000;
|
||||||
|
&.active{
|
||||||
|
background-color: #d4ebff;
|
||||||
|
border-color: #007bff;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { Component, signal } from '@angular/core';
|
import { Component, signal } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet, RouterLink } from '@angular/router';
|
||||||
import { CalendarPreviousViewDirective, CalendarTodayDirective, CalendarNextViewDirective, CalendarMonthViewComponent, CalendarWeekViewComponent, CalendarDayViewComponent, CalendarDatePipe, DateAdapter, provideCalendar } from 'angular-calendar';
|
import { CalendarPreviousViewDirective, CalendarTodayDirective, CalendarNextViewDirective, CalendarMonthViewComponent, CalendarWeekViewComponent, CalendarDayViewComponent, CalendarDatePipe, DateAdapter, provideCalendar } from 'angular-calendar';
|
||||||
import { adapterFactory } from 'angular-calendar/date-adapters/moment';
|
import { adapterFactory } from 'angular-calendar/date-adapters/moment';
|
||||||
import * as moment from 'moment';
|
import * as moment from 'moment';
|
||||||
|
@ -10,7 +10,7 @@ export function momentAdapterFactory() {
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, CalendarPreviousViewDirective, CalendarTodayDirective, CalendarNextViewDirective, CalendarMonthViewComponent, CalendarWeekViewComponent, CalendarDayViewComponent, CalendarDatePipe],
|
imports: [RouterOutlet, RouterLink, CalendarPreviousViewDirective, CalendarTodayDirective, CalendarNextViewDirective, CalendarMonthViewComponent, CalendarWeekViewComponent, CalendarDayViewComponent, CalendarDatePipe],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
providers: [
|
providers: [
|
||||||
|
|
|
@ -1,17 +1,35 @@
|
||||||
<p>
|
<div class="osm-auth">
|
||||||
osm works!
|
@if (isAuthenticated) {
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-avatar">
|
||||||
@if(isLogginIn){
|
@if (currentUser?.img?.href) {
|
||||||
<div class="pseudo">
|
<img [src]="currentUser?.img?.href" [alt]="currentUser?.display_name || 'Utilisateur OSM'" class="avatar">
|
||||||
{{osmPseudo}}
|
} @else {
|
||||||
</div>
|
<div class="avatar-placeholder">👤</div>
|
||||||
<button (click)="logout()">logout</button>
|
}
|
||||||
}
|
</div>
|
||||||
@else{
|
<div class="user-details">
|
||||||
<div class="pseudo">
|
<div class="username">{{getUsername()}}</div>
|
||||||
pas connecté
|
<div class="user-stats">
|
||||||
</div>
|
<span class="stat">{{currentUser?.changesets?.count || 0}} changesets</span>
|
||||||
<button (click)="login()">osm login</button>
|
</div>
|
||||||
}
|
</div>
|
||||||
</p>
|
<button class="btn btn-sm btn-outline" (click)="logout()">
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="login-prompt">
|
||||||
|
<div class="login-text">
|
||||||
|
<p>Connectez-vous à votre compte OpenStreetMap pour :</p>
|
||||||
|
<ul>
|
||||||
|
<li>Ajouter automatiquement votre pseudo aux événements créés</li>
|
||||||
|
<li>Bénéficier de fonctionnalités avancées</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" (click)="login()">
|
||||||
|
🗺️ Se connecter à OSM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
|
@ -0,0 +1,114 @@
|
||||||
|
.osm-auth {
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stats {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
background: #e9ecef;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-prompt {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.login-text {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #555;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: #6c757d;
|
||||||
|
border: 1px solid #6c757d;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,20 +1,44 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { OsmAuth, OsmUser } from '../../services/osm-auth';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-osm',
|
selector: 'app-osm',
|
||||||
imports: [],
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
templateUrl: './osm.html',
|
templateUrl: './osm.html',
|
||||||
styleUrl: './osm.scss'
|
styleUrl: './osm.scss'
|
||||||
})
|
})
|
||||||
export class Osm {
|
export class Osm implements OnInit, OnDestroy {
|
||||||
osmPseudo: string='';
|
private osmAuth = inject(OsmAuth);
|
||||||
isLogginIn: any = false;
|
private subscription?: Subscription;
|
||||||
|
|
||||||
|
currentUser: OsmUser | null = null;
|
||||||
|
isAuthenticated = false;
|
||||||
|
|
||||||
logout() {
|
ngOnInit() {
|
||||||
|
this.subscription = this.osmAuth.currentUser$.subscribe(user => {
|
||||||
|
this.currentUser = user;
|
||||||
|
this.isAuthenticated = !!user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
if (this.subscription) {
|
||||||
|
this.subscription.unsubscribe();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
login() {
|
login() {
|
||||||
|
this.osmAuth.initiateOAuthLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.osmAuth.logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
getUsername(): string {
|
||||||
|
return this.osmAuth.getUsername() || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -175,7 +175,7 @@ export class AllEvents implements OnInit, OnDestroy {
|
||||||
} else if (coords.length === 4) {
|
} else if (coords.length === 4) {
|
||||||
const maplibregl = (window as any).maplibregl;
|
const maplibregl = (window as any).maplibregl;
|
||||||
const bounds = new maplibregl.LngLatBounds([coords[0], coords[1]], [coords[2], coords[3]]);
|
const bounds = new maplibregl.LngLatBounds([coords[0], coords[1]], [coords[2], coords[3]]);
|
||||||
this.map.fitBounds(bounds, { padding: 40 });
|
// this.map.fitBounds(bounds, { padding: 40 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
@ -237,18 +237,26 @@ export class AllEvents implements OnInit, OnDestroy {
|
||||||
el.style.boxShadow = '0 0 0 4px rgba(25,118,210,0.25)';
|
el.style.boxShadow = '0 0 0 4px rgba(25,118,210,0.25)';
|
||||||
el.style.borderRadius = '50%';
|
el.style.borderRadius = '50%';
|
||||||
}
|
}
|
||||||
el.addEventListener('click', () => {
|
const popupHtml = this.buildPopupHtml(p, (p && (p.id ?? p.uuid)) ?? f?.id);
|
||||||
|
const marker = new maplibregl.Marker({ element: el })
|
||||||
|
.setLngLat(coords)
|
||||||
|
.setPopup(new maplibregl.Popup({
|
||||||
|
offset: 12,
|
||||||
|
closeOnClick: false, // Empêcher la fermeture au clic sur la carte
|
||||||
|
closeButton: true
|
||||||
|
}).setHTML(popupHtml))
|
||||||
|
.addTo(this.map);
|
||||||
|
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation(); // Empêcher la propagation du clic vers la carte
|
||||||
|
// Ouvrir la popup du marqueur
|
||||||
|
marker.togglePopup();
|
||||||
this.select.emit({
|
this.select.emit({
|
||||||
id: fid,
|
id: fid,
|
||||||
properties: p,
|
properties: p,
|
||||||
geometry: { type: 'Point', coordinates: coords }
|
geometry: { type: 'Point', coordinates: coords }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const popupHtml = this.buildPopupHtml(p, (p && (p.id ?? p.uuid)) ?? f?.id);
|
|
||||||
const marker = new maplibregl.Marker({ element: el })
|
|
||||||
.setLngLat(coords)
|
|
||||||
.setPopup(new maplibregl.Popup({ offset: 12 }).setHTML(popupHtml))
|
|
||||||
.addTo(this.map);
|
|
||||||
|
|
||||||
const popup = marker.getPopup && marker.getPopup();
|
const popup = marker.getPopup && marker.getPopup();
|
||||||
if (popup && popup.on) {
|
if (popup && popup.on) {
|
||||||
|
@ -272,17 +280,15 @@ export class AllEvents implements OnInit, OnDestroy {
|
||||||
bounds.extend(coords);
|
bounds.extend(coords);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ne pas faire de fitBounds lors du chargement initial si on a des paramètres URL
|
// Ne faire fitBounds que lors du chargement initial et seulement si pas de paramètres URL
|
||||||
if (!bounds.isEmpty() && this.isInitialLoad) {
|
if (!bounds.isEmpty() && this.isInitialLoad) {
|
||||||
const hasUrlParams = this.route.snapshot.queryParams['lat'] || this.route.snapshot.queryParams['lon'] || this.route.snapshot.queryParams['zoom'];
|
const hasUrlParams = this.route.snapshot.queryParams['lat'] || this.route.snapshot.queryParams['lon'] || this.route.snapshot.queryParams['zoom'];
|
||||||
if (!hasUrlParams) {
|
if (!hasUrlParams) {
|
||||||
this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
|
// this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
|
||||||
}
|
}
|
||||||
this.isInitialLoad = false;
|
this.isInitialLoad = false;
|
||||||
} else if (!bounds.isEmpty() && !this.isInitialLoad) {
|
|
||||||
// Pour les mises à jour suivantes, on peut faire un fitBounds léger
|
|
||||||
this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
|
|
||||||
}
|
}
|
||||||
|
// Supprimer le fitBounds automatique lors des mises à jour pour éviter le dézoom
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildMarkerElement(props: any): HTMLDivElement {
|
private buildMarkerElement(props: any): HTMLDivElement {
|
||||||
|
@ -365,15 +371,54 @@ export class AllEvents implements OnInit, OnDestroy {
|
||||||
private buildPopupHtml(props: any, id?: any): string {
|
private buildPopupHtml(props: any, id?: any): string {
|
||||||
const title = this.escapeHtml(String(props?.name || props?.label || props?.what || 'évènement'));
|
const title = this.escapeHtml(String(props?.name || props?.label || props?.what || 'évènement'));
|
||||||
const titleId = typeof id !== 'undefined' ? String(id) : '';
|
const titleId = typeof id !== 'undefined' ? String(id) : '';
|
||||||
const rows = Object.keys(props || {}).sort().map(k => {
|
|
||||||
|
// Informations principales à afficher en priorité
|
||||||
|
const mainInfo = [];
|
||||||
|
if (props?.what) mainInfo.push({ key: 'Type', value: props.what });
|
||||||
|
if (props?.where) mainInfo.push({ key: 'Lieu', value: props.where });
|
||||||
|
if (props?.start) mainInfo.push({ key: 'Début', value: this.formatDate(props.start) });
|
||||||
|
if (props?.stop) mainInfo.push({ key: 'Fin', value: this.formatDate(props.stop) });
|
||||||
|
if (props?.url) mainInfo.push({ key: 'Lien', value: `<a href="${this.escapeHtml(props.url)}" target="_blank">Voir l'événement</a>` });
|
||||||
|
|
||||||
|
const mainRows = mainInfo.map(info =>
|
||||||
|
`<tr><td style="font-weight:bold;vertical-align:top;padding:4px 8px;color:#666;">${this.escapeHtml(info.key)}</td><td style="padding:4px 8px;">${info.value}</td></tr>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Autres propriétés
|
||||||
|
const otherProps = Object.keys(props || {})
|
||||||
|
.filter(k => !['name', 'label', 'what', 'where', 'start', 'stop', 'url', 'id', 'uuid'].includes(k))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const otherRows = otherProps.map(k => {
|
||||||
const v = props[k];
|
const v = props[k];
|
||||||
const value = typeof v === 'object' ? `<pre>${this.escapeHtml(JSON.stringify(v, null, 2))}</pre>` : this.escapeHtml(String(v));
|
const value = typeof v === 'object' ? `<pre style="font-size:11px;margin:0;">${this.escapeHtml(JSON.stringify(v, null, 2))}</pre>` : this.escapeHtml(String(v));
|
||||||
return `<tr><td style="font-weight:bold;vertical-align:top;padding:2px 6px;">${this.escapeHtml(k)}</td><td style="padding:2px 6px;">${value}</td></tr>`;
|
return `<tr><td style="font-weight:bold;vertical-align:top;padding:2px 8px;color:#999;font-size:12px;">${this.escapeHtml(k)}</td><td style="padding:2px 8px;font-size:12px;">${value}</td></tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
const clickable = `<div style="font-weight:700;margin:0 0 6px 0;">
|
|
||||||
<a href="#" data-feature-id="${this.escapeHtml(titleId)}" style="text-decoration:none;color:#1976d2;">${title}</a>
|
const clickable = `<div style="font-weight:700;margin:0 0 8px 0;font-size:16px;color:#1976d2;">
|
||||||
|
<a href="#" data-feature-id="${this.escapeHtml(titleId)}" style="text-decoration:none;color:inherit;">${title}</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
return `<div style="max-width:320px">${clickable}<table style="border-collapse:collapse;width:100%">${rows}</table></div>`;
|
|
||||||
|
return `<div style="max-width:350px;font-family:Arial,sans-serif;">
|
||||||
|
${clickable}
|
||||||
|
<table style="border-collapse:collapse;width:100%;margin-bottom:8px;">${mainRows}</table>
|
||||||
|
${otherRows ? `<details style="margin-top:8px;"><summary style="cursor:pointer;color:#666;font-size:12px;">Plus de détails</summary><table style="border-collapse:collapse;width:100%;margin-top:4px;">${otherRows}</table></details>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString('fr-FR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private escapeHtml(s: string): string {
|
private escapeHtml(s: string): string {
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<p>unlocated-events works!</p>
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { UnlocatedEvents } from './unlocated-events';
|
||||||
|
|
||||||
|
describe('UnlocatedEvents', () => {
|
||||||
|
let component: UnlocatedEvents;
|
||||||
|
let fixture: ComponentFixture<UnlocatedEvents>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [UnlocatedEvents]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(UnlocatedEvents);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
11
frontend/src/app/page/unlocated-events/unlocated-events.ts
Normal file
11
frontend/src/app/page/unlocated-events/unlocated-events.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-unlocated-events',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './unlocated-events.html',
|
||||||
|
styleUrl: './unlocated-events.scss'
|
||||||
|
})
|
||||||
|
export class UnlocatedEvents {
|
||||||
|
|
||||||
|
}
|
|
@ -22,9 +22,52 @@
|
||||||
(click)="setView(CalendarView.Day)">
|
(click)="setView(CalendarView.Day)">
|
||||||
Jour
|
Jour
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
(click)="toggleFiltersPanel()">
|
||||||
|
{{showFiltersPanel ? 'Masquer' : 'Afficher'}} les filtres
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Panneau de filtres latéral -->
|
||||||
|
@if (showFiltersPanel) {
|
||||||
|
<div class="filters-panel">
|
||||||
|
<h3>Filtres d'événements</h3>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[(ngModel)]="hideTrafficEvents"
|
||||||
|
(change)="onHideTrafficChange()">
|
||||||
|
Masquer les événements de circulation
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<h4>Types d'événements</h4>
|
||||||
|
<div class="event-types-list">
|
||||||
|
@for (eventType of availableEventTypes; track eventType) {
|
||||||
|
<label class="event-type-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="isEventTypeSelected(eventType)"
|
||||||
|
(change)="onEventTypeChange(eventType, $event.target.checked)">
|
||||||
|
{{eventType}}
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button class="btn btn-sm" (click)="clearAllFilters()">
|
||||||
|
Effacer tous les filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="agenda-content">
|
<div class="agenda-content">
|
||||||
<mwl-calendar-month-view
|
<mwl-calendar-month-view
|
||||||
*ngIf="view === CalendarView.Month"
|
*ngIf="view === CalendarView.Month"
|
||||||
|
|
|
@ -47,6 +47,74 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Panneau de filtres
|
||||||
|
.filters-panel {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #555;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-types-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
background: white;
|
||||||
|
|
||||||
|
.event-type-item {
|
||||||
|
display: block;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
padding-top: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.agenda-content {
|
.agenda-content {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Component, inject, OnInit, ViewChild, TemplateRef } from '@angular/core';
|
import { Component, inject, OnInit, ViewChild, TemplateRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { OedbApi } from '../../services/oedb-api';
|
import { OedbApi } from '../../services/oedb-api';
|
||||||
import { EditForm } from '../../forms/edit-form/edit-form';
|
import { EditForm } from '../../forms/edit-form/edit-form';
|
||||||
import { CalendarModule, CalendarView, CalendarEvent } from 'angular-calendar';
|
import { CalendarModule, CalendarView, CalendarEvent } from 'angular-calendar';
|
||||||
|
@ -32,7 +33,7 @@ interface DayEvents {
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-agenda',
|
selector: 'app-agenda',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, EditForm, CalendarModule],
|
imports: [CommonModule, FormsModule, EditForm, CalendarModule],
|
||||||
templateUrl: './agenda.html',
|
templateUrl: './agenda.html',
|
||||||
styleUrl: './agenda.scss'
|
styleUrl: './agenda.scss'
|
||||||
})
|
})
|
||||||
|
@ -42,13 +43,20 @@ export class Agenda implements OnInit {
|
||||||
@ViewChild('eventTitleTemplate', { static: true }) eventTitleTemplate!: TemplateRef<any>;
|
@ViewChild('eventTitleTemplate', { static: true }) eventTitleTemplate!: TemplateRef<any>;
|
||||||
|
|
||||||
events: OedbEvent[] = [];
|
events: OedbEvent[] = [];
|
||||||
|
filteredEvents: OedbEvent[] = [];
|
||||||
calendarEvents: CalendarEvent[] = [];
|
calendarEvents: CalendarEvent[] = [];
|
||||||
selectedEvent: OedbEvent | null = null;
|
selectedEvent: OedbEvent | null = null;
|
||||||
showSidePanel = false;
|
showSidePanel = false;
|
||||||
|
showFiltersPanel = false;
|
||||||
view: CalendarView = CalendarView.Month;
|
view: CalendarView = CalendarView.Month;
|
||||||
viewDate: Date = new Date();
|
viewDate: Date = new Date();
|
||||||
oedbPresets = oedb.presets.what;
|
oedbPresets = oedb.presets.what;
|
||||||
|
|
||||||
|
// Propriétés pour les filtres
|
||||||
|
hideTrafficEvents = true; // Par défaut, masquer les événements de type traffic
|
||||||
|
selectedEventTypes: string[] = [];
|
||||||
|
availableEventTypes: string[] = [];
|
||||||
|
|
||||||
// Exposer CalendarView pour l'utiliser dans le template
|
// Exposer CalendarView pour l'utiliser dans le template
|
||||||
CalendarView = CalendarView;
|
CalendarView = CalendarView;
|
||||||
|
|
||||||
|
@ -72,12 +80,44 @@ export class Agenda implements OnInit {
|
||||||
|
|
||||||
this.oedbApi.getEvents(params).subscribe((response: any) => {
|
this.oedbApi.getEvents(params).subscribe((response: any) => {
|
||||||
this.events = Array.isArray(response?.features) ? response.features : [];
|
this.events = Array.isArray(response?.features) ? response.features : [];
|
||||||
this.organizeEventsByDay();
|
this.updateAvailableEventTypes();
|
||||||
|
this.applyFilters();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAvailableEventTypes() {
|
||||||
|
const eventTypes = new Set<string>();
|
||||||
|
this.events.forEach(event => {
|
||||||
|
if (event?.properties?.what) {
|
||||||
|
eventTypes.add(event.properties.what);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.availableEventTypes = Array.from(eventTypes).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
let filtered = [...this.events];
|
||||||
|
|
||||||
|
// Filtre par défaut : masquer les événements de type traffic
|
||||||
|
if (this.hideTrafficEvents) {
|
||||||
|
filtered = filtered.filter(event =>
|
||||||
|
!event?.properties?.what?.startsWith('traffic.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par types d'événements sélectionnés
|
||||||
|
if (this.selectedEventTypes.length > 0) {
|
||||||
|
filtered = filtered.filter(event =>
|
||||||
|
this.selectedEventTypes.includes(event?.properties?.what || '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.filteredEvents = filtered;
|
||||||
|
this.organizeEventsByDay();
|
||||||
|
}
|
||||||
|
|
||||||
organizeEventsByDay() {
|
organizeEventsByDay() {
|
||||||
this.calendarEvents = this.events.map(event => {
|
this.calendarEvents = this.filteredEvents.map(event => {
|
||||||
const eventDate = this.getEventDate(event);
|
const eventDate = this.getEventDate(event);
|
||||||
const preset = this.getEventPreset(event);
|
const preset = this.getEventPreset(event);
|
||||||
|
|
||||||
|
@ -220,4 +260,33 @@ export class Agenda implements OnInit {
|
||||||
}: CalendarEventTimesChangedEvent): void {
|
}: CalendarEventTimesChangedEvent): void {
|
||||||
console.log('Event times changed:', event, newStart, newEnd);
|
console.log('Event times changed:', event, newStart, newEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleFiltersPanel() {
|
||||||
|
this.showFiltersPanel = !this.showFiltersPanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
onHideTrafficChange() {
|
||||||
|
this.applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
onEventTypeChange(eventType: string, checked: boolean) {
|
||||||
|
if (checked) {
|
||||||
|
if (!this.selectedEventTypes.includes(eventType)) {
|
||||||
|
this.selectedEventTypes.push(eventType);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.selectedEventTypes = this.selectedEventTypes.filter(type => type !== eventType);
|
||||||
|
}
|
||||||
|
this.applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
isEventTypeSelected(eventType: string): boolean {
|
||||||
|
return this.selectedEventTypes.includes(eventType);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAllFilters() {
|
||||||
|
this.selectedEventTypes = [];
|
||||||
|
this.hideTrafficEvents = true;
|
||||||
|
this.applyFilters();
|
||||||
|
}
|
||||||
}
|
}
|
160
frontend/src/app/pages/agenda/calendar/calendar.ts
Normal file
160
frontend/src/app/pages/agenda/calendar/calendar.ts
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
export interface CalendarEvent {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
start: Date;
|
||||||
|
end?: Date;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
type?: string;
|
||||||
|
properties?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-calendar',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './calendar.html',
|
||||||
|
styleUrl: './calendar.scss'
|
||||||
|
})
|
||||||
|
export class CalendarComponent implements OnInit, OnDestroy {
|
||||||
|
@Input() events: CalendarEvent[] = [];
|
||||||
|
@Output() eventClick = new EventEmitter<CalendarEvent>();
|
||||||
|
@Output() dateClick = new EventEmitter<Date>();
|
||||||
|
|
||||||
|
currentDate = new Date();
|
||||||
|
selectedDate: Date | null = null;
|
||||||
|
selectedEvent: CalendarEvent | null = null;
|
||||||
|
showEventDetails = false;
|
||||||
|
|
||||||
|
// Configuration du calendrier
|
||||||
|
weekDays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
||||||
|
months = [
|
||||||
|
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||||
|
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
|
||||||
|
];
|
||||||
|
|
||||||
|
calendarDays: Date[] = [];
|
||||||
|
currentMonth: number;
|
||||||
|
currentYear: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.currentMonth = this.currentDate.getMonth();
|
||||||
|
this.currentYear = this.currentDate.getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.generateCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
// Cleanup si nécessaire
|
||||||
|
}
|
||||||
|
|
||||||
|
generateCalendar() {
|
||||||
|
this.calendarDays = [];
|
||||||
|
|
||||||
|
// Premier jour du mois
|
||||||
|
const firstDay = new Date(this.currentYear, this.currentMonth, 1);
|
||||||
|
// Dernier jour du mois
|
||||||
|
const lastDay = new Date(this.currentYear, this.currentMonth + 1, 0);
|
||||||
|
|
||||||
|
// Commencer le lundi de la semaine qui contient le premier jour
|
||||||
|
const startDate = new Date(firstDay);
|
||||||
|
const dayOfWeek = firstDay.getDay();
|
||||||
|
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; // Lundi = 1
|
||||||
|
startDate.setDate(firstDay.getDate() + mondayOffset);
|
||||||
|
|
||||||
|
// Générer 42 jours (6 semaines)
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const date = new Date(startDate);
|
||||||
|
date.setDate(startDate.getDate() + i);
|
||||||
|
this.calendarDays.push(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getEventsForDate(date: Date): CalendarEvent[] {
|
||||||
|
return this.events.filter(event => {
|
||||||
|
const eventDate = new Date(event.start);
|
||||||
|
return eventDate.toDateString() === date.toDateString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getEventCountForDate(date: Date): number {
|
||||||
|
return this.getEventsForDate(date).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
isToday(date: Date): boolean {
|
||||||
|
const today = new Date();
|
||||||
|
return date.toDateString() === today.toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
isCurrentMonth(date: Date): boolean {
|
||||||
|
return date.getMonth() === this.currentMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
isWeekend(date: Date): boolean {
|
||||||
|
const day = date.getDay();
|
||||||
|
return day === 0 || day === 6; // Dimanche ou Samedi
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateClick(date: Date) {
|
||||||
|
this.selectedDate = date;
|
||||||
|
this.dateClick.emit(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEventClick(event: CalendarEvent, $event: Event) {
|
||||||
|
$event.stopPropagation();
|
||||||
|
this.selectedEvent = event;
|
||||||
|
this.showEventDetails = true;
|
||||||
|
this.eventClick.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEventDetails() {
|
||||||
|
this.showEventDetails = false;
|
||||||
|
this.selectedEvent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMonth() {
|
||||||
|
this.currentMonth--;
|
||||||
|
if (this.currentMonth < 0) {
|
||||||
|
this.currentMonth = 11;
|
||||||
|
this.currentYear--;
|
||||||
|
}
|
||||||
|
this.generateCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
nextMonth() {
|
||||||
|
this.currentMonth++;
|
||||||
|
if (this.currentMonth > 11) {
|
||||||
|
this.currentMonth = 0;
|
||||||
|
this.currentYear++;
|
||||||
|
}
|
||||||
|
this.generateCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
goToToday() {
|
||||||
|
this.currentDate = new Date();
|
||||||
|
this.currentMonth = this.currentDate.getMonth();
|
||||||
|
this.currentYear = this.currentDate.getFullYear();
|
||||||
|
this.generateCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
getMonthName(): string {
|
||||||
|
return this.months[this.currentMonth];
|
||||||
|
}
|
||||||
|
|
||||||
|
getTotalEventsCount(): number {
|
||||||
|
return this.events.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEventsThisMonth(): number {
|
||||||
|
return this.events.filter(event => {
|
||||||
|
const eventDate = new Date(event.start);
|
||||||
|
return eventDate.getMonth() === this.currentMonth &&
|
||||||
|
eventDate.getFullYear() === this.currentYear;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="aside">
|
<div class="aside">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<strong>OpenEventDatabase</strong>
|
<strong>OpenEventDatabase</strong>
|
||||||
<span class="muted">{{features.length}} évènements</span>
|
<span class="muted">{{filteredFeatures.length}} évènements</span>
|
||||||
@if (isLoading) {
|
@if (isLoading) {
|
||||||
<span class="loading">⏳ Chargement...</span>
|
<span class="loading">⏳ Chargement...</span>
|
||||||
}
|
}
|
||||||
|
@ -42,18 +42,29 @@
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<label>Filtre rapide</label>
|
<label>Filtre rapide</label>
|
||||||
<input class="input" type="text" placeholder="Rechercher...">
|
<input class="input" type="text" placeholder="Rechercher..." [(ngModel)]="searchText" (ngModelChange)="onSearchChange()">
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Filtrer par type d'événement</label>
|
||||||
|
<select class="input" [(ngModel)]="selectedWhatFilter" (ngModelChange)="onWhatFilterChange()">
|
||||||
|
<option value="">Tous les types</option>
|
||||||
|
@for (whatType of availableWhatTypes; track whatType) {
|
||||||
|
<option [value]="whatType">{{whatType}}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<app-unlocated-events [events]="features"></app-unlocated-events>
|
<app-unlocated-events [events]="filteredFeatures"></app-unlocated-events>
|
||||||
<app-menu></app-menu>
|
<app-menu></app-menu>
|
||||||
<hr>
|
<hr>
|
||||||
|
<app-osm></app-osm>
|
||||||
<app-edit-form [selected]="selected" (saved)="onSaved($event)" (created)="onCreated($event)" (deleted)="onDeleted($event)"></app-edit-form>
|
<app-edit-form [selected]="selected" (saved)="onSaved($event)" (created)="onCreated($event)" (deleted)="onDeleted($event)"></app-edit-form>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
@if (!showTable) {
|
@if (!showTable) {
|
||||||
<div class="map">
|
<div class="map">
|
||||||
<app-all-events [features]="features" [selected]="selected" (select)="onSelect($event)" (pickCoords)="onPickCoords($event)"></app-all-events>
|
<app-all-events [features]="filteredFeatures" [selected]="selected" (select)="onSelect($event)" (pickCoords)="onPickCoords($event)"></app-all-events>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="table-wrapper" style="overflow:auto;height:100%;">
|
<div class="table-wrapper" style="overflow:auto;height:100%;">
|
||||||
|
@ -67,7 +78,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (f of features; track f.id) {
|
@for (f of filteredFeatures; track f.id) {
|
||||||
<tr (click)="onSelect({ id: f?.properties?.id ?? f?.id, properties: f.properties, geometry: f.geometry })" style="cursor:pointer;">
|
<tr (click)="onSelect({ id: f?.properties?.id ?? f?.id, properties: f.properties, geometry: f.geometry })" style="cursor:pointer;">
|
||||||
<td style="padding:6px;border-bottom:1px solid #f1f5f9;">{{f?.properties?.what}}</td>
|
<td style="padding:6px;border-bottom:1px solid #f1f5f9;">{{f?.properties?.what}}</td>
|
||||||
<td style="padding:6px;border-bottom:1px solid #f1f5f9;">{{f?.properties?.label || f?.properties?.name}}</td>
|
<td style="padding:6px;border-bottom:1px solid #f1f5f9;">{{f?.properties?.label || f?.properties?.name}}</td>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 340px 1fr;
|
grid-template-columns: 400px 1fr;
|
||||||
grid-template-rows: 100vh;
|
grid-template-rows: 100vh;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@
|
||||||
border-right: 1px solid rgba(0,0,0,0.06);
|
border-right: 1px solid rgba(0,0,0,0.06);
|
||||||
box-shadow: 2px 0 12px rgba(0,0,0,0.03);
|
box-shadow: 2px 0 12px rgba(0,0,0,0.03);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
padding-bottom: 150px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,8 @@ import { AllEvents } from '../../maps/all-events/all-events';
|
||||||
import { EditForm } from '../../forms/edit-form/edit-form';
|
import { EditForm } from '../../forms/edit-form/edit-form';
|
||||||
import { OedbApi } from '../../services/oedb-api';
|
import { OedbApi } from '../../services/oedb-api';
|
||||||
import { UnlocatedEvents } from '../../shared/unlocated-events/unlocated-events';
|
import { UnlocatedEvents } from '../../shared/unlocated-events/unlocated-events';
|
||||||
|
import { OsmAuth } from '../../services/osm-auth';
|
||||||
|
import { Osm } from '../../forms/osm/osm';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
@ -14,6 +16,7 @@ import { UnlocatedEvents } from '../../shared/unlocated-events/unlocated-events'
|
||||||
AllEvents,
|
AllEvents,
|
||||||
UnlocatedEvents,
|
UnlocatedEvents,
|
||||||
EditForm,
|
EditForm,
|
||||||
|
Osm,
|
||||||
FormsModule
|
FormsModule
|
||||||
],
|
],
|
||||||
templateUrl: './home.html',
|
templateUrl: './home.html',
|
||||||
|
@ -23,8 +26,10 @@ export class Home implements OnInit, OnDestroy {
|
||||||
|
|
||||||
OedbApi = inject(OedbApi);
|
OedbApi = inject(OedbApi);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private osmAuth = inject(OsmAuth);
|
||||||
|
|
||||||
features: Array<any> = [];
|
features: Array<any> = [];
|
||||||
|
filteredFeatures: Array<any> = [];
|
||||||
selected: any | null = null;
|
selected: any | null = null;
|
||||||
showTable = false;
|
showTable = false;
|
||||||
|
|
||||||
|
@ -33,6 +38,11 @@ export class Home implements OnInit, OnDestroy {
|
||||||
autoReloadInterval: any = null;
|
autoReloadInterval: any = null;
|
||||||
daysAhead = 7; // Nombre de jours dans le futur par défaut
|
daysAhead = 7; // Nombre de jours dans le futur par défaut
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|
||||||
|
// Propriétés pour les filtres
|
||||||
|
searchText = '';
|
||||||
|
selectedWhatFilter = '';
|
||||||
|
availableWhatTypes: string[] = [];
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.loadEvents();
|
this.loadEvents();
|
||||||
|
@ -57,6 +67,8 @@ export class Home implements OnInit, OnDestroy {
|
||||||
|
|
||||||
this.OedbApi.getEvents(params).subscribe((events: any) => {
|
this.OedbApi.getEvents(params).subscribe((events: any) => {
|
||||||
this.features = Array.isArray(events?.features) ? events.features : [];
|
this.features = Array.isArray(events?.features) ? events.features : [];
|
||||||
|
this.updateAvailableWhatTypes();
|
||||||
|
this.applyFilters();
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -89,6 +101,50 @@ export class Home implements OnInit, OnDestroy {
|
||||||
this.loadEvents();
|
this.loadEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAvailableWhatTypes() {
|
||||||
|
const whatTypes = new Set<string>();
|
||||||
|
this.features.forEach(feature => {
|
||||||
|
if (feature?.properties?.what) {
|
||||||
|
whatTypes.add(feature.properties.what);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.availableWhatTypes = Array.from(whatTypes).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchChange() {
|
||||||
|
this.applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
onWhatFilterChange() {
|
||||||
|
this.applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
let filtered = [...this.features];
|
||||||
|
|
||||||
|
// Filtre par texte de recherche
|
||||||
|
if (this.searchText.trim()) {
|
||||||
|
const searchLower = this.searchText.toLowerCase();
|
||||||
|
filtered = filtered.filter(feature => {
|
||||||
|
const label = feature?.properties?.label || feature?.properties?.name || '';
|
||||||
|
const description = feature?.properties?.description || '';
|
||||||
|
const what = feature?.properties?.what || '';
|
||||||
|
return label.toLowerCase().includes(searchLower) ||
|
||||||
|
description.toLowerCase().includes(searchLower) ||
|
||||||
|
what.toLowerCase().includes(searchLower);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par type d'événement
|
||||||
|
if (this.selectedWhatFilter) {
|
||||||
|
filtered = filtered.filter(feature =>
|
||||||
|
feature?.properties?.what === this.selectedWhatFilter
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.filteredFeatures = filtered;
|
||||||
|
}
|
||||||
|
|
||||||
goToNewCategories() {
|
goToNewCategories() {
|
||||||
this.router.navigate(['/nouvelles-categories']);
|
this.router.navigate(['/nouvelles-categories']);
|
||||||
}
|
}
|
||||||
|
@ -106,9 +162,16 @@ export class Home implements OnInit, OnDestroy {
|
||||||
geometry: { type: 'Point', coordinates: [lon, lat] }
|
geometry: { type: 'Point', coordinates: [lon, lat] }
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
const osmUsername = this.osmAuth.getUsername();
|
||||||
this.selected = {
|
this.selected = {
|
||||||
id: null,
|
id: null,
|
||||||
properties: { label: '', description: '', what: '', where: '' },
|
properties: {
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
what: '',
|
||||||
|
where: '',
|
||||||
|
...(osmUsername && { last_modified_by: osmUsername })
|
||||||
|
},
|
||||||
geometry: { type: 'Point', coordinates: [lon, lat] }
|
geometry: { type: 'Point', coordinates: [lon, lat] }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -141,7 +204,7 @@ export class Home implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadGeoJSON() {
|
downloadGeoJSON() {
|
||||||
const blob = new Blob([JSON.stringify({ type: 'FeatureCollection', features: this.features }, null, 2)], { type: 'application/geo+json' });
|
const blob = new Blob([JSON.stringify({ type: 'FeatureCollection', features: this.filteredFeatures }, null, 2)], { type: 'application/geo+json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
@ -154,7 +217,7 @@ export class Home implements OnInit, OnDestroy {
|
||||||
|
|
||||||
downloadCSV() {
|
downloadCSV() {
|
||||||
const header = ['id', 'what', 'label', 'start', 'stop', 'lon', 'lat'];
|
const header = ['id', 'what', 'label', 'start', 'stop', 'lon', 'lat'];
|
||||||
const rows = this.features.map((f: any) => [
|
const rows = this.filteredFeatures.map((f: any) => [
|
||||||
JSON.stringify(f?.properties?.id ?? f?.id ?? ''),
|
JSON.stringify(f?.properties?.id ?? f?.id ?? ''),
|
||||||
JSON.stringify(f?.properties?.what ?? ''),
|
JSON.stringify(f?.properties?.what ?? ''),
|
||||||
JSON.stringify(f?.properties?.label ?? f?.properties?.name ?? ''),
|
JSON.stringify(f?.properties?.label ?? f?.properties?.name ?? ''),
|
||||||
|
|
|
@ -1,15 +1,19 @@
|
||||||
<menu>
|
<menu>
|
||||||
OpenEventDatabase
|
OpenEventDatabase
|
||||||
|
<nav>
|
||||||
|
|
||||||
|
|
||||||
<a routerLink="/agenda">agenda</a>
|
<a routerLink="/agenda">agenda</a>
|
||||||
|
<a routerLink="/unlocated-events">événements non localisés</a>
|
||||||
<a href="/demo/stats">stats</a>
|
<a href="/demo/stats">stats</a>
|
||||||
<a href="https://source.cipherbliss.com/tykayn/oedb-backend">sources</a>
|
<a href="https://source.cipherbliss.com/tykayn/oedb-backend">sources</a>
|
||||||
|
</nav>
|
||||||
(editor)
|
|
||||||
<div id="editor_form">
|
<div id="editor_form">
|
||||||
<div id="search_input">
|
<!-- <div id="search_input">
|
||||||
<input type="text" value="" placeholder="Rechercher une catégorie d'évènement">
|
<input type="text" value="" placeholder="Rechercher une catégorie d'évènement">
|
||||||
</div>
|
</div>
|
||||||
<!-- <div id="what_categories">
|
<div id="what_categories">
|
||||||
@for (oedbc of oedb_what_categories; track $index) {
|
@for (oedbc of oedb_what_categories; track $index) {
|
||||||
<div class="category">
|
<div class="category">
|
||||||
<div class="emoji">
|
<div class="emoji">
|
||||||
|
@ -23,13 +27,13 @@
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div> -->
|
</div> -->
|
||||||
|
<!--
|
||||||
<hr>
|
<hr>
|
||||||
(bouton de template pour ne pas remplir le formulaire)
|
(bouton de template pour ne pas remplir le formulaire)
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
(reste optionnel du formulaire)
|
(reste optionnel du formulaire)
|
||||||
<!-- <label for="where">Nom</label>
|
<label for="where">Nom</label>
|
||||||
<input type="text" name="name">
|
<input type="text" name="name">
|
||||||
|
|
||||||
<label for="where">Description</label>
|
<label for="where">Description</label>
|
||||||
|
@ -56,7 +60,7 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="found_list">
|
<!-- <div id="found_list">
|
||||||
<h2>données</h2>
|
<h2>données</h2>
|
||||||
(liste des éléments trouvés)
|
(liste des éléments trouvés)
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -86,6 +90,6 @@
|
||||||
<br>
|
<br>
|
||||||
points de l'utilisateur:
|
points de l'utilisateur:
|
||||||
12 points.
|
12 points.
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
</menu>
|
</menu>
|
||||||
|
|
|
@ -1,5 +1,17 @@
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
nav{
|
||||||
|
a {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
&:hover{
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#what_categories {
|
#what_categories {
|
||||||
|
|
285
frontend/src/app/pages/unlocated-events/unlocated-events.html
Normal file
285
frontend/src/app/pages/unlocated-events/unlocated-events.html
Normal file
|
@ -0,0 +1,285 @@
|
||||||
|
<div class="unlocated-events-page">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Événements non localisés</h1>
|
||||||
|
<p class="subtitle">{{unlocatedEvents.length}} événement(s) nécessitant une géolocalisation</p>
|
||||||
|
@if (isLoading) {
|
||||||
|
<div class="loading">⏳ Chargement...</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="events-list">
|
||||||
|
<h2>Liste des événements</h2>
|
||||||
|
@if (unlocatedEvents.length === 0) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Aucun événement non localisé trouvé.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="events-grid">
|
||||||
|
@for (event of unlocatedEvents; track event.id || event.properties?.id) {
|
||||||
|
<div class="event-card" (click)="selectEvent(event)" [class.selected]="selectedEvent?.id === event.id || selectedEvent?.properties?.id === event.properties?.id">
|
||||||
|
<div class="event-header">
|
||||||
|
<h3>{{getEventTitle(event)}}</h3>
|
||||||
|
<span class="event-type">{{event?.properties?.what || 'Non défini'}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-details">
|
||||||
|
<p class="event-description">{{getEventDescription(event)}}</p>
|
||||||
|
<div class="event-meta">
|
||||||
|
@if (event?.properties?.start || event?.properties?.when) {
|
||||||
|
<span class="event-date">📅 {{event?.properties?.start || event?.properties?.when}}</span>
|
||||||
|
}
|
||||||
|
@if (event?.properties?.where) {
|
||||||
|
<span class="event-location">📍 {{event?.properties?.where}}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedEvent) {
|
||||||
|
<div class="event-editor">
|
||||||
|
<div class="editor-header">
|
||||||
|
<h2>Modifier l'événement</h2>
|
||||||
|
<div class="editor-actions">
|
||||||
|
@if (!isEditing) {
|
||||||
|
<button class="btn btn-primary" (click)="startEditing()">Modifier</button>
|
||||||
|
} @else {
|
||||||
|
<button class="btn btn-secondary" (click)="cancelEditing()">Annuler</button>
|
||||||
|
<button class="btn btn-danger" (click)="deleteEvent()" [disabled]="isLoading">Supprimer</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isEditing) {
|
||||||
|
<div class="editor-content">
|
||||||
|
<!-- Géolocalisation -->
|
||||||
|
<div class="geolocation-section">
|
||||||
|
<h3>📍 Géolocalisation</h3>
|
||||||
|
<div class="search-location">
|
||||||
|
<div class="search-input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
[(ngModel)]="searchQuery"
|
||||||
|
placeholder="Rechercher un lieu (ex: Paris, France)"
|
||||||
|
[disabled]="isSearchingLocation">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary search-btn"
|
||||||
|
(click)="searchLocation()"
|
||||||
|
[disabled]="!searchQuery.trim() || isSearchingLocation">
|
||||||
|
@if (isSearchingLocation) {
|
||||||
|
⏳ Recherche...
|
||||||
|
} @else {
|
||||||
|
🔍 Rechercher
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (nominatimResults.length > 0 || searchQuery.trim()) {
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary clear-btn"
|
||||||
|
(click)="clearSearch()"
|
||||||
|
[disabled]="isSearchingLocation">
|
||||||
|
✕ Effacer
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (isSearchingLocation) {
|
||||||
|
<div class="searching">Recherche en cours...</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (nominatimResults.length > 0) {
|
||||||
|
<div class="location-results">
|
||||||
|
<h4>Résultats de recherche ({{nominatimResults.length}} trouvé(s)) :</h4>
|
||||||
|
@for (result of nominatimResults; track result.place_id) {
|
||||||
|
<div class="location-option" (click)="selectLocation(result)" [class.selected]="selectedLocation?.place_id === result.place_id">
|
||||||
|
<div class="location-header">
|
||||||
|
<div class="location-name">{{result.display_name}}</div>
|
||||||
|
<div class="location-type">{{result.type}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="location-details">
|
||||||
|
<div class="location-coords">📍 {{result.lat}}, {{result.lon}}</div>
|
||||||
|
@if (result.importance) {
|
||||||
|
<div class="location-importance">Importance: {{(result.importance * 100).toFixed(1)}}%</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (result.address) {
|
||||||
|
<div class="location-address">
|
||||||
|
@if (result.address.house_number && result.address.road) {
|
||||||
|
{{result.address.house_number}} {{result.address.road}}
|
||||||
|
}
|
||||||
|
@if (result.address.postcode) {
|
||||||
|
{{result.address.postcode}}
|
||||||
|
}
|
||||||
|
@if (result.address.city) {
|
||||||
|
{{result.address.city}}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (!isSearchingLocation && searchQuery.trim() && nominatimResults.length === 0) {
|
||||||
|
<div class="no-results">Aucun résultat trouvé pour "{{searchQuery}}"</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedLocation) {
|
||||||
|
<div class="selected-location">
|
||||||
|
<strong>Lieu sélectionné :</strong> {{selectedLocation.display_name}}
|
||||||
|
<br>
|
||||||
|
<small>Coordonnées : {{selectedLocation.lat}}, {{selectedLocation.lon}}</small>
|
||||||
|
@if (selectedLocation.address) {
|
||||||
|
<br>
|
||||||
|
<small>Adresse :
|
||||||
|
@if (selectedLocation.address.house_number && selectedLocation.address.road) {
|
||||||
|
{{selectedLocation.address.house_number}} {{selectedLocation.address.road}},
|
||||||
|
}
|
||||||
|
@if (selectedLocation.address.postcode) {
|
||||||
|
{{selectedLocation.address.postcode}}
|
||||||
|
}
|
||||||
|
@if (selectedLocation.address.city) {
|
||||||
|
{{selectedLocation.address.city}}
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedEvent?.geometry?.coordinates) {
|
||||||
|
<div class="current-coordinates">
|
||||||
|
<strong>Coordonnées actuelles :</strong>
|
||||||
|
{{selectedEvent.geometry.coordinates[1]}}, {{selectedEvent.geometry.coordinates[0]}}
|
||||||
|
@if (selectedEvent.geometry.coordinates[0] === 0 && selectedEvent.geometry.coordinates[1] === 0) {
|
||||||
|
<span class="warning">⚠️ Coordonnées par défaut (0,0)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Formulaire de coordonnées -->
|
||||||
|
<div class="coordinates-form">
|
||||||
|
<h4>Coordonnées géographiques</h4>
|
||||||
|
<div class="coordinates-inputs">
|
||||||
|
<div class="coordinate-field">
|
||||||
|
<label for="latitude">Latitude :</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="latitude"
|
||||||
|
class="input coordinate-input"
|
||||||
|
[(ngModel)]="selectedEvent.geometry.coordinates[1]"
|
||||||
|
(ngModelChange)="updateCoordinates()"
|
||||||
|
step="0.000001"
|
||||||
|
placeholder="Ex: 48.8566">
|
||||||
|
</div>
|
||||||
|
<div class="coordinate-field">
|
||||||
|
<label for="longitude">Longitude :</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="longitude"
|
||||||
|
class="input coordinate-input"
|
||||||
|
[(ngModel)]="selectedEvent.geometry.coordinates[0]"
|
||||||
|
(ngModelChange)="updateCoordinates()"
|
||||||
|
step="0.000001"
|
||||||
|
placeholder="Ex: 2.3522">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="coordinate-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-secondary"
|
||||||
|
(click)="clearCoordinates()">
|
||||||
|
Effacer les coordonnées
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
(click)="validateCoordinates()"
|
||||||
|
[disabled]="!areCoordinatesValid()">
|
||||||
|
@if (areCoordinatesValid()) {
|
||||||
|
✅ Valider les coordonnées
|
||||||
|
} @else {
|
||||||
|
Valider les coordonnées
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (areCoordinatesValid()) {
|
||||||
|
<div class="coordinates-valid">
|
||||||
|
✅ Coordonnées valides et prêtes à être sauvegardées
|
||||||
|
</div>
|
||||||
|
} @else if (selectedEvent?.geometry?.coordinates[0] !== 0 || selectedEvent?.geometry?.coordinates[1] !== 0) {
|
||||||
|
<div class="coordinates-invalid">
|
||||||
|
⚠️ Coordonnées invalides ou incomplètes
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Propriétés de l'événement -->
|
||||||
|
<div class="properties-section">
|
||||||
|
<h3>Propriétés de l'événement</h3>
|
||||||
|
<div class="properties-list">
|
||||||
|
@for (prop of getObjectKeys(selectedEvent?.properties || {}); track prop) {
|
||||||
|
<div class="property-item" [class.geocoding-property]="isGeocodingProperty(prop)">
|
||||||
|
<label class="property-key">
|
||||||
|
{{prop}}
|
||||||
|
@if (isGeocodingProperty(prop)) {
|
||||||
|
<span class="geocoding-badge">📍</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input property-value"
|
||||||
|
[(ngModel)]="selectedEvent.properties[prop]"
|
||||||
|
[placeholder]="'Valeur pour ' + prop"
|
||||||
|
[readonly]="isGeocodingProperty(prop) && prop !== 'where'">
|
||||||
|
@if (!isGeocodingProperty(prop) || prop === 'where') {
|
||||||
|
<button class="btn btn-sm btn-danger" (click)="removeProperty(prop)">×</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ajouter une nouvelle propriété -->
|
||||||
|
<div class="add-property">
|
||||||
|
<h4>Ajouter une propriété</h4>
|
||||||
|
<div class="add-property-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
[(ngModel)]="newKey"
|
||||||
|
placeholder="Clé (ex: website, contact)">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
[(ngModel)]="newValue"
|
||||||
|
placeholder="Valeur">
|
||||||
|
<button class="btn btn-sm btn-primary" (click)="addProperty()">Ajouter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-large"
|
||||||
|
(click)="updateEvent()"
|
||||||
|
[disabled]="isLoading">
|
||||||
|
@if (isLoading) {
|
||||||
|
⏳ Mise à jour...
|
||||||
|
} @else {
|
||||||
|
💾 Mettre à jour l'événement
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="event-preview">
|
||||||
|
<h3>Aperçu de l'événement</h3>
|
||||||
|
<div class="preview-content">
|
||||||
|
<pre>{{selectedEvent | json}}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
644
frontend/src/app/pages/unlocated-events/unlocated-events.scss
Normal file
644
frontend/src/app/pages/unlocated-events/unlocated-events.scss
Normal file
|
@ -0,0 +1,644 @@
|
||||||
|
.unlocated-events-page {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
color: #3498db;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 30px;
|
||||||
|
min-height: 600px;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-list {
|
||||||
|
h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #7f8c8d;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px dashed #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 10px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #3498db;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: #75a0f6;
|
||||||
|
background: #f8f5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-type {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-details {
|
||||||
|
.event-description {
|
||||||
|
color: #5a6c7d;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
.event-date, .event-location {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-editor {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 25px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 2px solid #f1f3f4;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content {
|
||||||
|
.geolocation-section, .properties-section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
|
||||||
|
h3, h4 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-location {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
.search-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
padding: 10px 15px;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.searching {
|
||||||
|
color: #3498db;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 5px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f8f9ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f5fffb;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-results {
|
||||||
|
margin-top: 15px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-option {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #3498db;
|
||||||
|
background: #f8f9ff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: #859fdb;
|
||||||
|
background: #f5fffb;
|
||||||
|
box-shadow: 0 2px 8px rgba(231, 76, 60, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.location-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2c3e50;
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-type {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 10px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
.location-coords {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-score {
|
||||||
|
color: #27ae60;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-address {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-location {
|
||||||
|
background: #d4edda;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 15px;
|
||||||
|
color: #155724;
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-coordinates {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #856404;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-form {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 15px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-inputs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinate-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinate-input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:invalid {
|
||||||
|
border-color: #e74c3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinate-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-valid {
|
||||||
|
background: #d4edda;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #155724;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-invalid {
|
||||||
|
background: #f8d7da;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #721c24;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-list {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.property-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&.geocoding-property {
|
||||||
|
background: #f8f9ff;
|
||||||
|
border-color: #e3f2fd;
|
||||||
|
border-left: 3px solid #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-key {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2c3e50;
|
||||||
|
min-width: 120px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
.geocoding-badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-value {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[readonly] {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-property {
|
||||||
|
.add-property-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 2px solid #f1f3f4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-preview {
|
||||||
|
.preview-content {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles pour les boutons
|
||||||
|
.btn {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #2980b9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-secondary {
|
||||||
|
background: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #7f8c8d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-danger {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-sm {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-large {
|
||||||
|
padding: 15px 30px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
381
frontend/src/app/pages/unlocated-events/unlocated-events.ts
Normal file
381
frontend/src/app/pages/unlocated-events/unlocated-events.ts
Normal file
|
@ -0,0 +1,381 @@
|
||||||
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { OedbApi } from '../../services/oedb-api';
|
||||||
|
import { OsmAuth } from '../../services/osm-auth';
|
||||||
|
|
||||||
|
interface NominatimResult {
|
||||||
|
place_id: number;
|
||||||
|
display_name: string;
|
||||||
|
lat: string;
|
||||||
|
lon: string;
|
||||||
|
type: string;
|
||||||
|
importance: number;
|
||||||
|
address?: {
|
||||||
|
house_number?: string;
|
||||||
|
road?: string;
|
||||||
|
postcode?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
country?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-unlocated-events-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './unlocated-events.html',
|
||||||
|
styleUrl: './unlocated-events.scss'
|
||||||
|
})
|
||||||
|
export class UnlocatedEventsPage implements OnInit {
|
||||||
|
OedbApi = inject(OedbApi);
|
||||||
|
private osmAuth = inject(OsmAuth);
|
||||||
|
|
||||||
|
events: Array<any> = [];
|
||||||
|
unlocatedEvents: Array<any> = [];
|
||||||
|
isLoading = false;
|
||||||
|
selectedEvent: any = null;
|
||||||
|
isEditing = false;
|
||||||
|
newKey = '';
|
||||||
|
newValue = '';
|
||||||
|
|
||||||
|
// Géolocalisation
|
||||||
|
searchQuery = '';
|
||||||
|
nominatimResults: NominatimResult[] = [];
|
||||||
|
isSearchingLocation = false;
|
||||||
|
selectedLocation: NominatimResult | null = null;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEvents() {
|
||||||
|
this.isLoading = true;
|
||||||
|
const today = new Date();
|
||||||
|
const endDate = new Date(today);
|
||||||
|
endDate.setDate(today.getDate() + 30); // Charger 30 jours pour avoir plus d'événements
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
start: today.toISOString().split('T')[0],
|
||||||
|
end: endDate.toISOString().split('T')[0],
|
||||||
|
limit: 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
this.OedbApi.getEvents(params).subscribe((events: any) => {
|
||||||
|
this.events = Array.isArray(events?.features) ? events.features : [];
|
||||||
|
this.filterUnlocatedEvents();
|
||||||
|
this.isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filterUnlocatedEvents() {
|
||||||
|
this.unlocatedEvents = (this.events || []).filter(ev => {
|
||||||
|
// Vérifie si la géométrie est un point
|
||||||
|
if (!ev.geometry || ev.geometry.type !== 'Point') return false;
|
||||||
|
const coords = ev.geometry.coordinates;
|
||||||
|
// Vérifie si les coordonnées sont valides
|
||||||
|
if (!Array.isArray(coords) || coords.length !== 2) return true;
|
||||||
|
// Si les coordonnées sont [0,0], on considère comme non localisé
|
||||||
|
if (coords[0] === 0 && coords[1] === 0) return true;
|
||||||
|
// Si l'une des coordonnées est manquante ou nulle
|
||||||
|
if (coords[0] == null || coords[1] == null) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectEvent(event: any) {
|
||||||
|
this.selectedEvent = { ...event };
|
||||||
|
this.isEditing = true; // Ouvrir directement le formulaire d'édition
|
||||||
|
this.searchQuery = event?.properties?.where || '';
|
||||||
|
this.nominatimResults = [];
|
||||||
|
this.selectedLocation = null;
|
||||||
|
|
||||||
|
// S'assurer que l'événement a une géométrie valide
|
||||||
|
if (!this.selectedEvent.geometry) {
|
||||||
|
this.selectedEvent.geometry = {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [0, 0]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si l'événement a une propriété 'where', proposer automatiquement une recherche
|
||||||
|
if (event?.properties?.where) {
|
||||||
|
this.searchLocation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startEditing() {
|
||||||
|
this.isEditing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelEditing() {
|
||||||
|
this.isEditing = false;
|
||||||
|
this.selectedEvent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchLocation() {
|
||||||
|
if (!this.searchQuery.trim()) {
|
||||||
|
this.nominatimResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isSearchingLocation = true;
|
||||||
|
this.nominatimResults = [];
|
||||||
|
|
||||||
|
// Utiliser la propriété 'where' de l'événement si disponible, sinon utiliser la recherche manuelle
|
||||||
|
const searchTerm = this.selectedEvent?.properties?.where || this.searchQuery;
|
||||||
|
|
||||||
|
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchTerm)}&limit=10&addressdetails=1&countrycodes=fr&extratags=1`;
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'OpenEventDatabase/1.0'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur HTTP: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((data: NominatimResult[]) => {
|
||||||
|
this.nominatimResults = data;
|
||||||
|
this.isSearchingLocation = false;
|
||||||
|
console.log('Résultats Nominatim:', data);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Erreur lors de la recherche Nominatim:', error);
|
||||||
|
this.isSearchingLocation = false;
|
||||||
|
// Afficher un message d'erreur à l'utilisateur
|
||||||
|
this.nominatimResults = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectLocation(location: NominatimResult) {
|
||||||
|
this.selectedLocation = location;
|
||||||
|
if (this.selectedEvent) {
|
||||||
|
// Mettre à jour la géométrie
|
||||||
|
this.selectedEvent.geometry = {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [parseFloat(location.lon), parseFloat(location.lat)]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mettre à jour les propriétés de l'événement
|
||||||
|
if (!this.selectedEvent.properties) {
|
||||||
|
this.selectedEvent.properties = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour la propriété 'where' avec le nom du lieu
|
||||||
|
this.selectedEvent.properties.where = location.display_name;
|
||||||
|
|
||||||
|
// Ajouter d'autres propriétés utiles si elles n'existent pas
|
||||||
|
if (!this.selectedEvent.properties.label && !this.selectedEvent.properties.name) {
|
||||||
|
this.selectedEvent.properties.label = location.display_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter des informations géographiques détaillées
|
||||||
|
this.selectedEvent.properties.lat = location.lat;
|
||||||
|
this.selectedEvent.properties.lon = location.lon;
|
||||||
|
|
||||||
|
// Ajouter des informations détaillées de Nominatim
|
||||||
|
if (location.address) {
|
||||||
|
if (location.address.house_number) this.selectedEvent.properties.housenumber = location.address.house_number;
|
||||||
|
if (location.address.road) this.selectedEvent.properties.street = location.address.road;
|
||||||
|
if (location.address.postcode) this.selectedEvent.properties.postcode = location.address.postcode;
|
||||||
|
if (location.address.city) this.selectedEvent.properties.city = location.address.city;
|
||||||
|
if (location.address.state) this.selectedEvent.properties.region = location.address.state;
|
||||||
|
if (location.address.country) this.selectedEvent.properties.country = location.address.country;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.type) this.selectedEvent.properties.place_type = location.type;
|
||||||
|
if (location.importance) this.selectedEvent.properties.place_importance = location.importance.toString();
|
||||||
|
|
||||||
|
// Ajouter une note sur la source de géolocalisation
|
||||||
|
this.selectedEvent.properties.geocoding_source = 'Nominatim';
|
||||||
|
this.selectedEvent.properties.geocoding_date = new Date().toISOString();
|
||||||
|
|
||||||
|
// S'assurer que les coordonnées sont bien mises à jour dans le formulaire
|
||||||
|
this.updateCoordinates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearch() {
|
||||||
|
this.searchQuery = '';
|
||||||
|
this.nominatimResults = [];
|
||||||
|
this.selectedLocation = null;
|
||||||
|
this.isSearchingLocation = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCoordinates() {
|
||||||
|
// Cette méthode est appelée quand les coordonnées sont modifiées dans le formulaire
|
||||||
|
// Elle s'assure que la géométrie est correctement mise à jour
|
||||||
|
if (this.selectedEvent && this.selectedEvent.geometry) {
|
||||||
|
const lat = parseFloat(this.selectedEvent.geometry.coordinates[1]);
|
||||||
|
const lon = parseFloat(this.selectedEvent.geometry.coordinates[0]);
|
||||||
|
|
||||||
|
if (!isNaN(lat) && !isNaN(lon)) {
|
||||||
|
this.selectedEvent.geometry.coordinates = [lon, lat];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCoordinates() {
|
||||||
|
if (this.selectedEvent) {
|
||||||
|
this.selectedEvent.geometry = {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [0, 0]
|
||||||
|
};
|
||||||
|
this.selectedLocation = null;
|
||||||
|
|
||||||
|
// Remettre à zéro les propriétés de localisation
|
||||||
|
if (this.selectedEvent.properties) {
|
||||||
|
this.selectedEvent.properties.where = '';
|
||||||
|
// Ne pas effacer le label/name s'ils existent déjà
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateCoordinates() {
|
||||||
|
if (this.selectedEvent && this.selectedEvent.geometry) {
|
||||||
|
const lat = this.selectedEvent.geometry.coordinates[1];
|
||||||
|
const lon = this.selectedEvent.geometry.coordinates[0];
|
||||||
|
|
||||||
|
if (this.areCoordinatesValid()) {
|
||||||
|
console.log('Coordonnées validées:', { lat, lon });
|
||||||
|
this.selectedEvent.geometry.coordinates = [lon, lat];
|
||||||
|
this.updateCoordinates();
|
||||||
|
// Ici on pourrait ajouter une validation supplémentaire ou une notification
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
areCoordinatesValid(): boolean {
|
||||||
|
if (!this.selectedEvent || !this.selectedEvent.geometry) return false;
|
||||||
|
|
||||||
|
const lat = this.selectedEvent.geometry.coordinates[1];
|
||||||
|
const lon = this.selectedEvent.geometry.coordinates[0];
|
||||||
|
|
||||||
|
// Vérifier que les coordonnées sont des nombres valides
|
||||||
|
if (isNaN(lat) || isNaN(lon)) return false;
|
||||||
|
|
||||||
|
// Vérifier que les coordonnées sont dans des plages valides
|
||||||
|
if (lat < -90 || lat > 90) return false;
|
||||||
|
if (lon < -180 || lon > 180) return false;
|
||||||
|
|
||||||
|
// Vérifier que ce ne sont pas les coordonnées par défaut (0,0)
|
||||||
|
if (lat === 0 && lon === 0) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
addProperty() {
|
||||||
|
if (this.newKey.trim() && this.newValue.trim()) {
|
||||||
|
if (!this.selectedEvent.properties) {
|
||||||
|
this.selectedEvent.properties = {};
|
||||||
|
}
|
||||||
|
this.selectedEvent.properties[this.newKey.trim()] = this.newValue.trim();
|
||||||
|
this.newKey = '';
|
||||||
|
this.newValue = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeProperty(key: string) {
|
||||||
|
if (this.selectedEvent?.properties) {
|
||||||
|
delete this.selectedEvent.properties[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEvent() {
|
||||||
|
if (!this.selectedEvent) return;
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
const eventId = this.selectedEvent.id || this.selectedEvent.properties?.id;
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
// Mettre à jour un événement existant
|
||||||
|
this.OedbApi.updateEvent(eventId, this.selectedEvent).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
console.log('Événement mis à jour:', response);
|
||||||
|
this.loadEvents();
|
||||||
|
this.selectedEvent = null;
|
||||||
|
this.isEditing = false;
|
||||||
|
this.isLoading = false;
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Erreur lors de la mise à jour:', error);
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Créer un nouvel événement
|
||||||
|
const osmUsername = this.osmAuth.getUsername();
|
||||||
|
if (osmUsername) {
|
||||||
|
this.selectedEvent.properties.last_modified_by = osmUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.OedbApi.createEvent(this.selectedEvent).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
console.log('Événement créé:', response);
|
||||||
|
this.loadEvents();
|
||||||
|
this.selectedEvent = null;
|
||||||
|
this.isEditing = false;
|
||||||
|
this.isLoading = false;
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Erreur lors de la création:', error);
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEvent() {
|
||||||
|
if (!this.selectedEvent) return;
|
||||||
|
|
||||||
|
const eventId = this.selectedEvent.id || this.selectedEvent.properties?.id;
|
||||||
|
if (!eventId) return;
|
||||||
|
|
||||||
|
if (confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')) {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.OedbApi.deleteEvent(eventId).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
console.log('Événement supprimé:', response);
|
||||||
|
this.loadEvents();
|
||||||
|
this.selectedEvent = null;
|
||||||
|
this.isEditing = false;
|
||||||
|
this.isLoading = false;
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Erreur lors de la suppression:', error);
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getEventTitle(event: any): string {
|
||||||
|
return event?.properties?.what ||
|
||||||
|
event?.properties?.label ||
|
||||||
|
event?.properties?.name ||
|
||||||
|
'Événement sans nom';
|
||||||
|
}
|
||||||
|
|
||||||
|
getEventDescription(event: any): string {
|
||||||
|
return event?.properties?.description ||
|
||||||
|
event?.properties?.where ||
|
||||||
|
'Aucune description';
|
||||||
|
}
|
||||||
|
|
||||||
|
getObjectKeys(obj: any): string[] {
|
||||||
|
return Object.keys(obj || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
isGeocodingProperty(prop: string): boolean {
|
||||||
|
const geocodingProps = [
|
||||||
|
'lat', 'lon', 'place_type', 'place_importance', 'housenumber', 'street',
|
||||||
|
'postcode', 'city', 'region', 'country', 'geocoding_source', 'geocoding_date'
|
||||||
|
];
|
||||||
|
return geocodingProps.includes(prop);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,222 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||||
|
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
export interface OsmUser {
|
||||||
|
id: number;
|
||||||
|
display_name: string;
|
||||||
|
account_created: string;
|
||||||
|
description: string;
|
||||||
|
contributor_terms: {
|
||||||
|
agreed: boolean;
|
||||||
|
pd: boolean;
|
||||||
|
};
|
||||||
|
img: {
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
roles: string[];
|
||||||
|
changesets: {
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
traces: {
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
blocks: {
|
||||||
|
received: {
|
||||||
|
count: number;
|
||||||
|
active: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
home: {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
zoom: number;
|
||||||
|
};
|
||||||
|
languages: string[];
|
||||||
|
messages: {
|
||||||
|
received: {
|
||||||
|
count: number;
|
||||||
|
unread: number;
|
||||||
|
};
|
||||||
|
sent: {
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
preferences: any;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class OsmAuth {
|
export class OsmAuth {
|
||||||
|
private readonly STORAGE_KEY = 'osm_auth_data';
|
||||||
|
private readonly OAUTH_BASE_URL = 'https://www.openstreetmap.org/oauth';
|
||||||
|
|
||||||
|
private currentUserSubject = new BehaviorSubject<OsmUser | null>(null);
|
||||||
|
public currentUser$ = this.currentUserSubject.asObservable();
|
||||||
|
|
||||||
|
private accessToken: string | null = null;
|
||||||
|
private clientId: string | null = null;
|
||||||
|
private redirectUri: string | null = null;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {
|
||||||
|
this.loadStoredAuthData();
|
||||||
|
this.loadEnvironmentConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadEnvironmentConfig() {
|
||||||
|
// Charger la configuration depuis les variables d'environnement
|
||||||
|
this.clientId = environment.osmClientId;
|
||||||
|
this.redirectUri = window.location.origin + '/oauth/callback';
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadStoredAuthData() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const authData = JSON.parse(stored);
|
||||||
|
this.accessToken = authData.accessToken;
|
||||||
|
if (authData.user) {
|
||||||
|
this.currentUserSubject.next(authData.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des données OSM:', error);
|
||||||
|
this.clearStoredAuthData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveAuthData(user: OsmUser, accessToken: string) {
|
||||||
|
const authData = {
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(authData));
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.currentUserSubject.next(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearStoredAuthData() {
|
||||||
|
localStorage.removeItem(this.STORAGE_KEY);
|
||||||
|
this.accessToken = null;
|
||||||
|
this.currentUserSubject.next(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
return this.accessToken !== null && this.currentUserSubject.value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentUser(): OsmUser | null {
|
||||||
|
return this.currentUserSubject.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAccessToken(): string | null {
|
||||||
|
return this.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUsername(): string | null {
|
||||||
|
return this.currentUserSubject.value?.display_name || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
initiateOAuthLogin(): void {
|
||||||
|
if (!this.clientId) {
|
||||||
|
console.error('Client ID OSM non configuré');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = this.generateRandomState();
|
||||||
|
sessionStorage.setItem('osm_oauth_state', state);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: this.clientId,
|
||||||
|
redirect_uri: this.redirectUri!,
|
||||||
|
scope: 'read_prefs',
|
||||||
|
state: state
|
||||||
|
});
|
||||||
|
|
||||||
|
const authUrl = `${this.OAUTH_BASE_URL}/authorize?${params.toString()}`;
|
||||||
|
window.location.href = authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOAuthCallback(code: string, state: string): Observable<boolean> {
|
||||||
|
const storedState = sessionStorage.getItem('osm_oauth_state');
|
||||||
|
if (state !== storedState) {
|
||||||
|
console.error('État OAuth invalide');
|
||||||
|
return of(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.removeItem('osm_oauth_state');
|
||||||
|
|
||||||
|
if (!this.clientId) {
|
||||||
|
console.error('Client ID OSM non configuré');
|
||||||
|
return of(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// En production, l'échange du code contre un token se ferait côté serveur
|
||||||
|
// pour des raisons de sécurité (client_secret)
|
||||||
|
const tokenData = {
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: code,
|
||||||
|
redirect_uri: this.redirectUri!,
|
||||||
|
client_id: this.clientId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pour l'instant, on simule une authentification réussie
|
||||||
|
// En production, il faudrait faire un appel au backend
|
||||||
|
return this.http.post<any>(`${this.OAUTH_BASE_URL}/token`, tokenData).pipe(
|
||||||
|
switchMap(response => {
|
||||||
|
if (response.access_token) {
|
||||||
|
this.accessToken = response.access_token;
|
||||||
|
// Appeler fetchUserDetails et retourner son résultat
|
||||||
|
return this.fetchUserDetails();
|
||||||
|
}
|
||||||
|
return of(false);
|
||||||
|
}),
|
||||||
|
catchError(error => {
|
||||||
|
console.error('Erreur lors de l\'obtention du token OAuth:', error);
|
||||||
|
return of(false);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchUserDetails(): Observable<boolean> {
|
||||||
|
if (!this.accessToken) {
|
||||||
|
return of(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<OsmUser>('https://api.openstreetmap.org/api/0.6/user/details.json', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.accessToken}`
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
map(user => {
|
||||||
|
this.saveAuthData(user, this.accessToken!);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
catchError(error => {
|
||||||
|
console.error('Erreur lors de la récupération des détails utilisateur:', error);
|
||||||
|
this.logout();
|
||||||
|
return of(false);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
this.clearStoredAuthData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateRandomState(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 15) +
|
||||||
|
Math.random().toString(36).substring(2, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthode pour configurer les credentials OSM (à appeler depuis l'app)
|
||||||
|
configureOsmCredentials(clientId: string, clientSecret?: string) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
// Le client_secret ne doit jamais être stocké côté client
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
6
frontend/src/environments/environment.prod.ts
Normal file
6
frontend/src/environments/environment.prod.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
osmClientId: 'your_production_osm_client_id_here',
|
||||||
|
osmClientSecret: 'your_production_osm_client_secret_here',
|
||||||
|
apiBaseUrl: 'https://your-production-api-url.com'
|
||||||
|
};
|
6
frontend/src/environments/environment.ts
Normal file
6
frontend/src/environments/environment.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
osmClientId: 'your_osm_client_id_here', // À remplacer par la vraie valeur
|
||||||
|
osmClientSecret: 'your_osm_client_secret_here', // À remplacer par la vraie valeur
|
||||||
|
apiBaseUrl: 'http://localhost:5000' // URL de base de l'API backend
|
||||||
|
};
|
|
@ -125,4 +125,74 @@ label { font-size: 0.85rem; color: $color-muted; }
|
||||||
|
|
||||||
.search{
|
.search{
|
||||||
width: 20%;
|
width: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside{
|
||||||
|
padding-bottom: 150px;
|
||||||
|
}
|
||||||
|
.actions{
|
||||||
|
|
||||||
|
position: fixed;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 340px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 1000;
|
||||||
|
background: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre{
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlocated-events-page{
|
||||||
|
.event-card{
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
.event-description{
|
||||||
|
max-height: 50px;
|
||||||
|
overflow: auto;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nav{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
a {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
&:hover{
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row, .filters, .controls{
|
||||||
|
input{
|
||||||
|
display: block;
|
||||||
|
max-width: 93%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets{
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
|
@ -7,4 +7,5 @@ pyproj==3.7.2
|
||||||
pytz==2025.2
|
pytz==2025.2
|
||||||
Requests==2.32.5
|
Requests==2.32.5
|
||||||
waitress==3.0.2
|
waitress==3.0.2
|
||||||
jinja2
|
jinja2
|
||||||
|
icalendar
|
Loading…
Add table
Add a link
Reference in a new issue