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
8 changes: 4 additions & 4 deletions playwright/e2e/basic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@

test('Notes app is visible', async ({ page }) => {
await page.goto('/index.php/apps/notes/')
await expect(page).toHaveTitle(/Notes/)

Check failure on line 17 in playwright/e2e/basic.spec.ts

View workflow job for this annotation

GitHub Actions / test

[chromium] › playwright/e2e/basic.spec.ts:15:6 › Basic checks › Notes app is visible

1) [chromium] › playwright/e2e/basic.spec.ts:15:6 › Basic checks › Notes app is visible ────────── Error: expect(page).toHaveTitle(expected) failed Expected pattern: /Notes/ Received string: "Nextcloud" Timeout: 5000ms Call log: - Expect "toHaveTitle" with timeout 5000ms 9 × unexpected value "Nextcloud" 15 | test('Notes app is visible', async ({ page }) => { 16 | await page.goto('/index.php/apps/notes/') > 17 | await expect(page).toHaveTitle(/Notes/) | ^ 18 | }) 19 | 20 | test('Create note and type', async ({ page }) => { at /home/runner/actions-runner/_work/notes/notes/playwright/e2e/basic.spec.ts:17:22
})

test('Create note and type', async ({ page }) => {
await page.goto('/index.php/apps/notes/')
await page
.locator('#app-navigation-vue')
.getByRole('button', { name: 'New note' })
.click()
const newNoteButton = page.getByRole('button', { name: 'New note', exact: true })
await expect(newNoteButton).toBeVisible()
await newNoteButton.click()

const editor = new NoteEditor(page)
await editor.type('Hello from Playwright')
await expect(editor.content).toContainText('Hello from Playwright')
Expand Down
157 changes: 157 additions & 0 deletions playwright/e2e/category-actions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect, type Locator, type Page, type TestInfo, test } from '@playwright/test'
import { login } from '../support/login'

function appNavigation(page: Page): Locator {
return page.getByRole('navigation').filter({
has: page.getByRole('button', { name: 'New category', exact: true }),
}).first()
}

function newCategoryButton(page: Page): Locator {
return page.getByRole('button', { name: 'New category', exact: true })
}

function contentNewNoteButton(page: Page): Locator {
return page.getByRole('button', { name: 'New note', exact: true })
}

function notesSearchField(page: Page): Locator {
return page.getByRole('textbox', { name: 'Search for notes', exact: true })
}

function notesViewNewNoteButton(page: Page): Locator {
return notesSearchField(page)
.locator('xpath=ancestor::div[contains(@class, "content-list__search")][1]')
.getByRole('button', { name: 'New note', exact: true })
}

function navigationRow(page: Page, name: string): Locator {
return page.getByRole('link', { name, exact: true })
.locator('xpath=ancestor::li[1]')
.first()
}

function uniqueCategoryName(prefix: string, testInfo: TestInfo): string {
return `Playwright ${prefix} ${testInfo.parallelIndex}-${Date.now()}`
}

function currentNoteId(page: Page): number | null {
const match = page.url().match(/\/note\/(\d+)(?:\?.*)?$/)
return match ? Number(match[1]) : null
}

async function openNotesApp(page: Page): Promise<void> {
await page.goto('/index.php/apps/notes/')
await expect(newCategoryButton(page)).toBeVisible()
await expect(contentNewNoteButton(page)).toHaveCount(1)
await expect(contentNewNoteButton(page)).toBeVisible()
}

async function createCategory(page: Page, name: string): Promise<void> {
const navigation = appNavigation(page)

await newCategoryButton(page).click()

const input = navigation.getByPlaceholder('New category', { exact: true })
await expect(input).toBeVisible()
await input.fill(name)
await input.press('Enter')

await expect(navigationRow(page, name)).toBeVisible()
await expect(navigationRow(page, name)).toHaveClass(/active/)
}

async function waitForNewNoteRoute(page: Page, previousNoteId: number | null): Promise<number> {
await expect.poll(() => currentNoteId(page)).not.toBe(previousNoteId)

const noteId = currentNoteId(page)
if (noteId === null || noteId === previousNoteId) {
throw new Error('Expected a new note route after creating a note')
}

return noteId
}

async function ensureNotesView(page: Page): Promise<void> {
if (await notesSearchField(page).isVisible()) {
return
}

const previousNoteId = currentNoteId(page)
await contentNewNoteButton(page).click()
await waitForNewNoteRoute(page, previousNoteId)
await expect(notesSearchField(page)).toBeVisible()
}

async function createNoteInSelectedCategory(page: Page, category: string): Promise<number> {
await ensureNotesView(page)

const previousNoteId = currentNoteId(page)
await notesViewNewNoteButton(page).click()

const noteId = await waitForNewNoteRoute(page, previousNoteId)
await expect(navigationRow(page, category).locator('.app-navigation-entry__counter-wrapper')).toContainText('1')

return noteId
}

async function openCategoryActions(page: Page, category: string): Promise<void> {
const row = navigationRow(page, category)

await expect(row).toBeVisible()
await row.getByRole('button', { name: 'Actions', exact: true }).click()
}

test.describe('Category actions', () => {
test.beforeEach(async ({ page }) => {
await login(page)
await openNotesApp(page)
})

test('renames a category from the actions menu', async ({ page }, testInfo: TestInfo) => {
const category = uniqueCategoryName('rename', testInfo)
const renamedCategory = `${category} renamed`
const noteId = await (async () => {
await createCategory(page, category)
return createNoteInSelectedCategory(page, category)
})()

await openCategoryActions(page, category)
await page.getByRole('menuitem', { name: 'Rename category', exact: true }).click()

const renameInput = appNavigation(page).getByPlaceholder(category, { exact: true })
await expect(renameInput).toBeVisible()
await renameInput.fill(renamedCategory)
await renameInput.press('Enter')

await expect(navigationRow(page, renamedCategory)).toBeVisible()
await expect(navigationRow(page, renamedCategory)).toHaveClass(/active/)
await expect(navigationRow(page, renamedCategory).locator('.app-navigation-entry__counter-wrapper')).toContainText('1')
await expect(navigationRow(page, category)).toHaveCount(0)
await expect(page).toHaveURL(new RegExp(`/note/${noteId}(\\?.*)?$`))
})

test('deletes a category from the actions menu', async ({ page }, testInfo: TestInfo) => {
const category = uniqueCategoryName('delete', testInfo)
await createCategory(page, category)
const noteId = await createNoteInSelectedCategory(page, category)
const deletedNoteUrl = new RegExp(`/note/${noteId}(\\?.*)?$`)

await openCategoryActions(page, category)
await page.getByRole('menuitem', { name: 'Delete category', exact: true }).click()

const confirmationText = `Delete category "${category}" and its 1 note?`
await expect(page.getByText(confirmationText, { exact: true })).toBeVisible()

await page.getByRole('button', { name: 'Delete', exact: true }).click()

await expect(navigationRow(page, category)).toHaveCount(0)
await expect(navigationRow(page, 'All notes')).toHaveClass(/active/)
await expect(page).not.toHaveURL(deletedNoteUrl)
})
})
Loading
Loading