check for update conflicts and provide solutions

This commit is contained in:
korelstar 2021-03-11 10:17:40 +01:00
parent 892deaf5bb
commit 002dc3ff16
7 changed files with 310 additions and 48 deletions

44
package-lock.json generated
View File

@ -14,11 +14,13 @@
"@nextcloud/vue": "^3.8.0",
"@nextcloud/vue-dashboard": "^2.0.0",
"@nextcloud/webpack-vue-config": "^4.0.1",
"diff": "^5.0.0",
"easymde": "^2.14.0",
"markdown-it": "^12.0.4",
"stylelint-webpack-plugin": "^2.1.1",
"vue": "^2.6.12",
"vue-fragment": "1.5.1",
"vue-material-design-icons": "^4.11.0",
"vue-observe-visibility": "^1.0.0",
"vue-router": "^3.5.1",
"vuex": "^3.6.2"
@ -3492,6 +3494,14 @@
"minimalistic-assert": "^1.0.0"
}
},
"node_modules/diff": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
"integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diffie-hellman": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
@ -5019,19 +5029,6 @@
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@ -10511,6 +10508,11 @@
}
}
},
"node_modules/vue-material-design-icons": {
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-4.11.0.tgz",
"integrity": "sha512-3Tyeqi9jtONQ/x8WkJqiBs4t5Bd5O1t7RdM/GIPKVYoVdaRy0oy3nbRjnMGyONBlqC/NpPjzhWeoZWUMEI04nA=="
},
"node_modules/vue-multiselect": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-2.1.6.tgz",
@ -13668,6 +13670,11 @@
"minimalistic-assert": "^1.0.0"
}
},
"diff": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
"integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w=="
},
"diffie-hellman": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
@ -14809,12 +14816,6 @@
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"optional": true
},
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@ -18904,6 +18905,11 @@
"vue-style-loader": "^4.1.0"
}
},
"vue-material-design-icons": {
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-4.11.0.tgz",
"integrity": "sha512-3Tyeqi9jtONQ/x8WkJqiBs4t5Bd5O1t7RdM/GIPKVYoVdaRy0oy3nbRjnMGyONBlqC/NpPjzhWeoZWUMEI04nA=="
},
"vue-multiselect": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-2.1.6.tgz",

View File

@ -21,11 +21,13 @@
"@nextcloud/vue": "^3.8.0",
"@nextcloud/vue-dashboard": "^2.0.0",
"@nextcloud/webpack-vue-config": "^4.0.1",
"diff": "^5.0.0",
"easymde": "^2.14.0",
"markdown-it": "^12.0.4",
"stylelint-webpack-plugin": "^2.1.1",
"vue": "^2.6.12",
"vue-fragment": "1.5.1",
"vue-material-design-icons": "^4.11.0",
"vue-observe-visibility": "^1.0.0",
"vue-router": "^3.5.1",
"vuex": "^3.6.2"

View File

@ -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 => {

21
src/Util.js Normal file
View File

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

View File

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

View File

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

View File

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