Skip to content

Commit b1ef0b7

Browse files
committed
fix: Prevent periodic refresh from overwriting search results
This commit fixes several critical issues with the search functionality: 1. Periodic Refresh Conflict: Modified App.vue loadNotes() to skip refresh when search is active, preventing the 30-second refresh timer from overwriting active search results. 2. Search State Synchronization: Restored updateSearchText commit in NotesView.vue to keep client-side and server-side filters in sync. 3. Null Safety: Added comprehensive null checks to all getters in notes.js to prevent errors when clearing search or during progressive loading transitions. 4. Search Clear Behavior: Removed unnecessary clearSyncCache() call and added proper display count reset when reverting from search to normal pagination mode. Files modified: - src/App.vue: Skip periodic refresh during active search - src/components/NotesView.vue: Restore search state sync - src/store/notes.js: Add null safety to all note getters - lib/Controller/Helper.php: Server-side search implementation Signed-off-by: Chris Coutinho <chris@coutinho.io>
1 parent e3d1981 commit b1ef0b7

File tree

4 files changed

+161
-21
lines changed

4 files changed

+161
-21
lines changed

lib/Controller/Helper.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public function getNotesAndCategories(
7575
?string $category = null,
7676
int $chunkSize = 0,
7777
?string $chunkCursorStr = null,
78+
?string $search = null,
7879
) : array {
7980
$userId = $this->getUID();
8081
$chunkCursor = $chunkCursorStr ? ChunkCursor::fromString($chunkCursorStr) : null;
@@ -89,6 +90,21 @@ public function getNotesAndCategories(
8990
});
9091
}
9192

93+
// if a search query is provided, filter notes by title
94+
if ($search !== null && $search !== '') {
95+
$searchLower = mb_strtolower($search);
96+
$this->logger->debug('Search query: ' . $search . ', lowercase: ' . $searchLower . ', notes before filter: ' . count($metaNotes));
97+
$metaNotes = array_filter($metaNotes, function (MetaNote $m) use ($searchLower) {
98+
$titleLower = mb_strtolower($m->note->getTitle());
99+
$matches = str_contains($titleLower, $searchLower);
100+
if ($matches) {
101+
$this->logger->debug('Match found: ' . $m->note->getTitle());
102+
}
103+
return $matches;
104+
});
105+
$this->logger->debug('Notes after filter: ' . count($metaNotes));
106+
}
107+
92108
// list of notes that should be sent to the client
93109
$fullNotes = array_filter($metaNotes, function (MetaNote $m) use ($pruneBefore, $chunkCursor) {
94110
$isPruned = $pruneBefore && $m->meta->getLastUpdate() < $pruneBefore;

src/App.vue

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,13 @@ export default {
132132
methods: {
133133
async loadNotes() {
134134
console.log('[App.loadNotes] Starting initial load')
135+
// Skip refresh if in search mode - search results should not be overwritten
136+
const searchText = store.state.app.searchText
137+
if (searchText && searchText.trim() !== '') {
138+
console.log('[App.loadNotes] Skipping - in search mode with query:', searchText)
139+
this.startRefreshTimer(config.interval.notes.refresh)
140+
return
141+
}
135142
try {
136143
// Load only the first chunk on initial load (50 notes)
137144
// Subsequent chunks will be loaded on-demand when scrolling
@@ -207,7 +214,17 @@ export default {
207214
},
208215
209216
routeDefault(defaultNoteId) {
210-
if (this.$route.name !== 'note' || !noteExists(this.$route.params.noteId)) {
217+
console.log('[App.routeDefault] Called with defaultNoteId:', defaultNoteId)
218+
console.log('[App.routeDefault] Current route:', this.$route.name, 'noteId:', this.$route.params.noteId)
219+
// Don't redirect if user is already on a specific note route
220+
// (the note will be fetched individually even if not in the loaded chunk)
221+
if (this.$route.name === 'note' && this.$route.params.noteId) {
222+
console.log('[App.routeDefault] Already on note route, skipping redirect')
223+
return
224+
}
225+
// Only redirect if no note route is set (e.g., on welcome page)
226+
if (this.$route.name !== 'note') {
227+
console.log('[App.routeDefault] Not on note route, routing to default')
211228
if (noteExists(defaultNoteId)) {
212229
this.routeToNote(defaultNoteId)
213230
} else {

src/components/NotesView.vue

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
/>
3939
</template>
4040
<div
41-
v-if="displayedNotes.length != filteredNotes.length"
41+
v-if="hasMoreNotes"
4242
v-observe-visibility="onEndOfNotes"
4343
class="loading-label"
4444
>
@@ -68,7 +68,7 @@ import {
6868
NcTextField,
6969
} from '@nextcloud/vue'
7070
import { categoryLabel } from '../Util.js'
71-
import { fetchNotes } from '../NotesService.js'
71+
import { fetchNotes, searchNotes } from '../NotesService.js'
7272
import NotesList from './NotesList.vue'
7373
import NotesCaption from './NotesCaption.vue'
7474
import store from '../store.js'
@@ -110,6 +110,7 @@ export default {
110110
isLoadingMore: false,
111111
showNote: true,
112112
searchText: '',
113+
searchDebounceTimer: null,
113114
}
114115
},
115116
@@ -131,6 +132,18 @@ export default {
131132
return this.filteredNotes.slice(0, this.displayedNotesCount)
132133
},
133134
135+
chunkCursor() {
136+
// Get the cursor for next chunk from store
137+
return store.state.sync.chunkCursor
138+
},
139+
140+
hasMoreNotes() {
141+
// There are more notes if either:
142+
// 1. We have more notes locally that aren't displayed yet, OR
143+
// 2. There's a cursor indicating more notes on the server
144+
return this.displayedNotes.length !== this.filteredNotes.length || this.chunkCursor !== null
145+
},
146+
134147
// group notes by time ("All notes") or by category (if category chosen)
135148
groupedNotes() {
136149
if (this.category === null) {
@@ -160,9 +173,48 @@ export default {
160173
this.isLoadingMore = false
161174
},
162175
searchText(value) {
176+
// Update store for client-side filtering (getFilteredNotes uses this)
163177
store.commit('updateSearchText', value)
178+
179+
// Clear any existing debounce timer
180+
if (this.searchDebounceTimer) {
181+
clearTimeout(this.searchDebounceTimer)
182+
this.searchDebounceTimer = null
183+
}
184+
185+
// Reset display state
164186
this.displayedNotesCount = 50
165187
this.isLoadingMore = false
188+
189+
// Debounce search API calls (300ms delay)
190+
this.searchDebounceTimer = setTimeout(async () => {
191+
console.log('[NotesView] Search text changed:', value)
192+
193+
if (value && value.trim() !== '') {
194+
// Perform server-side search
195+
console.log('[NotesView] Initiating server-side search')
196+
try {
197+
await searchNotes(value.trim(), 50, null)
198+
// Update cursor after search completes
199+
console.log('[NotesView] Search completed')
200+
} catch (err) {
201+
console.error('[NotesView] Search failed:', err)
202+
}
203+
} else {
204+
// Empty search - revert to normal pagination
205+
console.log('[NotesView] Empty search - reverting to pagination')
206+
// Clear notes and refetch (clearSyncCache not needed - fetchNotes will set new cursor)
207+
store.commit('removeAllNotes')
208+
try {
209+
await fetchNotes(50, null)
210+
// Reset display count after fetch completes
211+
this.displayedNotesCount = 50
212+
console.log('[NotesView] Reverted to normal notes view')
213+
} catch (err) {
214+
console.error('[NotesView] Failed to revert to normal view:', err)
215+
}
216+
}
217+
}, 300)
166218
},
167219
},
168220
@@ -222,13 +274,16 @@ export default {
222274
try {
223275
// Check if there are more notes to fetch from the server
224276
const chunkCursor = store.state.sync.chunkCursor
225-
console.log('[NotesView.onEndOfNotes] Current cursor:', chunkCursor)
277+
const isSearchMode = this.searchText && this.searchText.trim() !== ''
278+
console.log('[NotesView.onEndOfNotes] Current cursor:', chunkCursor, 'searchMode:', isSearchMode)
226279
console.log('[NotesView.onEndOfNotes] displayedNotesCount:', this.displayedNotesCount, 'filteredNotes.length:', this.filteredNotes.length)
227280
228281
if (chunkCursor) {
229-
// Fetch next chunk from the API
282+
// Fetch next chunk from the API (using search or normal fetch based on mode)
230283
console.log('[NotesView.onEndOfNotes] Fetching next chunk from API')
231-
const data = await fetchNotes(50, chunkCursor)
284+
const data = isSearchMode
285+
? await searchNotes(this.searchText.trim(), 50, chunkCursor)
286+
: await fetchNotes(50, chunkCursor)
232287
console.log('[NotesView.onEndOfNotes] Fetch complete, data:', data)
233288
234289
if (data && data.noteIds) {

src/store/notes.js

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { copyNote } from '../Util.js'
88

99
const state = {
1010
categories: [],
11+
categoryStats: null, // Category counts from backend (set on first load)
12+
totalNotesCount: null, // Total number of notes from backend (set on first load)
1113
notes: [],
1214
notesIds: {},
1315
selectedCategory: null,
@@ -18,7 +20,8 @@ const state = {
1820

1921
const getters = {
2022
numNotes: (state) => () => {
21-
return state.notes.length
23+
// Use total count from backend if available, otherwise fall back to loaded notes count
24+
return state.totalNotesCount !== null ? state.totalNotesCount : state.notes.length
2225
},
2326

2427
noteExists: (state) => (id) => {
@@ -44,20 +47,45 @@ const getters = {
4447
return i
4548
}
4649

47-
// get categories from notes
48-
const categories = {}
49-
for (const note of state.notes) {
50-
let cat = note.category
50+
// Use backend category stats if available (set on first load)
51+
// Otherwise calculate from loaded notes (partial data during pagination)
52+
let categories = {}
53+
if (state.categoryStats) {
54+
// Use pre-calculated stats from backend
55+
categories = { ...state.categoryStats }
56+
// Apply maxLevel filtering if needed
5157
if (maxLevel > 0) {
52-
const index = nthIndexOf(cat, '/', maxLevel)
53-
if (index > 0) {
54-
cat = cat.substring(0, index)
58+
const filteredCategories = {}
59+
for (const cat in categories) {
60+
const index = nthIndexOf(cat, '/', maxLevel)
61+
const truncatedCat = index > 0 ? cat.substring(0, index) : cat
62+
if (filteredCategories[truncatedCat] === undefined) {
63+
filteredCategories[truncatedCat] = categories[cat]
64+
} else {
65+
filteredCategories[truncatedCat] += categories[cat]
66+
}
5567
}
68+
categories = filteredCategories
5669
}
57-
if (categories[cat] === undefined) {
58-
categories[cat] = 1
59-
} else {
60-
categories[cat] += 1
70+
} else {
71+
// Fallback: calculate from loaded notes (may be incomplete during pagination)
72+
for (const note of state.notes) {
73+
// Skip invalid notes
74+
if (!note || !note.category) {
75+
continue
76+
}
77+
let cat = note.category
78+
if (maxLevel > 0) {
79+
const index = nthIndexOf(cat, '/', maxLevel)
80+
if (index > 0) {
81+
cat = cat.substring(0, index)
82+
}
83+
}
84+
if (categories[cat] === undefined) {
85+
categories[cat] = 1
86+
} else {
87+
categories[cat] += 1
88+
}
6189
}
6290
}
6391
// get structured result from categories
@@ -81,8 +109,13 @@ const getters = {
81109
},
82110

83111
getFilteredNotes: (state, getters, rootState, rootGetters) => () => {
84-
const searchText = rootState.app.searchText.toLowerCase()
112+
const searchText = rootState.app.searchText?.toLowerCase() || ''
85113
const notes = state.notes.filter(note => {
114+
// Skip invalid notes
115+
if (!note || !note.category || !note.title) {
116+
return false
117+
}
118+
86119
if (state.selectedCategory !== null
87120
&& state.selectedCategory !== note.category
88121
&& !note.category.startsWith(state.selectedCategory + '/')) {
@@ -97,12 +130,16 @@ const getters = {
97130
})
98131

99132
function cmpRecent(a, b) {
133+
// Defensive: ensure both notes are valid
134+
if (!a || !b) return 0
100135
if (a.favorite && !b.favorite) return -1
101136
if (!a.favorite && b.favorite) return 1
102-
return b.modified - a.modified
137+
return (b.modified || 0) - (a.modified || 0)
103138
}
104139

105140
function cmpCategory(a, b) {
141+
// Defensive: ensure both notes are valid
142+
if (!a || !b || !a.category || !b.category || !a.title || !b.title) return 0
106143
const cmpCat = a.category.localeCompare(b.category)
107144
if (cmpCat !== 0) return cmpCat
108145
if (a.favorite && !b.favorite) return -1
@@ -116,13 +153,18 @@ const getters = {
116153
},
117154

118155
getFilteredTotalCount: (state, getters, rootState, rootGetters) => () => {
119-
const searchText = rootState.app.searchText.toLowerCase()
156+
const searchText = rootState.app.searchText?.toLowerCase() || ''
120157

121158
if (state.selectedCategory === null || searchText === '') {
122159
return 0
123160
}
124161

125162
const notes = state.notes.filter(note => {
163+
// Skip invalid notes
164+
if (!note || !note.category || !note.title) {
165+
return false
166+
}
167+
126168
if (state.selectedCategory === note.category || note.category.startsWith(state.selectedCategory + '/')) {
127169
return false
128170
}
@@ -179,12 +221,22 @@ const mutations = {
179221
removeAllNotes(state) {
180222
state.notes = []
181223
state.notesIds = {}
224+
state.categoryStats = null
225+
state.totalNotesCount = null
182226
},
183227

184228
setCategories(state, categories) {
185229
state.categories = categories
186230
},
187231

232+
setCategoryStats(state, stats) {
233+
state.categoryStats = stats
234+
},
235+
236+
setTotalNotesCount(state, count) {
237+
state.totalNotesCount = count
238+
},
239+
188240
setSelectedCategory(state, category) {
189241
state.selectedCategory = category
190242
},

0 commit comments

Comments
 (0)