Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 //////////

Expand Down
20 changes: 20 additions & 0 deletions lib/Controller/NotesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions lib/Service/NoteUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
80 changes: 80 additions & 0 deletions lib/Service/NotesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
68 changes: 46 additions & 22 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@
<NcAppNavigation :class="{loading: loading.notes, 'icon-error': error}">
<NcAppNavigationNew
v-show="!loading.notes && !error"
:text="t('notes', 'New note')"
@click="onNewNote"
:text="t('notes', 'New category')"
@click="onNewCategory"
@dragover.native="onNewCategoryDragOver"
@drop.native="onNewCategoryDrop"
>
<PlusIcon slot="icon" :size="20" />
<FolderPlusIcon slot="icon" :size="20" />
</NcAppNavigationNew>

<template #list>
<CategoriesList v-show="!loading.notes"
v-if="numNotes"
/>
<CategoriesList v-show="!loading.notes" />
</template>

<template #footer>
Expand Down Expand Up @@ -54,16 +54,18 @@ import NcContent from '@nextcloud/vue/components/NcContent'
import { loadState } from '@nextcloud/initial-state'
import { showSuccess, TOAST_UNDO_TIMEOUT, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
import '@nextcloud/dialogs/style.css'
import { emit } from '@nextcloud/event-bus'

import PlusIcon from 'vue-material-design-icons/Plus.vue'
import CogIcon from 'vue-material-design-icons/CogOutline.vue'
import FolderPlusIcon from 'vue-material-design-icons/FolderPlus.vue'

import AppSettings from './components/AppSettings.vue'
import CategoriesList from './components/CategoriesList.vue'
import EditorHint from './components/Modal/EditorHint.vue'

import { config } from './config.js'
import { fetchNotes, noteExists, createNote, undoDeleteNote } from './NotesService.js'
import { fetchNotes, noteExists, undoDeleteNote } from './NotesService.js'
import { getDraggedNoteId, isNoteDrag } from './Util.js'
import store from './store.js'

export default {
Expand All @@ -79,7 +81,7 @@ export default {
NcAppNavigationNew,
NcAppNavigationItem,
NcContent,
PlusIcon,
FolderPlusIcon,
},

data() {
Expand All @@ -89,7 +91,6 @@ export default {
},
loading: {
notes: true,
create: false,
},
error: false,
undoNotification: null,
Expand Down Expand Up @@ -227,20 +228,28 @@ export default {
this.settingsVisible = true
},

onNewNote() {
if (this.loading.create) {
onNewCategory() {
emit('notes:category:new')
},

onNewCategoryDragOver(event) {
if (!isNoteDrag(event)) {
return
}
this.loading.create = true
createNote(store.getters.getSelectedCategory())
.then(note => {
this.routeToNote(note.id, { new: null })
})
.catch(() => {
})
.finally(() => {
this.loading.create = false
})
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
},

onNewCategoryDrop(event) {
const noteId = getDraggedNoteId(event, noteId => store.getters.getNote(noteId))
if (noteId === null) {
return
}
event.preventDefault()
event.stopPropagation()
emit('notes:category:new', { noteId })
},

onNoteDeleted(note) {
Expand Down Expand Up @@ -325,4 +334,19 @@ export default {
padding-inline-start: 3px;
margin: 0 3px;
}

:deep(.app-navigation__body) {
overflow: hidden !important;
flex: 0 0 auto;
}

:deep(.app-navigation__content) {
min-height: 0;
}

:deep(.app-navigation__list) {
flex: 1 1 auto;
min-height: 0;
height: auto !important;
}
</style>
26 changes: 26 additions & 0 deletions src/NotesService.js
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,32 @@ export const setCategory = (noteId, category) => {
})
}

export const renameCategory = (oldCategory, newCategory) => {
return axios
.patch(url('/notes/category'), null, { params: { oldCategory, newCategory } })
.then(response => {
return response.data
})
.catch(err => {
console.error(err)
handleSyncError(t('notes', 'Renaming category "{category}" has failed.', { category: oldCategory }), err)
throw err
})
}

export const deleteCategory = (category) => {
return axios
.delete(url('/notes/category'), { params: { category } })
.then(response => {
return response.data
})
.catch(err => {
console.error(err)
handleSyncError(t('notes', 'Deleting category "{category}" has failed.', { category }), err)
throw err
})
}

export const queueCommand = (noteId, type) => {
store.commit('addToQueue', { noteId, type })
_processQueue()
Expand Down
62 changes: 62 additions & 0 deletions src/Util.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,68 @@ export const routeIsNewNote = ($route) => {
return {}.hasOwnProperty.call($route.query, 'new')
}

export const isNoteDrag = (event) => {
const dt = event?.dataTransfer
if (!dt) {
return false
}

const types = Array.from(dt.types ?? [])
if (types.includes('application/x-nextcloud-notes-note-id')) {
return true
}
if (types.includes('text/uri-list')) {
return false
}
try {
return /^\s*\d+\s*$/.test(dt.getData('text/plain'))
} catch {
return false
}
}

export const getDraggedNoteId = (event, getNoteById) => {
const dt = event?.dataTransfer
if (!dt) {
return null
}

const types = Array.from(dt.types ?? [])
const hasCustom = types.includes('application/x-nextcloud-notes-note-id')
const hasUri = types.includes('text/uri-list')
if (!hasCustom && hasUri) {
return null
}

let raw = ''
if (hasCustom) {
try {
raw = dt.getData('application/x-nextcloud-notes-note-id')
} catch {
// Some browsers only allow specific mime types.
}
}
if (!raw) {
try {
raw = dt.getData('text/plain')
} catch {
raw = ''
}
}

const match = /^\s*(\d+)\s*$/.exec(raw)
const noteId = match ? Number.parseInt(match[1], 10) : Number.NaN
if (!Number.isFinite(noteId)) {
return null
}
const note = getNoteById ? getNoteById(noteId) : null
if (!note || note.readonly) {
return null
}

return noteId
}

export const getDefaultSampleNoteTitle = () => {
return t('notes', 'Sample note')
}
Expand Down
Loading