diff --git a/lib/Controller/Helper.php b/lib/Controller/Helper.php index 065c38b9d..1069d67fe 100644 --- a/lib/Controller/Helper.php +++ b/lib/Controller/Helper.php @@ -75,6 +75,7 @@ public function getNotesAndCategories( ?string $category = null, int $chunkSize = 0, ?string $chunkCursorStr = null, + ?string $search = null, ) : array { $userId = $this->getUID(); $chunkCursor = $chunkCursorStr ? ChunkCursor::fromString($chunkCursorStr) : null; @@ -89,6 +90,21 @@ public function getNotesAndCategories( }); } + // if a search query is provided, filter notes by title + if ($search !== null && $search !== '') { + $searchLower = mb_strtolower($search); + $this->logger->debug('Search query: ' . $search . ', lowercase: ' . $searchLower . ', notes before filter: ' . count($metaNotes)); + $metaNotes = array_filter($metaNotes, function (MetaNote $m) use ($searchLower) { + $titleLower = mb_strtolower($m->note->getTitle()); + $matches = str_contains($titleLower, $searchLower); + if ($matches) { + $this->logger->debug('Match found: ' . $m->note->getTitle()); + } + return $matches; + }); + $this->logger->debug('Notes after filter: ' . count($metaNotes)); + } + // list of notes that should be sent to the client $fullNotes = array_filter($metaNotes, function (MetaNote $m) use ($pruneBefore, $chunkCursor) { $isPruned = $pruneBefore && $m->meta->getLastUpdate() < $pruneBefore; diff --git a/lib/Controller/NotesApiController.php b/lib/Controller/NotesApiController.php index a04d8db05..35b0e7c7d 100644 --- a/lib/Controller/NotesApiController.php +++ b/lib/Controller/NotesApiController.php @@ -62,25 +62,27 @@ public function index( int $pruneBefore = 0, int $chunkSize = 0, ?string $chunkCursor = null, + ?string $search = null, ) : JSONResponse { return $this->helper->handleErrorResponse(function () use ( $category, $exclude, $pruneBefore, $chunkSize, - $chunkCursor + $chunkCursor, + $search ) { // initialize settings $userId = $this->helper->getUID(); $this->settingsService->getAll($userId, true); // load notes and categories $exclude = explode(',', $exclude); - $data = $this->helper->getNotesAndCategories($pruneBefore, $exclude, $category, $chunkSize, $chunkCursor); + $data = $this->helper->getNotesAndCategories($pruneBefore, $exclude, $category, $chunkSize, $chunkCursor, $search); $notesData = $data['notesData']; if (!$data['chunkCursor']) { - // if last chunk, then send all notes (pruned) - $notesData += array_map(function (MetaNote $m) { - return [ 'id' => $m->note->getId() ]; + // if last chunk, then send all notes (pruned) with full metadata + $notesData += array_map(function (MetaNote $m) use ($exclude) { + return $this->helper->getNoteData($m->note, $exclude, $m->meta); }, $data['notesAll']); } $response = new JSONResponse(array_values($notesData)); @@ -90,6 +92,19 @@ public function index( $response->addHeader('X-Notes-Chunk-Cursor', $data['chunkCursor']->toString()); $response->addHeader('X-Notes-Chunk-Pending', $data['numPendingNotes']); } + // Add category statistics and total count on first chunk only (when no cursor provided) + if ($chunkCursor === null) { + $categoryStats = []; + foreach ($data['notesAll'] as $metaNote) { + $cat = $metaNote->note->getCategory(); + if (!isset($categoryStats[$cat])) { + $categoryStats[$cat] = 0; + } + $categoryStats[$cat]++; + } + $response->addHeader('X-Notes-Category-Stats', json_encode($categoryStats)); + $response->addHeader('X-Notes-Total-Count', (string)count($data['notesAll'])); + } return $response; }); } diff --git a/src/App.vue b/src/App.vue index 31a8eff99..6adb30d40 100644 --- a/src/App.vue +++ b/src/App.vue @@ -128,33 +128,53 @@ export default { }, methods: { - loadNotes() { - fetchNotes() - .then(data => { - if (data === null) { - // nothing changed - return - } - if (data.notes !== null) { - this.error = false - this.routeDefault(data.lastViewedNote) - } else if (this.loading.notes) { - // only show error state if not loading in background - this.error = data.errorMessage - } else { - console.error('Server error while updating list of notes: ' + data.errorMessage) - } - }) - .catch(() => { + async loadNotes() { + console.log('[App.loadNotes] Starting initial load') + // Skip refresh if in search mode - search results should not be overwritten + const searchText = store.state.app.searchText + if (searchText && searchText.trim() !== '') { + console.log('[App.loadNotes] Skipping - in search mode with query:', searchText) + this.startRefreshTimer(config.interval.notes.refresh) + return + } + try { + // Load only the first chunk on initial load (50 notes) + // Subsequent chunks will be loaded on-demand when scrolling + const data = await fetchNotes(50, null) + console.log('[App.loadNotes] fetchNotes returned:', data) + + if (data === null) { + // nothing changed (304 response) + console.log('[App.loadNotes] 304 Not Modified - no changes') + return + } + + if (data && data.noteIds) { + console.log('[App.loadNotes] Success - received', data.noteIds.length, 'note IDs') + console.log('[App.loadNotes] Next cursor:', data.chunkCursor) + this.error = false + // Route to default note after first chunk + this.routeDefault(0) + + // Store cursor for next chunk (will be used by scroll handler) + store.commit('setNotesChunkCursor', data.chunkCursor || null) + } else if (this.loading.notes) { // only show error state if not loading in background - if (this.loading.notes) { - this.error = true - } - }) - .then(() => { - this.loading.notes = false - this.startRefreshTimer(config.interval.notes.refresh) - }) + console.log('[App.loadNotes] Error - no noteIds in response') + this.error = data?.errorMessage || true + } else { + console.error('Server error while updating list of notes: ' + (data?.errorMessage || 'Unknown error')) + } + } catch (err) { + // only show error state if not loading in background + if (this.loading.notes) { + this.error = true + } + console.error('[App.loadNotes] Exception:', err) + } finally { + this.loading.notes = false + this.startRefreshTimer(config.interval.notes.refresh) + } }, startRefreshTimer(seconds) { @@ -192,7 +212,17 @@ export default { }, routeDefault(defaultNoteId) { - if (this.$route.name !== 'note' || !noteExists(this.$route.params.noteId)) { + console.log('[App.routeDefault] Called with defaultNoteId:', defaultNoteId) + console.log('[App.routeDefault] Current route:', this.$route.name, 'noteId:', this.$route.params.noteId) + // Don't redirect if user is already on a specific note route + // (the note will be fetched individually even if not in the loaded chunk) + if (this.$route.name === 'note' && this.$route.params.noteId) { + console.log('[App.routeDefault] Already on note route, skipping redirect') + return + } + // Only redirect if no note route is set (e.g., on welcome page) + if (this.$route.name !== 'note') { + console.log('[App.routeDefault] Not on note route, routing to default') if (noteExists(defaultNoteId)) { this.routeToNote(defaultNoteId) } else { diff --git a/src/NotesService.js b/src/NotesService.js index 393cefb61..2590eef3c 100644 --- a/src/NotesService.js +++ b/src/NotesService.js @@ -73,44 +73,179 @@ export const getDashboardData = () => { }) } -export const fetchNotes = () => { +export const fetchNotes = async (chunkSize = 50, chunkCursor = null) => { + console.log('[fetchNotes] Called with chunkSize:', chunkSize, 'cursor:', chunkCursor) const lastETag = store.state.sync.etag const lastModified = store.state.sync.lastModified const headers = {} if (lastETag) { headers['If-None-Match'] = lastETag } - return axios - .get( - url('/notes' + (lastModified ? '?pruneBefore=' + lastModified : '')), - { headers }, - ) - .then(response => { - store.commit('setSettings', response.data.settings) - if (response.data.categories) { - store.commit('setCategories', response.data.categories) - } - if (response.data.noteIds && response.data.notesData) { - store.dispatch('updateNotes', { noteIds: response.data.noteIds, notes: response.data.notesData }) + + try { + // Signal start of loading + store.commit('setNotesLoadingInProgress', true) + + // Fetch settings first (only on first load) + if (!store.state.app.settings || Object.keys(store.state.app.settings).length === 0) { + try { + const settingsResponse = await axios.get(generateUrl('/apps/notes/api/v1/settings')) + store.commit('setSettings', settingsResponse.data) + } catch (err) { + console.warn('Failed to fetch settings, will continue with defaults', err) } - if (response.data.errorMessage) { - showError(t('notes', 'Error from Nextcloud server: {msg}', { msg: response.data.errorMessage })) - } else { - store.commit('setSyncETag', response.headers.etag) - store.commit('setSyncLastModified', response.headers['last-modified']) + } + + // Load notes metadata in chunks excluding content for performance + // Content is loaded on-demand when user selects a note + const params = new URLSearchParams() + if (lastModified) { + params.append('pruneBefore', lastModified) + } + params.append('exclude', 'content') // Exclude heavy content field + params.append('chunkSize', chunkSize.toString()) // Request chunked data + if (chunkCursor) { + params.append('chunkCursor', chunkCursor) // Continue from previous chunk + } + + const url = generateUrl('/apps/notes/api/v1/notes' + (params.toString() ? '?' + params.toString() : '')) + console.log('[fetchNotes] Requesting:', url) + + const response = await axios.get(url, { headers }) + + console.log('[fetchNotes] Response received, status:', response.status) + console.log('[fetchNotes] Response data type:', Array.isArray(response.data) ? 'array' : typeof response.data) + console.log('[fetchNotes] Response headers:', response.headers) + + // Backend returns array of notes directly + const notes = Array.isArray(response.data) ? response.data : [] + const noteIds = notes.map(note => note.id) + + // Cursor is in response headers, not body + const nextCursor = response.headers['x-notes-chunk-cursor'] || null + const pendingCount = response.headers['x-notes-chunk-pending'] ? parseInt(response.headers['x-notes-chunk-pending']) : 0 + const isLastChunk = !nextCursor + + // Category statistics and total count from first chunk (if available) + const categoryStats = response.headers['x-notes-category-stats'] + if (categoryStats) { + try { + const stats = JSON.parse(categoryStats) + console.log('[fetchNotes] Received category stats:', Object.keys(stats).length, 'categories') + store.commit('setCategoryStats', stats) + } catch (e) { + console.warn('[fetchNotes] Failed to parse category stats:', e) } - return response.data - }) - .catch(err => { - if (err?.response?.status === 304) { - store.commit('setSyncLastModified', err.response.headers['last-modified']) - return null - } else { - console.error(err) - handleSyncError(t('notes', 'Fetching notes has failed.'), err) - throw err + } + const totalCount = response.headers['x-notes-total-count'] + if (totalCount) { + const count = parseInt(totalCount) + console.log('[fetchNotes] Total notes count:', count) + store.commit('setTotalNotesCount', count) + } + + console.log('[fetchNotes] Processed:', notes.length, 'notes, noteIds:', noteIds.length) + console.log('[fetchNotes] Cursor:', nextCursor, 'Pending:', pendingCount, 'isLastChunk:', isLastChunk) + + // Update notes incrementally + if (chunkCursor) { + // Subsequent chunk - use incremental update + console.log('[fetchNotes] Using incremental update for subsequent chunk') + store.dispatch('updateNotesIncremental', { notes, isLastChunk }) + if (isLastChunk) { + // Final chunk - clean up deleted notes + console.log('[fetchNotes] Final chunk - cleaning up deleted notes') + store.dispatch('finalizeNotesUpdate', noteIds) } - }) + } else { + // First chunk - use full update + console.log('[fetchNotes] Using full update for first chunk') + store.dispatch('updateNotes', { noteIds, notes }) + } + + // Update ETag and last modified + store.commit('setSyncETag', response.headers.etag) + store.commit('setSyncLastModified', response.headers['last-modified']) + store.commit('setNotesLoadingInProgress', false) + + console.log('[fetchNotes] Completed successfully') + return { + noteIds, + chunkCursor: nextCursor, + isLastChunk, + } + } catch (err) { + store.commit('setNotesLoadingInProgress', false) + if (err?.response?.status === 304) { + console.log('[fetchNotes] 304 Not Modified - no changes') + store.commit('setSyncLastModified', err.response.headers['last-modified']) + return null + } else { + console.error('[fetchNotes] Error:', err) + handleSyncError(t('notes', 'Fetching notes has failed.'), err) + throw err + } + } +} + +export const searchNotes = async (searchQuery, chunkSize = 50, chunkCursor = null) => { + console.log('[searchNotes] Called with query:', searchQuery, 'chunkSize:', chunkSize, 'cursor:', chunkCursor) + + try { + // Signal start of loading + store.commit('setNotesLoadingInProgress', true) + + // Build search parameters + const params = new URLSearchParams() + params.append('search', searchQuery) + params.append('exclude', 'content') // Exclude heavy content field + params.append('chunkSize', chunkSize.toString()) + if (chunkCursor) { + params.append('chunkCursor', chunkCursor) + } + + const url = generateUrl('/apps/notes/api/v1/notes' + (params.toString() ? '?' + params.toString() : '')) + console.log('[searchNotes] Requesting:', url) + + const response = await axios.get(url) + + console.log('[searchNotes] Response received, status:', response.status) + + // Backend returns array of notes directly + const notes = Array.isArray(response.data) ? response.data : [] + const noteIds = notes.map(note => note.id) + + // Cursor is in response headers, not body + const nextCursor = response.headers['x-notes-chunk-cursor'] || null + const isLastChunk = !nextCursor + + console.log('[searchNotes] Processed:', notes.length, 'notes, cursor:', nextCursor) + + // For search, we want to replace notes on first chunk, then append on subsequent chunks + if (chunkCursor) { + // Subsequent chunk - use incremental update + console.log('[searchNotes] Using incremental update for subsequent chunk') + store.dispatch('updateNotesIncremental', { notes, isLastChunk }) + } else { + // First chunk - replace with search results + console.log('[searchNotes] Using full update for first chunk') + store.dispatch('updateNotes', { noteIds, notes }) + } + + store.commit('setNotesLoadingInProgress', false) + + console.log('[searchNotes] Completed successfully') + return { + noteIds, + chunkCursor: nextCursor, + isLastChunk, + } + } catch (err) { + store.commit('setNotesLoadingInProgress', false) + console.error('[searchNotes] Error:', err) + handleSyncError(t('notes', 'Searching notes has failed.'), err) + throw err + } } export const fetchNote = noteId => { diff --git a/src/components/NoteRich.vue b/src/components/NoteRich.vue index 846c0779f..d3c0a1033 100644 --- a/src/components/NoteRich.vue +++ b/src/components/NoteRich.vue @@ -13,7 +13,7 @@ import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile' import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' -import { queueCommand, refreshNote } from '../NotesService.js' +import { queueCommand, refreshNote, fetchNote } from '../NotesService.js' import { routeIsNewNote } from '../Util.js' import store from '../store.js' @@ -83,6 +83,9 @@ export default { this.loading = true + // Fetch note data if not already in store (e.g., when navigating directly to a note URL) + await fetchNote(parseInt(this.noteId)) + await this.loadTextEditor() }, diff --git a/src/components/NotesView.vue b/src/components/NotesView.vue index 1c14cb9ee..2e36ee29f 100644 --- a/src/components/NotesView.vue +++ b/src/components/NotesView.vue @@ -38,7 +38,7 @@ />
@@ -66,6 +66,7 @@ import NcAppContentDetails from '@nextcloud/vue/components/NcAppContentDetails' import NcButton from '@nextcloud/vue/components/NcButton' import NcTextField from '@nextcloud/vue/components/NcTextField' import { categoryLabel } from '../Util.js' +import { fetchNotes, searchNotes } from '../NotesService.js' import NotesList from './NotesList.vue' import NotesCaption from './NotesCaption.vue' import store from '../store.js' @@ -103,9 +104,11 @@ export default { timeslots: [], monthFormat: new Intl.DateTimeFormat(OC.getLanguage(), { month: 'long', year: 'numeric' }), lastYear: new Date(new Date().getFullYear() - 1, 0), - showFirstNotesOnly: true, + displayedNotesCount: 50, + isLoadingMore: false, showNote: true, searchText: '', + searchDebounceTimer: null, } }, @@ -123,11 +126,20 @@ export default { }, displayedNotes() { - if (this.filteredNotes.length > 40 && this.showFirstNotesOnly) { - return this.filteredNotes.slice(0, 30) - } else { - return this.filteredNotes - } + // Show notes up to displayedNotesCount, incrementally loading more as user scrolls + return this.filteredNotes.slice(0, this.displayedNotesCount) + }, + + chunkCursor() { + // Get the cursor for next chunk from store + return store.state.sync.chunkCursor + }, + + hasMoreNotes() { + // There are more notes if either: + // 1. We have more notes locally that aren't displayed yet, OR + // 2. There's a cursor indicating more notes on the server + return this.displayedNotes.length !== this.filteredNotes.length || this.chunkCursor !== null }, // group notes by time ("All notes") or by category (if category chosen) @@ -154,8 +166,54 @@ export default { }, watch: { - category() { this.showFirstNotesOnly = true }, - searchText(value) { store.commit('updateSearchText', value) }, + category() { + this.displayedNotesCount = 50 + this.isLoadingMore = false + }, + searchText(value) { + // Update store for client-side filtering (getFilteredNotes uses this) + store.commit('updateSearchText', value) + + // Clear any existing debounce timer + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer) + this.searchDebounceTimer = null + } + + // Reset display state + this.displayedNotesCount = 50 + this.isLoadingMore = false + + // Debounce search API calls (300ms delay) + this.searchDebounceTimer = setTimeout(async () => { + console.log('[NotesView] Search text changed:', value) + + if (value && value.trim() !== '') { + // Perform server-side search + console.log('[NotesView] Initiating server-side search') + try { + await searchNotes(value.trim(), 50, null) + // Update cursor after search completes + console.log('[NotesView] Search completed') + } catch (err) { + console.error('[NotesView] Search failed:', err) + } + } else { + // Empty search - revert to normal pagination + console.log('[NotesView] Empty search - reverting to pagination') + // Clear notes and refetch (clearSyncCache not needed - fetchNotes will set new cursor) + store.commit('removeAllNotes') + try { + await fetchNotes(50, null) + // Reset display count after fetch completes + this.displayedNotesCount = 50 + console.log('[NotesView] Reverted to normal notes view') + } catch (err) { + console.error('[NotesView] Failed to revert to normal view:', err) + } + } + }, 300) + }, }, created() { @@ -200,9 +258,65 @@ export default { } }, - onEndOfNotes(isVisible) { - if (isVisible) { - this.showFirstNotesOnly = false + async onEndOfNotes(isVisible) { + console.log('[NotesView.onEndOfNotes] Triggered, isVisible:', isVisible, 'isLoadingMore:', this.isLoadingMore) + // Prevent rapid-fire loading by checking if we're already loading a batch + if (!isVisible || this.isLoadingMore) { + console.log('[NotesView.onEndOfNotes] Skipping - not visible or already loading') + return + } + + // Set loading flag to prevent concurrent loads + this.isLoadingMore = true + + try { + // Check if there are more notes to fetch from the server + const chunkCursor = store.state.sync.chunkCursor + const isSearchMode = this.searchText && this.searchText.trim() !== '' + console.log('[NotesView.onEndOfNotes] Current cursor:', chunkCursor, 'searchMode:', isSearchMode) + console.log('[NotesView.onEndOfNotes] displayedNotesCount:', this.displayedNotesCount, 'filteredNotes.length:', this.filteredNotes.length) + + if (chunkCursor) { + // Fetch next chunk from the API (using search or normal fetch based on mode) + console.log('[NotesView.onEndOfNotes] Fetching next chunk from API') + const data = isSearchMode + ? await searchNotes(this.searchText.trim(), 50, chunkCursor) + : await fetchNotes(50, chunkCursor) + console.log('[NotesView.onEndOfNotes] Fetch complete, data:', data) + + if (data && data.noteIds) { + // Update cursor for next fetch + console.log('[NotesView.onEndOfNotes] Updating cursor to:', data.chunkCursor) + store.commit('setNotesChunkCursor', data.chunkCursor || null) + + // Increment display count to show newly loaded notes + const newCount = Math.min( + this.displayedNotesCount + 50, + this.filteredNotes.length + ) + console.log('[NotesView.onEndOfNotes] Updating displayedNotesCount from', this.displayedNotesCount, 'to', newCount) + this.displayedNotesCount = newCount + } + } else if (this.displayedNotesCount < this.filteredNotes.length) { + // No more chunks to fetch, but we have cached notes to display + console.log('[NotesView.onEndOfNotes] No cursor, but have cached notes to display') + this.$nextTick(() => { + const newCount = Math.min( + this.displayedNotesCount + 50, + this.filteredNotes.length + ) + console.log('[NotesView.onEndOfNotes] Updating displayedNotesCount from', this.displayedNotesCount, 'to', newCount) + this.displayedNotesCount = newCount + }) + } else { + console.log('[NotesView.onEndOfNotes] All notes loaded, nothing to do') + } + } finally { + // Reset loading flag after operation completes + this.$nextTick(() => { + console.log('[NotesView.onEndOfNotes] Resetting isLoadingMore flag') + this.isLoadingMore = false + }) } }, diff --git a/src/store/notes.js b/src/store/notes.js index b3b5de320..24c94030c 100644 --- a/src/store/notes.js +++ b/src/store/notes.js @@ -8,16 +8,20 @@ import { copyNote } from '../Util.js' const state = { categories: [], + categoryStats: null, // Category counts from backend (set on first load) + totalNotesCount: null, // Total number of notes from backend (set on first load) notes: [], notesIds: {}, selectedCategory: null, selectedNote: null, filterString: '', + notesLoadingInProgress: false, } const getters = { numNotes: (state) => () => { - return state.notes.length + // Use total count from backend if available, otherwise fall back to loaded notes count + return state.totalNotesCount !== null ? state.totalNotesCount : state.notes.length }, noteExists: (state) => (id) => { @@ -43,20 +47,45 @@ const getters = { return i } - // get categories from notes - const categories = {} - for (const note of state.notes) { - let cat = note.category + // Use backend category stats if available (set on first load) + // Otherwise calculate from loaded notes (partial data during pagination) + let categories = {} + if (state.categoryStats) { + // Use pre-calculated stats from backend + categories = { ...state.categoryStats } + // Apply maxLevel filtering if needed if (maxLevel > 0) { - const index = nthIndexOf(cat, '/', maxLevel) - if (index > 0) { - cat = cat.substring(0, index) + const filteredCategories = {} + for (const cat in categories) { + const index = nthIndexOf(cat, '/', maxLevel) + const truncatedCat = index > 0 ? cat.substring(0, index) : cat + if (filteredCategories[truncatedCat] === undefined) { + filteredCategories[truncatedCat] = categories[cat] + } else { + filteredCategories[truncatedCat] += categories[cat] + } } + categories = filteredCategories } - if (categories[cat] === undefined) { - categories[cat] = 1 - } else { - categories[cat] += 1 + } else { + // Fallback: calculate from loaded notes (may be incomplete during pagination) + for (const note of state.notes) { + // Skip invalid notes + if (!note || !note.category) { + continue + } + let cat = note.category + if (maxLevel > 0) { + const index = nthIndexOf(cat, '/', maxLevel) + if (index > 0) { + cat = cat.substring(0, index) + } + } + if (categories[cat] === undefined) { + categories[cat] = 1 + } else { + categories[cat] += 1 + } } } // get structured result from categories @@ -80,8 +109,13 @@ const getters = { }, getFilteredNotes: (state, getters, rootState, rootGetters) => () => { - const searchText = rootState.app.searchText.toLowerCase() + const searchText = rootState.app.searchText?.toLowerCase() || '' const notes = state.notes.filter(note => { + // Skip invalid notes + if (!note || !note.category || !note.title) { + return false + } + if (state.selectedCategory !== null && state.selectedCategory !== note.category && !note.category.startsWith(state.selectedCategory + '/')) { @@ -96,12 +130,16 @@ const getters = { }) function cmpRecent(a, b) { + // Defensive: ensure both notes are valid + if (!a || !b) return 0 if (a.favorite && !b.favorite) return -1 if (!a.favorite && b.favorite) return 1 - return b.modified - a.modified + return (b.modified || 0) - (a.modified || 0) } function cmpCategory(a, b) { + // Defensive: ensure both notes are valid + if (!a || !b || !a.category || !b.category || !a.title || !b.title) return 0 const cmpCat = a.category.localeCompare(b.category) if (cmpCat !== 0) return cmpCat if (a.favorite && !b.favorite) return -1 @@ -115,13 +153,18 @@ const getters = { }, getFilteredTotalCount: (state, getters, rootState, rootGetters) => () => { - const searchText = rootState.app.searchText.toLowerCase() + const searchText = rootState.app.searchText?.toLowerCase() || '' if (state.selectedCategory === null || searchText === '') { return 0 } const notes = state.notes.filter(note => { + // Skip invalid notes + if (!note || !note.category || !note.title) { + return false + } + if (state.selectedCategory === note.category || note.category.startsWith(state.selectedCategory + '/')) { return false } @@ -178,12 +221,22 @@ const mutations = { removeAllNotes(state) { state.notes = [] state.notesIds = {} + state.categoryStats = null + state.totalNotesCount = null }, setCategories(state, categories) { state.categories = categories }, + setCategoryStats(state, stats) { + state.categoryStats = stats + }, + + setTotalNotesCount(state, count) { + state.totalNotesCount = count + }, + setSelectedCategory(state, category) { state.selectedCategory = category }, @@ -191,6 +244,10 @@ const mutations = { setSelectedNote(state, note) { state.selectedNote = note }, + + setNotesLoadingInProgress(state, loading) { + state.notesLoadingInProgress = loading + }, } const actions = { @@ -216,6 +273,31 @@ const actions = { } }) }, + + updateNotesIncremental(context, { notes, isLastChunk }) { + // Add/update notes from current chunk + if (!notes) { + return + } + for (const note of notes) { + // TODO check for parallel (local) changes! + context.commit('updateNote', note) + } + // Note: We don't remove deleted notes here - that's done in finalizeNotesUpdate + }, + + finalizeNotesUpdate(context, allNoteIds) { + // Remove notes that are no longer on the server + // This is only called after all chunks have been loaded + if (!allNoteIds) { + return + } + context.state.notes.forEach(note => { + if (!allNoteIds.includes(note.id)) { + context.commit('removeNote', note.id) + } + }) + }, } export default { state, getters, mutations, actions } diff --git a/src/store/sync.js b/src/store/sync.js index 285fc17a4..100b6eb7d 100644 --- a/src/store/sync.js +++ b/src/store/sync.js @@ -10,6 +10,7 @@ const state = { etag: null, lastModified: 0, active: false, + chunkCursor: null, // TODO add list of notes with changes during sync } @@ -43,11 +44,16 @@ const mutations = { clearSyncCache(state) { state.etag = null state.lastModified = 0 + state.chunkCursor = null }, setSyncActive(state, active) { state.active = active }, + + setNotesChunkCursor(state, cursor) { + state.chunkCursor = cursor + }, } const actions = {