From feb67ab5003d2450d14948eca65dadab0a4abb1e Mon Sep 17 00:00:00 2001 From: Donkie Date: Sun, 13 Sep 2026 12:19:50 +0200 Subject: [PATCH] feat(external): page through SpoolmanDB search results The filament pickers only ever showed the first 8 SpoolmanDB matches, so entries further down a broad search could not be reached. They now load 20 at a time and fetch the next page when the result list is scrolled to the bottom. GET /external/filament/search takes an optional offset and returns the total match count in x-total-count, like the other list endpoints. Requests without offset behave as before. The lowercased search text is built once per catalog load instead of on every search, since paging means one scan per page. Fixes #1136 --- .../src/lib/api/externalSearch.svelte.ts | 94 +++++++++++++++++++ client_v2/src/lib/api/spoolSource.ts | 12 ++- .../src/lib/components/AddSpoolModal.svelte | 29 +++--- .../library/ChangeFilamentModal.svelte | 27 +++--- spoolman/api/v1/externaldb.py | 13 ++- spoolman/externaldb.py | 33 +++---- tests/test_externaldb_search.py | 52 ++++++++++ 7 files changed, 207 insertions(+), 53 deletions(-) create mode 100644 client_v2/src/lib/api/externalSearch.svelte.ts create mode 100644 tests/test_externaldb_search.py diff --git a/client_v2/src/lib/api/externalSearch.svelte.ts b/client_v2/src/lib/api/externalSearch.svelte.ts new file mode 100644 index 00000000..cf99676c --- /dev/null +++ b/client_v2/src/lib/api/externalSearch.svelte.ts @@ -0,0 +1,94 @@ +import { spoolSource } from './spoolSource'; +import type { ExternalFilament } from './external'; + +/** Rows per request. Enough to overflow a picker's result list, so there is something to scroll. */ +const PAGE = 20; +/** Distance from the bottom of the result list at which the next page is fetched. */ +const SCROLL_MARGIN = 120; + +/** + * The external-catalog half of a filament picker's search (#1136): `search` loads the + * first page of matches, and scrolling the result list towards its end appends the next. + * Shared by the add-spools and change-filament dialogs. + */ +export class ExternalSearch { + items = $state([]); + error = $state(false); + loadingMore = $state(false); + #total = 0; + #query = ''; + #loading = false; + // Bumped by every new search, so a page that lands after the query changed is dropped. + #req = 0; + #ctrl: AbortController | undefined; + // The result list, remembered from its first scroll event. + #list: HTMLElement | undefined; + + async search(query: string): Promise { + const req = this.#restart(); + this.#query = query.trim(); + this.#loading = true; + this.error = false; + try { + const page = await spoolSource.searchExternalFilaments(this.#query, PAGE, 0, this.#ctrl!.signal); + if (req !== this.#req) return; + this.items = page.items; + this.#total = page.total; + // A new query starts at the top: left scrolled down, the list would fetch + // its second page straight away. + this.#list?.scrollTo(0, 0); + } catch { + if (req !== this.#req) return; + this.items = []; + this.#total = 0; + this.error = true; + } finally { + if (req === this.#req) this.#loading = false; + } + } + + async more(): Promise { + if (this.#loading || this.loadingMore || this.error || this.items.length >= this.#total) return; + const req = this.#req; + this.loadingMore = true; + try { + const page = await spoolSource.searchExternalFilaments( + this.#query, + PAGE, + this.items.length, + this.#ctrl!.signal + ); + if (req !== this.#req) return; + // A catalog sync between two pages shifts the offsets and can hand back a row + // that is already listed; a repeated id would break the keyed list. + const fresh = page.items.filter((f) => !this.items.some((e) => e.id === f.id)); + this.items = [...this.items, ...fresh]; + this.#total = page.total; + } catch (e) { + if (req === this.#req) console.error('Failed to load more external filaments', e); + } finally { + if (req === this.#req) this.loadingMore = false; + } + } + + /** Forget the results, dropping any request still in flight. */ + clear(): void { + this.#restart(); + this.items = []; + this.#total = 0; + } + + /** Scroll handler for the result list. An arrow function so it can be passed as-is. */ + onscroll = (e: Event) => { + const el = (this.#list = e.currentTarget as HTMLElement); + if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_MARGIN) void this.more(); + }; + + #restart(): number { + this.#ctrl?.abort(); + this.#ctrl = new AbortController(); + this.#loading = false; + this.loadingMore = false; + return ++this.#req; + } +} diff --git a/client_v2/src/lib/api/spoolSource.ts b/client_v2/src/lib/api/spoolSource.ts index 946f6b07..02e146b3 100644 --- a/client_v2/src/lib/api/spoolSource.ts +++ b/client_v2/src/lib/api/spoolSource.ts @@ -234,12 +234,18 @@ class HttpSpoolSource { return filaments.map((m) => m.entity).slice(0, limit); } - async searchExternalFilaments(query: string, limit = 8): Promise { + async searchExternalFilaments( + query: string, + limit: number, + offset = 0, + signal?: AbortSignal + ): Promise> { // Filtering happens server-side (/external/filament/search) so the whole catalog // — thousands of entries — never has to be downloaded to the client. const q = query.trim(); - if (!q) return []; - return getJson('/external/filament/search', { query: q, limit }); + if (!q) return { items: [], total: 0 }; + const { items, total } = await getList('/external/filament/search', { query: q, limit, offset }, signal); + return { items: items as ExternalFilament[], total }; } /** diff --git a/client_v2/src/lib/components/AddSpoolModal.svelte b/client_v2/src/lib/components/AddSpoolModal.svelte index 71f9bfcc..6487bd9d 100644 --- a/client_v2/src/lib/components/AddSpoolModal.svelte +++ b/client_v2/src/lib/components/AddSpoolModal.svelte @@ -17,6 +17,7 @@ import { fields } from '$lib/stores/fields.svelte'; import type { EntityType } from '$lib/api/fields'; import { externalColors, externalDirection, type ExternalFilament } from '$lib/api/external'; + import { ExternalSearch } from '$lib/api/externalSearch.svelte'; import { roundGrams, weightAuto } from '$lib/utils/format'; import { numErr } from '$lib/utils/validate'; import { @@ -49,9 +50,8 @@ let query = $state(''); let searchInput = $state(); let localResults = $state([]); - let externalResults = $state([]); + const extSearch = new ExternalSearch(); let searching = $state(false); - let extError = $state(false); let chosen = $state(null); let creating = $state(false); let submitting = $state(false); @@ -123,17 +123,11 @@ } async function runSearch() { searching = true; - extError = false; - const [local, external] = await Promise.allSettled([ + const [local] = await Promise.allSettled([ spoolSource.searchFilaments(query.trim()), - spoolSource.searchExternalFilaments(query.trim()) + extSearch.search(query) ]); localResults = local.status === 'fulfilled' ? local.value : []; - if (external.status === 'fulfilled') externalResults = external.value; - else { - externalResults = []; - extError = true; - } searching = false; } @@ -339,7 +333,7 @@ step = 1; query = ''; localResults = []; - externalResults = []; + extSearch.clear(); chosen = null; creating = false; cloneSource = null; @@ -536,7 +530,7 @@ oninput={(e) => onSearch(e.currentTarget.value)} placeholder={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })} /> -
+
{m['add.yourCatalog']()}
{#if searching && localResults.length === 0}
{m['add.searching']()}
@@ -562,16 +556,16 @@ {/if}
{serverInfo.externalDbName}
- {#if extError} + {#if extSearch.error}
{m['add.dbUnavailable']({ name: serverInfo.externalDbName })}
- {:else if searching && externalResults.length === 0} + {:else if searching && extSearch.items.length === 0}
{m['add.searching']()}
- {:else if externalResults.length === 0} + {:else if extSearch.items.length === 0}
{query.trim() ? m['add.typeToSearchMatches']() : m['add.typeToSearchAll']()}
{:else} - {#each externalResults as ext (ext.id)} + {#each extSearch.items as ext (ext.id)} {/each} + {#if extSearch.loadingMore} +
{m['add.searching']()}
+ {/if} {/if}
diff --git a/client_v2/src/lib/components/library/ChangeFilamentModal.svelte b/client_v2/src/lib/components/library/ChangeFilamentModal.svelte index 23bbcbe0..0330ff05 100644 --- a/client_v2/src/lib/components/library/ChangeFilamentModal.svelte +++ b/client_v2/src/lib/components/library/ChangeFilamentModal.svelte @@ -20,6 +20,7 @@ import { spoolSource } from '$lib/api/spoolSource'; import { fields } from '$lib/stores/fields.svelte'; import { externalColors, externalDirection, type ExternalFilament } from '$lib/api/external'; + import { ExternalSearch } from '$lib/api/externalSearch.svelte'; import { toasts } from '$lib/stores/toasts.svelte'; import { weightAuto } from '$lib/utils/format'; import { parseDecimal } from '$lib/utils/numeric'; @@ -53,9 +54,8 @@ let query = $state(''); let searchInput = $state(); let localResults = $state([]); - let externalResults = $state([]); + const extSearch = new ExternalSearch(); let searching = $state(false); - let extError = $state(false); let chosen = $state(null); let busy = $state(false); @@ -187,17 +187,11 @@ } async function runSearch() { searching = true; - extError = false; - const [local, external] = await Promise.allSettled([ + const [local] = await Promise.allSettled([ spoolSource.searchFilaments(query.trim()), - spoolSource.searchExternalFilaments(query.trim()) + extSearch.search(query) ]); localResults = local.status === 'fulfilled' ? local.value : []; - if (external.status === 'fulfilled') externalResults = external.value; - else { - externalResults = []; - extError = true; - } searching = false; } @@ -434,7 +428,7 @@ placeholder={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })} aria-label={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })} /> -
+
{m['add.yourCatalog']()}
{#if searching && localResults.length === 0}
{m['add.searching']()}
@@ -465,16 +459,16 @@ {/if}
{serverInfo.externalDbName}
- {#if extError} + {#if extSearch.error}
{m['add.dbUnavailable']({ name: serverInfo.externalDbName })}
- {:else if searching && externalResults.length === 0} + {:else if searching && extSearch.items.length === 0}
{m['add.searching']()}
- {:else if externalResults.length === 0} + {:else if extSearch.items.length === 0}
{query.trim() ? m['add.typeToSearchMatches']() : m['add.typeToSearchAll']()}
{:else} - {#each externalResults as ext (ext.id)} + {#each extSearch.items as ext (ext.id)} {/each} + {#if extSearch.loadingMore} +
{m['add.searching']()}
+ {/if} {/if}