diff --git a/lib/Controller/NotesController.php b/lib/Controller/NotesController.php index dc07415c..8dd14de8 100644 --- a/lib/Controller/NotesController.php +++ b/lib/Controller/NotesController.php @@ -3,6 +3,7 @@ namespace OCA\Notes\Controller; use OCA\Notes\Service\NotesService; +use OCA\Notes\Service\MetaService; use OCA\Notes\Service\SettingsService; use OCP\AppFramework\Controller; @@ -16,6 +17,8 @@ class NotesController extends Controller { /** @var NotesService */ private $notesService; + /** @var MetaService */ + private $metaService; /** @var SettingsService */ private $settingsService; /** @var Helper */ @@ -31,6 +34,7 @@ class NotesController extends Controller { string $AppName, IRequest $request, NotesService $notesService, + MetaService $metaService, SettingsService $settingsService, Helper $helper, IConfig $settings, @@ -39,6 +43,7 @@ class NotesController extends Controller { ) { parent::__construct($AppName, $request); $this->notesService = $notesService; + $this->metaService = $metaService; $this->settingsService = $settingsService; $this->helper = $helper; $this->settings = $settings; @@ -50,8 +55,9 @@ class NotesController extends Controller { /** * @NoAdminRequired */ - public function index() : JSONResponse { - return $this->helper->handleErrorResponse(function () { + public function index(int $pruneBefore = 0) : JSONResponse { + 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); $errorMessage = null; @@ -65,9 +71,15 @@ class NotesController extends Controller { $categories = null; try { $data = $this->notesService->getAll($this->userId); + $metas = $this->metaService->updateAll($this->userId, $data['notes']); $categories = $data['categories']; - $notesData = array_map(function ($note) { - return $note->getData([ 'content' ]); + $notesData = array_map(function ($note) use ($metas, $pruneBefore) { + $lastUpdate = $metas[$note->getId()]->getLastUpdate(); + if ($pruneBefore && $lastUpdate<$pruneBefore) { + return [ 'id' => $note->getId() ]; + } else { + return $note->getData([ 'content' ]); + } }, $data['notes']); if ($lastViewedNote) { // 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()); } - return [ + $result = [ 'notes' => $notesData, 'categories' => $categories, 'settings' => $settings, 'lastViewedNote' => $lastViewedNote, 'errorMessage' => $errorMessage, ]; + $etag = md5(json_encode($result)); + return (new JSONResponse($result)) + ->setLastModified($now) + ->setETag($etag) + ; }); } diff --git a/lib/Service/MetaService.php b/lib/Service/MetaService.php index f731f317..d7aab439 100644 --- a/lib/Service/MetaService.php +++ b/lib/Service/MetaService.php @@ -118,7 +118,13 @@ class MetaService { $meta->setFileId($note->getId()); $meta->setLastUpdate(time()); $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; } diff --git a/src/App.vue b/src/App.vue index 9bfda586..27751eed 100644 --- a/src/App.vue +++ b/src/App.vue @@ -67,7 +67,7 @@ export default { search: '', }, loading: { - notes: false, + notes: true, create: false, }, error: false, @@ -79,7 +79,7 @@ export default { computed: { notes() { - return store.state.notes + return store.state.notes.notes }, filteredNotes() { @@ -129,9 +129,12 @@ export default { methods: { loadNotes() { - this.loading.notes = true fetchNotes() .then(data => { + if (data === null) { + // nothing changed + return + } if (data.notes !== null) { this.error = false this.routeDefault(data.lastViewedNote) @@ -140,10 +143,14 @@ export default { } }) .catch(() => { - this.error = true + // only show error state if not loading in background + if (this.loading.notes) { + this.error = true + } }) .then(() => { this.loading.notes = false + setTimeout(this.loadNotes, 25000) }) }, @@ -151,7 +158,8 @@ export default { if (this.$route.path !== '/') { this.$router.push('/') } - store.commit('removeAll') + store.commit('removeAllNotes') + this.loading.notes = true this.loadNotes() }, diff --git a/src/NotesService.js b/src/NotesService.js index 473559c6..c633b9eb 100644 --- a/src/NotesService.js +++ b/src/NotesService.js @@ -36,23 +36,39 @@ export const setSettings = settings => { } 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 - .get(url('/notes')) + .get( + url('/notes' + (lastModified ? '?pruneBefore=' + lastModified : '')), + { headers } + ) .then(response => { store.commit('setSettings', response.data.settings) store.commit('setCategories', response.data.categories) if (response.data.notes !== null) { - store.dispatch('addAll', response.data.notes) + store.dispatch('updateNotes', response.data.notes) } if (response.data.errorMessage) { showError(response.data.errorMessage) } + store.commit('setSyncETag', response.headers['etag']) + store.commit('setSyncLastModified', response.headers['last-modified']) return response.data }) .catch(err => { - console.error(err) - handleSyncError(t('notes', 'Fetching notes has failed.')) - throw err + if (err.response && err.response.status === 304) { + store.commit('setSyncLastModified', err.response.headers['last-modified']) + 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)) // only overwrite if there are no unsaved changes if (!localNote || !localNote.unsaved) { - store.commit('add', response.data) + store.commit('updateNote', response.data) } return response.data }) @@ -97,7 +113,7 @@ export const createNote = category => { return axios .post(url('/notes'), { category: category }) .then(response => { - store.commit('add', response.data) + store.commit('updateNote', response.data) return response.data }) .catch(err => { @@ -122,7 +138,7 @@ function _updateNote(note) { if (updated.content === note.content) { note.unsaved = false } - store.commit('add', note) + store.commit('updateNote', note) return note }) .catch(err => { @@ -140,7 +156,7 @@ export const undoDeleteNote = (note) => { return axios .post(url('/notes/undo'), note) .then(response => { - store.commit('add', response.data) + store.commit('updateNote', response.data) return response.data }) .catch(err => { @@ -159,13 +175,13 @@ export const deleteNote = noteId => { return axios .delete(url('/notes/' + noteId)) .then(() => { - store.commit('remove', noteId) + store.commit('removeNote', noteId) }) .catch(err => { console.error(err) handleSyncError(t('notes', 'Deleting note {id} has failed.', { id: noteId })) // remove note always since we don't know when the error happened - store.commit('remove', noteId) + store.commit('removeNote', noteId) throw err }) .then(() => { @@ -211,8 +227,8 @@ export const saveNote = (noteId, manualSave = false) => { } function _saveNotes() { - const unsavedNotes = Object.values(store.state.unsaved) - if (store.state.isSaving || unsavedNotes.length === 0) { + const unsavedNotes = Object.values(store.state.notes.unsaved) + if (store.state.app.isSaving || unsavedNotes.length === 0) { return } store.commit('setSaving', true) @@ -237,7 +253,7 @@ export const noteExists = (noteId) => { export const getCategories = (maxLevel, details) => { const categories = store.getters.getCategories(maxLevel, details) if (maxLevel === 0) { - return [...new Set([...categories, ...store.state.categories])] + return [...new Set([...categories, ...store.state.notes.categories])] } else { return categories } diff --git a/src/components/AppSettings.vue b/src/components/AppSettings.vue index d0318026..e19d8b7a 100644 --- a/src/components/AppSettings.vue +++ b/src/components/AppSettings.vue @@ -51,7 +51,7 @@ export default { computed: { settings() { - return store.state.settings + return store.state.app.settings }, }, diff --git a/src/components/Note.vue b/src/components/Note.vue index 0731f0ef..ab4acc85 100644 --- a/src/components/Note.vue +++ b/src/components/Note.vue @@ -108,10 +108,10 @@ export default { return this.note ? this.note.title : '' }, isManualSave() { - return store.state.isManualSave + return store.state.app.isManualSave }, sidebarOpen() { - return store.state.sidebarOpen + return store.state.app.sidebarOpen }, }, @@ -163,7 +163,7 @@ export default { }, onUpdateTitle(title) { - const defaultTitle = store.state.documentTitle + const defaultTitle = store.state.app.documentTitle if (title) { document.title = title + ' - ' + defaultTitle } else { @@ -212,7 +212,7 @@ export default { }, onToggleSidebar() { - store.commit('setSidebarOpen', !store.state.sidebarOpen) + store.commit('setSidebarOpen', !store.state.app.sidebarOpen) this.actionsOpen = false }, @@ -224,7 +224,7 @@ export default { unsaved: true, autotitle: routeIsNewNote(this.$route), } - store.commit('add', note) + store.commit('updateNote', note) if (this.autosaveTimer === null) { this.autosaveTimer = setTimeout(() => { this.autosaveTimer = null diff --git a/src/components/Sidebar.vue b/src/components/Sidebar.vue index 28f686cc..2f4a7a85 100644 --- a/src/components/Sidebar.vue +++ b/src/components/Sidebar.vue @@ -138,7 +138,7 @@ export default { return [ '', ...getCategories(0, false) ] }, sidebarOpen() { - return store.state.sidebarOpen + return store.state.app.sidebarOpen }, }, diff --git a/src/store.js b/src/store.js index 4514ea0d..683967f2 100644 --- a/src/store.js +++ b/src/store.js @@ -1,166 +1,16 @@ import Vue from 'vue' import Vuex from 'vuex' +import app from './store/app' +import notes from './store/notes' +import sync from './store/sync' + Vue.use(Vuex) export default new Vuex.Store({ - state: { - settings: {}, - categories: [], - notes: [], - 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) - } - }, + modules: { + app, + notes, + sync, }, }) diff --git a/src/store/app.js b/src/store/app.js new file mode 100644 index 00000000..3f291bee --- /dev/null +++ b/src/store/app.js @@ -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 } diff --git a/src/store/notes.js b/src/store/notes.js new file mode 100644 index 00000000..e33dfa7d --- /dev/null +++ b/src/store/notes.js @@ -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 } diff --git a/src/store/sync.js b/src/store/sync.js new file mode 100644 index 00000000..dbc38e1d --- /dev/null +++ b/src/store/sync.js @@ -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 }