Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/notes.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorkorelstar <korelstar@users.noreply.github.com>2021-03-11 12:17:40 +0300
committerkorelstar <korelstar@users.noreply.github.com>2021-04-19 07:37:50 +0300
commit002dc3ff1614153bd4c444646ff2bf786e76f37b (patch)
treeb42021f37a1e2d9a9ef7ef6c6b49e630c080bfdd /src
parent892deaf5bbecfdbf8330d82f171006339bc276d1 (diff)
check for update conflicts and provide solutions
Diffstat (limited to 'src')
-rw-r--r--src/NotesService.js96
-rw-r--r--src/Util.js21
-rw-r--r--src/components/ConflictSolution.vue94
-rw-r--r--src/components/Note.vue92
-rw-r--r--src/store/notes.js9
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)