Compare commits

...

3 commits

Author SHA1 Message Date
Tykayn
ba6ec93860 add nav 2025-10-04 19:33:22 +02:00
Tykayn
74738772b4 scrapping agendadulibre 2025-10-04 19:26:00 +02:00
Tykayn
6deed13d0b up display; ajout scrap agendadulibre; qa évènements sans localisation 2025-10-04 19:18:10 +02:00
44 changed files with 65918 additions and 66 deletions

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"makefile.configureOnOpen": false
}

114
extractors/Makefile Normal file
View 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'"

View 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
View 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()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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)

View 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()

View 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()

View file

@ -0,0 +1,2 @@
requests>=2.28.0
icalendar>=5.0.0

98
extractors/setup_cron.sh Executable file
View 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"

View 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
View 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()

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -15,8 +15,22 @@
<div class="content">
<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>
<main>
<router-outlet/>

View file

@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
import {Home} from './pages/home/home';
import { Agenda } from './pages/agenda/agenda';
import { NouvellesCategories } from './pages/nouvelles-categories/nouvelles-categories';
import { UnlocatedEventsPage } from './pages/unlocated-events/unlocated-events';
export const routes: Routes = [
{
@ -15,5 +16,9 @@ export const routes: Routes = [
{
path : 'nouvelles-categories',
component: NouvellesCategories
},
{
path : 'unlocated-events',
component: UnlocatedEventsPage
}
];

View file

@ -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;
}
}
}

View file

@ -1,5 +1,5 @@
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 { adapterFactory } from 'angular-calendar/date-adapters/moment';
import * as moment from 'moment';
@ -10,7 +10,7 @@ export function momentAdapterFactory() {
@Component({
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',
styleUrl: './app.scss',
providers: [

View file

@ -1,17 +1,35 @@
<p>
osm works!
@if(isLogginIn){
<div class="pseudo">
{{osmPseudo}}
<div class="osm-auth">
@if (isAuthenticated) {
<div class="user-info">
<div class="user-avatar">
@if (currentUser?.img?.href) {
<img [src]="currentUser?.img?.href" [alt]="currentUser?.display_name || 'Utilisateur OSM'" class="avatar">
} @else {
<div class="avatar-placeholder">👤</div>
}
</div>
<div class="user-details">
<div class="username">{{getUsername()}}</div>
<div class="user-stats">
<span class="stat">{{currentUser?.changesets?.count || 0}} changesets</span>
</div>
</div>
<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>
<button (click)="logout()">logout</button>
}
@else{
<div class="pseudo">
pas connecté
</div>
<button (click)="login()">osm login</button>
}
</p>

View file

@ -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;
}
}
}
}
}

View file

@ -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({
selector: 'app-osm',
imports: [],
standalone: true,
imports: [CommonModule],
templateUrl: './osm.html',
styleUrl: './osm.scss'
})
export class Osm {
osmPseudo: string='';
isLogginIn: any = false;
export class Osm implements OnInit, OnDestroy {
private osmAuth = inject(OsmAuth);
private subscription?: Subscription;
logout() {
currentUser: OsmUser | null = null;
isAuthenticated = false;
ngOnInit() {
this.subscription = this.osmAuth.currentUser$.subscribe(user => {
this.currentUser = user;
this.isAuthenticated = !!user;
});
}
ngOnDestroy() {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
login() {
this.osmAuth.initiateOAuthLogin();
}
logout() {
this.osmAuth.logout();
}
getUsername(): string {
return this.osmAuth.getUsername() || '';
}
}

View file

@ -175,7 +175,7 @@ export class AllEvents implements OnInit, OnDestroy {
} else if (coords.length === 4) {
const maplibregl = (window as any).maplibregl;
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 {}
@ -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.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({
id: fid,
properties: p,
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();
if (popup && popup.on) {
@ -272,17 +280,15 @@ export class AllEvents implements OnInit, OnDestroy {
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) {
const hasUrlParams = this.route.snapshot.queryParams['lat'] || this.route.snapshot.queryParams['lon'] || this.route.snapshot.queryParams['zoom'];
if (!hasUrlParams) {
this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
// this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
}
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 {
@ -365,15 +371,54 @@ export class AllEvents implements OnInit, OnDestroy {
private buildPopupHtml(props: any, id?: any): string {
const title = this.escapeHtml(String(props?.name || props?.label || props?.what || 'évènement'));
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 value = typeof v === 'object' ? `<pre>${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>`;
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 8px;color:#999;font-size:12px;">${this.escapeHtml(k)}</td><td style="padding:2px 8px;font-size:12px;">${value}</td></tr>`;
}).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>`;
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 {

View file

@ -0,0 +1 @@
<p>unlocated-events works!</p>

View file

@ -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();
});
});

View 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 {
}

View file

@ -22,9 +22,52 @@
(click)="setView(CalendarView.Day)">
Jour
</button>
<button
class="btn btn-sm"
(click)="toggleFiltersPanel()">
{{showFiltersPanel ? 'Masquer' : 'Afficher'}} les filtres
</button>
</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">
<mwl-calendar-month-view
*ngIf="view === CalendarView.Month"

View file

@ -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 {
margin-bottom: 20px;

View file

@ -1,5 +1,6 @@
import { Component, inject, OnInit, ViewChild, TemplateRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { OedbApi } from '../../services/oedb-api';
import { EditForm } from '../../forms/edit-form/edit-form';
import { CalendarModule, CalendarView, CalendarEvent } from 'angular-calendar';
@ -32,7 +33,7 @@ interface DayEvents {
@Component({
selector: 'app-agenda',
standalone: true,
imports: [CommonModule, EditForm, CalendarModule],
imports: [CommonModule, FormsModule, EditForm, CalendarModule],
templateUrl: './agenda.html',
styleUrl: './agenda.scss'
})
@ -42,13 +43,20 @@ export class Agenda implements OnInit {
@ViewChild('eventTitleTemplate', { static: true }) eventTitleTemplate!: TemplateRef<any>;
events: OedbEvent[] = [];
filteredEvents: OedbEvent[] = [];
calendarEvents: CalendarEvent[] = [];
selectedEvent: OedbEvent | null = null;
showSidePanel = false;
showFiltersPanel = false;
view: CalendarView = CalendarView.Month;
viewDate: Date = new Date();
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
CalendarView = CalendarView;
@ -72,12 +80,44 @@ export class Agenda implements OnInit {
this.oedbApi.getEvents(params).subscribe((response: any) => {
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() {
this.calendarEvents = this.events.map(event => {
this.calendarEvents = this.filteredEvents.map(event => {
const eventDate = this.getEventDate(event);
const preset = this.getEventPreset(event);
@ -220,4 +260,33 @@ export class Agenda implements OnInit {
}: CalendarEventTimesChangedEvent): void {
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();
}
}

View 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;
}
}

View file

@ -2,7 +2,7 @@
<div class="aside">
<div class="toolbar">
<strong>OpenEventDatabase</strong>
<span class="muted">{{features.length}} évènements</span>
<span class="muted">{{filteredFeatures.length}} évènements</span>
@if (isLoading) {
<span class="loading">⏳ Chargement...</span>
}
@ -42,18 +42,29 @@
<div class="filters">
<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>
<hr>
<app-unlocated-events [events]="features"></app-unlocated-events>
<app-unlocated-events [events]="filteredFeatures"></app-unlocated-events>
<app-menu></app-menu>
<hr>
<app-osm></app-osm>
<app-edit-form [selected]="selected" (saved)="onSaved($event)" (created)="onCreated($event)" (deleted)="onDeleted($event)"></app-edit-form>
</div>
<div class="main">
@if (!showTable) {
<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>
} @else {
<div class="table-wrapper" style="overflow:auto;height:100%;">
@ -67,7 +78,7 @@
</tr>
</thead>
<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;">
<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>

View file

@ -4,7 +4,7 @@
.layout {
display: grid;
grid-template-columns: 340px 1fr;
grid-template-columns: 400px 1fr;
grid-template-rows: 100vh;
gap: 0;
}
@ -14,6 +14,7 @@
border-right: 1px solid rgba(0,0,0,0.06);
box-shadow: 2px 0 12px rgba(0,0,0,0.03);
padding: 16px;
padding-bottom: 150px;
overflow: auto;
}

View file

@ -6,6 +6,8 @@ import { AllEvents } from '../../maps/all-events/all-events';
import { EditForm } from '../../forms/edit-form/edit-form';
import { OedbApi } from '../../services/oedb-api';
import { UnlocatedEvents } from '../../shared/unlocated-events/unlocated-events';
import { OsmAuth } from '../../services/osm-auth';
import { Osm } from '../../forms/osm/osm';
@Component({
selector: 'app-home',
standalone: true,
@ -14,6 +16,7 @@ import { UnlocatedEvents } from '../../shared/unlocated-events/unlocated-events'
AllEvents,
UnlocatedEvents,
EditForm,
Osm,
FormsModule
],
templateUrl: './home.html',
@ -23,8 +26,10 @@ export class Home implements OnInit, OnDestroy {
OedbApi = inject(OedbApi);
private router = inject(Router);
private osmAuth = inject(OsmAuth);
features: Array<any> = [];
filteredFeatures: Array<any> = [];
selected: any | null = null;
showTable = false;
@ -34,6 +39,11 @@ export class Home implements OnInit, OnDestroy {
daysAhead = 7; // Nombre de jours dans le futur par défaut
isLoading = false;
// Propriétés pour les filtres
searchText = '';
selectedWhatFilter = '';
availableWhatTypes: string[] = [];
ngOnInit() {
this.loadEvents();
this.startAutoReload();
@ -57,6 +67,8 @@ export class Home implements OnInit, OnDestroy {
this.OedbApi.getEvents(params).subscribe((events: any) => {
this.features = Array.isArray(events?.features) ? events.features : [];
this.updateAvailableWhatTypes();
this.applyFilters();
this.isLoading = false;
});
}
@ -89,6 +101,50 @@ export class Home implements OnInit, OnDestroy {
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() {
this.router.navigate(['/nouvelles-categories']);
}
@ -106,9 +162,16 @@ export class Home implements OnInit, OnDestroy {
geometry: { type: 'Point', coordinates: [lon, lat] }
};
} else {
const osmUsername = this.osmAuth.getUsername();
this.selected = {
id: null,
properties: { label: '', description: '', what: '', where: '' },
properties: {
label: '',
description: '',
what: '',
where: '',
...(osmUsername && { last_modified_by: osmUsername })
},
geometry: { type: 'Point', coordinates: [lon, lat] }
};
}
@ -141,7 +204,7 @@ export class Home implements OnInit, OnDestroy {
}
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 a = document.createElement('a');
a.href = url;
@ -154,7 +217,7 @@ export class Home implements OnInit, OnDestroy {
downloadCSV() {
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?.what ?? ''),
JSON.stringify(f?.properties?.label ?? f?.properties?.name ?? ''),

View file

@ -1,15 +1,19 @@
<menu>
OpenEventDatabase
<nav>
<a routerLink="/agenda">agenda</a>
<a routerLink="/unlocated-events">événements non localisés</a>
<a href="/demo/stats">stats</a>
<a href="https://source.cipherbliss.com/tykayn/oedb-backend">sources</a>
</nav>
(editor)
<div id="editor_form">
<div id="search_input">
<!-- <div id="search_input">
<input type="text" value="" placeholder="Rechercher une catégorie d'évènement">
</div>
<!-- <div id="what_categories">
<div id="what_categories">
@for (oedbc of oedb_what_categories; track $index) {
<div class="category">
<div class="emoji">
@ -23,13 +27,13 @@
</div>
}
</div> -->
<!--
<hr>
(bouton de template pour ne pas remplir le formulaire)
<hr>
(reste optionnel du formulaire)
<!-- <label for="where">Nom</label>
<label for="where">Nom</label>
<input type="text" name="name">
<label for="where">Description</label>
@ -56,7 +60,7 @@
</div>
<div id="found_list">
<!-- <div id="found_list">
<h2>données</h2>
(liste des éléments trouvés)
<ul>
@ -86,6 +90,6 @@
<br>
points de l'utilisateur:
12 points.
</div>
</div> -->
</menu>

View file

@ -1,5 +1,17 @@
:host {
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 {

View 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>

View 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;
}
}
}

View 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);
}
}

View file

@ -1,8 +1,222 @@
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({
providedIn: 'root'
})
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
}
}

View 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'
};

View 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
};

View file

@ -126,3 +126,73 @@ label { font-size: 0.85rem; color: $color-muted; }
.search{
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;
}

View file

@ -8,3 +8,4 @@ pytz==2025.2
Requests==2.32.5
waitress==3.0.2
jinja2
icalendar