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 @@ />