Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions core/src/services/UnifiedSearchController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { search as unifiedSearch } from './UnifiedSearchService.js'

type CategorySearchStatus = 'loading' | 'loaded' | 'failed' | 'blocked'

interface CategorySearchState {
status: CategorySearchStatus
entries: unknown[]
cursor: string | null
hasMore: boolean
loadMoreFailed: boolean
}
Comment thread
pringelmann marked this conversation as resolved.

export const REVEAL_INTERVAL_MS = 1500

/**
* Runs a unified search across categories in priority order, blocking
* lower-priority results until their predecessors arrive or a timer reveals them.
*/
export class UnifiedSearchController {
private query: string = ''
private searchStates: Record<string, CategorySearchState> = {}
private searchGeneration: number = 0
private revealTimer: ReturnType<typeof setTimeout> | null = null
private pendingCancels: (() => void)[] = []

/**
* Start a search. Cancels and replaces any search already in flight.
*
* @param query the search term
* @param categories category ids in priority order
* @return resolves once every category has settled
*/
async search(query: string, categories: string[]): Promise<void> {
this.cancelPendingRequests()
this.searchStates = {}
this.searchGeneration++
const generation = this.searchGeneration
this.query = query

this.startRevealTimer()

await Promise.allSettled(categories.map((category) => this.searchCategory(category, generation, categories)))
}

/**
* Fetch the next page for one category and append it. A no-op unless the
* category is loaded with more pages. On failure the existing results stay
* and `loadMoreFailed` is raised, so calling again retries.
*
* @param category the category id to page
*/
async loadMore(category: string): Promise<void> {
const generation = this.searchGeneration
const categoryState = this.searchStates[category]
if (!categoryState || !categoryState.hasMore || categoryState.status !== 'loaded') {
return
}
categoryState.status = 'loading'
categoryState.loadMoreFailed = false

const { request, cancel } = unifiedSearch({
type: category,
query: this.query,
cursor: categoryState.cursor,
})

this.pendingCancels.push(cancel)

try {
const response = await request()
if (this.searchGeneration !== generation) {
return
}
const { entries, cursor, hasMore } = response.data.ocs.data
categoryState.entries.push(...entries)
categoryState.cursor = cursor
categoryState.hasMore = hasMore
} catch {
if (this.searchGeneration !== generation) {
return
}
categoryState.loadMoreFailed = true
}

categoryState.status = 'loaded'
}

/**
* A shallow copy of the current per-category state, safe to read for rendering.
*
* @return the current search states keyed by category id
*/
getSnapshot(): Record<string, CategorySearchState> {
return { ...this.searchStates }
}

/**
* Tear down on unmount: cancels in-flight requests and stops the reveal timer.
*/
dispose(): void {
this.cancelPendingRequests()
this.stopRevealTimer()
}

private async searchCategory(category: string, generation: number, categories: string[]): Promise<void> {
this.searchStates[category] = {
status: 'loading',
entries: [],
cursor: null,
hasMore: false,
loadMoreFailed: false,
}
const { request, cancel } = unifiedSearch({
type: category,
query: this.query,
cursor: null,
})

this.pendingCancels.push(cancel)

try {
const response = await request()
if (this.searchGeneration !== generation) {
// A new search has been started, ignore this result
return
}

const { entries, cursor, hasMore } = response.data.ocs.data
this.searchStates[category] = {
status: 'loaded',
entries,
cursor,
hasMore,
loadMoreFailed: false,
}
} catch {
if (this.searchGeneration !== generation) {
return
}
this.searchStates[category] = {
status: 'failed',
entries: [],
cursor: null,
hasMore: false,
loadMoreFailed: false,
}
}

this.reconcileCategoryStatuses(categories)
}

private reconcileCategoryStatuses(categories: string[]): void {
categories.forEach((category) => {
if (['loading', 'failed'].includes(this.searchStates[category].status)) {
return
}
this.searchStates[category].status = this.shouldBlockCategory(category, categories) ? 'blocked' : 'loaded'
})
}

private startRevealTimer(): void {
this.stopRevealTimer()
this.revealTimer = setTimeout(() => {
const categories = Object.keys(this.searchStates)
const hasPendingCategories = categories.some((category) => ['loading', 'blocked'].includes(this.searchStates[category].status))
this.unblockAllCategories(categories)
if (hasPendingCategories) {
this.startRevealTimer()
}
}, REVEAL_INTERVAL_MS)
}

private stopRevealTimer(): void {
if (this.revealTimer) {
clearTimeout(this.revealTimer)
this.revealTimer = null
}
}

private cancelPendingRequests(): void {
this.pendingCancels.forEach((cancel) => cancel())
this.pendingCancels = []
}

private unblockAllCategories(categories: string[]): void {
categories.forEach((category) => {
if (this.searchStates[category].status === 'blocked') {
this.searchStates[category].status = 'loaded'
}
})
}

private shouldBlockCategory(category: string, categories: string[]): boolean {
if (!this.searchStates[category]) {
return false
}

return categories.slice(0, categories.indexOf(category)).some((c) => {
const categoryState = this.searchStates[c]
return categoryState && ['loading', 'blocked'].includes(categoryState.status)
})
}
}
14 changes: 7 additions & 7 deletions core/src/services/UnifiedSearchService.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ export async function getProviders() {
*
* @param {object} options destructuring object
* @param {string} options.type the type to search
* @param {string} options.query the search
* @param {number|string|undefined} options.cursor the offset for paginated searches
* @param {string} options.since the search
* @param {string} options.until the search
* @param {string} options.limit the search
* @param {string} options.person the search
* @param {object} options.extraQueries additional queries to filter search results

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small cleanup since the param docs were copy-paste stubs. No behaviour change.

* @param {string} options.query the search term
* @param {number|string|null} [options.cursor] the offset for paginated searches
* @param {string} [options.since] start of the date-range filter
* @param {string} [options.until] end of the date-range filter
* @param {string} [options.limit] maximum number of results
* @param {string} [options.person] filter results by person
* @param {object} [options.extraQueries] additional queries to filter search results
* @return {object} {request: Promise, cancel: Promise}
*/
export function search({ type, query, cursor, since, until, limit, person, extraQueries = {} }) {
Expand Down
Loading
Loading