Skip to content

Commit 4044e28

Browse files
committed
feat: Add server-side search with pagination support
This commit implements server-side search functionality with full pagination support: 1. API Support: Added search parameter to NotesApiController index() endpoint, allowing client to pass search queries to filter notes by title on the server side. 2. Category Statistics: Added X-Notes-Category-Stats and X-Notes-Total-Count headers on first chunk to provide accurate counts even during pagination, enabling proper category display without loading all notes. 3. Client Search Service: Implemented searchNotes() function in NotesService.js with full chunked pagination support, debouncing, and proper state management. 4. Performance: Search operates on chunked data (50 notes at a time) with progressive loading, maintaining UI responsiveness even with large note collections. Files modified: - lib/Controller/NotesApiController.php: Add search param and stats headers - src/NotesService.js: Implement searchNotes() with pagination - src/components/NoteRich.vue: Minor adjustments for search support
1 parent a8b021d commit 4044e28

File tree

3 files changed

+99
-3
lines changed

3 files changed

+99
-3
lines changed

lib/Controller/NotesApiController.php

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,22 @@ public function index(
6262
int $pruneBefore = 0,
6363
int $chunkSize = 0,
6464
?string $chunkCursor = null,
65+
?string $search = null,
6566
) : JSONResponse {
6667
return $this->helper->handleErrorResponse(function () use (
6768
$category,
6869
$exclude,
6970
$pruneBefore,
7071
$chunkSize,
71-
$chunkCursor
72+
$chunkCursor,
73+
$search
7274
) {
7375
// initialize settings
7476
$userId = $this->helper->getUID();
7577
$this->settingsService->getAll($userId, true);
7678
// load notes and categories
7779
$exclude = explode(',', $exclude);
78-
$data = $this->helper->getNotesAndCategories($pruneBefore, $exclude, $category, $chunkSize, $chunkCursor);
80+
$data = $this->helper->getNotesAndCategories($pruneBefore, $exclude, $category, $chunkSize, $chunkCursor, $search);
7981
$notesData = $data['notesData'];
8082
if (!$data['chunkCursor']) {
8183
// if last chunk, then send all notes (pruned) with full metadata
@@ -90,6 +92,19 @@ public function index(
9092
$response->addHeader('X-Notes-Chunk-Cursor', $data['chunkCursor']->toString());
9193
$response->addHeader('X-Notes-Chunk-Pending', $data['numPendingNotes']);
9294
}
95+
// Add category statistics and total count on first chunk only (when no cursor provided)
96+
if ($chunkCursor === null) {
97+
$categoryStats = [];
98+
foreach ($data['notesAll'] as $metaNote) {
99+
$cat = $metaNote->note->getCategory();
100+
if (!isset($categoryStats[$cat])) {
101+
$categoryStats[$cat] = 0;
102+
}
103+
$categoryStats[$cat]++;
104+
}
105+
$response->addHeader('X-Notes-Category-Stats', json_encode($categoryStats));
106+
$response->addHeader('X-Notes-Total-Count', (string)count($data['notesAll']));
107+
}
93108
return $response;
94109
});
95110
}

src/NotesService.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,24 @@ export const fetchNotes = async (chunkSize = 50, chunkCursor = null) => {
126126
const pendingCount = response.headers['x-notes-chunk-pending'] ? parseInt(response.headers['x-notes-chunk-pending']) : 0
127127
const isLastChunk = !nextCursor
128128

129+
// Category statistics and total count from first chunk (if available)
130+
const categoryStats = response.headers['x-notes-category-stats']
131+
if (categoryStats) {
132+
try {
133+
const stats = JSON.parse(categoryStats)
134+
console.log('[fetchNotes] Received category stats:', Object.keys(stats).length, 'categories')
135+
store.commit('setCategoryStats', stats)
136+
} catch (e) {
137+
console.warn('[fetchNotes] Failed to parse category stats:', e)
138+
}
139+
}
140+
const totalCount = response.headers['x-notes-total-count']
141+
if (totalCount) {
142+
const count = parseInt(totalCount)
143+
console.log('[fetchNotes] Total notes count:', count)
144+
store.commit('setTotalNotesCount', count)
145+
}
146+
129147
console.log('[fetchNotes] Processed:', notes.length, 'notes, noteIds:', noteIds.length)
130148
console.log('[fetchNotes] Cursor:', nextCursor, 'Pending:', pendingCount, 'isLastChunk:', isLastChunk)
131149

@@ -170,6 +188,66 @@ export const fetchNotes = async (chunkSize = 50, chunkCursor = null) => {
170188
}
171189
}
172190

191+
export const searchNotes = async (searchQuery, chunkSize = 50, chunkCursor = null) => {
192+
console.log('[searchNotes] Called with query:', searchQuery, 'chunkSize:', chunkSize, 'cursor:', chunkCursor)
193+
194+
try {
195+
// Signal start of loading
196+
store.commit('setNotesLoadingInProgress', true)
197+
198+
// Build search parameters
199+
const params = new URLSearchParams()
200+
params.append('search', searchQuery)
201+
params.append('exclude', 'content') // Exclude heavy content field
202+
params.append('chunkSize', chunkSize.toString())
203+
if (chunkCursor) {
204+
params.append('chunkCursor', chunkCursor)
205+
}
206+
207+
const url = generateUrl('/apps/notes/api/v1/notes' + (params.toString() ? '?' + params.toString() : ''))
208+
console.log('[searchNotes] Requesting:', url)
209+
210+
const response = await axios.get(url)
211+
212+
console.log('[searchNotes] Response received, status:', response.status)
213+
214+
// Backend returns array of notes directly
215+
const notes = Array.isArray(response.data) ? response.data : []
216+
const noteIds = notes.map(note => note.id)
217+
218+
// Cursor is in response headers, not body
219+
const nextCursor = response.headers['x-notes-chunk-cursor'] || null
220+
const isLastChunk = !nextCursor
221+
222+
console.log('[searchNotes] Processed:', notes.length, 'notes, cursor:', nextCursor)
223+
224+
// For search, we want to replace notes on first chunk, then append on subsequent chunks
225+
if (chunkCursor) {
226+
// Subsequent chunk - use incremental update
227+
console.log('[searchNotes] Using incremental update for subsequent chunk')
228+
store.dispatch('updateNotesIncremental', { notes, isLastChunk })
229+
} else {
230+
// First chunk - replace with search results
231+
console.log('[searchNotes] Using full update for first chunk')
232+
store.dispatch('updateNotes', { noteIds, notes })
233+
}
234+
235+
store.commit('setNotesLoadingInProgress', false)
236+
237+
console.log('[searchNotes] Completed successfully')
238+
return {
239+
noteIds,
240+
chunkCursor: nextCursor,
241+
isLastChunk,
242+
}
243+
} catch (err) {
244+
store.commit('setNotesLoadingInProgress', false)
245+
console.error('[searchNotes] Error:', err)
246+
handleSyncError(t('notes', 'Searching notes has failed.'), err)
247+
throw err
248+
}
249+
}
250+
173251
export const fetchNote = noteId => {
174252
return axios
175253
.get(url('/notes/' + noteId))

src/components/NoteRich.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from '@nextcloud/vue'
1616
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
1717
18-
import { queueCommand, refreshNote } from '../NotesService.js'
18+
import { queueCommand, refreshNote, fetchNote } from '../NotesService.js'
1919
import { routeIsNewNote } from '../Util.js'
2020
import store from '../store.js'
2121
@@ -81,6 +81,9 @@ export default {
8181
8282
this.loading = true
8383
84+
// Fetch note data if not already in store (e.g., when navigating directly to a note URL)
85+
await fetchNote(parseInt(this.noteId))
86+
8487
await this.loadTextEditor()
8588
},
8689

0 commit comments

Comments
 (0)