diff --git a/appinfo/routes.php b/appinfo/routes.php index 22c4b132c..63a3d023a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -92,6 +92,16 @@ 'verb' => 'DELETE', 'requirements' => ['id' => '\d+'], ], + [ + 'name' => 'notes#renameCategory', + 'url' => '/notes/category', + 'verb' => 'PATCH', + ], + [ + 'name' => 'notes#deleteCategory', + 'url' => '/notes/category', + 'verb' => 'DELETE', + ], ////////// A T T A C H M E N T S ////////// diff --git a/lib/Controller/NotesController.php b/lib/Controller/NotesController.php index c5c1c9419..2308dd23e 100644 --- a/lib/Controller/NotesController.php +++ b/lib/Controller/NotesController.php @@ -329,6 +329,26 @@ public function destroy(int $id) : JSONResponse { }); } + /** + * + */ + #[NoAdminRequired] + public function renameCategory(string $oldCategory, string $newCategory) : JSONResponse { + return $this->helper->handleErrorResponse(function () use ($oldCategory, $newCategory) { + return $this->notesService->renameCategory($this->helper->getUID(), $oldCategory, $newCategory); + }); + } + + /** + * + */ + #[NoAdminRequired] + public function deleteCategory(string $category) : JSONResponse { + return $this->helper->handleErrorResponse(function () use ($category) { + return $this->notesService->deleteCategory($this->helper->getUID(), $category); + }); + } + /** * With help from: https://github.com/nextcloud/cookbook * @return JSONResponse|StreamResponse diff --git a/lib/Service/NoteUtil.php b/lib/Service/NoteUtil.php index 38f066348..5fd1f37e7 100644 --- a/lib/Service/NoteUtil.php +++ b/lib/Service/NoteUtil.php @@ -60,16 +60,22 @@ public function getTagService() : TagService { return $this->tagService; } - public function getCategoryFolder(Folder $notesFolder, string $category) { - $path = $notesFolder->getPath(); - // sanitise path + public function normalizeCategoryPath(string $category) : string { $cats = explode('/', $category); $cats = array_map([$this, 'sanitisePath'], $cats); $cats = array_filter($cats, function ($str) { return $str !== ''; }); - $path .= '/' . implode('/', $cats); - return $this->getOrCreateFolder($path); + return implode('/', $cats); + } + + public function getCategoryFolder(Folder $notesFolder, string $category, bool $create = true) : Folder { + $path = $notesFolder->getPath(); + $normalized = $this->normalizeCategoryPath($category); + if ($normalized !== '') { + $path .= '/' . $normalized; + } + return $this->getOrCreateFolder($path, $create); } /** diff --git a/lib/Service/NotesService.php b/lib/Service/NotesService.php index 1716bc106..9eb955b9e 100644 --- a/lib/Service/NotesService.php +++ b/lib/Service/NotesService.php @@ -153,6 +153,86 @@ public function delete(string $userId, int $id) { $this->noteUtil->deleteEmptyFolder($parent, $notesFolder); } + /** + * @throws NoteDoesNotExistException + */ + public function renameCategory(string $userId, string $oldCategory, string $newCategory) : array { + $oldCategory = $this->noteUtil->normalizeCategoryPath($oldCategory); + $newCategory = $this->noteUtil->normalizeCategoryPath($newCategory); + if ($oldCategory === '' || $newCategory === '') { + throw new \InvalidArgumentException('Category must not be empty'); + } + if ($oldCategory === $newCategory) { + return [ + 'oldCategory' => $oldCategory, + 'newCategory' => $newCategory, + ]; + } + if (str_starts_with($newCategory, $oldCategory . '/')) { + throw new \InvalidArgumentException('Target category must not be a descendant of source category'); + } + + $notesFolder = $this->getNotesFolder($userId); + try { + $oldFolder = $this->noteUtil->getCategoryFolder($notesFolder, $oldCategory, false); + } catch (NotesFolderException $e) { + throw new NoteDoesNotExistException(); + } + + if ($notesFolder->nodeExists($newCategory)) { + throw new \InvalidArgumentException('Target category already exists'); + } + + $targetParentCategory = dirname($newCategory); + if ($targetParentCategory === '.') { + $targetParentCategory = ''; + } + $targetParent = $this->noteUtil->getCategoryFolder($notesFolder, $targetParentCategory, true); + + $oldParent = $oldFolder->getParent(); + $targetPath = $targetParent->getPath() . '/' . basename($newCategory); + $oldFolder->move($targetPath); + if ($oldParent instanceof Folder) { + $this->noteUtil->deleteEmptyFolder($oldParent, $notesFolder); + } + + return [ + 'oldCategory' => $oldCategory, + 'newCategory' => $newCategory, + ]; + } + + /** + * @throws NoteDoesNotExistException + */ + public function deleteCategory(string $userId, string $category) : array { + $category = $this->noteUtil->normalizeCategoryPath($category); + if ($category === '') { + throw new \InvalidArgumentException('Category must not be empty'); + } + + $notesFolder = $this->getNotesFolder($userId); + try { + $folder = $this->noteUtil->getCategoryFolder($notesFolder, $category, false); + } catch (NotesFolderException $e) { + // If category folder was already removed (e.g. last note moved away), + // treat delete as idempotent success. + return [ + 'category' => $category, + ]; + } + + $parent = $folder->getParent(); + $folder->delete(); + if ($parent instanceof Folder) { + $this->noteUtil->deleteEmptyFolder($parent, $notesFolder); + } + + return [ + 'category' => $category, + ]; + } + public function getTitleFromContent(string $content) : string { $content = $this->noteUtil->stripMarkdown($content); return $this->noteUtil->getSafeTitle($content); diff --git a/src/App.vue b/src/App.vue index 31a8eff99..d05f94164 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,16 +9,16 @@ - + @@ -235,4 +466,8 @@ export default { outline: 2px dashed var(--color-primary-element); outline-offset: -2px; } + +.app-navigation-entry-wrapper.category-no-actions:deep(.app-navigation-entry__counter-wrapper) { + margin-inline-end: calc(var(--default-grid-baseline) * 2 + var(--default-clickable-area)); +} diff --git a/src/components/NotePlain.vue b/src/components/NotePlain.vue index 707758bfb..ee2938e68 100644 --- a/src/components/NotePlain.vue +++ b/src/components/NotePlain.vue @@ -311,6 +311,10 @@ export default { }, refreshNote() { + if (!this.note) { + this.startRefreshTimer() + return + } if (this.note.unsaved && !this.note.conflict) { this.startRefreshTimer() return @@ -402,7 +406,7 @@ export default { async onFileRestoreRequested(event) { const { fileInfo } = event - if (fileInfo.id !== this.note.id) { + if (!this.note || fileInfo.id !== this.note.id) { return } @@ -410,7 +414,7 @@ export default { }, async onFileRestored(version) { - if (version.fileId !== this.note.id) { + if (!this.note || version.fileId !== this.note.id) { return } diff --git a/src/components/NoteRich.vue b/src/components/NoteRich.vue index 846c0779f..f8010ad51 100644 --- a/src/components/NoteRich.vue +++ b/src/components/NoteRich.vue @@ -123,6 +123,9 @@ export default { onClose(noteId) { const note = store.getters.getNote(parseInt(noteId)) + if (!note || !Number.isFinite(note.id)) { + return + } store.commit('updateNote', { ...note, unsaved: false, @@ -130,7 +133,7 @@ export default { }, fileUpdated({ fileid }) { - if (this.note.id === fileid) { + if (this.note && this.note.id === fileid) { this.onEdit({ unsaved: false }) if (this.shouldAutotitle) { queueCommand(fileid, 'autotitle') @@ -156,7 +159,7 @@ export default { async onFileRestoreRequested(event) { const { fileInfo } = event - if (fileInfo.id !== this.note.id) { + if (!this.note || fileInfo.id !== this.note.id) { return } @@ -164,7 +167,7 @@ export default { }, async onFileRestored(version) { - if (version.fileId !== this.note.id) { + if (!this.note || version.fileId !== this.note.id) { return } diff --git a/src/components/NotesView.vue b/src/components/NotesView.vue index 1c14cb9ee..df28ff2dd 100644 --- a/src/components/NotesView.vue +++ b/src/components/NotesView.vue @@ -8,6 +8,12 @@