rewrite settings

This commit is contained in:
nkanaev
2026-05-18 21:38:39 +01:00
parent 847ec3861a
commit 76529c895e
5 changed files with 285 additions and 61 deletions

View File

@@ -47,12 +47,12 @@ func (m *Middleware) Handler(c *router.Context) {
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{ c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
"username": username, "username": username,
"error": "Invalid username/password", "error": "Invalid username/password",
"settings": m.DB.GetSettings(), "settings": m.DB.GetSettings().Map(),
}) })
return return
} }
} }
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{ c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
"settings": m.DB.GetSettings(), "settings": m.DB.GetSettings().Map(),
}) })
} }

View File

@@ -65,7 +65,7 @@ func (s *Server) handler() http.Handler {
func (s *Server) handleIndex(c *router.Context) { func (s *Server) handleIndex(c *router.Context) {
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]any{ c.HTML(http.StatusOK, assets.Template("index.html"), map[string]any{
"settings": s.db.GetSettings(), "settings": s.db.GetSettings().Map(),
"authenticated": s.Username != "" && s.Password != "", "authenticated": s.Username != "" && s.Password != "",
}) })
} }
@@ -423,14 +423,14 @@ func (s *Server) handleSettings(c *router.Context) {
if c.Req.Method == "GET" { if c.Req.Method == "GET" {
c.JSON(http.StatusOK, s.db.GetSettings()) c.JSON(http.StatusOK, s.db.GetSettings())
} else if c.Req.Method == "PUT" { } else if c.Req.Method == "PUT" {
settings := make(map[string]any) var params storage.UpdateSettingsParams
if err := json.NewDecoder(c.Req.Body).Decode(&settings); err != nil { if err := json.NewDecoder(c.Req.Body).Decode(&params); err != nil {
c.Out.WriteHeader(http.StatusBadRequest) c.Out.WriteHeader(http.StatusBadRequest)
return return
} }
if s.db.UpdateSettings(settings) { if s.db.UpdateSettings(params) {
if _, ok := settings["refresh_rate"]; ok { if params.RefreshRate != nil {
s.worker.SetRefreshRate(s.db.GetSettingsValueInt64("refresh_rate")) s.worker.SetRefreshRate(s.db.GetSettings().RefreshRate)
} }
c.Out.WriteHeader(http.StatusOK) c.Out.WriteHeader(http.StatusOK)
} else { } else {

View File

@@ -48,7 +48,7 @@ func (h *Server) GetAddr() string {
} }
func (s *Server) Start() { func (s *Server) Start() {
refreshRate := s.db.GetSettingsValueInt64("refresh_rate") refreshRate := s.db.GetSettings().RefreshRate
s.worker.FindFavicons() s.worker.FindFavicons()
s.worker.StartFeedCleaner() s.worker.StartFeedCleaner()
s.worker.SetRefreshRate(refreshRate) s.worker.SetRefreshRate(refreshRate)

View File

@@ -6,92 +6,166 @@ import (
"log" "log"
) )
func settingsDefaults() map[string]any { type Settings struct {
Filter string `json:"filter"`
Feed string `json:"feed"`
FeedListWidth int `json:"feed_list_width"`
ItemListWidth int `json:"item_list_width"`
SortNewestFirst bool `json:"sort_newest_first"`
ThemeName string `json:"theme_name"`
ThemeFont string `json:"theme_font"`
ThemeSize int `json:"theme_size"`
RefreshRate int64 `json:"refresh_rate"`
Language string `json:"language"`
}
func (s Settings) Map() map[string]any {
return map[string]any{ return map[string]any{
"filter": "", "filter": s.Filter,
"feed": "", "feed": s.Feed,
"feed_list_width": 300, "feed_list_width": s.FeedListWidth,
"item_list_width": 300, "item_list_width": s.ItemListWidth,
"sort_newest_first": true, "sort_newest_first": s.SortNewestFirst,
"theme_name": "light", "theme_name": s.ThemeName,
"theme_font": "", "theme_font": s.ThemeFont,
"theme_size": 1, "theme_size": s.ThemeSize,
"refresh_rate": 0, "refresh_rate": s.RefreshRate,
"language": "en", "language": s.Language,
} }
} }
func (s *Storage) GetSettingsValue(key string) any { func settingsDefaults() Settings {
row := s.db.QueryRow(`select val from settings where key=:key`, sql.Named("key", key)) return Settings{
if row == nil { Filter: "",
return settingsDefaults()[key] Feed: "",
FeedListWidth: 300,
ItemListWidth: 300,
SortNewestFirst: true,
ThemeName: "light",
ThemeFont: "",
ThemeSize: 1,
RefreshRate: 0,
Language: "en",
} }
var val []byte
row.Scan(&val)
if len(val) == 0 {
return nil
}
var valDecoded any
if err := json.Unmarshal([]byte(val), &valDecoded); err != nil {
log.Print(err)
return nil
}
return valDecoded
} }
func (s *Storage) GetSettingsValueInt64(key string) int64 { func (s *Storage) GetSettings() Settings {
val := s.GetSettingsValue(key)
if val != nil {
if fval, ok := val.(float64); ok {
return int64(fval)
}
}
return 0
}
func (s *Storage) GetSettings() map[string]any {
result := settingsDefaults() result := settingsDefaults()
rows, err := s.db.Query(`select key, val from settings;`) rows, err := s.db.Query(`select key, val from settings;`)
if err != nil { if err != nil {
log.Print(err) log.Print(err)
return result return result
} }
defer rows.Close()
for rows.Next() { for rows.Next() {
var key string var key string
var val []byte var val []byte
var valDecoded any
rows.Scan(&key, &val) rows.Scan(&key, &val)
if err = json.Unmarshal([]byte(val), &valDecoded); err != nil {
log.Print(err) switch key {
continue case "filter":
json.Unmarshal(val, &result.Filter)
case "feed":
json.Unmarshal(val, &result.Feed)
case "feed_list_width":
json.Unmarshal(val, &result.FeedListWidth)
case "item_list_width":
json.Unmarshal(val, &result.ItemListWidth)
case "sort_newest_first":
json.Unmarshal(val, &result.SortNewestFirst)
case "theme_name":
json.Unmarshal(val, &result.ThemeName)
case "theme_font":
json.Unmarshal(val, &result.ThemeFont)
case "theme_size":
json.Unmarshal(val, &result.ThemeSize)
case "refresh_rate":
json.Unmarshal(val, &result.RefreshRate)
case "language":
json.Unmarshal(val, &result.Language)
} }
result[key] = valDecoded
} }
return result return result
} }
func (s *Storage) UpdateSettings(kv map[string]any) bool { type UpdateSettingsParams struct {
defaults := settingsDefaults() Filter *string `json:"filter"`
for key, val := range kv { Feed *string `json:"feed"`
if defaults[key] == nil { FeedListWidth *int `json:"feed_list_width"`
continue ItemListWidth *int `json:"item_list_width"`
} SortNewestFirst *bool `json:"sort_newest_first"`
ThemeName *string `json:"theme_name"`
ThemeFont *string `json:"theme_font"`
ThemeSize *int `json:"theme_size"`
RefreshRate *int64 `json:"refresh_rate"`
Language *string `json:"language"`
}
func (s *Storage) UpdateSettings(params UpdateSettingsParams) bool {
tx, err := s.db.Begin()
if err != nil {
log.Print(err)
return false
}
defer tx.Rollback()
update := func(key string, val any) error {
valEncoded, err := json.Marshal(val) valEncoded, err := json.Marshal(val)
if err != nil { if err != nil {
log.Print(err) return err
return false
} }
_, err = s.db.Exec(` _, err = tx.Exec(`
insert into settings (key, val) values (:key, :val) insert into settings (key, val) values (:key, :val)
on conflict (key) do update set val=:val`, on conflict (key) do update set val=:val`,
sql.Named("key", key), sql.Named("key", key),
sql.Named("val", valEncoded), sql.Named("val", valEncoded),
) )
return err
}
var errs []error
if params.Filter != nil {
errs = append(errs, update("filter", *params.Filter))
}
if params.Feed != nil {
errs = append(errs, update("feed", *params.Feed))
}
if params.FeedListWidth != nil {
errs = append(errs, update("feed_list_width", *params.FeedListWidth))
}
if params.ItemListWidth != nil {
errs = append(errs, update("item_list_width", *params.ItemListWidth))
}
if params.SortNewestFirst != nil {
errs = append(errs, update("sort_newest_first", *params.SortNewestFirst))
}
if params.ThemeName != nil {
errs = append(errs, update("theme_name", *params.ThemeName))
}
if params.ThemeFont != nil {
errs = append(errs, update("theme_font", *params.ThemeFont))
}
if params.ThemeSize != nil {
errs = append(errs, update("theme_size", *params.ThemeSize))
}
if params.RefreshRate != nil {
errs = append(errs, update("refresh_rate", *params.RefreshRate))
}
if params.Language != nil {
errs = append(errs, update("language", *params.Language))
}
for _, err := range errs {
if err != nil { if err != nil {
log.Print(err) log.Print(err)
return false return false
} }
} }
if err := tx.Commit(); err != nil {
log.Print(err)
return false
}
return true return true
} }

View File

@@ -0,0 +1,150 @@
package storage
import (
"reflect"
"strings"
"testing"
)
func TestSettingsDefaults(t *testing.T) {
s := testDB()
defer s.Close()
settings := s.GetSettings()
defaults := settingsDefaults()
if !reflect.DeepEqual(settings, defaults) {
t.Errorf("expected defaults %+v, got %+v", defaults, settings)
}
}
func TestUpdateSettings(t *testing.T) {
s := testDB()
defer s.Close()
params := UpdateSettingsParams{
ThemeName: ptr("night"),
FeedListWidth: ptr(400),
RefreshRate: ptr(int64(15)),
}
if ok := s.UpdateSettings(params); !ok {
t.Fatal("UpdateSettings failed")
}
settings := s.GetSettings()
if settings.ThemeName != "night" {
t.Errorf("expected theme_name night, got %s", settings.ThemeName)
}
if settings.FeedListWidth != 400 {
t.Errorf("expected feed_list_width 400, got %d", settings.FeedListWidth)
}
if settings.RefreshRate != 15 {
t.Errorf("expected refresh_rate 15, got %d", settings.RefreshRate)
}
}
func TestGetSettings(t *testing.T) {
s := testDB()
defer s.Close()
s.UpdateSettings(UpdateSettingsParams{Language: ptr("fr")})
settings := s.GetSettings()
if settings.Language != "fr" {
t.Errorf("expected fr, got %v", settings.Language)
}
if settings.ThemeName != "light" {
t.Errorf("expected light, got %v", settings.ThemeName)
}
}
func TestSettingsExhaustive(t *testing.T) {
s := testDB()
defer s.Close()
settingsType := reflect.TypeOf(Settings{})
paramsType := reflect.TypeOf(UpdateSettingsParams{})
settings := s.GetSettings()
m := settings.Map()
for i := 0; i < settingsType.NumField(); i++ {
field := settingsType.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag == "" {
t.Errorf("Field %s missing json tag", field.Name)
continue
}
// json tags might have options like "name,omitempty", take only the first part
jsonKey := strings.Split(jsonTag, ",")[0]
// 1. Check Map()
if _, ok := m[jsonKey]; !ok {
t.Errorf("Key %q (from field %s) missing from Settings.Map()", jsonKey, field.Name)
}
// 2. Check UpdateSettingsParams
foundInParams := false
for j := 0; j < paramsType.NumField(); j++ {
pField := paramsType.Field(j)
pJsonTag := strings.Split(pField.Tag.Get("json"), ",")[0]
if pJsonTag == jsonKey {
foundInParams = true
// Also check it's a pointer
if pField.Type.Kind() != reflect.Ptr {
t.Errorf("Field %s in UpdateSettingsParams should be a pointer", pField.Name)
}
break
}
}
if !foundInParams {
t.Errorf("Key %q (from field %s) missing from UpdateSettingsParams", jsonKey, field.Name)
}
// 3. Test round-trip update
// We'll create a new UpdateSettingsParams and set ONLY this field
paramsValue := reflect.New(paramsType).Elem()
for j := 0; j < paramsType.NumField(); j++ {
pField := paramsType.Field(j)
pJsonTag := strings.Split(pField.Tag.Get("json"), ",")[0]
if pJsonTag == jsonKey {
// Create a new value of the underlying type
val := reflect.New(field.Type).Elem()
switch field.Type.Kind() {
case reflect.String:
val.SetString("test_" + jsonKey)
case reflect.Int, reflect.Int64:
val.SetInt(42)
case reflect.Bool:
val.SetBool(false)
}
paramsValue.Field(j).Set(val.Addr())
break
}
}
if ok := s.UpdateSettings(paramsValue.Interface().(UpdateSettingsParams)); !ok {
t.Errorf("UpdateSettings failed for %q", jsonKey)
}
updated := s.GetSettings()
updatedValue := reflect.ValueOf(updated).Field(i)
switch field.Type.Kind() {
case reflect.String:
if updatedValue.String() != "test_"+jsonKey {
t.Errorf("Round-trip failed for %q: expected %q, got %q (check UpdateSettings/GetSettings switch)", jsonKey, "test_"+jsonKey, updatedValue.String())
}
case reflect.Int, reflect.Int64:
if updatedValue.Int() != 42 {
t.Errorf("Round-trip failed for %q: expected 42, got %d (check UpdateSettings/GetSettings switch)", jsonKey, updatedValue.Int())
}
case reflect.Bool:
if updatedValue.Bool() != false {
t.Errorf("Round-trip failed for %q: expected false, got %v (check UpdateSettings/GetSettings switch)", jsonKey, updatedValue.Bool())
}
}
}
}