OSM-Beiträge: Map-Button (dog=yes), Changeset-Upload, Confirm/Pro-Job
- Map-Popup: "Hund war willkommen"-Button (dog=yes) für Restaurant/Hotel/ Shop/Tierarzt/Hundesalon → POST /osm-contrib/dog-friendly. - OSM-Changeset-Upload (write_api): Element holen (node/way) → dog=yes → Changeset create/upload/close; idempotent; best-effort beim Tap. - OSM-Endpunkte konfigurierbar (OSM_OAUTH_BASE/OSM_API_BASE) — Staging gegen Dev-Sandbox, KEINE echten Edits auf Produktiv-OSM. - Scheduler-Job (täglich 03:40): Pending-Retry + Revert-Überleben (7 Tage) → confirmed/rejected; Pro-Freischaltung (100 confirmed = 1 Jahr, idempotent via osm_pro_grants). HINWEIS: is_premium/subscription direkt gesetzt — vor Prod mit Billing abgleichen. - Native Attestierung/Sensoren: bewusst NICHT (iOS-App-Thema, nicht PWA).
This commit is contained in:
parent
dc9c0d2cc0
commit
57849515ea
10 changed files with 239 additions and 26 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1156
|
||||
1157
|
||||
|
|
@ -392,6 +392,14 @@ def init_db():
|
|||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_osm_contrib_user ON osm_contributions(user_id, status);
|
||||
|
||||
-- Pro-Freischaltungen aus OSM-Beiträgen (1 Zeile = 1 freigeschaltetes Jahr).
|
||||
-- Idempotenz: earned = confirmed//100; nur (earned - vorhandene Zeilen) neu gewähren.
|
||||
CREATE TABLE IF NOT EXISTS osm_pro_grants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- VERLORENE HUNDE
|
||||
CREATE TABLE IF NOT EXISTS lost_dogs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
|
|||
|
|
@ -35,9 +35,15 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter()
|
||||
|
||||
# --- OSM-OAuth2-Endpunkte ---
|
||||
OSM_AUTHORIZE = "https://www.openstreetmap.org/oauth2/authorize"
|
||||
OSM_TOKEN = "https://www.openstreetmap.org/oauth2/token"
|
||||
OSM_USER_API = "https://api.openstreetmap.org/api/0.6/user/details.json"
|
||||
# Konfigurierbar, damit Staging gegen die Dev-Sandbox laufen kann (KEINE echten
|
||||
# Edits auf der Produktiv-OSM!). Staging-.env:
|
||||
# OSM_OAUTH_BASE=https://master.apis.dev.openstreetmap.org
|
||||
# OSM_API_BASE=https://master.apis.dev.openstreetmap.org
|
||||
OSM_OAUTH_BASE = os.getenv("OSM_OAUTH_BASE", "https://www.openstreetmap.org").rstrip("/")
|
||||
OSM_API_BASE = os.getenv("OSM_API_BASE", "https://api.openstreetmap.org").rstrip("/")
|
||||
OSM_AUTHORIZE = OSM_OAUTH_BASE + "/oauth2/authorize"
|
||||
OSM_TOKEN = OSM_OAUTH_BASE + "/oauth2/token"
|
||||
OSM_USER_API = OSM_API_BASE + "/api/0.6/user/details.json"
|
||||
OSM_SCOPES = "read_prefs write_api"
|
||||
|
||||
CLIENT_ID = os.getenv("OSM_CLIENT_ID", "")
|
||||
|
|
|
|||
|
|
@ -16,13 +16,16 @@ status='pending' verifiziert erfasst; der Zähler ist provisorisch.
|
|||
"""
|
||||
import json
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
from math_utils import haversine_m
|
||||
from routes.osm_auth import OSM_API_BASE, _decrypt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
|
@ -54,6 +57,73 @@ def _verified_count(conn, uid: int) -> int:
|
|||
).fetchone()[0]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OSM-Changeset-Upload (write_api): Element holen → dog=yes → Changeset.
|
||||
# ------------------------------------------------------------------
|
||||
_CHANGESET_XML = (
|
||||
'<osm><changeset>'
|
||||
'<tag k="created_by" v="BanYaro/1.0"/>'
|
||||
'<tag k="comment" v="Hund willkommen (dog=yes) — via Ban Yaro"/>'
|
||||
'<tag k="source" v="survey"/>'
|
||||
'</changeset></osm>'
|
||||
)
|
||||
|
||||
|
||||
def _mark_submitted(contrib_id: int, etype: str, changeset_id):
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE osm_contributions SET status='submitted', osm_type=?, "
|
||||
"changeset_id=?, submitted_at=datetime('now') WHERE id=?",
|
||||
(etype, changeset_id, contrib_id)
|
||||
)
|
||||
|
||||
|
||||
async def submit_dog_yes(contrib_id: int, osm_id: int, osm_type: str, token: str) -> bool:
|
||||
"""Setzt dog=yes am OSM-Element des Nutzers (eigener OAuth-Token). Idempotent.
|
||||
Wirft bei Fehler → Beitrag bleibt 'pending' (Retry über den Job)."""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
order = [osm_type, "way" if osm_type == "node" else "node"]
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
# 1) Element holen (node/way auto-detect)
|
||||
elem_xml = etype = None
|
||||
for t in order:
|
||||
r = await client.get(f"{OSM_API_BASE}/api/0.6/{t}/{osm_id}", headers=headers)
|
||||
if r.status_code == 200:
|
||||
elem_xml, etype = r.text, t
|
||||
break
|
||||
if elem_xml is None:
|
||||
raise RuntimeError(f"OSM-Element {osm_id} nicht gefunden")
|
||||
root = ET.fromstring(elem_xml)
|
||||
el = root.find(etype)
|
||||
existing = el.find("./tag[@k='dog']")
|
||||
if existing is not None and existing.get("v") == "yes":
|
||||
_mark_submitted(contrib_id, etype, None) # schon gesetzt → fertig
|
||||
return True
|
||||
|
||||
# 2) Changeset öffnen
|
||||
cs = await client.put(f"{OSM_API_BASE}/api/0.6/changeset/create",
|
||||
headers=headers, content=_CHANGESET_XML)
|
||||
cs.raise_for_status()
|
||||
changeset_id = cs.text.strip()
|
||||
|
||||
# 3) dog=yes setzen + Element hochladen (Geometrie/andere Tags bleiben)
|
||||
if existing is not None:
|
||||
existing.set("v", "yes")
|
||||
else:
|
||||
ET.SubElement(el, "tag", {"k": "dog", "v": "yes"})
|
||||
el.set("changeset", changeset_id)
|
||||
up = await client.put(f"{OSM_API_BASE}/api/0.6/{etype}/{osm_id}",
|
||||
headers=headers, content=ET.tostring(root, encoding="unicode"))
|
||||
up.raise_for_status()
|
||||
|
||||
# 4) Changeset schließen
|
||||
await client.put(f"{OSM_API_BASE}/api/0.6/changeset/{changeset_id}/close",
|
||||
headers=headers)
|
||||
|
||||
_mark_submitted(contrib_id, etype, int(changeset_id))
|
||||
return True
|
||||
|
||||
|
||||
@router.post('/dog-friendly')
|
||||
async def mark_dog_friendly(body: DogFriendlyIn, user=Depends(get_current_user)):
|
||||
uid = user['id']
|
||||
|
|
@ -114,8 +184,8 @@ async def mark_dog_friendly(body: DogFriendlyIn, user=Depends(get_current_user))
|
|||
if poi and haversine_m(body.lat, body.lon, poi['lat'], poi['lon']) > POI_NEAR_M:
|
||||
raise HTTPException(422, "Position passt nicht zum gewählten Ort.")
|
||||
|
||||
# 5) verifiziert erfassen (status pending — OSM-Upload folgt separat)
|
||||
conn.execute(
|
||||
# 5) verifiziert erfassen (pending; OSM-Upload gleich best-effort)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO osm_contributions
|
||||
(user_id, osm_id, osm_type, poi_type, tag_key, tag_value, lat, lon,
|
||||
route_id, gps_distance_m, gps_points_near, status)
|
||||
|
|
@ -123,13 +193,24 @@ async def mark_dog_friendly(body: DogFriendlyIn, user=Depends(get_current_user))
|
|||
(uid, body.osm_id, body.osm_type, body.poi_type, body.lat, body.lon,
|
||||
best[0], round(best[1], 1), best[2])
|
||||
)
|
||||
contrib_id = cur.lastrowid
|
||||
total = _verified_count(conn, uid)
|
||||
token_enc = conn.execute(
|
||||
"SELECT token_enc FROM user_osm WHERE user_id=?", (uid,)
|
||||
).fetchone()[0]
|
||||
|
||||
logger.info("dog=yes erfasst: user %s, osm %s, Tour %s (%.0fm, %d Pkt)",
|
||||
uid, body.osm_id, best[0], best[1], best[2])
|
||||
# 6) OSM-Upload best-effort — Fehler → bleibt 'pending', Job versucht erneut
|
||||
submitted = False
|
||||
try:
|
||||
submitted = await submit_dog_yes(contrib_id, body.osm_id, body.osm_type, _decrypt(token_enc))
|
||||
except Exception as e:
|
||||
logger.warning("OSM-Upload später erneut (contrib %s): %s", contrib_id, e)
|
||||
|
||||
logger.info("dog=yes erfasst: user %s, osm %s, Tour %s (%.0fm, %d Pkt), submitted=%s",
|
||||
uid, body.osm_id, best[0], best[1], best[2], submitted)
|
||||
return {
|
||||
"status": "erfasst", "verified": True, "verified_count": total,
|
||||
"badge": total >= BADGE_AT,
|
||||
"status": "erfasst", "verified": True, "submitted": submitted,
|
||||
"verified_count": total, "badge": total >= BADGE_AT,
|
||||
"pro_progress": min(total, PRO_AT), "pro_at": PRO_AT,
|
||||
}
|
||||
|
||||
|
|
@ -148,3 +229,78 @@ async def contrib_status(user=Depends(get_current_user)):
|
|||
"badge": total >= BADGE_AT,
|
||||
"pro_progress": min(total, PRO_AT), "pro_at": PRO_AT,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Confirm/Revert + Pro-Freischaltung (vom Scheduler-Job aufgerufen)
|
||||
# ------------------------------------------------------------------
|
||||
CONFIRM_AFTER_DAYS = 7 # Edit muss so lange in OSM ohne Revert überleben
|
||||
|
||||
|
||||
def _grant_pro_if_earned(uid: int):
|
||||
"""100 bestätigte Beiträge = 1 Jahr Pro. Idempotent über osm_pro_grants.
|
||||
HINWEIS: setzt is_premium/subscription_* direkt — vor Produktion mit dem
|
||||
Abo-/Billing-System abgleichen."""
|
||||
with db() as conn:
|
||||
confirmed = conn.execute(
|
||||
"SELECT COUNT(*) FROM osm_contributions WHERE user_id=? AND status='confirmed'",
|
||||
(uid,)).fetchone()[0]
|
||||
granted = conn.execute(
|
||||
"SELECT COUNT(*) FROM osm_pro_grants WHERE user_id=?", (uid,)).fetchone()[0]
|
||||
for _ in range(confirmed // PRO_AT - granted):
|
||||
conn.execute("INSERT INTO osm_pro_grants (user_id) VALUES (?)", (uid,))
|
||||
conn.execute(
|
||||
"UPDATE users SET is_premium=1, subscription_tier='pro', "
|
||||
"subscription_expires_at=datetime("
|
||||
" MAX(COALESCE(subscription_expires_at, datetime('now')), datetime('now')), '+1 year') "
|
||||
"WHERE id=?", (uid,))
|
||||
logger.info("OSM-Pro freigeschaltet: user %s (+1 Jahr)", uid)
|
||||
|
||||
|
||||
async def run_confirmation_round():
|
||||
"""Täglich: (1) hängengebliebene 'pending' erneut hochladen, (2) 'submitted'
|
||||
nach CONFIRM_AFTER_DAYS auf Revert-Überleben prüfen → confirmed|rejected,
|
||||
(3) Pro-Freischaltung prüfen."""
|
||||
# (1) Pending-Retry
|
||||
with db() as conn:
|
||||
pend = conn.execute(
|
||||
"SELECT c.id, c.osm_id, c.osm_type, o.token_enc FROM osm_contributions c "
|
||||
"JOIN user_osm o ON o.user_id=c.user_id WHERE c.status='pending' LIMIT 50"
|
||||
).fetchall()
|
||||
for r in pend:
|
||||
try:
|
||||
await submit_dog_yes(r["id"], r["osm_id"], r["osm_type"] or "node", _decrypt(r["token_enc"]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# (2) Confirm/Revert
|
||||
with db() as conn:
|
||||
subs = conn.execute(
|
||||
"SELECT id, user_id, osm_id, osm_type FROM osm_contributions "
|
||||
"WHERE status='submitted' AND submitted_at < datetime('now', ?)",
|
||||
(f"-{CONFIRM_AFTER_DAYS} days",)
|
||||
).fetchall()
|
||||
affected = set()
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
for r in subs:
|
||||
etype = r["osm_type"] or "node"
|
||||
try:
|
||||
resp = await client.get(f"{OSM_API_BASE}/api/0.6/{etype}/{r['osm_id']}")
|
||||
ok = False
|
||||
if resp.status_code == 200:
|
||||
el = ET.fromstring(resp.text).find(etype)
|
||||
tag = el.find("./tag[@k='dog']") if el is not None else None
|
||||
ok = tag is not None and tag.get("v") == "yes"
|
||||
new_status = "confirmed" if ok else "rejected"
|
||||
except Exception:
|
||||
continue # nächste Runde erneut
|
||||
with db() as conn:
|
||||
conn.execute("UPDATE osm_contributions SET status=? WHERE id=?", (new_status, r["id"]))
|
||||
affected.add(r["user_id"])
|
||||
|
||||
# (3) Pro-Freischaltung
|
||||
for uid in affected:
|
||||
_grant_pro_if_earned(uid)
|
||||
if subs or pend:
|
||||
logger.info("OSM-Confirm-Runde: %d pending-retry, %d geprüft, %d User betroffen",
|
||||
len(pend), len(subs), len(affected))
|
||||
|
|
|
|||
|
|
@ -186,6 +186,16 @@ def start():
|
|||
misfire_grace_time=3600,
|
||||
coalesce=True,
|
||||
)
|
||||
# Täglich 03:40 Uhr — OSM-Beiträge: Pending-Retry + Revert-Überleben prüfen
|
||||
# + Pro-Freischaltung (staggered, ruhige Zeit)
|
||||
_scheduler.add_job(
|
||||
_job_osm_confirm,
|
||||
CronTrigger(hour=3, minute=40),
|
||||
id="osm_confirm",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
coalesce=True,
|
||||
)
|
||||
# Jeden Montag 08:10 Uhr — Neue Foto-Challenge anlegen (staggered weg von 08:00)
|
||||
_scheduler.add_job(
|
||||
_job_new_foto_challenge,
|
||||
|
|
@ -1720,6 +1730,16 @@ async def _job_streak_reminder():
|
|||
# ------------------------------------------------------------------
|
||||
# JOB: Tierfutter-Rückrufe prüfen (RASFF, täglich 08:00)
|
||||
# ------------------------------------------------------------------
|
||||
async def _job_osm_confirm():
|
||||
"""OSM-Beiträge: Pending-Retry + Revert-Überleben prüfen + Pro-Freischaltung.
|
||||
Import innen → kein Zirkel-Import beim Modul-Load."""
|
||||
try:
|
||||
from routes.osm_contrib import run_confirmation_round
|
||||
await run_confirmation_round()
|
||||
except Exception as e:
|
||||
logger.warning("OSM-Confirm-Job Fehler: %s", e)
|
||||
|
||||
|
||||
async def _job_recall_check():
|
||||
"""
|
||||
Fragt täglich die RASFF EU-API nach neuen Tierfutter-Rückrufen ab.
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<script src="/js/boot-early.js?v=1156"></script>
|
||||
<script src="/js/boot-early.js?v=1157"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1156">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1156">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1156">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1156">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1156">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1157">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1157">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1157">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1157">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1157">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -617,11 +617,11 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1156"></script>
|
||||
<script src="/js/ui.js?v=1156"></script>
|
||||
<script src="/js/app.js?v=1156"></script>
|
||||
<script src="/js/worlds.js?v=1156"></script>
|
||||
<script src="/js/offline-indicator.js?v=1156"></script>
|
||||
<script src="/js/api.js?v=1157"></script>
|
||||
<script src="/js/ui.js?v=1157"></script>
|
||||
<script src="/js/app.js?v=1157"></script>
|
||||
<script src="/js/worlds.js?v=1157"></script>
|
||||
<script src="/js/offline-indicator.js?v=1157"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -631,7 +631,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1156"></script>
|
||||
<script src="/js/boot.js?v=1157"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1156'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1157'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
|
||||
window.APP_VERSION = APP_VERSION;
|
||||
|
|
|
|||
|
|
@ -1103,6 +1103,15 @@ window.Page_map = (() => {
|
|||
? `<button class="btn btn-danger btn-sm" id="mp-action">Löschen</button>`
|
||||
: `<button class="btn btn-secondary btn-sm" id="mp-action">Als ungültig melden</button>`;
|
||||
|
||||
// "Hund war willkommen" (dog=yes) — nur bei OSM-POIs, wo's Sinn ergibt
|
||||
const DOG_TYPES = ['restaurant', 'hotel', 'shop', 'tierarzt', 'hundesalon'];
|
||||
const dogBtn = (poi.source === 'osm' && DOG_TYPES.includes(layerKey))
|
||||
? `<button class="btn btn-primary btn-sm" id="mp-dogyes" style="width:100%;margin-bottom:6px">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#dog"></use></svg>
|
||||
Hund war willkommen
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
const openHours = poi.opening_hours
|
||||
? `<div style="font-size:11px;color:#555;margin-bottom:4px"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg> ${poi.opening_hours}</div>` : '';
|
||||
const phone = poi.phone
|
||||
|
|
@ -1120,7 +1129,7 @@ window.Page_map = (() => {
|
|||
? `<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#push-pin"></use></svg> Community-Pin${poi.username ? ' · <b>' + poi.username + '</b>' : ''}`
|
||||
: '<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#map-trifold"></use></svg> OpenStreetMap'}
|
||||
</div>
|
||||
${actionBtn}
|
||||
${dogBtn}${actionBtn}
|
||||
</div>
|
||||
`, { maxWidth: 260 }).openPopup();
|
||||
|
||||
|
|
@ -1130,6 +1139,20 @@ window.Page_map = (() => {
|
|||
if (isOwn) _deleteUserPoi(poi.user_poi_id, marker, layerKey);
|
||||
else _showReportDialog(poi);
|
||||
});
|
||||
document.getElementById('mp-dogyes')?.addEventListener('click', async () => {
|
||||
const b = document.getElementById('mp-dogyes');
|
||||
b.disabled = true;
|
||||
try {
|
||||
const r = await API.post('/osm-contrib/dog-friendly', {
|
||||
osm_id: poi.id, osm_type: 'node', poi_type: layerKey, lat: poi.lat, lon: poi.lon,
|
||||
});
|
||||
UI.toast.success(r.submitted ? 'Eingetragen — danke! 🐾' : 'Erfasst — wird übertragen 🐾');
|
||||
b.innerHTML = '✓ Eingetragen';
|
||||
} catch (e) {
|
||||
UI.toast.error(e?.message || 'Konnte nicht eintragen.');
|
||||
b.disabled = false;
|
||||
}
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<script src="/js/landing-init.js?v=1156"></script>
|
||||
<script src="/js/landing-init.js?v=1157"></script>
|
||||
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
|
||||
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
|
||||
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
============================================================ */
|
||||
|
||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
||||
const VER = '1156';
|
||||
const VER = '1157';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue