Compare commits

...

5 Commits

Author SHA1 Message Date
gilles 9db5cbf236 before gemiin 2026-02-22 22:18:32 +01:00
gilles fb33540bb0 refactor(settings): extraire UI_SIZE_DEFAULTS partagé + catch erreur saveUiSettings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 20:15:57 +01:00
gilles 155de270dc feat(settings): sliders taille texte/menu/icônes/miniatures + CSS vars globales
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 20:12:22 +01:00
gilles 0d3bf205b1 feat(saints-dictons): table saint_du_jour + API + import standalone 366j
- Nouveau modèle SaintDuJour (mois+jour+saints_json, indépendant de l'année)
- Router /api/saints et /api/saints/jour (mois+jour → liste de prénoms)
- Script standalone import_webapp_db.py : saints_du_jour.json → saint_du_jour,
  dictons_du_jour.json → dicton ; modes replace/append, --dry-run, --region
- Données JSON 366 jours : saints_du_jour.json + dictons_du_jour.json
- Scripts scraping/export calendrier_lunaire/saints_dictons/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 19:54:47 +01:00
gilles a9f0556d73 fix(router): guard OperationalError si tables météo inexistantes 2026-02-22 19:16:46 +01:00
159 changed files with 17065 additions and 560 deletions
+11 -1
View File
@@ -65,7 +65,17 @@
"Bash(find:*)",
"Bash(.venv/bin/python:*)",
"Bash(.venv/bin/pip install:*)",
"Bash(grep:*)"
"Bash(grep:*)",
"Bash(wc:*)",
"Bash(DB=/home/gilles/Documents/vscode/jardin/data/jardin.db)",
"Bash(__NEW_LINE_c71d992d355b0a42__ echo \"=== Comptages ===\")",
"Bash(__NEW_LINE_c71d992d355b0a42__ echo \"\")",
"Bash(__NEW_LINE_bc37df477a517ffd__ echo \"\")",
"Bash(__NEW_LINE_cef0a7fc7759860e__ echo \"\")",
"Bash(docker compose restart:*)",
"Bash(docker compose build:*)",
"Bash(__NEW_LINE_5f780afd9b58590d__ echo \"\")",
"Read(//home/gilles/.claude/projects/-home-gilles-Documents-vscode-jardin/**)"
],
"additionalDirectories": [
"/home/gilles/Documents/vscode/jardin/frontend/src",
+90
View File
@@ -0,0 +1,90 @@
# 🌿 Jardin — Application de gestion de jardins
Interface web **mobile-first** pour gérer jardins, cultures, tâches et calendrier lunaire, avec détection d'espèces via IA.
Thème visuel : **Gruvbox Dark Seventies**.
## 🏗️ Architecture du Projet
Le projet est composé de trois services principaux orchestrés par Docker Compose :
1. **Backend (FastAPI)** : API REST gérant la logique métier, la base de données (SQLite/SQLModel) et l'intégration des services (lunaire, météo).
2. **Frontend (Vue 3)** : Interface utilisateur réactive avec Vite, Pinia pour le store, et Tailwind CSS pour le style.
3. **AI Service (FastAPI + YOLO)** : Service spécialisé dans la détection et classification de plantes via un modèle YOLOv8 (`ultralytics`).
4. **Redis** : Utilisé pour le cache et les tâches planifiées.
## 🚀 Démarrage Rapide
### Avec Docker (Recommandé)
```bash
cp .env.example .env
docker compose up --build
```
- **Application** : [http://localhost:8061](http://localhost:8061)
- **API Documentation (Swagger)** : [http://localhost:8060/docs](http://localhost:8060/docs)
### Développement Local
#### Backend
```bash
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Variables d'env par défaut pour le dev local
export DATABASE_URL=sqlite:///./data/jardin.db
export UPLOAD_DIR=./data/uploads
uvicorn app.main:app --reload --port 8060
```
#### Frontend
```bash
cd frontend
npm install
npm run dev -- --port 8061
```
#### AI Service
```bash
cd ai-service
pip install -r requirements.txt
uvicorn main:app --reload --port 8070
```
## 🛠️ Commandes de Test et Qualité
### Backend
```bash
cd backend
pytest tests/ -v
```
### Frontend
```bash
cd frontend
npm run lint # Vérification TypeScript (vue-tsc)
```
## 📋 Conventions de Développement
- **Langue** : Code en anglais (variables, fonctions, routes), commentaires et documentation en français.
- **Style Backend** : PEP 8. Utilisation de `SQLModel` pour les modèles de données (union de SQLAlchemy et Pydantic).
- **Style Frontend** : Composition API (Vue 3). Utilisation de TypeScript obligatoire. Tailwind CSS pour le styling atomique.
- **API** : Préfixe `/api` pour tous les endpoints. Documentation automatique via Swagger.
- **Base de données** : SQLite par défaut pour la simplicité et la portabilité (située dans `data/jardin.db`).
- **Media** : Les images uploadées sont stockées dans `data/uploads/` et servies via `/uploads`.
## 📂 Structure des Données (Modèles SQLModel)
- `Garden` : Jardins (nom, dimensions, exposition, géolocalisation).
- `Plant` : Bibliothèque de plantes (nom, famille, exigences).
- `Variety` : Variétés spécifiques de plantes.
- `Planting` : Instances de plantation dans un jardin (date, état, position).
- `Task` : Tâches à accomplir (arrosage, taille, etc.).
- `Settings` : Paramètres utilisateur (lat/long pour météo/lune).
- `Meteo` : Données météo locales et prévisions.
- `Lunar` : Calculs de phases et conseils lunaires.
## 🤖 Service IA
Le service de détection utilise le modèle `foduucom/plant-leaf-detection-and-classification` via YOLOv8.
Endpoint : `POST /detect` acceptant une image.
Il est intégré au backend via le router `identify`.
+2
View File
@@ -82,3 +82,5 @@ bibliotehque photo:
- [ ] methode simple pour mettre a jours la base de donnée ; brainstorming
- [ ] mise a jours bdd via api puis je peut ajouter des script dans mon openclaw]
- [ ] ajouter des etoiles 1 à 5 si j'ai été satisfait de la plante
+40
View File
@@ -7457,3 +7457,43 @@ You've hit your limit · resets 7pm (Europe/Paris)
- Note tests backend:
- `pytest backend/tests/test_tools.py` reste bloque dans ce contexte d'execution,
mais les changements de schema/code compilent et la colonne DB est presente.
## Mise a jour Codex - 2026-02-22 (Planning, Settings, Saints/Dictons, Outils)
### Planning (frontend)
- Fichier: `frontend/src/views/PlanningView.vue`
- Vue planning etendue a 4 semaines (28 jours)
- Navigation par boutons `Prev`, `Today`, `Next`
- Selection d'une case/jour avec panneau "Detail du jour"
- Ajout de marqueurs visuels (petits ronds colores) dans les cases pour signaler les taches non terminees
### Outils: notice en texte libre
- Fichier: `frontend/src/views/OutilsView.vue`
- Remplacement du champ "notice fichier texte" par une zone de texte (`notice_texte`)
- Affichage de la notice texte sur la carte outil
- Compatibilite conservee pour l'existant (`notice_fichier_url` en fallback)
- Test backend ajoute:
- `backend/tests/test_tools.py::test_tool_with_notice_texte`
### Settings: backup ZIP + test API backend
- Backend:
- `backend/app/routers/settings.py`
- nouvel endpoint `GET /api/settings/backup/download`
- archive ZIP contenant: base SQLite, uploads (images/videos), fichiers texte utiles, `manifest.json`
- Frontend:
- `frontend/src/api/settings.ts`: `downloadBackup()`
- `frontend/src/views/ReglagesView.vue`:
- bouton "Telecharger la sauvegarde (.zip)"
- section "Test API backend" avec liens rapides:
- `/docs`, `/redoc`, `/api/health`
### Saints / dictons (hors webapp)
- Dossier: `calendrier_lunaire/saints_dictons/`
- Fichiers JSON generes:
- `saints_du_jour.json`
- `dictons_du_jour.json`
- Scripts ajoutes:
- `export_saints_dictons_json.py` (source `saints_2026.json` -> 2 JSON separes)
- `import_saints_dictons_db.py` (import SQLite `replace`/`append`)
- Import teste sur base temporaire:
- resultat: `366` jours de saints + `366` dictons importes
+2
View File
@@ -49,6 +49,7 @@ from app.routers import ( # noqa
media,
tools,
dictons,
saints,
astuces,
recoltes,
lunar,
@@ -64,6 +65,7 @@ app.include_router(settings.router, prefix="/api")
app.include_router(media.router, prefix="/api")
app.include_router(tools.router, prefix="/api")
app.include_router(dictons.router, prefix="/api")
app.include_router(saints.router, prefix="/api")
app.include_router(astuces.router, prefix="/api")
app.include_router(recoltes.router, prefix="/api")
app.include_router(lunar.router, prefix="/api")
+1
View File
@@ -40,6 +40,7 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
"tool": [
("photo_url", "TEXT", None),
("video_url", "TEXT", None),
("notice_texte", "TEXT", None),
("notice_fichier_url", "TEXT", None),
("boutique_nom", "TEXT", None),
("boutique_url", "TEXT", None),
+1
View File
@@ -9,3 +9,4 @@ from app.models.dicton import Dicton # noqa
from app.models.astuce import Astuce # noqa
from app.models.recolte import Recolte, Observation # noqa
from app.models.meteo import MeteoStation, MeteoOpenMeteo # noqa
from app.models.saint import SaintDuJour # noqa
+14
View File
@@ -0,0 +1,14 @@
from typing import Optional
from sqlmodel import Field, SQLModel
class SaintDuJour(SQLModel, table=True):
"""Saints fêtés pour un jour donné (indépendant de l'année)."""
__tablename__ = "saint_du_jour"
id: Optional[int] = Field(default=None, primary_key=True)
mois: int = Field(index=True) # 1-12
jour: int = Field(index=True) # 1-31
saints_json: str = Field(default="[]") # JSON array : ["St-Basile", "St-Grégoire", ...]
source_url: Optional[str] = None # URL source de scraping
+1
View File
@@ -10,6 +10,7 @@ class Tool(SQLModel, table=True):
categorie: Optional[str] = None # beche|fourche|griffe|arrosage|taille|autre
photo_url: Optional[str] = None
video_url: Optional[str] = None
notice_texte: Optional[str] = None
notice_fichier_url: Optional[str] = None
boutique_nom: Optional[str] = None
boutique_url: Optional[str] = None
+106 -12
View File
@@ -1,8 +1,9 @@
import os
import unicodedata
import uuid
from typing import List, Optional
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from pydantic import BaseModel
from sqlmodel import Session, select
@@ -16,9 +17,91 @@ class MediaPatch(BaseModel):
entity_id: Optional[int] = None
titre: Optional[str] = None
CANONICAL_ENTITY_TYPES = {
"jardin",
"plante",
"adventice",
"outil",
"plantation",
"bibliotheque",
}
ENTITY_TYPE_ALIASES = {
# Canonique
"jardin": "jardin",
"plante": "plante",
"adventice": "adventice",
"outil": "outil",
"plantation": "plantation",
"bibliotheque": "bibliotheque",
# Variantes FR
"jardins": "jardin",
"plantes": "plante",
"adventices": "adventice",
"outils": "outil",
"plantations": "plantation",
"bibliotheques": "bibliotheque",
"bibliotheque_media": "bibliotheque",
# Variantes EN (courantes via API)
"garden": "jardin",
"gardens": "jardin",
"plant": "plante",
"plants": "plante",
"weed": "adventice",
"weeds": "adventice",
"tool": "outil",
"tools": "outil",
"planting": "plantation",
"plantings": "plantation",
"library": "bibliotheque",
"media_library": "bibliotheque",
}
router = APIRouter(tags=["media"])
def _normalize_token(value: str) -> str:
token = (value or "").strip().lower()
token = unicodedata.normalize("NFKD", token).encode("ascii", "ignore").decode("ascii")
return token.replace("-", "_").replace(" ", "_")
def _normalize_entity_type(value: str, *, strict: bool = True) -> str:
token = _normalize_token(value)
canonical = ENTITY_TYPE_ALIASES.get(token, token)
if canonical in CANONICAL_ENTITY_TYPES:
return canonical
if strict:
allowed = ", ".join(sorted(CANONICAL_ENTITY_TYPES))
raise HTTPException(
status_code=422,
detail=f"entity_type invalide: '{value}'. Valeurs autorisees: {allowed}",
)
return value
def _entity_type_candidates(value: str) -> set[str]:
canonical = _normalize_entity_type(value, strict=True)
candidates = {canonical}
for alias, target in ENTITY_TYPE_ALIASES.items():
if target == canonical:
candidates.add(alias)
return candidates
def _canonicalize_rows(rows: List[Media], session: Session) -> None:
changed = False
for media in rows:
normalized = _normalize_entity_type(media.entity_type, strict=False)
if normalized in CANONICAL_ENTITY_TYPES and normalized != media.entity_type:
media.entity_type = normalized
session.add(media)
changed = True
if changed:
session.commit()
def _save_webp(data: bytes, max_px: int) -> str:
try:
from PIL import Image
@@ -47,12 +130,12 @@ async def upload_file(file: UploadFile = File(...)):
name = _save_webp(data, 1200)
thumb = _save_webp(data, 300)
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"}
else:
name = f"{uuid.uuid4()}_{file.filename}"
path = os.path.join(UPLOAD_DIR, name)
with open(path, "wb") as f:
f.write(data)
return {"url": f"/uploads/{name}", "thumbnail_url": None}
name = f"{uuid.uuid4()}_{file.filename}"
path = os.path.join(UPLOAD_DIR, name)
with open(path, "wb") as f:
f.write(data)
return {"url": f"/uploads/{name}", "thumbnail_url": None}
@router.get("/media/all", response_model=List[Media])
@@ -63,8 +146,10 @@ def list_all_media(
"""Retourne tous les médias, filtrés optionnellement par entity_type."""
q = select(Media).order_by(Media.created_at.desc())
if entity_type:
q = q.where(Media.entity_type == entity_type)
return session.exec(q).all()
q = q.where(Media.entity_type.in_(_entity_type_candidates(entity_type)))
rows = session.exec(q).all()
_canonicalize_rows(rows, session)
return rows
@router.get("/media", response_model=List[Media])
@@ -73,15 +158,19 @@ def list_media(
entity_id: int = Query(...),
session: Session = Depends(get_session),
):
return session.exec(
rows = session.exec(
select(Media).where(
Media.entity_type == entity_type, Media.entity_id == entity_id
Media.entity_type.in_(_entity_type_candidates(entity_type)),
Media.entity_id == entity_id,
)
).all()
_canonicalize_rows(rows, session)
return rows
@router.post("/media", response_model=Media, status_code=status.HTTP_201_CREATED)
def create_media(m: Media, session: Session = Depends(get_session)):
m.entity_type = _normalize_entity_type(m.entity_type, strict=True)
session.add(m)
session.commit()
session.refresh(m)
@@ -93,7 +182,12 @@ def update_media(id: int, payload: MediaPatch, session: Session = Depends(get_se
m = session.get(Media, id)
if not m:
raise HTTPException(404, "Media introuvable")
for k, v in payload.model_dump(exclude_none=True).items():
updates = payload.model_dump(exclude_none=True)
if "entity_type" in updates:
updates["entity_type"] = _normalize_entity_type(updates["entity_type"], strict=True)
for k, v in updates.items():
setattr(m, k, v)
session.add(m)
session.commit()
+30 -20
View File
@@ -4,6 +4,7 @@ from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from sqlmodel import Session
from app.database import get_session
@@ -13,13 +14,16 @@ router = APIRouter(tags=["météo"])
def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]:
"""Agrège les mesures horaires d'une journée en résumé."""
rows = session.exec(
text(
"SELECT temp_ext, t_min, t_max, pluie_mm, vent_kmh, humidite "
"FROM meteostation WHERE substr(date_heure, 1, 10) = :d"
),
params={"d": iso_date},
).fetchall()
try:
rows = session.exec(
text(
"SELECT temp_ext, t_min, t_max, pluie_mm, vent_kmh, humidite "
"FROM meteostation WHERE substr(date_heure, 1, 10) = :d"
),
params={"d": iso_date},
).fetchall()
except OperationalError:
return None
if not rows:
return None
@@ -46,12 +50,15 @@ def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]:
def _station_current_row(session: Session) -> Optional[dict]:
"""Dernière mesure station (max 2h d'ancienneté)."""
row = session.exec(
text(
"SELECT temp_ext, humidite, pression, pluie_mm, vent_kmh, vent_dir, uv, solaire, date_heure "
"FROM meteostation WHERE type='current' ORDER BY date_heure DESC LIMIT 1"
)
).fetchone()
try:
row = session.exec(
text(
"SELECT temp_ext, humidite, pression, pluie_mm, vent_kmh, vent_dir, uv, solaire, date_heure "
"FROM meteostation WHERE type='current' ORDER BY date_heure DESC LIMIT 1"
)
).fetchone()
except OperationalError:
return None
if not row:
return None
@@ -64,13 +71,16 @@ def _station_current_row(session: Session) -> Optional[dict]:
def _open_meteo_day(session: Session, iso_date: str) -> Optional[dict]:
row = session.exec(
text(
"SELECT t_min, t_max, pluie_mm, vent_kmh, wmo, label, humidite_moy, sol_0cm, etp_mm "
"FROM meteoopenmeteo WHERE date = :d"
),
params={"d": iso_date},
).fetchone()
try:
row = session.exec(
text(
"SELECT t_min, t_max, pluie_mm, vent_kmh, wmo, label, humidite_moy, sol_0cm, etp_mm "
"FROM meteoopenmeteo WHERE date = :d"
),
params={"d": iso_date},
).fetchone()
except OperationalError:
return None
if not row:
return None
+1 -1
View File
@@ -12,7 +12,7 @@ def list_plants(
categorie: Optional[str] = Query(None),
session: Session = Depends(get_session),
):
q = select(Plant)
q = select(Plant).order_by(Plant.nom_commun, Plant.variete, Plant.id)
if categorie:
q = q.where(Plant.categorie == categorie)
return session.exec(q).all()
+57
View File
@@ -0,0 +1,57 @@
"""Router saints — consultation des saints du jour (indépendant de l'année)."""
import json
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select
from app.database import get_session
from app.models.saint import SaintDuJour
router = APIRouter(tags=["saints"])
@router.get("/saints", response_model=List[SaintDuJour])
def list_saints(
mois: Optional[int] = Query(None, ge=1, le=12),
jour: Optional[int] = Query(None, ge=1, le=31),
session: Session = Depends(get_session),
):
"""Liste les saints. Filtrer par mois et/ou jour."""
q = select(SaintDuJour)
if mois is not None:
q = q.where(SaintDuJour.mois == mois)
if jour is not None:
q = q.where(SaintDuJour.jour == jour)
q = q.order_by(SaintDuJour.mois, SaintDuJour.jour)
return session.exec(q).all()
@router.get("/saints/jour", response_model=dict)
def get_saints_du_jour(
mois: int = Query(..., ge=1, le=12),
jour: int = Query(..., ge=1, le=31),
session: Session = Depends(get_session),
) -> dict[str, Any]:
"""Retourne les saints et leur liste parsée pour un jour précis."""
row = session.exec(
select(SaintDuJour).where(
SaintDuJour.mois == mois,
SaintDuJour.jour == jour,
)
).first()
if not row:
return {"mois": mois, "jour": jour, "saints": []}
try:
saints_list = json.loads(row.saints_json)
except (json.JSONDecodeError, TypeError):
saints_list = []
return {
"mois": row.mois,
"jour": row.jour,
"saints": saints_list,
"source_url": row.source_url,
}
+121 -1
View File
@@ -1,18 +1,28 @@
import os
import shutil
import time
import json
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from sqlmodel import Session, select
from app.database import get_session
from app.models.settings import UserSettings
from app.config import UPLOAD_DIR
from app.config import DATABASE_URL, UPLOAD_DIR
router = APIRouter(tags=["réglages"])
_PREV_CPU_USAGE_USEC: int | None = None
_PREV_CPU_TS: float | None = None
_TEXT_EXTENSIONS = {
".txt", ".md", ".markdown", ".json", ".csv", ".log", ".ini", ".yaml", ".yml", ".xml"
}
def _read_int_from_paths(paths: list[str]) -> int | None:
@@ -113,6 +123,68 @@ def _disk_stats() -> dict[str, Any]:
}
def _safe_remove(path: str) -> None:
try:
os.remove(path)
except OSError:
pass
def _resolve_sqlite_db_path() -> Path | None:
prefix = "sqlite:///"
if not DATABASE_URL.startswith(prefix):
return None
raw = DATABASE_URL[len(prefix):]
if not raw:
return None
db_path = Path(raw)
if db_path.is_absolute():
return db_path
return (Path.cwd() / db_path).resolve()
def _zip_directory(zipf: zipfile.ZipFile, source_dir: Path, arc_prefix: str) -> int:
count = 0
if not source_dir.is_dir():
return count
for root, _, files in os.walk(source_dir):
root_path = Path(root)
for name in files:
file_path = root_path / name
if not file_path.is_file():
continue
rel = file_path.relative_to(source_dir)
arcname = str(Path(arc_prefix) / rel)
zipf.write(file_path, arcname=arcname)
count += 1
return count
def _zip_data_text_files(
zipf: zipfile.ZipFile,
data_root: Path,
db_path: Path | None,
uploads_dir: Path,
) -> int:
count = 0
if not data_root.is_dir():
return count
for root, _, files in os.walk(data_root):
root_path = Path(root)
for name in files:
file_path = root_path / name
if db_path and file_path == db_path:
continue
if uploads_dir in file_path.parents:
continue
if file_path.suffix.lower() not in _TEXT_EXTENSIONS:
continue
rel = file_path.relative_to(data_root)
zipf.write(file_path, arcname=str(Path("data_text") / rel))
count += 1
return count
@router.get("/settings")
def get_settings(session: Session = Depends(get_session)):
rows = session.exec(select(UserSettings)).all()
@@ -161,3 +233,51 @@ def get_debug_system_stats() -> dict[str, Any]:
"memory": _memory_stats(),
"disk": _disk_stats(),
}
@router.get("/settings/backup/download")
def download_backup_zip() -> FileResponse:
now = datetime.now(timezone.utc)
ts = now.strftime("%Y%m%d_%H%M%S")
db_path = _resolve_sqlite_db_path()
uploads_dir = Path(UPLOAD_DIR).resolve()
data_root = db_path.parent if db_path else uploads_dir.parent
fd, tmp_zip_path = tempfile.mkstemp(prefix=f"jardin_backup_{ts}_", suffix=".zip")
os.close(fd)
tmp_zip = Path(tmp_zip_path)
stats = {
"database_files": 0,
"upload_files": 0,
"text_files": 0,
}
with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipf:
if db_path and db_path.is_file():
zipf.write(db_path, arcname=f"db/{db_path.name}")
stats["database_files"] = 1
stats["upload_files"] = _zip_directory(zipf, uploads_dir, "uploads")
stats["text_files"] = _zip_data_text_files(zipf, data_root, db_path, uploads_dir)
manifest = {
"generated_at_utc": now.isoformat(),
"database_url": DATABASE_URL,
"paths": {
"database_path": str(db_path) if db_path else None,
"uploads_path": str(uploads_dir),
"data_root": str(data_root),
},
"included": stats,
"text_extensions": sorted(_TEXT_EXTENSIONS),
}
zipf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
download_name = f"jardin_backup_{ts}.zip"
return FileResponse(
path=str(tmp_zip),
media_type="application/zip",
filename=download_name,
background=BackgroundTask(_safe_remove, str(tmp_zip)),
)
+4
View File
@@ -12,6 +12,7 @@ router = APIRouter(tags=["tâches"])
def list_tasks(
statut: Optional[str] = None,
garden_id: Optional[int] = None,
planting_id: Optional[int] = None,
session: Session = Depends(get_session),
):
q = select(Task)
@@ -19,6 +20,9 @@ def list_tasks(
q = q.where(Task.statut == statut)
if garden_id:
q = q.where(Task.garden_id == garden_id)
if planting_id:
q = q.where(Task.planting_id == planting_id)
q = q.order_by(Task.echeance, Task.created_at.desc())
return session.exec(q).all()
+26
View File
@@ -0,0 +1,26 @@
def test_create_media_normalizes_english_entity_type(client):
r = client.post(
"/api/media",
json={
"entity_type": "plant",
"entity_id": 12,
"url": "/uploads/test.webp",
},
)
assert r.status_code == 201
assert r.json()["entity_type"] == "plante"
def test_list_media_accepts_alias_entity_type_filter(client):
client.post(
"/api/media",
json={
"entity_type": "plante",
"entity_id": 99,
"url": "/uploads/test2.webp",
},
)
r = client.get("/api/media", params={"entity_type": "plant", "entity_id": 99})
assert r.status_code == 200
assert len(r.json()) == 1
assert r.json()[0]["entity_type"] == "plante"
+10
View File
@@ -12,6 +12,16 @@ def test_list_plants(client):
assert len(r.json()) == 2
def test_allow_same_common_name_with_different_varieties(client):
client.post("/api/plants", json={"nom_commun": "Tomate", "variete": "Roma"})
client.post("/api/plants", json={"nom_commun": "Tomate", "variete": "Andine Cornue"})
r = client.get("/api/plants")
assert r.status_code == 200
tomates = [p for p in r.json() if p["nom_commun"] == "Tomate"]
assert len(tomates) == 2
assert {p.get("variete") for p in tomates} == {"Roma", "Andine Cornue"}
def test_get_plant(client):
r = client.post("/api/plants", json={"nom_commun": "Basilic"})
id = r.json()["id"]
+10
View File
@@ -26,3 +26,13 @@ def test_update_task_statut(client):
r2 = client.put(f"/api/tasks/{id}", json={"titre": "À faire", "statut": "fait"})
assert r2.status_code == 200
assert r2.json()["statut"] == "fait"
def test_filter_tasks_by_planting_id(client):
client.post("/api/tasks", json={"titre": "Template arrosage", "statut": "template"})
client.post("/api/tasks", json={"titre": "Arroser rang 1", "statut": "a_faire", "planting_id": 10})
client.post("/api/tasks", json={"titre": "Arroser rang 2", "statut": "a_faire", "planting_id": 11})
r = client.get("/api/tasks?planting_id=10")
assert r.status_code == 200
assert len(r.json()) == 1
assert r.json()[0]["planting_id"] == 10
+12
View File
@@ -28,3 +28,15 @@ def test_tool_with_video_url(client):
)
assert r.status_code == 201
assert r.json()["video_url"] == "/uploads/demo-outil.mp4"
def test_tool_with_notice_texte(client):
r = client.post(
"/api/tools",
json={
"nom": "Sécateur",
"notice_texte": "Aiguiser la lame tous les 3 mois.",
},
)
assert r.status_code == 201
assert r.json()["notice_texte"] == "Aiguiser la lame tous les 3 mois."
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
def _parse_mmdd(mmdd: str) -> tuple[int, int]:
if len(mmdd) != 4 or not mmdd.isdigit():
raise ValueError(f"MMDD invalide: {mmdd}")
month = int(mmdd[:2])
day = int(mmdd[2:])
if not (1 <= month <= 12 and 1 <= day <= 31):
raise ValueError(f"MMDD hors plage: {mmdd}")
return month, day
def _as_list(value: object) -> list[str]:
if not value:
return []
if isinstance(value, list):
out: list[str] = []
for item in value:
txt = str(item).strip()
if txt and txt not in out:
out.append(txt)
return out
txt = str(value).strip()
return [txt] if txt else []
def export_files(source_file: Path, saints_out: Path, dictons_out: Path) -> tuple[int, int]:
source = json.loads(source_file.read_text(encoding="utf-8"))
rows = source.get("data", [])
saints_rows: list[dict] = []
dictons_rows: list[dict] = []
for row in rows:
mmdd = str(row.get("mmdd", "")).strip()
if not mmdd:
continue
try:
month, day = _parse_mmdd(mmdd)
except ValueError:
continue
saints = _as_list(row.get("saints"))
dictons = _as_list(row.get("dictons"))
source_url = row.get("source_url")
if saints:
saints_rows.append(
{
"mois": month,
"jour": day,
"saints": saints,
"source_url": source_url,
}
)
if dictons:
dictons_rows.append(
{
"mois": month,
"jour": day,
"dictons": dictons,
"source_url": source_url,
}
)
saints_rows.sort(key=lambda r: (r["mois"], r["jour"]))
dictons_rows.sort(key=lambda r: (r["mois"], r["jour"]))
saints_out.parent.mkdir(parents=True, exist_ok=True)
dictons_out.parent.mkdir(parents=True, exist_ok=True)
saints_out.write_text(json.dumps(saints_rows, ensure_ascii=False, indent=2), encoding="utf-8")
dictons_out.write_text(json.dumps(dictons_rows, ensure_ascii=False, indent=2), encoding="utf-8")
return len(saints_rows), len(dictons_rows)
def main() -> int:
parser = argparse.ArgumentParser(
description="Génère saints_du_jour.json et dictons_du_jour.json depuis saints_YYYY.json."
)
parser.add_argument(
"--source",
default="calendrier_lunaire/saints_dictons/saints_2026.json",
help="Source JSON issue du scraper annuel",
)
parser.add_argument(
"--saints-out",
default="calendrier_lunaire/saints_dictons/saints_du_jour.json",
help="Fichier JSON de sortie pour les saints",
)
parser.add_argument(
"--dictons-out",
default="calendrier_lunaire/saints_dictons/dictons_du_jour.json",
help="Fichier JSON de sortie pour les dictons",
)
args = parser.parse_args()
source_file = Path(args.source)
saints_out = Path(args.saints_out)
dictons_out = Path(args.dictons_out)
saints_count, dictons_count = export_files(source_file, saints_out, dictons_out)
print(f"Saints exportés : {saints_count} jours -> {saints_out}")
print(f"Dictons exportés : {dictons_count} jours -> {dictons_out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS saint_du_jour (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mois INTEGER NOT NULL,
jour INTEGER NOT NULL,
saints_json TEXT NOT NULL,
source_url TEXT,
updated_at TEXT NOT NULL,
UNIQUE(mois, jour)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS dicton (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mois INTEGER NOT NULL,
jour INTEGER,
texte TEXT NOT NULL,
region TEXT
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_dicton_mois_jour ON dicton(mois, jour)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_saint_du_jour_mois_jour ON saint_du_jour(mois, jour)")
def _load_json_rows(path: Path, required_key: str) -> list[dict]:
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, list):
raise ValueError(f"{path}: format JSON attendu = liste d'objets")
rows: list[dict] = []
for item in raw:
if not isinstance(item, dict):
continue
if required_key not in item:
continue
rows.append(item)
return rows
def _import_saints(conn: sqlite3.Connection, saints_rows: list[dict], mode: str) -> int:
now = datetime.now(timezone.utc).isoformat()
inserted = 0
if mode == "replace":
conn.execute("DELETE FROM saint_du_jour")
for row in saints_rows:
mois = int(row["mois"])
jour = int(row["jour"])
saints = row.get("saints") or []
if not isinstance(saints, list):
saints = [str(saints)]
saints = [str(x).strip() for x in saints if str(x).strip()]
if not saints:
continue
saints_json = json.dumps(saints, ensure_ascii=False)
source_url = row.get("source_url")
conn.execute(
"""
INSERT INTO saint_du_jour (mois, jour, saints_json, source_url, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(mois, jour)
DO UPDATE SET
saints_json = excluded.saints_json,
source_url = excluded.source_url,
updated_at = excluded.updated_at
""",
(mois, jour, saints_json, source_url, now),
)
inserted += 1
return inserted
def _import_dictons(conn: sqlite3.Connection, dicton_rows: list[dict], mode: str, region: str | None) -> int:
inserted = 0
if mode == "replace":
if region:
conn.execute("DELETE FROM dicton WHERE region = ?", (region,))
else:
conn.execute("DELETE FROM dicton")
for row in dicton_rows:
mois = int(row["mois"])
jour = int(row["jour"])
dictons = row.get("dictons") or []
if not isinstance(dictons, list):
dictons = [str(dictons)]
dictons = [str(x).strip() for x in dictons if str(x).strip()]
if not dictons:
continue
for texte in dictons:
if mode == "append":
exists = conn.execute(
"""
SELECT 1
FROM dicton
WHERE mois = ? AND jour = ? AND texte = ? AND COALESCE(region, '') = COALESCE(?, '')
LIMIT 1
""",
(mois, jour, texte, region),
).fetchone()
if exists:
continue
conn.execute(
"INSERT INTO dicton (mois, jour, texte, region) VALUES (?, ?, ?, ?)",
(mois, jour, texte, region),
)
inserted += 1
return inserted
def main() -> int:
parser = argparse.ArgumentParser(
description="Importe saints du jour + dictons dans SQLite (hors webapp)."
)
parser.add_argument(
"--db",
default="data/jardin.db",
help="Chemin SQLite cible",
)
parser.add_argument(
"--saints-json",
default="calendrier_lunaire/saints_dictons/saints_du_jour.json",
help="JSON saints_du_jour",
)
parser.add_argument(
"--dictons-json",
default="calendrier_lunaire/saints_dictons/dictons_du_jour.json",
help="JSON dictons_du_jour",
)
parser.add_argument(
"--mode",
choices=["replace", "append"],
default="replace",
help="replace: purge puis recharge ; append: ajoute sans doublons",
)
parser.add_argument(
"--region",
default="National",
help="Région stockée dans table dicton (vide pour NULL)",
)
args = parser.parse_args()
db_path = Path(args.db)
saints_path = Path(args.saints_json)
dictons_path = Path(args.dictons_json)
region = args.region.strip() or None
saints_rows = _load_json_rows(saints_path, "saints")
dicton_rows = _load_json_rows(dictons_path, "dictons")
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
try:
_ensure_schema(conn)
conn.execute("BEGIN")
saints_count = _import_saints(conn, saints_rows, args.mode)
dictons_count = _import_dictons(conn, dicton_rows, args.mode, region)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
print(f"Import terminé ({args.mode})")
print(f" Saints importés: {saints_count}")
print(f" Dictons importés: {dictons_count} (region={region!r})")
print(f" Base: {db_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""
Import des saints et dictons dans la base de données de la webapp Jardin.
Les données sont INDÉPENDANTES DE L'ANNÉE : stockées par (mois, jour) uniquement.
Sources JSON :
- saints_du_jour.json → table saint_du_jour
- dictons_du_jour.json → table dicton
Usage :
# Import complet (remplace les données existantes)
python import_webapp_db.py
# Import vers une autre DB
python import_webapp_db.py --db /chemin/vers/jardin.db
# Mode append (ajoute sans supprimer les existants)
python import_webapp_db.py --mode append
# Seulement les saints ou seulement les dictons
python import_webapp_db.py --only saints
python import_webapp_db.py --only dictons
# Tagger les dictons avec une région
python import_webapp_db.py --region "Auvergne"
# Prévisualisation sans modification
python import_webapp_db.py --dry-run
"""
import argparse
import json
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
# Chemins par défaut relatifs à ce script
_HERE = Path(__file__).parent
_REPO_ROOT = _HERE.parent.parent # racine du projet jardin/
DEFAULT_DB = _REPO_ROOT / "data" / "jardin.db"
DEFAULT_SAINTS_JSON = _HERE / "saints_du_jour.json"
DEFAULT_DICTONS_JSON = _HERE / "dictons_du_jour.json"
# ─────────────────────────────────────────────
# Schémas SQL attendus par la webapp
# ─────────────────────────────────────────────
SQL_CREATE_SAINT_DU_JOUR = """
CREATE TABLE IF NOT EXISTS saint_du_jour (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mois INTEGER NOT NULL,
jour INTEGER NOT NULL,
saints_json TEXT NOT NULL DEFAULT '[]',
source_url TEXT,
UNIQUE (mois, jour)
)
"""
SQL_CREATE_INDEX_SAINT = """
CREATE INDEX IF NOT EXISTS idx_saint_du_jour_mois_jour
ON saint_du_jour (mois, jour)
"""
# La table dicton existe déjà dans la webapp (SQLModel).
# Colonnes: id, mois, jour, texte, region
# On s'assure juste qu'elle existe (ne recrée pas si déjà présente).
SQL_CREATE_DICTON = """
CREATE TABLE IF NOT EXISTS dicton (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mois INTEGER NOT NULL,
jour INTEGER,
texte TEXT NOT NULL,
region TEXT
)
"""
SQL_CREATE_INDEX_DICTON = """
CREATE INDEX IF NOT EXISTS idx_dicton_mois_jour ON dicton (mois, jour)
"""
# ─────────────────────────────────────────────
# Chargement JSON
# ─────────────────────────────────────────────
def load_json(path: Path, label: str) -> list:
if not path.exists():
print(f"[ERREUR] Fichier introuvable : {path}", file=sys.stderr)
sys.exit(1)
with path.open(encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
print(f"[ERREUR] {label} : attendu une liste JSON, obtenu {type(data).__name__}", file=sys.stderr)
sys.exit(1)
print(f" {label} : {len(data)} entrées chargées depuis {path.name}")
return data
# ─────────────────────────────────────────────
# Import saints
# ─────────────────────────────────────────────
def import_saints(conn: sqlite3.Connection, data: list, mode: str, dry_run: bool) -> int:
"""Importe les saints dans saint_du_jour. Retourne le nombre d'entrées traitées."""
conn.execute(SQL_CREATE_SAINT_DU_JOUR)
conn.execute(SQL_CREATE_INDEX_SAINT)
if mode == "replace" and not dry_run:
conn.execute("DELETE FROM saint_du_jour")
print(" [replace] saint_du_jour vidée")
count = 0
skipped = 0
for entry in data:
mois = entry.get("mois")
jour = entry.get("jour")
saints = entry.get("saints", [])
source_url = entry.get("source_url")
# Validation
if not isinstance(mois, int) or not isinstance(jour, int):
continue
if not (1 <= mois <= 12 and 1 <= jour <= 31):
continue
# Normaliser en liste de strings non vides
if isinstance(saints, str):
saints = [saints]
saints = [s.strip() for s in saints if isinstance(s, str) and s.strip()]
saints_json = json.dumps(saints, ensure_ascii=False)
if dry_run:
count += 1
continue
if mode == "append":
existing = conn.execute(
"SELECT id FROM saint_du_jour WHERE mois=? AND jour=?", (mois, jour)
).fetchone()
if existing:
skipped += 1
continue
conn.execute(
"INSERT OR REPLACE INTO saint_du_jour (mois, jour, saints_json, source_url) VALUES (?,?,?,?)",
(mois, jour, saints_json, source_url),
)
count += 1
if skipped:
print(f" saints ignorés (mode append, déjà présents) : {skipped}")
return count
# ─────────────────────────────────────────────
# Import dictons
# ─────────────────────────────────────────────
def import_dictons(conn: sqlite3.Connection, data: list, mode: str, region: str | None, dry_run: bool) -> int:
"""Importe les dictons dans la table dicton. Retourne le nombre d'entrées insérées."""
conn.execute(SQL_CREATE_DICTON)
conn.execute(SQL_CREATE_INDEX_DICTON)
if mode == "replace" and not dry_run:
conn.execute("DELETE FROM dicton")
print(" [replace] dicton vidée")
count = 0
for entry in data:
mois = entry.get("mois")
jour = entry.get("jour") # peut être None
dictons = entry.get("dictons", [])
# Validation
if not isinstance(mois, int):
continue
if not (1 <= mois <= 12):
continue
if jour is not None and not (1 <= jour <= 31):
continue
# Normaliser
if isinstance(dictons, str):
dictons = [dictons]
dictons = [d.strip() for d in dictons if isinstance(d, str) and d.strip()]
for texte in dictons:
if dry_run:
count += 1
continue
if mode == "append":
existing = conn.execute(
"SELECT id FROM dicton WHERE mois=? AND jour IS ? AND texte=?",
(mois, jour, texte),
).fetchone()
if existing:
continue
conn.execute(
"INSERT INTO dicton (mois, jour, texte, region) VALUES (?,?,?,?)",
(mois, jour, texte, region),
)
count += 1
return count
# ─────────────────────────────────────────────
# Point d'entrée
# ─────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Import saints et dictons dans la BDD webapp Jardin (indépendant de l'année)"
)
parser.add_argument("--db", default=str(DEFAULT_DB),
help=f"Chemin vers jardin.db (défaut: {DEFAULT_DB})")
parser.add_argument("--saints-json", default=str(DEFAULT_SAINTS_JSON),
help=f"Fichier saints_du_jour.json (défaut: {DEFAULT_SAINTS_JSON.name})")
parser.add_argument("--dictons-json", default=str(DEFAULT_DICTONS_JSON),
help=f"Fichier dictons_du_jour.json (défaut: {DEFAULT_DICTONS_JSON.name})")
parser.add_argument("--mode", choices=["replace", "append"], default="replace",
help="replace = purge + recharge (défaut) | append = ajoute sans doublons")
parser.add_argument("--only", choices=["saints", "dictons", "both"], default="both",
help="Importer uniquement saints, dictons, ou les deux (défaut: both)")
parser.add_argument("--region", default=None,
help="Tag région pour les dictons (ex: 'Auvergne', 'National'). Vide = NULL")
parser.add_argument("--dry-run", action="store_true",
help="Simulation : affiche les comptages sans modifier la BDD")
args = parser.parse_args()
db_path = Path(args.db)
if not db_path.exists():
print(f"[ERREUR] Base de données introuvable : {db_path}", file=sys.stderr)
print(" → Vérifiez que la webapp a démarré au moins une fois pour créer la BDD.")
sys.exit(1)
region = args.region if args.region else None
do_saints = args.only in ("saints", "both")
do_dictons = args.only in ("dictons", "both")
print(f"\n=== Import saints/dictons → {db_path} ===")
print(f" Mode : {args.mode}")
print(f" Scope : {args.only}")
if region:
print(f" Région: {region}")
if args.dry_run:
print(" *** DRY-RUN — aucune modification en BDD ***")
print()
# Charger les JSON nécessaires
saints_data = load_json(Path(args.saints_json), "saints_du_jour") if do_saints else []
dictons_data = load_json(Path(args.dictons_json), "dictons_du_jour") if do_dictons else []
print()
# Connexion SQLite
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=OFF") # pas de cascade gestion manuelle
saints_count = 0
dictons_count = 0
try:
conn.execute("BEGIN")
if do_saints:
saints_count = import_saints(conn, saints_data, args.mode, args.dry_run)
if do_dictons:
dictons_count = import_dictons(conn, dictons_data, args.mode, region, args.dry_run)
if not args.dry_run:
conn.commit()
else:
conn.rollback()
except Exception as exc:
conn.rollback()
print(f"\n[ERREUR] Transaction annulée : {exc}", file=sys.stderr)
raise
finally:
conn.close()
# Résumé
print()
print("=== Résultat ===")
if do_saints:
prefix = "[DRY-RUN] " if args.dry_run else ""
print(f" {prefix}Saints importés : {saints_count} jours → table saint_du_jour")
if do_dictons:
prefix = "[DRY-RUN] " if args.dry_run else ""
print(f" {prefix}Dictons importés : {dictons_count} entrées → table dicton")
print(f" Base : {db_path}")
if args.dry_run:
print("\n → Relancez sans --dry-run pour appliquer les changements.")
else:
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
print(f"\n Import terminé le {ts}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+32
View File
@@ -35,12 +35,21 @@ Dossier dédié: `calendrier_lunaire/saints_dictons/`
- `calendrier_lunaire/saints_dictons/saint_dicton_year_scraper.py`
- Exemple de sortie:
- `calendrier_lunaire/saints_dictons/saints_2026.json`
- Exports JSON séparés:
- `calendrier_lunaire/saints_dictons/saints_du_jour.json`
- `calendrier_lunaire/saints_dictons/dictons_du_jour.json`
- Scripts hors webapp:
- `calendrier_lunaire/saints_dictons/export_saints_dictons_json.py`
- `calendrier_lunaire/saints_dictons/import_saints_dictons_db.py`
### Fonctions/évolutions intégrées
- Format JSON cible: `date`, `saints[]`, `dictons[]`
- Support de formats de date multiples
- Ajout de logs de progression dans le scraper
- Enregistrement JSON (pas uniquement affichage terminal)
- Génération de 2 jeux de données dédiés (saints / dictons)
- Import automatisé en SQLite (`replace` ou `append`)
- Création table `saint_du_jour` si absente + alimentation table `dicton`
## 3) Prévisions météo Open-Meteo
@@ -103,3 +112,26 @@ Dossier: `test_yolo/`
- Plan d'amélioration: `amelioration.md`
- Plan météo/astuces: `avancement.md` (contient plan + logs de session)
## 8) Webapp - évolutions récentes
### Planning
- `frontend/src/views/PlanningView.vue`
- Passage en vue 4 semaines (28 jours)
- Navigation par période: `Prev`, `Today`, `Next`
- Sélection d'un jour avec panneau "Détail du jour"
- Marqueurs visuels par tâches non terminées (ronds colorés par priorité)
### Outils
- `frontend/src/views/OutilsView.vue`
- Le champ notice est désormais une zone de texte libre (`notice_texte`)
- Conserve compatibilité lecture des anciennes notices fichier (`notice_fichier_url`)
- Test backend ajouté: `backend/tests/test_tools.py::test_tool_with_notice_texte`
### Réglages
- `backend/app/routers/settings.py`
- `frontend/src/views/ReglagesView.vue`
- Sauvegarde ZIP téléchargeable (BDD + uploads + fichiers texte + manifeste)
- Liens rapides de test API backend:
- Swagger: `/docs`
- ReDoc: `/redoc`
- Santé: `/api/health`
Regular → Executable
View File
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Regular → Executable
View File
View File

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 254 KiB

View File

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

View File

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

View File

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 156 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

View File

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

View File

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 282 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 215 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 191 KiB

View File
View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 147 KiB

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Some files were not shown because too many files have changed in this diff Show More