From 938b408deb3702a3a4eadfbf5f7039262349922b Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 10:41:31 -0400 Subject: [PATCH] Pre-fill the SearXNG URL from the deployment default The service now reads the optional MEALQUEST_SEARXNG_URL, validates it at startup like any SearXNG URL, and returns it in the recipe search settings as the read-only default_base_url. base_url stays the saved URL only, and PATCH is unchanged: nothing is stored until the household saves. The recipe search card pre-fills the SearXNG URL from default_base_url while no URL is saved, and an edit now clears the last Test result so a stale count or error never sits beside "Save your changes to test them." Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++- cmd/server/main.go | 4 +- deploy/.env.example | 8 +-- deploy/RUNBOOK.md | 24 +++++--- docs/ARCHITECTURE.md | 3 + internal/config/config.go | 11 ++++ internal/config/config_test.go | 5 ++ internal/recipes/searchsettings.go | 57 ++++++++++++------ internal/recipes/searchsettings_test.go | 77 +++++++++++++++++++++++- internal/server/recipesearch_test.go | 2 +- internal/server/server_test.go | 2 +- web/src/settings/AISettingsPage.test.tsx | 72 ++++++++++++++++++++-- web/src/settings/RecipeSearchCard.tsx | 12 +++- web/src/settings/api.ts | 8 ++- web/src/test/ApiProviders.tsx | 1 + 15 files changed, 250 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index f9f43e2..97d7c82 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin # X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset # trusts no peer, which is right for local development. # export MEALQUEST_TRUSTED_PROXY=10.60.1.33 +# Optional: a SearXNG instance the Settings screen suggests as the recipe +# search URL until one is saved. +# export MEALQUEST_SEARXNG_URL=http://localhost:8888 export MEALQUEST_DEFAULT_TZ=America/New_York make dev ``` @@ -53,8 +56,9 @@ make dev http://localhost:5173. Configuration is environment-only and fails fast: every variable above is required except the commented-out session lifetimes (when set they must be positive, with MAX at least IDLE), -trusted proxy list (every entry must be an IP or CIDR) and -`MEALQUEST_DEFAULT_TZ`, and +trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL +(an absolute `http://` or `https://` URL without credentials, a query or a +fragment) and `MEALQUEST_DEFAULT_TZ`, and secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the endpoints the issuer advertises must be `https://`; plain `http://` is accepted only for `localhost`, `127.0.0.1` and `[::1]`. diff --git a/cmd/server/main.go b/cmd/server/main.go index c23b5db..4b78236 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -79,9 +79,9 @@ func run(cfg config.Config, logger *slog.Logger) error { if err != nil { return err } - search, err := recipes.NewSearchSettingsService(db, box) + search, err := recipes.NewSearchSettingsService(db, box, cfg.SearXNGURL) if err != nil { - return err + return fmt.Errorf("%s: %w", config.EnvSearXNGURL, err) } ai, err := aisettings.New(db, box, cfg.PublicURL.String()) diff --git a/deploy/.env.example b/deploy/.env.example index a21cf4a..a908b3f 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -68,10 +68,10 @@ MEALQUEST_SESSION_MAX=4320h # MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret # The Authentik provider's client secret. # MEALQUEST_SEARXNG_URL=http://searxng:8080 -# The SearXNG recipe-search sidecar on the compose network. Planned; not -# read by the service yet. The app takes its recipe-search provider and -# base URL from PATCH /api/v1/settings/recipe-search; see -# deploy/RUNBOOK.md "Recipe search (SearXNG)". +# The SearXNG recipe-search sidecar on the compose network. The Settings +# screen pre-fills it as the SearXNG URL until one is saved; the saved +# provider and base URL come from PATCH /api/v1/settings/recipe-search. +# See deploy/RUNBOOK.md "Recipe search (SearXNG)". # --- Planned; not read by the service yet ------------------------------------- # diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md index fe12662..14adb79 100644 --- a/deploy/RUNBOOK.md +++ b/deploy/RUNBOOK.md @@ -417,15 +417,23 @@ The `searxng` service in `deploy/compose.yaml` is a private SearXNG instance for recipe search. On the compose network it is reachable at `http://searxng:8080`; that is the address to configure in the app. -Starting the sidecar does not connect the app to it. The service does not -read `MEALQUEST_SEARXNG_URL`: `deploy/compose.yaml` sets it for a future -wiring, and today it does nothing. The recipe-search provider and base URL -live in the `recipe_search_settings` row and are set through -`GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the Settings -screen's recipe search; the screen itself is not built yet). Until that row -exists, recipe search answers 409 `not_configured`. +Starting the sidecar does not connect the app to it. The recipe-search +provider and base URL live in the `recipe_search_settings` row and are set +through `GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the +recipe search card on Settings → AI assistant). Until that row exists, +recipe search answers 409 `not_configured`. -After the first deploy, from a signed-in session (every non-`GET` request +`deploy/compose.yaml` sets `MEALQUEST_SEARXNG_URL=http://searxng:8080`. +The service only suggests it: `GET` returns it as `default_base_url`, and +the recipe search card pre-fills the SearXNG URL with it while no URL is +saved. Nothing is stored until the card is saved, and a saved `base_url` +always wins. A set value must be an absolute `http://` or `https://` URL +without credentials, a query or a fragment, or the service refuses to +start; unset, `default_base_url` is `null`. + +To set it up, open Settings → AI assistant, check that the recipe search +card shows SearXNG with `http://searxng:8080`, select **Save recipe search** +and then **Test**. Or, from a signed-in session (every non-`GET` request needs the `X-MealQuest-CSRF` header set to `csrfToken` from `GET /api/v1/me`): diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c6e0e82..226ab04 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -149,6 +149,9 @@ automatic** (D-021). the Brave key (`api_key`, write-only: responses carry only `api_key_set` and `api_key_last4`; an empty string removes it). The result must be usable: SearXNG needs a base_url, Brave a key. + Responses also carry the read-only `default_base_url`, the deployment's + `MEALQUEST_SEARXNG_URL` (or null), which the client pre-fills while no + base_url is saved; only a PATCH of base_url stores a URL. `POST /api/v1/settings/recipe-search/test` searches "chicken tikka masala" and returns `{query, count}`, 409 `not_configured`, or 502 `search_failed` with the provider's error. diff --git a/internal/config/config.go b/internal/config/config.go index 1c1dfd7..c838255 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,8 @@ const ( EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY" EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ" + + EnvSearXNGURL = "MEALQUEST_SEARXNG_URL" ) // Session lifetime defaults (D-004): long enough that the kitchen tablet @@ -73,6 +75,13 @@ type Config struct { // validates it only while the settings do not exist yet, and ignores it // once they do. Empty when unset. DefaultTZ string + + // SearXNGURL is the deployment's SearXNG instance, which the Settings + // screen suggests while no recipe-search URL is saved. It is optional + // and validated by recipes.NewSearchSettingsService: the compose + // network's http://searxng:8080 is neither https nor loopback, so the + // secure-URL rule does not apply. Empty when unset. + SearXNGURL string } // PublicOrigin returns the scheme and host of PublicURL, the only parts of it @@ -107,6 +116,8 @@ func load(lookup func(string) (string, bool)) (Config, error) { TrustedProxy: r.trustedProxy(EnvTrustedProxy), DefaultTZ: r.optional(EnvDefaultTZ), + + SearXNGURL: r.optional(EnvSearXNGURL), } if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle { r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 937474d..91e24c7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -422,6 +422,8 @@ func TestLoadValues(t *testing.T) { EnvOIDCAllowedGroups: " mealquest-admin ,family", EnvDefaultTZ: "Europe/London", + + EnvSearXNGURL: "http://searxng:8080", } cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) if err != nil { @@ -480,6 +482,9 @@ func TestLoadValues(t *testing.T) { if cfg.DefaultTZ != "Europe/London" { t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ) } + if cfg.SearXNGURL != "http://searxng:8080" { + t.Errorf("SearXNGURL = %q, want http://searxng:8080", cfg.SearXNGURL) + } } func TestSecretRedacted(t *testing.T) { diff --git a/internal/recipes/searchsettings.go b/internal/recipes/searchsettings.go index 55d148b..39c8f5c 100644 --- a/internal/recipes/searchsettings.go +++ b/internal/recipes/searchsettings.go @@ -30,10 +30,15 @@ var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configure type SearchSettings struct { // Provider is "searxng" or "brave", nil until first chosen. Provider *string `json:"provider"` - // BaseURL is the SearXNG instance's URL, nil when not set. - BaseURL *string `json:"base_url"` - APIKeySet bool `json:"api_key_set"` - APIKeyLast4 *string `json:"api_key_last4"` + // BaseURL is the saved SearXNG instance's URL, nil when not set. + BaseURL *string `json:"base_url"` + // DefaultBaseURL is the deployment's SearXNG URL + // (MEALQUEST_SEARXNG_URL), for the client to suggest while BaseURL is + // not set. It is never saved by itself: only a PATCH of base_url saves + // a URL. Nil when the deployment names none. + DefaultBaseURL *string `json:"default_base_url"` + APIKeySet bool `json:"api_key_set"` + APIKeyLast4 *string `json:"api_key_last4"` // UpdatedAt is nil until the settings are first saved. UpdatedAt *string `json:"updated_at"` } @@ -75,32 +80,49 @@ func (e *SearchSettingsError) Error() string { type SearchSettingsService struct { st *store.Store box *secretbox.Box + // defaultBaseURL is SearchSettings.DefaultBaseURL, "" for none. + defaultBaseURL string // newProvider is NewSearchProvider; tests point Brave elsewhere. newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error) } // NewSearchSettingsService returns the service over st, sealing keys with -// box. -func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) { +// box. defaultBaseURL is the deployment's SearXNG URL, "" for none; a set +// one must be a valid SearXNG URL. +func NewSearchSettingsService(st *store.Store, box *secretbox.Box, defaultBaseURL string) (*SearchSettingsService, error) { if st == nil { return nil, errors.New("recipes: nil store") } if box == nil { return nil, errors.New("recipes: nil secret box") } - return &SearchSettingsService{st: st, box: box, newProvider: NewSearchProvider}, nil + if defaultBaseURL != "" { + if _, err := NewSearXNG(defaultBaseURL); err != nil { + return nil, fmt.Errorf("recipes: default SearXNG URL: %s", strings.TrimPrefix(err.Error(), "recipes: ")) + } + } + return &SearchSettingsService{st: st, box: box, defaultBaseURL: defaultBaseURL, newProvider: NewSearchProvider}, nil } -// Get returns the settings; before any are saved every field is unset. +// Get returns the settings; before any are saved every field but +// DefaultBaseURL is unset. func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) { row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx) if errors.Is(err, sql.ErrNoRows) { - return SearchSettings{}, nil + return SearchSettings{DefaultBaseURL: s.defaultBase()}, nil } if err != nil { return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err) } - return searchSettingsFrom(row), nil + return s.settingsFrom(row), nil +} + +func (s *SearchSettingsService) defaultBase() *string { + if s.defaultBaseURL == "" { + return nil + } + u := s.defaultBaseURL + return &u } // Update applies patch and returns the settings it leaves. The first update @@ -141,7 +163,7 @@ func (s *SearchSettingsService) Update(ctx context.Context, patch SearchSettings if err != nil { return SearchSettings{}, err } - return searchSettingsFrom(saved), nil + return s.settingsFrom(saved), nil } // apply returns row with patch applied, validated. @@ -226,14 +248,15 @@ func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) { return len(cands), nil } -func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings { +func (s *SearchSettingsService) settingsFrom(row db.RecipeSearchSetting) SearchSettings { provider, updated := row.Provider, row.UpdatedAt return SearchSettings{ - Provider: &provider, - BaseURL: nullString(row.BaseUrl), - APIKeySet: len(row.ApiKeyCt) > 0, - APIKeyLast4: nullString(row.ApiKeyLast4), - UpdatedAt: &updated, + Provider: &provider, + BaseURL: nullString(row.BaseUrl), + DefaultBaseURL: s.defaultBase(), + APIKeySet: len(row.ApiKeyCt) > 0, + APIKeyLast4: nullString(row.ApiKeyLast4), + UpdatedAt: &updated, } } diff --git a/internal/recipes/searchsettings_test.go b/internal/recipes/searchsettings_test.go index 0de2ad5..d2dcc66 100644 --- a/internal/recipes/searchsettings_test.go +++ b/internal/recipes/searchsettings_test.go @@ -19,6 +19,13 @@ import ( const testBraveKey = "BSA-secret-brave-key-9f3c" func newTestSearchSettings(t *testing.T) *SearchSettingsService { + t.Helper() + return newTestSearchSettingsWithDefault(t, "") +} + +// newTestSearchSettingsWithDefault is newTestSearchSettings with +// defaultBaseURL as the deployment's SearXNG URL. +func newTestSearchSettingsWithDefault(t *testing.T, defaultBaseURL string) *SearchSettingsService { t.Helper() st, err := store.Open(t.Context(), t.TempDir()) if err != nil { @@ -38,7 +45,7 @@ func newTestSearchSettings(t *testing.T) *SearchSettingsService { if err != nil { t.Fatal(err) } - svc, err := NewSearchSettingsService(st, box) + svc, err := NewSearchSettingsService(st, box, defaultBaseURL) if err != nil { t.Fatal(err) } @@ -124,6 +131,74 @@ func TestSearchSettingsUpdate(t *testing.T) { } } +func TestSearchSettingsDefaultBaseURL(t *testing.T) { + t.Parallel() + svc := newTestSearchSettingsWithDefault(t, "http://searxng:8080") + got, err := svc.Get(t.Context()) + if err != nil { + t.Fatal(err) + } + if got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" || got.BaseURL != nil || got.Provider != nil { + t.Errorf("Get before any update = %+v, want only default_base_url set", got) + } + if _, err := svc.Provider(t.Context()); !errors.Is(err, ErrSearchNotConfigured) { + t.Errorf("Provider with only a default error = %v, want ErrSearchNotConfigured", err) + } + + // The default is a suggestion: SearXNG still needs a saved URL. + if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng")}); err == nil { + t.Error("choosing SearXNG without a URL succeeded with a default set") + } + + got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr("https://search.example")}) + if err != nil { + t.Fatal(err) + } + if *got.BaseURL != "https://search.example" || got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" { + t.Errorf("Update result = %+v, want the saved URL beside the default", got) + } + got, err = svc.Get(t.Context()) + if err != nil { + t.Fatal(err) + } + if *got.BaseURL != "https://search.example" { + t.Errorf("Get base_url = %q, want the saved URL, not the default", *got.BaseURL) + } + row, err := db.New(svc.st.Reader).GetRecipeSearchSettings(t.Context()) + if err != nil { + t.Fatal(err) + } + if row.BaseUrl.String != "https://search.example" { + t.Errorf("stored base_url = %q", row.BaseUrl.String) + } +} + +func TestNewSearchSettingsServiceRejectsBadDefault(t *testing.T) { + t.Parallel() + st, err := store.Open(t.Context(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + for _, u := range []string{"searxng:8080", "ftp://searxng", "http://user:pw@searxng:8080"} { + if _, err := NewSearchSettingsService(st, box, u); err == nil { + t.Errorf("NewSearchSettingsService(%q) succeeded", u) + } else if strings.Contains(err.Error(), "pw") { + t.Errorf("error for %q echoes the URL: %v", u, err) + } + } +} + func TestSearchSettingsTestSearch(t *testing.T) { t.Parallel() searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 4b60f41..09ec29e 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -46,7 +46,7 @@ func TestRecipeSearchSettingsAPI(t *testing.T) { } rec, body := call(http.MethodGet, recipeSearchPath, "") - if rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false { + if def, ok := body["default_base_url"]; rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false || !ok || def != nil { t.Errorf("GET unconfigured = %d %v", rec.Code, body) } rec, body = call(http.MethodPost, recipeSearchTestPath, "") diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8e16ff6..1704af7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -132,7 +132,7 @@ func newTestPhotos(t *testing.T) *recipes.PhotoCache { // configured, over a migrated temp database and a random secret box. func newTestSearch(t *testing.T) *recipes.SearchSettingsService { t.Helper() - search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t)) + search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t), "") if err != nil { t.Fatal(err) } diff --git a/web/src/settings/AISettingsPage.test.tsx b/web/src/settings/AISettingsPage.test.tsx index af9ed55..903fabd 100644 --- a/web/src/settings/AISettingsPage.test.tsx +++ b/web/src/settings/AISettingsPage.test.tsx @@ -462,16 +462,52 @@ describe('Recipe search card', () => { expect(testSearch()).toBeEnabled() }) - it('starts on SearXNG with an empty URL before recipe search is set up', async () => { - stubAPI({ - 'GET /api/v1/settings/recipe-search': () => - jsonResponse(200, { provider: null, base_url: null, api_key_set: false, api_key_last4: null, updated_at: null }), + const UNCONFIGURED = { + provider: null, + base_url: null, + default_base_url: 'http://searxng:8080', + api_key_set: false, + api_key_last4: null, + updated_at: null, + } + + it('starts on SearXNG with the deployment\'s URL before recipe search is set up, and saves it', async () => { + const fetchMock = stubAPI({ + 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED), + 'PATCH /api/v1/settings/recipe-search': (body) => + jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }), }) await renderCard() expect(searxng()).toHaveAttribute('aria-checked', 'true') - expect(url()).toHaveValue('') + expect(url()).toHaveValue('http://searxng:8080') expect(testSearch()).toBeDisabled() expect(saveSearch()).toBeEnabled() + fireEvent.click(saveSearch()) + expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { + request: 'PATCH /api/v1/settings/recipe-search', + body: { provider: 'searxng', base_url: 'http://searxng:8080' }, + }, + ]) + }) + + it('starts with an empty URL when the deployment names no SearXNG', async () => { + stubAPI({ + 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, { ...UNCONFIGURED, default_base_url: null }), + }) + await renderCard() + expect(url()).toHaveValue('') + }) + + it('shows the saved URL, not the deployment\'s', async () => { + stubAPI({ + 'GET /api/v1/settings/recipe-search': () => + jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }), + }) + await renderCard() + expect(url()).toHaveValue('https://search.example') + expect(saveSearch()).toBeDisabled() }) it('switches providers, keeping each provider\'s field', async () => { @@ -587,6 +623,32 @@ describe('Recipe search card', () => { ) }) + it('clears a test result when the settings are edited', async () => { + stubAPI({ + 'POST /api/v1/settings/recipe-search/test': () => + jsonResponse(200, { query: 'chicken tikka masala', count: 12 }), + }) + await renderCard() + fireEvent.click(testSearch()) + expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument() + fireEvent.change(url(), { target: { value: 'https://search.example' } }) + expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument() + expect(within(card()).queryByText(/Found 12 recipes/)).not.toBeInTheDocument() + }) + + it('clears a test error when the settings are edited', async () => { + stubAPI({ + 'POST /api/v1/settings/recipe-search/test': () => + apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'), + }) + await renderCard() + fireEvent.click(testSearch()) + expect(await within(card()).findByRole('alert')).toHaveTextContent('Test failed') + fireEvent.click(brave()) + expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument() + expect(within(card()).queryByRole('alert')).not.toBeInTheDocument() + }) + it('asks before leaving with an unsaved recipe search change', async () => { stubAPI() await renderCard() diff --git a/web/src/settings/RecipeSearchCard.tsx b/web/src/settings/RecipeSearchCard.tsx index 1aa12ad..9d54064 100644 --- a/web/src/settings/RecipeSearchCard.tsx +++ b/web/src/settings/RecipeSearchCard.tsx @@ -61,9 +61,14 @@ type Draft = { } // draftOf starts a household that has not set up recipe search on SearXNG, -// the sidecar every deployment runs. +// the sidecar every deployment runs. With no URL saved the field holds the +// deployment's SearXNG URL, which saving stores. function draftOf(saved: RecipeSearchSettings): Draft { - return { provider: saved.provider ?? 'searxng', base_url: saved.base_url ?? '', api_key: null } + return { + provider: saved.provider ?? 'searxng', + base_url: saved.base_url ?? saved.default_base_url ?? '', + api_key: null, + } } // patchOf is the PATCH body for what draft changes. Only the chosen @@ -88,8 +93,11 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting }, [dirty, onDirtyChange]) useEffect(() => () => onDirtyChange(false), [onDirtyChange]) + // An edit clears the last save and test results: they describe the saved + // settings, not the draft. function edit(change: Partial) { save.reset() + test.reset() setDraft((current) => ({ ...current, ...change })) } diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts index ea7b0ed..80f8c25 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -209,8 +209,12 @@ export type RecipeSearchProvider = keyof typeof recipeSearchProviderLabels export type RecipeSearchSettings = { // Null until recipe search is first set up. provider: RecipeSearchProvider | null - // The SearXNG instance's URL, null when none is set. + // The saved SearXNG instance's URL, null when none is set. base_url: string | null + // The deployment's SearXNG URL (MEALQUEST_SEARXNG_URL) to suggest while + // base_url is null; null when the deployment names none. Only saving + // base_url stores a URL. + default_base_url: string | null api_key_set: boolean api_key_last4: string | null updated_at: string | null @@ -237,6 +241,7 @@ function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSe !isRecord(body) || !isStringOrNull(body.provider) || !isStringOrNull(body.base_url) || + !isStringOrNull(body.default_base_url) || typeof body.api_key_set !== 'boolean' || !isStringOrNull(body.api_key_last4) || !isStringOrNull(body.updated_at) @@ -249,6 +254,7 @@ function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSe return { provider: body.provider, base_url: body.base_url, + default_base_url: body.default_base_url, api_key_set: body.api_key_set, api_key_last4: body.api_key_last4, updated_at: body.updated_at, diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index bad365d..81d20c2 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -21,6 +21,7 @@ export const AI_SETTINGS = { export const RECIPE_SEARCH_SETTINGS = { provider: 'searxng', base_url: 'http://searxng:8080', + default_base_url: 'http://searxng:8080', api_key_set: false, api_key_last4: null, updated_at: '2026-10-01T15:00:00.000Z',