refactor: aufgeraeumt — Pakete hinten, Widgets vorne
Go: store, auth, weather und config sind eigene Pakete unter internal/. Vorher lagen fuenf Dateien in einem Topf, und jede kannte jede. Web: os.js hatte 1425 Zeilen und alles darin. Jetzt core.js (Zustand, Server, Einstellungen), glyphs.js (die Pixelraster), js/widgets/ mit einem Widget je Datei, os.js nur noch die Ansichten. Stylesheet nach css/. Die HoneyBar bleibt eine einzelne Datei ohne Importe — sie wird von fremden Seiten mit einer Zeile eingebunden, da darf nichts nachladen. Nebenbei: Das gebaute Binary lag mit 9,8 MB im Repo und ist jetzt ignoriert. Und der Test gegen eine zweite Versionsnummer geht nicht mehr auf os.js, sondern durch alle .js — sonst haette ihn dieser Umbau still ausgehebelt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d08b0a229c
commit
52deffa30e
20 files changed
+780
-624
No files matched your search
@@ -0,0 +1,212 @@
|
||||
// Package weather holt das Wetter — serverseitig, damit nicht jede Seite in
|
||||
// jeder App selbst beim Wetterdienst anfragt.
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Wetter kommt vom Server, nicht aus dem Browser.
|
||||
//
|
||||
// Die Leiste läuft auf jeder Seite jeder App. Fragte sie selbst beim
|
||||
// Wetterdienst an, ginge bei jedem Seitenaufruf aus jeder App eine Anfrage samt
|
||||
// IP dorthin — und die CSP jeder App müsste den fremden Ursprung erlauben. Holt
|
||||
// HoneyOS es, sieht der Dienst einen Server, und die Apps bleiben unberührt.
|
||||
//
|
||||
// Open-Meteo braucht keinen Schlüssel und keine Anmeldung. Das ist der Grund für
|
||||
// die Wahl: ein Zugangsschlüssel in der .env wäre eine Sache mehr, die jemand
|
||||
// einrichten muss, bevor irgendetwas zu sehen ist.
|
||||
const (
|
||||
wetterFrisch = 10 * time.Minute // so lange gilt eine Antwort
|
||||
wetterTimeout = 6 * time.Second // ein hängender Dienst darf nichts aufhalten
|
||||
)
|
||||
|
||||
type Wetter struct {
|
||||
Ort string `json:"ort"`
|
||||
Grad float64 `json:"grad"`
|
||||
Code int `json:"code"`
|
||||
Text string `json:"text"`
|
||||
Glyph string `json:"glyph"`
|
||||
Stand string `json:"stand"`
|
||||
}
|
||||
|
||||
type Ort struct {
|
||||
Name string `json:"name"`
|
||||
Land string `json:"land"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
|
||||
type Dienst struct {
|
||||
mu sync.Mutex
|
||||
cache map[string]wetterEintrag
|
||||
client *http.Client
|
||||
|
||||
// Adressen als Feld, damit der Test einen eigenen Server unterschieben kann.
|
||||
wetterURL string
|
||||
sucheURL string
|
||||
}
|
||||
|
||||
type wetterEintrag struct {
|
||||
w Wetter
|
||||
bis time.Time
|
||||
}
|
||||
|
||||
func Neu() *Dienst {
|
||||
return &Dienst{
|
||||
cache: map[string]wetterEintrag{},
|
||||
client: &http.Client{Timeout: wetterTimeout},
|
||||
wetterURL: "https://api.open-meteo.com/v1/forecast",
|
||||
sucheURL: "https://geocoding-api.open-meteo.com/v1/search",
|
||||
}
|
||||
}
|
||||
|
||||
// wmo übersetzt den Wettercode in ein Pixelsymbol und einen kurzen Text.
|
||||
// Die Skala ist die der Weltorganisation für Meteorologie; Open-Meteo liefert
|
||||
// sie so. Zusammengefasst auf sechs Symbole — feiner lässt sich in zwölf mal
|
||||
// zwölf Pixeln ohnehin nichts unterscheiden.
|
||||
func wmo(code int) (glyph, text string) {
|
||||
switch {
|
||||
case code == 0:
|
||||
return "sonne", "klar"
|
||||
case code <= 2:
|
||||
return "heiter", "heiter"
|
||||
case code == 3:
|
||||
return "wolke", "bedeckt"
|
||||
case code == 45 || code == 48:
|
||||
return "nebel", "nebel"
|
||||
case code >= 51 && code <= 67, code >= 80 && code <= 82:
|
||||
return "regen", "regen"
|
||||
case code >= 71 && code <= 77, code == 85 || code == 86:
|
||||
return "schnee", "schnee"
|
||||
case code >= 95:
|
||||
return "gewitter", "gewitter"
|
||||
}
|
||||
return "wolke", "wolken"
|
||||
}
|
||||
|
||||
// Jetzt liefert das Wetter für einen Ort — aus dem Zwischenspeicher, solange er
|
||||
// frisch ist. Das Wetter ändert sich langsamer, als Seiten neu geladen werden;
|
||||
// ohne diesen Speicher stellte jeder Seitenaufruf in jeder App eine Anfrage
|
||||
// nach draußen.
|
||||
func (d *Dienst) Jetzt(ctx context.Context, ort string, lat, lon float64) (Wetter, error) {
|
||||
schlüssel := fmt.Sprintf("%.3f,%.3f", lat, lon)
|
||||
|
||||
d.mu.Lock()
|
||||
if e, ok := d.cache[schlüssel]; ok && time.Now().Before(e.bis) {
|
||||
d.mu.Unlock()
|
||||
e.w.Ort = ort // der Name gehört den Einstellungen, nicht dem Dienst
|
||||
return e.w, nil
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
u, _ := url.Parse(d.wetterURL)
|
||||
q := u.Query()
|
||||
q.Set("latitude", strconv.FormatFloat(lat, 'f', 4, 64))
|
||||
q.Set("longitude", strconv.FormatFloat(lon, 'f', 4, 64))
|
||||
q.Set("current", "temperature_2m,weather_code")
|
||||
q.Set("timezone", "auto")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return Wetter{}, err
|
||||
}
|
||||
res, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return Wetter{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return Wetter{}, fmt.Errorf("wetterdienst antwortet %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var antwort struct {
|
||||
Current struct {
|
||||
Temp float64 `json:"temperature_2m"`
|
||||
Code int `json:"weather_code"`
|
||||
} `json:"current"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&antwort); err != nil {
|
||||
return Wetter{}, err
|
||||
}
|
||||
|
||||
glyph, text := wmo(antwort.Current.Code)
|
||||
w := Wetter{
|
||||
Ort: ort,
|
||||
Grad: antwort.Current.Temp,
|
||||
Code: antwort.Current.Code,
|
||||
Text: text,
|
||||
Glyph: glyph,
|
||||
Stand: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
d.cache[schlüssel] = wetterEintrag{w: w, bis: time.Now().Add(wetterFrisch)}
|
||||
d.mu.Unlock()
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Suche findet Orte zu einem Namen. Nur für die Einstellungen: Der gefundene
|
||||
// Ort wird mit seinen Koordinaten gespeichert, damit die spätere Abfrage nicht
|
||||
// jedes Mal erneut sucht.
|
||||
func (d *Dienst) Suche(ctx context.Context, name string) ([]Ort, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("kein Suchbegriff")
|
||||
}
|
||||
|
||||
u, _ := url.Parse(d.sucheURL)
|
||||
q := u.Query()
|
||||
q.Set("name", name)
|
||||
q.Set("count", "6")
|
||||
q.Set("language", "de")
|
||||
q.Set("format", "json")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("ortssuche antwortet %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var antwort struct {
|
||||
Results []struct {
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
Admin1 string `json:"admin1"`
|
||||
Lat float64 `json:"latitude"`
|
||||
Lon float64 `json:"longitude"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&antwort); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orte := make([]Ort, 0, len(antwort.Results))
|
||||
for _, r := range antwort.Results {
|
||||
land := r.Country
|
||||
// „Köln, Nordrhein-Westfalen" trennt zwei Treffer besser als
|
||||
// „Köln, Deutschland" — davon gibt es mehrere.
|
||||
if r.Admin1 != "" && r.Admin1 != r.Name {
|
||||
land = r.Admin1 + ", " + r.Country
|
||||
}
|
||||
orte = append(orte, Ort{Name: r.Name, Land: land, Lat: r.Lat, Lon: r.Lon})
|
||||
}
|
||||
return orte, nil
|
||||
}
|
||||
Reference in new issue
Block a user