diff options
author | korelstar <korelstar@users.noreply.github.com> | 2021-03-11 12:17:40 +0300 |
---|---|---|
committer | korelstar <korelstar@users.noreply.github.com> | 2021-04-19 07:37:50 +0300 |
commit | 002dc3ff1614153bd4c444646ff2bf786e76f37b (patch) | |
tree | b42021f37a1e2d9a9ef7ef6c6b49e630c080bfdd /src | |
parent | 892deaf5bbecfdbf8330d82f171006339bc276d1 (diff) |
check for update conflicts and provide solutions
Diffstat (limited to 'src')
-rw-r--r-- | src/NotesService.js | 96 | ||||
-rw-r--r-- | src/Util.js | 21 | ||||
-rw-r--r-- | src/components/ConflictSolution.vue | 94 | ||||
-rw-r--r-- | src/components/Note.vue | 92 | ||||
-rw-r--r-- | src/store/notes.js | 9 |
5 files changed, 283 insertions, 29 deletions
diff --git a/src/NotesService.js b/src/NotesService.js index f6868e67..0273c1d4 100644 --- a/src/NotesService.js +++ b/src/NotesService.js @@ -3,6 +3,7 @@ import { generateUrl } from '@nextcloud/router' import { showError } from '@nextcloud/dialogs' import store from './store' +import { copyNote } from './Util' function url(url) { url = `apps/notes${url}` @@ -103,7 +104,7 @@ export const fetchNote = noteId => { const localNote = store.getters.getNote(parseInt(noteId)) // only overwrite if there are no unsaved changes if (!localNote || !localNote.unsaved) { - store.commit('updateNote', response.data) + _updateLocalNote(response.data) } return response.data }) @@ -125,17 +126,22 @@ export const refreshNote = (noteId, lastETag) => { if (lastETag) { headers['If-None-Match'] = lastETag } - const oldContent = store.getters.getNote(noteId).content + const note = store.getters.getNote(noteId) + const oldContent = note.content return axios .get( url('/notes/' + noteId), { headers } ) .then(response => { + if (note.conflict) { + store.commit('setNoteAttribute', { noteId, attribute: 'conflict', value: response.data }) + return response.headers.etag + } const currentContent = store.getters.getNote(noteId).content // only update if local content has not changed if (oldContent === currentContent) { - store.commit('updateNote', response.data) + _updateLocalNote(response.data) return response.headers.etag } return null @@ -166,7 +172,7 @@ export const createNote = category => { return axios .post(url('/notes'), { category }) .then(response => { - store.commit('updateNote', response.data) + _updateLocalNote(response.data) return response.data }) .catch(err => { @@ -176,27 +182,87 @@ export const createNote = category => { }) } +function _updateLocalNote(note, reference) { + if (reference === undefined) { + reference = copyNote(note, {}) + } + store.commit('updateNote', note) + store.commit('setNoteAttribute', { noteId: note.id, attribute: 'reference', value: reference }) +} + function _updateNote(note) { + const requestOptions = { headers: { 'If-Match': '"' + note.etag + '"' } } return axios - .put(url('/notes/' + note.id), { content: note.content }) + .put(url('/notes/' + note.id), { content: note.content }, requestOptions) .then(response => { - const updated = response.data note.saveError = false - note.title = updated.title - note.modified = updated.modified + store.commit('setNoteAttribute', { noteId: note.id, attribute: 'conflict', value: undefined }) + const updated = response.data if (updated.content === note.content) { - note.unsaved = false + // everything is fine + // => update note with remote data + _updateLocalNote( + { ...updated, unsaved: false } + ) + } else { + // content has changed locally in the meanwhile + // => merge note, but exclude content + _updateLocalNote( + copyNote(updated, note, ['content']), + copyNote(updated, {}) + ) } - store.commit('updateNote', note) - return note }) .catch(err => { - store.commit('setNoteAttribute', { noteId: note.id, attribute: 'saveError', value: true }) - console.error(err) - handleSyncError(t('notes', 'Saving note {id} has failed.', { id: note.id }), err) + if (err.response && err.response.status === 412) { + // ETag does not match, try to merge changes + note.saveError = false + store.commit('setNoteAttribute', { noteId: note.id, attribute: 'conflict', value: undefined }) + const reference = note.reference + const remote = err.response.data + if (remote.content === note.content) { + // content is already up-to-date + // => update note with remote data + _updateLocalNote( + { ...remote, unsaved: false } + ) + } else if (remote.content === reference.content) { + // remote content has not changed + // => use all other attributes and sync again + _updateLocalNote( + copyNote(remote, note, ['content']), + copyNote(remote, {}) + ) + saveNote(note.id) + } else { + console.info('Note update conflict. Manual resolution required.') + store.commit('setNoteAttribute', { noteId: note.id, attribute: 'conflict', value: remote }) + } + } else { + store.commit('setNoteAttribute', { noteId: note.id, attribute: 'saveError', value: true }) + console.error(err) + handleSyncError(t('notes', 'Saving note {id} has failed.', { id: note.id }), err) + } }) } +export const conflictSolutionLocal = note => { + note.etag = note.conflict.etag + _updateLocalNote( + copyNote(note.conflict, note, ['content']), + copyNote(note.conflict, {}) + ) + store.commit('setNoteAttribute', { noteId: note.id, attribute: 'conflict', value: undefined }) + saveNote(note.id) +} + +export const conflictSolutionRemote = note => { + _updateLocalNote( + { ...note.conflict, unsaved: false } + ) + store.commit('setNoteAttribute', { noteId: note.id, attribute: 'conflict', value: undefined }) +} + export const autotitleNote = noteId => { return axios .put(url('/notes/' + noteId + '/autotitle')) @@ -213,7 +279,7 @@ export const undoDeleteNote = (note) => { return axios .post(url('/notes/undo'), note) .then(response => { - store.commit('updateNote', response.data) + _updateLocalNote(response.data) return response.data }) .catch(err => { diff --git a/src/Util.js b/src/Util.js new file mode 100644 index 00000000..dd4aa5f2 --- /dev/null +++ b/src/Util.js @@ -0,0 +1,21 @@ +export const noteAttributes = [ + 'id', + 'etag', + 'title', + 'content', + 'modified', + 'favorite', + 'category', +] + +export const copyNote = (from, to, exclude) => { + if (exclude === undefined) { + exclude = [] + } + noteAttributes.forEach(attr => { + if (!exclude.includes(attr)) { + to[attr] = from[attr] + } + }) + return to +} diff --git a/src/components/ConflictSolution.vue b/src/components/ConflictSolution.vue new file mode 100644 index 00000000..535570e7 --- /dev/null +++ b/src/components/ConflictSolution.vue @@ -0,0 +1,94 @@ +<template> + <div class="conflict-solution"> + <div class="text"> + <pre v-for="(l, i) in diff" :key="i" :class="l.className">{{ l.line }}</pre> + </div> + <button @click="$emit('onChooseSolution')"> + {{ button }} + </button> + </div> +</template> +<script> + +import { diffLines } from 'diff' + +export default { + name: 'ConflictSolution', + + props: { + content: { + type: String, + required: true, + }, + reference: { + type: String, + required: true, + }, + button: { + type: String, + required: true, + }, + }, + + computed: { + diff() { + const diffs = diffLines(this.reference, this.content) + const line2class = function(line) { + if (line.added) { + return 'added' + } else if (line.removed) { + return 'removed' + } else { + return 'unchanged' + } + } + const lines = [] + diffs.forEach(diff => { + const className = line2class(diff) + diff.value.replace(/\r?\n$/, '').split(/\r?\n/).forEach(line => { + lines.push({ line, className }) + }) + }) + return lines + }, + }, +} +</script> +<style scoped> +.conflict-solution { + height: 100%; + padding: 1ex; + margin: 1ex; + flex: 1; +} + +.conflict-solution .text { + max-height: 60vh; + overflow: auto; + background-color: var(--color-background-darker); + padding: 0 1ex; +} + +.conflict-solution .text pre { + white-space: pre-wrap; + line-height: 1.2; + padding: 0.3ex 0.5ex; +} + +.conflict-solution .text .removed { + background-color: rgba(128, 128, 128, 0.2); + color: rgba(128, 128, 128, 1); + text-decoration: line-through; +} + +.conflict-solution .text .added { + background-color: rgba(70, 186, 97, 0.2); + color: rgba(70, 186, 97, 1); +} + +.conflict-solution button { + margin: auto; + margin-top: 2ex; + display: block; +} +</style> diff --git a/src/components/Note.vue b/src/components/Note.vue index 979fd12f..2b405448 100644 --- a/src/components/Note.vue +++ b/src/components/Note.vue @@ -5,6 +5,27 @@ class="note-container" :class="{ fullscreen: fullscreen }" > + <Modal v-if="note.conflict && showConflict" size="full" @close="showConflict=false"> + <div class="conflict-modal"> + <div class="conflict-header"> + {{ t('notes', 'The note has been changed in another session. Please choose which content should be saved.') }} + </div> + <div class="conflict-solutions"> + <ConflictSolution + :content="note.conflict.content" + :reference="note.reference.content" + :button="t('notes', 'Use version from server')" + @onChooseSolution="onUseRemoteVersion" + /> + <ConflictSolution + :content="note.content" + :reference="note.reference.content" + :button="t('notes', 'Use current version')" + @onChooseSolution="onUseLocalVersion" + /> + </div> + </div> + </Modal> <div class="note-editor"> <div v-show="!note.content" class="placeholder"> {{ preview ? t('notes', 'Empty note') : t('notes', 'Write …') }} @@ -35,11 +56,18 @@ {{ fullscreen ? t('notes', 'Exit full screen') : t('notes', 'Full screen') }} </ActionButton> </Actions> - <button v-show="note.saveError" - v-tooltip.right="t('notes', 'Save failed. Click to retry.')" - class="action-error icon-error-color" - @click="onManualSave" - /> + <Actions v-if="note.saveError" class="action-error"> + <ActionButton @click="onManualSave"> + <SyncAlertIcon slot="icon" :size="18" fill-color="var(--color-text)" /> + {{ t('notes', 'Save failed. Click to retry.') }} + </ActionButton> + </Actions> + <Actions v-if="note.conflict" class="action-error"> + <ActionButton @click="showConflict=true"> + <SyncAlertIcon slot="icon" :size="18" fill-color="var(--color-text)" /> + {{ t('notes', 'Update conflict. Click for resolving manually.') }} + </ActionButton> + </Actions> </span> </div> </AppContent> @@ -50,16 +78,20 @@ import { Actions, ActionButton, AppContent, + Modal, Tooltip, isMobile, } from '@nextcloud/vue' import { showError } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' +import SyncAlertIcon from 'vue-material-design-icons/SyncAlert' + import { config } from '../config' -import { fetchNote, refreshNote, saveNote, saveNoteManually, autotitleNote, routeIsNewNote } from '../NotesService' +import { fetchNote, refreshNote, saveNote, saveNoteManually, autotitleNote, routeIsNewNote, conflictSolutionLocal, conflictSolutionRemote } from '../NotesService' import TheEditor from './EditorEasyMDE' import ThePreview from './EditorMarkdownIt' +import ConflictSolution from './ConflictSolution' import store from '../store' export default { @@ -69,6 +101,9 @@ export default { Actions, ActionButton, AppContent, + ConflictSolution, + Modal, + SyncAlertIcon, TheEditor, ThePreview, }, @@ -96,6 +131,7 @@ export default { autotitleTimer: null, refreshTimer: null, etag: null, + showConflict: false, } }, @@ -124,6 +160,11 @@ export default { } }, title: 'onUpdateTitle', + 'note.conflict'(newConflict, oldConflict) { + if (newConflict) { + this.showConflict = true + } + }, }, created() { @@ -242,7 +283,7 @@ export default { }, refreshNote() { - if (this.note.unsaved) { + if (this.note.unsaved && !this.note.conflict) { this.startRefreshTimer() return } @@ -317,6 +358,16 @@ export default { store.commit('updateNote', note) saveNoteManually(this.note.id) }, + + onUseLocalVersion() { + conflictSolutionLocal(this.note) + this.showConflict = false + }, + + onUseRemoteVersion() { + conflictSolutionRemote(this.note) + this.showConflict = false + }, }, } </script> @@ -379,8 +430,8 @@ export default { } .action-buttons .action-error { - width: 44px; - height: 44px; + background-color: var(--color-error); + margin-top: 1ex; } .note-container.fullscreen .action-buttons { @@ -390,4 +441,27 @@ export default { .action-buttons button { padding: 15px; } + +/* Conflict Modal */ +.conflict-modal { + width: 70vw; +} + +.conflict-header { + padding: 1ex 1em; +} + +.conflict-solutions { + display: flex; + flex-direction: row-reverse; + max-height: 75vh; + overflow-y: auto; +} + +@media (max-width: 60em) { + .conflict-solutions { + flex-direction: column; + } +} + </style> diff --git a/src/store/notes.js b/src/store/notes.js index 4474ad8a..a1a9f452 100644 --- a/src/store/notes.js +++ b/src/store/notes.js @@ -1,4 +1,5 @@ import Vue from 'vue' +import { copyNote } from '../Util' const state = { categories: [], @@ -76,13 +77,11 @@ const mutations = { updateNote(state, updated) { const note = state.notesIds[updated.id] if (note) { - note.title = updated.title - note.modified = updated.modified - note.favorite = updated.favorite - note.category = updated.category + copyNote(updated, note, ['id', 'etag', 'content']) // don't update meta-data over full data - if (updated.content !== undefined || note.content === undefined) { + if (updated.content !== undefined && updated.etag !== undefined) { note.content = updated.content + note.etag = updated.etag Vue.set(note, 'unsaved', updated.unsaved) Vue.set(note, 'error', updated.error) Vue.set(note, 'errorMessage', updated.errorMessage) |