Skip to content
Open
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
16 changes: 16 additions & 0 deletions lib/Controller/Helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
25 changes: 20 additions & 5 deletions lib/Controller/NotesApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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;
});
}
Expand Down
84 changes: 57 additions & 27 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +133 to +136
Copy link
Contributor

Choose a reason for hiding this comment

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

When the search text is cleared, we should see the list of all notes. Currently, when that happens, it's empty

this.startRefreshTimer(config.interval.notes.refresh)
return
}
Comment on lines +137 to +139
Copy link
Contributor

Choose a reason for hiding this comment

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

Plus, why have we removed the separation of notes into "days" like "Today", "Yesterday"? Now, we don't have it:

Image

Copy link
Author

Choose a reason for hiding this comment

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

Great point, this was an oversight - I will try to bring those subheaders back

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) {
Expand Down Expand Up @@ -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 {
Expand Down
193 changes: 164 additions & 29 deletions src/NotesService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
Loading