diff --git a/appinfo/routes.php b/appinfo/routes.php index 69ab49a8..e38f5994 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -47,6 +47,11 @@ return ['routes' => [ 'url' => '/notes', 'verb' => 'POST', ], + [ + 'name' => 'notes#undo', + 'url' => '/notes/undo', + 'verb' => 'POST', + ], [ 'name' => 'notes#update', 'url' => '/notes/{id}', diff --git a/lib/Controller/NotesController.php b/lib/Controller/NotesController.php index a58b2f55..e7d9780f 100644 --- a/lib/Controller/NotesController.php +++ b/lib/Controller/NotesController.php @@ -140,6 +140,34 @@ class NotesController extends Controller { } + /** + * @NoAdminRequired + * + * @param string $content + */ + public function undo($id, $content, $category, $modified, $favorite) { + try { + // check if note still exists + $note = $this->notesService->get($id, $this->userId); + if ($note->getError()) { + throw new \Exception(); + } + } catch (\Throwable $e) { + // re-create if note doesn't exit anymore + $note = $this->notesService->create($this->userId); + $note = $this->notesService->update( + $note->getId(), + $content, + $this->userId, + $category, + $modified + ); + $note->favorite = $this->notesService->favorite($note->getId(), $favorite, $this->userId); + } + return new DataResponse($note); + } + + /** * @NoAdminRequired * diff --git a/src/App.vue b/src/App.vue index b3a7f569..162600dd 100644 --- a/src/App.vue +++ b/src/App.vue @@ -14,7 +14,7 @@ :category="filter.category" :search="filter.search" @category-selected="onSelectCategory" - @note-deleted="routeFirst" + @note-deleted="onNoteDeleted" /> @@ -40,8 +40,9 @@ import { AppNavigationNew, Content, } from '@nextcloud/vue' +import { showSuccess } from '@nextcloud/dialogs' -import { fetchNotes, noteExists, createNote } from './NotesService' +import { fetchNotes, noteExists, createNote, undoDeleteNote } from './NotesService' import { openNavbar } from './nextcloud' import AppSettings from './components/AppSettings' import NavigationList from './components/NavigationList' @@ -70,6 +71,9 @@ export default { create: false, }, error: false, + undoNotification: null, + undoTimer: null, + deletedNotes: [], } }, @@ -82,9 +86,6 @@ export default { const search = this.filter.search.toLowerCase() const notes = this.notes.filter(note => { - if (note.deleting === 'deleting') { - return false - } if (this.filter.category !== null && this.filter.category !== note.category && !note.category.startsWith(this.filter.category + '/')) { @@ -174,10 +175,12 @@ export default { }, routeToNote(noteId) { - this.$router.push({ - name: 'note', - params: { noteId: noteId.toString() }, - }) + if (this.$route.name !== 'note' || this.$route.params.noteId !== noteId.toString()) { + this.$router.push({ + name: 'note', + params: { noteId: noteId.toString() }, + }) + } }, onSearch(query) { @@ -215,6 +218,59 @@ export default { } }, + onNoteDeleted(note) { + this.deletedNotes.push(note) + this.clearUndoTimer() + let label + if (this.deletedNotes.length === 1) { + label = this.t('notes', 'Deleted {title}', { title: note.title }) + } else { + label = this.t('notes', 'Deleted {number} notes', { number: this.deletedNotes.length }) + } + if (this.undoNotification === null) { + const action = '' + this.undoNotification = showSuccess( + '' + label + ' ' + action, + { isHTML: true, timeout: -1, onShow: this.onUndoNotificationClosed } + ) + this.undoNotification.toastElement.getElementsByClassName('undo') + .forEach(element => { element.onclick = this.onUndoDelete }) + } else { + this.undoNotification.toastElement.getElementsByClassName('deletedLabel') + .forEach(element => { element.textContent = label }) + } + this.undoTimer = setTimeout(this.onRemoveUndoNotification, 12000) + this.routeFirst() + }, + + clearUndoTimer() { + if (this.undoTimer) { + clearTimeout(this.undoTimer) + this.undoTimer = null + } + }, + + onUndoDelete() { + this.deletedNotes.forEach(note => undoDeleteNote(note)) + this.onRemoveUndoNotification() + }, + + onUndoNotificationClosed() { + if (this.undoNotification) { + this.undoNotification = null + this.onRemoveUndoNotification() + } + }, + + onRemoveUndoNotification() { + this.deletedNotes = [] + if (this.undoNotification) { + this.undoNotification.hideToast() + this.undoNotification = null + } + this.clearUndoTimer() + }, + onClose(event) { if (!this.notes.every(note => !note.unsaved)) { event.preventDefault() diff --git a/src/NotesService.js b/src/NotesService.js index 7c7786ae..a56f6989 100644 --- a/src/NotesService.js +++ b/src/NotesService.js @@ -1,6 +1,7 @@ import AppGlobal from './mixins/AppGlobal' import store from './store' import axios from '@nextcloud/axios' +import { showError } from '@nextcloud/dialogs' const t = AppGlobal.methods.t @@ -10,11 +11,11 @@ function url(url) { } function handleSyncError(message) { - OC.Notification.showTemporary(message + ' ' + t('notes', 'See JavaScript console for details.')) + showError(message + ' ' + t('notes', 'See JavaScript console and server log for details.')) } function handleInsufficientStorage() { - OC.Notification.showTemporary(t('notes', 'Saving the note has failed due to insufficient storage.')) + showError(t('notes', 'Saving the note has failed due to insufficient storage.')) } export const setSettings = settings => { @@ -41,7 +42,7 @@ export const fetchNotes = () => { store.dispatch('addAll', response.data.notes) } if (response.data.errorMessage) { - OC.Notification.showTemporary(response.data.errorMessage) + showError(response.data.errorMessage) } return response.data }) @@ -119,12 +120,22 @@ function _updateNote(note) { }) } -export const prepareDeleteNote = noteId => { - store.commit('setNoteAttribute', { noteId: noteId, attribute: 'deleting', value: 'prepare' }) -} - -export const undoDeleteNote = noteId => { - store.commit('setNoteAttribute', { noteId: noteId, attribute: 'deleting', value: null }) +export const undoDeleteNote = (note) => { + return axios + .post(url('/notes/undo'), note) + .then(response => { + store.commit('add', response.data) + return response.data + }) + .catch(err => { + console.error(err) + if (err.response.status === 507) { + handleInsufficientStorage() + } else { + handleSyncError(t('notes', 'Undo delete has failed for note {title}.', { title: note.title })) + } + throw err + }) } export const deleteNote = noteId => { @@ -137,9 +148,12 @@ export const deleteNote = noteId => { .catch(err => { console.error(err) handleSyncError(t('notes', 'Deleting note {id} has failed.', { id: noteId })) - undoDeleteNote(noteId) + // remove note always since we don't know when the error happened + store.commit('remove', noteId) throw err }) + .then(() => { + }) } export const setFavorite = (noteId, favorite) => { diff --git a/src/components/NavigationCategoriesItem.vue b/src/components/NavigationCategoriesItem.vue index 013a3aa2..6b7857f6 100644 --- a/src/components/NavigationCategoriesItem.vue +++ b/src/components/NavigationCategoriesItem.vue @@ -14,7 +14,7 @@ icon="icon-recent" @click.prevent.stop="onSelectCategory(null)" > - + {{ numNotes }} @@ -25,7 +25,7 @@ :icon="category.name === '' ? 'icon-emptyfolder' : 'icon-files'" @click.prevent.stop="onSelectCategory(category.name)" > - + {{ category.count }} diff --git a/src/components/NavigationList.vue b/src/components/NavigationList.vue index 8ca9ff06..fcd96fe7 100644 --- a/src/components/NavigationList.vue +++ b/src/components/NavigationList.vue @@ -42,7 +42,7 @@ :key="note.id" :note="note" @category-selected="$emit('category-selected', $event)" - @note-deleted="$emit('note-deleted')" + @note-deleted="$emit('note-deleted', $event)" /> -