Skip to content

Commit e14a959

Browse files
committed
feat(ui): Add rename/delete category actions in the sidebar
1 parent f5affbc commit e14a959

12 files changed

Lines changed: 633 additions & 60 deletions

File tree

appinfo/routes.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@
9292
'verb' => 'DELETE',
9393
'requirements' => ['id' => '\d+'],
9494
],
95+
[
96+
'name' => 'notes#renameCategory',
97+
'url' => '/notes/category',
98+
'verb' => 'PATCH',
99+
],
100+
[
101+
'name' => 'notes#deleteCategory',
102+
'url' => '/notes/category',
103+
'verb' => 'DELETE',
104+
],
95105

96106
////////// A T T A C H M E N T S //////////
97107

lib/Controller/NotesController.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,26 @@ public function destroy(int $id) : JSONResponse {
329329
});
330330
}
331331

332+
/**
333+
*
334+
*/
335+
#[NoAdminRequired]
336+
public function renameCategory(string $oldCategory, string $newCategory) : JSONResponse {
337+
return $this->helper->handleErrorResponse(function () use ($oldCategory, $newCategory) {
338+
return $this->notesService->renameCategory($this->helper->getUID(), $oldCategory, $newCategory);
339+
});
340+
}
341+
342+
/**
343+
*
344+
*/
345+
#[NoAdminRequired]
346+
public function deleteCategory(string $category) : JSONResponse {
347+
return $this->helper->handleErrorResponse(function () use ($category) {
348+
return $this->notesService->deleteCategory($this->helper->getUID(), $category);
349+
});
350+
}
351+
332352
/**
333353
* With help from: https://github.com/nextcloud/cookbook
334354
* @return JSONResponse|StreamResponse

lib/Service/NoteUtil.php

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,22 @@ public function getTagService() : TagService {
6060
return $this->tagService;
6161
}
6262

63-
public function getCategoryFolder(Folder $notesFolder, string $category) {
64-
$path = $notesFolder->getPath();
65-
// sanitise path
63+
public function normalizeCategoryPath(string $category) : string {
6664
$cats = explode('/', $category);
6765
$cats = array_map([$this, 'sanitisePath'], $cats);
6866
$cats = array_filter($cats, function ($str) {
6967
return $str !== '';
7068
});
71-
$path .= '/' . implode('/', $cats);
72-
return $this->getOrCreateFolder($path);
69+
return implode('/', $cats);
70+
}
71+
72+
public function getCategoryFolder(Folder $notesFolder, string $category, bool $create = true) : Folder {
73+
$path = $notesFolder->getPath();
74+
$normalized = $this->normalizeCategoryPath($category);
75+
if ($normalized !== '') {
76+
$path .= '/' . $normalized;
77+
}
78+
return $this->getOrCreateFolder($path, $create);
7379
}
7480

7581
/**

lib/Service/NotesService.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,86 @@ public function delete(string $userId, int $id) {
153153
$this->noteUtil->deleteEmptyFolder($parent, $notesFolder);
154154
}
155155

156+
/**
157+
* @throws NoteDoesNotExistException
158+
*/
159+
public function renameCategory(string $userId, string $oldCategory, string $newCategory) : array {
160+
$oldCategory = $this->noteUtil->normalizeCategoryPath($oldCategory);
161+
$newCategory = $this->noteUtil->normalizeCategoryPath($newCategory);
162+
if ($oldCategory === '' || $newCategory === '') {
163+
throw new \InvalidArgumentException('Category must not be empty');
164+
}
165+
if ($oldCategory === $newCategory) {
166+
return [
167+
'oldCategory' => $oldCategory,
168+
'newCategory' => $newCategory,
169+
];
170+
}
171+
if (str_starts_with($newCategory, $oldCategory . '/')) {
172+
throw new \InvalidArgumentException('Target category must not be a descendant of source category');
173+
}
174+
175+
$notesFolder = $this->getNotesFolder($userId);
176+
try {
177+
$oldFolder = $this->noteUtil->getCategoryFolder($notesFolder, $oldCategory, false);
178+
} catch (NotesFolderException $e) {
179+
throw new NoteDoesNotExistException();
180+
}
181+
182+
if ($notesFolder->nodeExists($newCategory)) {
183+
throw new \InvalidArgumentException('Target category already exists');
184+
}
185+
186+
$targetParentCategory = dirname($newCategory);
187+
if ($targetParentCategory === '.') {
188+
$targetParentCategory = '';
189+
}
190+
$targetParent = $this->noteUtil->getCategoryFolder($notesFolder, $targetParentCategory, true);
191+
192+
$oldParent = $oldFolder->getParent();
193+
$targetPath = $targetParent->getPath() . '/' . basename($newCategory);
194+
$oldFolder->move($targetPath);
195+
if ($oldParent instanceof Folder) {
196+
$this->noteUtil->deleteEmptyFolder($oldParent, $notesFolder);
197+
}
198+
199+
return [
200+
'oldCategory' => $oldCategory,
201+
'newCategory' => $newCategory,
202+
];
203+
}
204+
205+
/**
206+
* @throws NoteDoesNotExistException
207+
*/
208+
public function deleteCategory(string $userId, string $category) : array {
209+
$category = $this->noteUtil->normalizeCategoryPath($category);
210+
if ($category === '') {
211+
throw new \InvalidArgumentException('Category must not be empty');
212+
}
213+
214+
$notesFolder = $this->getNotesFolder($userId);
215+
try {
216+
$folder = $this->noteUtil->getCategoryFolder($notesFolder, $category, false);
217+
} catch (NotesFolderException $e) {
218+
// If category folder was already removed (e.g. last note moved away),
219+
// treat delete as idempotent success.
220+
return [
221+
'category' => $category,
222+
];
223+
}
224+
225+
$parent = $folder->getParent();
226+
$folder->delete();
227+
if ($parent instanceof Folder) {
228+
$this->noteUtil->deleteEmptyFolder($parent, $notesFolder);
229+
}
230+
231+
return [
232+
'category' => $category,
233+
];
234+
}
235+
156236
public function getTitleFromContent(string $content) : string {
157237
$content = $this->noteUtil->stripMarkdown($content);
158238
return $this->noteUtil->getSafeTitle($content);

src/App.vue

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@
99
<NcAppNavigation :class="{loading: loading.notes, 'icon-error': error}">
1010
<NcAppNavigationNew
1111
v-show="!loading.notes && !error"
12-
:text="t('notes', 'New note')"
13-
@click="onNewNote"
12+
:text="t('notes', 'New category')"
13+
@click="onNewCategory"
14+
@dragover.native="onNewCategoryDragOver"
15+
@drop.native="onNewCategoryDrop"
1416
>
15-
<PlusIcon slot="icon" :size="20" />
17+
<FolderPlusIcon slot="icon" :size="20" />
1618
</NcAppNavigationNew>
1719

1820
<template #list>
19-
<CategoriesList v-show="!loading.notes"
20-
v-if="numNotes"
21-
/>
21+
<CategoriesList v-show="!loading.notes" />
2222
</template>
2323

2424
<template #footer>
@@ -54,16 +54,18 @@ import NcContent from '@nextcloud/vue/components/NcContent'
5454
import { loadState } from '@nextcloud/initial-state'
5555
import { showSuccess, TOAST_UNDO_TIMEOUT, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
5656
import '@nextcloud/dialogs/style.css'
57+
import { emit } from '@nextcloud/event-bus'
5758
58-
import PlusIcon from 'vue-material-design-icons/Plus.vue'
5959
import CogIcon from 'vue-material-design-icons/CogOutline.vue'
60+
import FolderPlusIcon from 'vue-material-design-icons/FolderPlus.vue'
6061
6162
import AppSettings from './components/AppSettings.vue'
6263
import CategoriesList from './components/CategoriesList.vue'
6364
import EditorHint from './components/Modal/EditorHint.vue'
6465
6566
import { config } from './config.js'
66-
import { fetchNotes, noteExists, createNote, undoDeleteNote } from './NotesService.js'
67+
import { fetchNotes, noteExists, undoDeleteNote } from './NotesService.js'
68+
import { getDraggedNoteId, isNoteDrag } from './Util.js'
6769
import store from './store.js'
6870
6971
export default {
@@ -79,7 +81,7 @@ export default {
7981
NcAppNavigationNew,
8082
NcAppNavigationItem,
8183
NcContent,
82-
PlusIcon,
84+
FolderPlusIcon,
8385
},
8486
8587
data() {
@@ -89,7 +91,6 @@ export default {
8991
},
9092
loading: {
9193
notes: true,
92-
create: false,
9394
},
9495
error: false,
9596
undoNotification: null,
@@ -227,20 +228,28 @@ export default {
227228
this.settingsVisible = true
228229
},
229230
230-
onNewNote() {
231-
if (this.loading.create) {
231+
onNewCategory() {
232+
emit('notes:category:new')
233+
},
234+
235+
onNewCategoryDragOver(event) {
236+
if (!isNoteDrag(event)) {
232237
return
233238
}
234-
this.loading.create = true
235-
createNote(store.getters.getSelectedCategory())
236-
.then(note => {
237-
this.routeToNote(note.id, { new: null })
238-
})
239-
.catch(() => {
240-
})
241-
.finally(() => {
242-
this.loading.create = false
243-
})
239+
event.preventDefault()
240+
if (event.dataTransfer) {
241+
event.dataTransfer.dropEffect = 'move'
242+
}
243+
},
244+
245+
onNewCategoryDrop(event) {
246+
const noteId = getDraggedNoteId(event, noteId => store.getters.getNote(noteId))
247+
if (noteId === null) {
248+
return
249+
}
250+
event.preventDefault()
251+
event.stopPropagation()
252+
emit('notes:category:new', { noteId })
244253
},
245254
246255
onNoteDeleted(note) {
@@ -325,4 +334,19 @@ export default {
325334
padding-inline-start: 3px;
326335
margin: 0 3px;
327336
}
337+
338+
:deep(.app-navigation__body) {
339+
overflow: hidden !important;
340+
flex: 0 0 auto;
341+
}
342+
343+
:deep(.app-navigation__content) {
344+
min-height: 0;
345+
}
346+
347+
:deep(.app-navigation__list) {
348+
flex: 1 1 auto;
349+
min-height: 0;
350+
height: auto !important;
351+
}
328352
</style>

src/NotesService.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,32 @@ export const setCategory = (noteId, category) => {
364364
})
365365
}
366366

367+
export const renameCategory = (oldCategory, newCategory) => {
368+
return axios
369+
.patch(url('/notes/category'), null, { params: { oldCategory, newCategory } })
370+
.then(response => {
371+
return response.data
372+
})
373+
.catch(err => {
374+
console.error(err)
375+
handleSyncError(t('notes', 'Renaming category "{category}" has failed.', { category: oldCategory }), err)
376+
throw err
377+
})
378+
}
379+
380+
export const deleteCategory = (category) => {
381+
return axios
382+
.delete(url('/notes/category'), { params: { category } })
383+
.then(response => {
384+
return response.data
385+
})
386+
.catch(err => {
387+
console.error(err)
388+
handleSyncError(t('notes', 'Deleting category "{category}" has failed.', { category }), err)
389+
throw err
390+
})
391+
}
392+
367393
export const queueCommand = (noteId, type) => {
368394
store.commit('addToQueue', { noteId, type })
369395
_processQueue()

src/Util.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,68 @@ export const routeIsNewNote = ($route) => {
3333
return {}.hasOwnProperty.call($route.query, 'new')
3434
}
3535

36+
export const isNoteDrag = (event) => {
37+
const dt = event?.dataTransfer
38+
if (!dt) {
39+
return false
40+
}
41+
42+
const types = Array.from(dt.types ?? [])
43+
if (types.includes('application/x-nextcloud-notes-note-id')) {
44+
return true
45+
}
46+
if (types.includes('text/uri-list')) {
47+
return false
48+
}
49+
try {
50+
return /^\s*\d+\s*$/.test(dt.getData('text/plain'))
51+
} catch {
52+
return false
53+
}
54+
}
55+
56+
export const getDraggedNoteId = (event, getNoteById) => {
57+
const dt = event?.dataTransfer
58+
if (!dt) {
59+
return null
60+
}
61+
62+
const types = Array.from(dt.types ?? [])
63+
const hasCustom = types.includes('application/x-nextcloud-notes-note-id')
64+
const hasUri = types.includes('text/uri-list')
65+
if (!hasCustom && hasUri) {
66+
return null
67+
}
68+
69+
let raw = ''
70+
if (hasCustom) {
71+
try {
72+
raw = dt.getData('application/x-nextcloud-notes-note-id')
73+
} catch {
74+
// Some browsers only allow specific mime types.
75+
}
76+
}
77+
if (!raw) {
78+
try {
79+
raw = dt.getData('text/plain')
80+
} catch {
81+
raw = ''
82+
}
83+
}
84+
85+
const match = /^\s*(\d+)\s*$/.exec(raw)
86+
const noteId = match ? Number.parseInt(match[1], 10) : Number.NaN
87+
if (!Number.isFinite(noteId)) {
88+
return null
89+
}
90+
const note = getNoteById ? getNoteById(noteId) : null
91+
if (!note || note.readonly) {
92+
return null
93+
}
94+
95+
return noteId
96+
}
97+
3698
export const getDefaultSampleNoteTitle = () => {
3799
return t('notes', 'Sample note')
38100
}

0 commit comments

Comments
 (0)