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