add auto-refresh / split Vuex store into modules

This commit is contained in:
korelstar 2020-06-03 21:45:33 +02:00
parent 57632ffe8c
commit 386a3ab502
11 changed files with 308 additions and 190 deletions

View File

@ -3,6 +3,7 @@
namespace OCA\Notes\Controller; namespace OCA\Notes\Controller;
use OCA\Notes\Service\NotesService; use OCA\Notes\Service\NotesService;
use OCA\Notes\Service\MetaService;
use OCA\Notes\Service\SettingsService; use OCA\Notes\Service\SettingsService;
use OCP\AppFramework\Controller; use OCP\AppFramework\Controller;
@ -16,6 +17,8 @@ class NotesController extends Controller {
/** @var NotesService */ /** @var NotesService */
private $notesService; private $notesService;
/** @var MetaService */
private $metaService;
/** @var SettingsService */ /** @var SettingsService */
private $settingsService; private $settingsService;
/** @var Helper */ /** @var Helper */
@ -31,6 +34,7 @@ class NotesController extends Controller {
string $AppName, string $AppName,
IRequest $request, IRequest $request,
NotesService $notesService, NotesService $notesService,
MetaService $metaService,
SettingsService $settingsService, SettingsService $settingsService,
Helper $helper, Helper $helper,
IConfig $settings, IConfig $settings,
@ -39,6 +43,7 @@ class NotesController extends Controller {
) { ) {
parent::__construct($AppName, $request); parent::__construct($AppName, $request);
$this->notesService = $notesService; $this->notesService = $notesService;
$this->metaService = $metaService;
$this->settingsService = $settingsService; $this->settingsService = $settingsService;
$this->helper = $helper; $this->helper = $helper;
$this->settings = $settings; $this->settings = $settings;
@ -50,8 +55,9 @@ class NotesController extends Controller {
/** /**
* @NoAdminRequired * @NoAdminRequired
*/ */
public function index() : JSONResponse { public function index(int $pruneBefore = 0) : JSONResponse {
return $this->helper->handleErrorResponse(function () { return $this->helper->handleErrorResponse(function () use ($pruneBefore) {
$now = new \DateTime(); // this must be before loading notes if there are concurrent changes possible
$settings = $this->settingsService->getAll($this->userId); $settings = $this->settingsService->getAll($this->userId);
$errorMessage = null; $errorMessage = null;
@ -65,9 +71,15 @@ class NotesController extends Controller {
$categories = null; $categories = null;
try { try {
$data = $this->notesService->getAll($this->userId); $data = $this->notesService->getAll($this->userId);
$metas = $this->metaService->updateAll($this->userId, $data['notes']);
$categories = $data['categories']; $categories = $data['categories'];
$notesData = array_map(function ($note) { $notesData = array_map(function ($note) use ($metas, $pruneBefore) {
return $note->getData([ 'content' ]); $lastUpdate = $metas[$note->getId()]->getLastUpdate();
if ($pruneBefore && $lastUpdate<$pruneBefore) {
return [ 'id' => $note->getId() ];
} else {
return $note->getData([ 'content' ]);
}
}, $data['notes']); }, $data['notes']);
if ($lastViewedNote) { if ($lastViewedNote) {
// check if note exists // check if note exists
@ -83,13 +95,18 @@ class NotesController extends Controller {
$errorMessage = $this->l10n->t('The notes folder is not accessible: %s', $e->getMessage()); $errorMessage = $this->l10n->t('The notes folder is not accessible: %s', $e->getMessage());
} }
return [ $result = [
'notes' => $notesData, 'notes' => $notesData,
'categories' => $categories, 'categories' => $categories,
'settings' => $settings, 'settings' => $settings,
'lastViewedNote' => $lastViewedNote, 'lastViewedNote' => $lastViewedNote,
'errorMessage' => $errorMessage, 'errorMessage' => $errorMessage,
]; ];
$etag = md5(json_encode($result));
return (new JSONResponse($result))
->setLastModified($now)
->setETag($etag)
;
}); });
} }

View File

@ -118,7 +118,13 @@ class MetaService {
$meta->setFileId($note->getId()); $meta->setFileId($note->getId());
$meta->setLastUpdate(time()); $meta->setLastUpdate(time());
$this->updateIfNeeded($meta, $note, true); $this->updateIfNeeded($meta, $note, true);
$this->metaMapper->insert($meta); try {
$this->metaMapper->insert($meta);
/* @phan-suppress-next-line PhanUndeclaredClassCatch */
} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) {
// It's likely that a concurrent request created this entry, too.
// We can ignore this, since the result should be the same.
}
return $meta; return $meta;
} }

View File

@ -67,7 +67,7 @@ export default {
search: '', search: '',
}, },
loading: { loading: {
notes: false, notes: true,
create: false, create: false,
}, },
error: false, error: false,
@ -79,7 +79,7 @@ export default {
computed: { computed: {
notes() { notes() {
return store.state.notes return store.state.notes.notes
}, },
filteredNotes() { filteredNotes() {
@ -129,9 +129,12 @@ export default {
methods: { methods: {
loadNotes() { loadNotes() {
this.loading.notes = true
fetchNotes() fetchNotes()
.then(data => { .then(data => {
if (data === null) {
// nothing changed
return
}
if (data.notes !== null) { if (data.notes !== null) {
this.error = false this.error = false
this.routeDefault(data.lastViewedNote) this.routeDefault(data.lastViewedNote)
@ -140,10 +143,14 @@ export default {
} }
}) })
.catch(() => { .catch(() => {
this.error = true // only show error state if not loading in background
if (this.loading.notes) {
this.error = true
}
}) })
.then(() => { .then(() => {
this.loading.notes = false this.loading.notes = false
setTimeout(this.loadNotes, 25000)
}) })
}, },
@ -151,7 +158,8 @@ export default {
if (this.$route.path !== '/') { if (this.$route.path !== '/') {
this.$router.push('/') this.$router.push('/')
} }
store.commit('removeAll') store.commit('removeAllNotes')
this.loading.notes = true
this.loadNotes() this.loadNotes()
}, },

View File

@ -36,23 +36,39 @@ export const setSettings = settings => {
} }
export const fetchNotes = () => { export const fetchNotes = () => {
const lastETag = store.state.sync.etag
const lastModified = store.state.sync.lastModified
const headers = {}
if (lastETag) {
headers['If-None-Match'] = lastETag
}
return axios return axios
.get(url('/notes')) .get(
url('/notes' + (lastModified ? '?pruneBefore=' + lastModified : '')),
{ headers }
)
.then(response => { .then(response => {
store.commit('setSettings', response.data.settings) store.commit('setSettings', response.data.settings)
store.commit('setCategories', response.data.categories) store.commit('setCategories', response.data.categories)
if (response.data.notes !== null) { if (response.data.notes !== null) {
store.dispatch('addAll', response.data.notes) store.dispatch('updateNotes', response.data.notes)
} }
if (response.data.errorMessage) { if (response.data.errorMessage) {
showError(response.data.errorMessage) showError(response.data.errorMessage)
} }
store.commit('setSyncETag', response.headers['etag'])
store.commit('setSyncLastModified', response.headers['last-modified'])
return response.data return response.data
}) })
.catch(err => { .catch(err => {
console.error(err) if (err.response && err.response.status === 304) {
handleSyncError(t('notes', 'Fetching notes has failed.')) store.commit('setSyncLastModified', err.response.headers['last-modified'])
throw err return null
} else {
console.error(err)
handleSyncError(t('notes', 'Fetching notes has failed.'))
throw err
}
}) })
} }
@ -63,7 +79,7 @@ export const fetchNote = noteId => {
const localNote = store.getters.getNote(parseInt(noteId)) const localNote = store.getters.getNote(parseInt(noteId))
// only overwrite if there are no unsaved changes // only overwrite if there are no unsaved changes
if (!localNote || !localNote.unsaved) { if (!localNote || !localNote.unsaved) {
store.commit('add', response.data) store.commit('updateNote', response.data)
} }
return response.data return response.data
}) })
@ -97,7 +113,7 @@ export const createNote = category => {
return axios return axios
.post(url('/notes'), { category: category }) .post(url('/notes'), { category: category })
.then(response => { .then(response => {
store.commit('add', response.data) store.commit('updateNote', response.data)
return response.data return response.data
}) })
.catch(err => { .catch(err => {
@ -122,7 +138,7 @@ function _updateNote(note) {
if (updated.content === note.content) { if (updated.content === note.content) {
note.unsaved = false note.unsaved = false
} }
store.commit('add', note) store.commit('updateNote', note)
return note return note
}) })
.catch(err => { .catch(err => {
@ -140,7 +156,7 @@ export const undoDeleteNote = (note) => {
return axios return axios
.post(url('/notes/undo'), note) .post(url('/notes/undo'), note)
.then(response => { .then(response => {
store.commit('add', response.data) store.commit('updateNote', response.data)
return response.data return response.data
}) })
.catch(err => { .catch(err => {
@ -159,13 +175,13 @@ export const deleteNote = noteId => {
return axios return axios
.delete(url('/notes/' + noteId)) .delete(url('/notes/' + noteId))
.then(() => { .then(() => {
store.commit('remove', noteId) store.commit('removeNote', noteId)
}) })
.catch(err => { .catch(err => {
console.error(err) console.error(err)
handleSyncError(t('notes', 'Deleting note {id} has failed.', { id: noteId })) handleSyncError(t('notes', 'Deleting note {id} has failed.', { id: noteId }))
// remove note always since we don't know when the error happened // remove note always since we don't know when the error happened
store.commit('remove', noteId) store.commit('removeNote', noteId)
throw err throw err
}) })
.then(() => { .then(() => {
@ -211,8 +227,8 @@ export const saveNote = (noteId, manualSave = false) => {
} }
function _saveNotes() { function _saveNotes() {
const unsavedNotes = Object.values(store.state.unsaved) const unsavedNotes = Object.values(store.state.notes.unsaved)
if (store.state.isSaving || unsavedNotes.length === 0) { if (store.state.app.isSaving || unsavedNotes.length === 0) {
return return
} }
store.commit('setSaving', true) store.commit('setSaving', true)
@ -237,7 +253,7 @@ export const noteExists = (noteId) => {
export const getCategories = (maxLevel, details) => { export const getCategories = (maxLevel, details) => {
const categories = store.getters.getCategories(maxLevel, details) const categories = store.getters.getCategories(maxLevel, details)
if (maxLevel === 0) { if (maxLevel === 0) {
return [...new Set([...categories, ...store.state.categories])] return [...new Set([...categories, ...store.state.notes.categories])]
} else { } else {
return categories return categories
} }

View File

@ -51,7 +51,7 @@ export default {
computed: { computed: {
settings() { settings() {
return store.state.settings return store.state.app.settings
}, },
}, },

View File

@ -108,10 +108,10 @@ export default {
return this.note ? this.note.title : '' return this.note ? this.note.title : ''
}, },
isManualSave() { isManualSave() {
return store.state.isManualSave return store.state.app.isManualSave
}, },
sidebarOpen() { sidebarOpen() {
return store.state.sidebarOpen return store.state.app.sidebarOpen
}, },
}, },
@ -163,7 +163,7 @@ export default {
}, },
onUpdateTitle(title) { onUpdateTitle(title) {
const defaultTitle = store.state.documentTitle const defaultTitle = store.state.app.documentTitle
if (title) { if (title) {
document.title = title + ' - ' + defaultTitle document.title = title + ' - ' + defaultTitle
} else { } else {
@ -212,7 +212,7 @@ export default {
}, },
onToggleSidebar() { onToggleSidebar() {
store.commit('setSidebarOpen', !store.state.sidebarOpen) store.commit('setSidebarOpen', !store.state.app.sidebarOpen)
this.actionsOpen = false this.actionsOpen = false
}, },
@ -224,7 +224,7 @@ export default {
unsaved: true, unsaved: true,
autotitle: routeIsNewNote(this.$route), autotitle: routeIsNewNote(this.$route),
} }
store.commit('add', note) store.commit('updateNote', note)
if (this.autosaveTimer === null) { if (this.autosaveTimer === null) {
this.autosaveTimer = setTimeout(() => { this.autosaveTimer = setTimeout(() => {
this.autosaveTimer = null this.autosaveTimer = null

View File

@ -138,7 +138,7 @@ export default {
return [ '', ...getCategories(0, false) ] return [ '', ...getCategories(0, false) ]
}, },
sidebarOpen() { sidebarOpen() {
return store.state.sidebarOpen return store.state.app.sidebarOpen
}, },
}, },

View File

@ -1,166 +1,16 @@
import Vue from 'vue' import Vue from 'vue'
import Vuex from 'vuex' import Vuex from 'vuex'
import app from './store/app'
import notes from './store/notes'
import sync from './store/sync'
Vue.use(Vuex) Vue.use(Vuex)
export default new Vuex.Store({ export default new Vuex.Store({
state: { modules: {
settings: {}, app,
categories: [], notes,
notes: [], sync,
notesIds: {},
unsaved: {},
isSaving: false,
isManualSave: false,
documentTitle: null,
sidebarOpen: false,
},
getters: {
numNotes: (state) => () => {
return state.notes.length
},
noteExists: (state) => (id) => {
return state.notesIds[id] !== undefined
},
getNote: (state) => (id) => {
if (state.notesIds[id] === undefined) {
return null
}
return state.notesIds[id]
},
getCategories: (state) => (maxLevel, details) => {
function nthIndexOf(str, pattern, n) {
let i = -1
while (n-- && i++ < str.length) {
i = str.indexOf(pattern, i)
if (i < 0) {
break
}
}
return i
}
// get categories from notes
const categories = {}
for (const note of state.notes) {
let cat = note.category
if (maxLevel > 0) {
const index = nthIndexOf(cat, '/', maxLevel)
if (index > 0) {
cat = cat.substring(0, index)
}
}
if (categories[cat] === undefined) {
categories[cat] = 1
} else {
categories[cat] += 1
}
}
// get structured result from categories
const result = []
for (const category in categories) {
if (details) {
result.push({
name: category,
count: categories[category],
})
} else if (category) {
result.push(category)
}
}
if (details) {
result.sort((a, b) => a.name.localeCompare(b.name))
} else {
result.sort()
}
return result
},
},
mutations: {
add(state, updated) {
const note = state.notesIds[updated.id]
if (note) {
// don't update meta-data over full data
if (updated.content !== undefined || note.content === undefined) {
note.title = updated.title
note.modified = updated.modified
note.content = updated.content
note.favorite = updated.favorite
note.category = updated.category
Vue.set(note, 'autotitle', updated.autotitle)
Vue.set(note, 'unsaved', updated.unsaved)
Vue.set(note, 'error', updated.error)
Vue.set(note, 'errorMessage', updated.errorMessage)
}
} else {
state.notes.push(updated)
Vue.set(state.notesIds, updated.id, updated)
}
},
setNoteAttribute(state, params) {
const note = state.notesIds[params.noteId]
if (note) {
Vue.set(note, params.attribute, params.value)
}
},
remove(state, id) {
const index = state.notes.findIndex(note => note.id === id)
if (index !== -1) {
state.notes.splice(index, 1)
delete state.notesIds[id]
}
},
removeAll(state) {
state.notes = []
state.notesIds = {}
},
addUnsaved(state, id) {
Vue.set(state.unsaved, id, state.notesIds[id])
},
clearUnsaved(state) {
state.unsaved = {}
},
setSettings(state, settings) {
state.settings = settings
},
setCategories(state, categories) {
state.categories = categories
},
setSaving(state, isSaving) {
state.isSaving = isSaving
},
setManualSave(state, isManualSave) {
state.isManualSave = isManualSave
},
setDocumentTitle(state, title) {
state.documentTitle = title
},
setSidebarOpen(state, open) {
state.sidebarOpen = open
},
},
actions: {
addAll(context, notes) {
for (const note of notes) {
context.commit('add', note)
}
},
}, },
}) })

37
src/store/app.js Normal file
View File

@ -0,0 +1,37 @@
const state = {
settings: {},
isSaving: false,
isManualSave: false,
documentTitle: null,
sidebarOpen: false,
}
const getters = {
}
const mutations = {
setSettings(state, settings) {
state.settings = settings
},
setSaving(state, isSaving) {
state.isSaving = isSaving
},
setManualSave(state, isManualSave) {
state.isManualSave = isManualSave
},
setDocumentTitle(state, title) {
state.documentTitle = title
},
setSidebarOpen(state, open) {
state.sidebarOpen = open
},
}
const actions = {
}
export default { state, getters, mutations, actions }

151
src/store/notes.js Normal file
View File

@ -0,0 +1,151 @@
import Vue from 'vue'
const state = {
categories: [],
notes: [],
notesIds: {},
unsaved: {},
}
const getters = {
numNotes: (state) => () => {
return state.notes.length
},
noteExists: (state) => (id) => {
return state.notesIds[id] !== undefined
},
getNote: (state) => (id) => {
if (state.notesIds[id] === undefined) {
return null
}
return state.notesIds[id]
},
getCategories: (state) => (maxLevel, details) => {
function nthIndexOf(str, pattern, n) {
let i = -1
while (n-- && i++ < str.length) {
i = str.indexOf(pattern, i)
if (i < 0) {
break
}
}
return i
}
// get categories from notes
const categories = {}
for (const note of state.notes) {
let cat = note.category
if (maxLevel > 0) {
const index = nthIndexOf(cat, '/', maxLevel)
if (index > 0) {
cat = cat.substring(0, index)
}
}
if (categories[cat] === undefined) {
categories[cat] = 1
} else {
categories[cat] += 1
}
}
// get structured result from categories
const result = []
for (const category in categories) {
if (details) {
result.push({
name: category,
count: categories[category],
})
} else if (category) {
result.push(category)
}
}
if (details) {
result.sort((a, b) => a.name.localeCompare(b.name))
} else {
result.sort()
}
return result
},
}
const mutations = {
updateNote(state, updated) {
const note = state.notesIds[updated.id]
if (note) {
// don't update meta-data over full data
if (updated.content !== undefined || note.content === undefined) {
note.title = updated.title
note.modified = updated.modified
note.content = updated.content
note.favorite = updated.favorite
note.category = updated.category
Vue.set(note, 'autotitle', updated.autotitle)
Vue.set(note, 'unsaved', updated.unsaved)
Vue.set(note, 'error', updated.error)
Vue.set(note, 'errorMessage', updated.errorMessage)
}
} else {
state.notes.push(updated)
Vue.set(state.notesIds, updated.id, updated)
}
},
setNoteAttribute(state, params) {
const note = state.notesIds[params.noteId]
if (note) {
Vue.set(note, params.attribute, params.value)
}
},
removeNote(state, id) {
const index = state.notes.findIndex(note => note.id === id)
if (index !== -1) {
state.notes.splice(index, 1)
delete state.notesIds[id]
}
},
removeAllNotes(state) {
state.notes = []
state.notesIds = {}
},
addUnsaved(state, id) {
Vue.set(state.unsaved, id, state.notesIds[id])
},
clearUnsaved(state) {
state.unsaved = {}
},
setCategories(state, categories) {
state.categories = categories
},
}
const actions = {
updateNotes(context, notes) {
const noteIds = {}
// add/update new notes
for (const note of notes) {
noteIds[note.id] = true
// TODO check for parallel (local) changes!
// only update, if note has changes (see API "purgeBefore")
if (note.title !== undefined) {
context.commit('updateNote', note)
}
}
// remove deleted notes
context.state.notes.forEach(note => {
if (noteIds[note.id] === undefined) {
context.commit('removeNote', note.id)
}
})
},
}
export default { state, getters, mutations, actions }

33
src/store/sync.js Normal file
View File

@ -0,0 +1,33 @@
const state = {
etag: null,
lastModified: 0,
active: false,
// TODO add list of notes with changes during sync
}
const getters = {
}
const mutations = {
setSyncETag(state, etag) {
if (etag) {
state.etag = etag
}
},
setSyncLastModified(state, strLastModified) {
const lastModified = Date.parse(strLastModified)
if (lastModified) {
state.lastModified = lastModified / 1000
}
},
setSyncActive(state, active) {
state.active = active
},
}
const actions = {
}
export default { state, getters, mutations, actions }