feat: support deleting photo folders with content

This commit is contained in:
2026-04-08 17:08:55 +03:00
parent 4384b291e5
commit a2d133386c
9 changed files with 149 additions and 27 deletions

View File

@@ -121,10 +121,12 @@ final class PhotoController extends OCSController {
/**
* Delete a photo folder
*
* Photos in this folder are moved to the board root.
* When deleteContents is false (default), photos are moved to the board root.
* When true, the folder and all its photos (including files) are permanently deleted.
*
* @param int $houseId House id.
* @param int $folderId Folder id.
* @param bool $deleteContents Whether to also delete photos inside the folder.
*
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
*
@@ -132,12 +134,13 @@ final class PhotoController extends OCSController {
*/
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/photos/folders/{folderId}')]
#[NoAdminRequired]
public function deleteFolder(int $houseId, int $folderId): DataResponse {
return $this->runAction(function () use ($houseId, $folderId): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
public function deleteFolder(int $houseId, int $folderId, bool $deleteContents = false): DataResponse {
return $this->runAction(function () use ($houseId, $folderId, $deleteContents): DataResponse {
$uid = $this->requireUid();
$this->auth->requireMember($houseId, $uid);
$existing = $this->photos->getFolder($folderId);
$this->assertInHouse($existing->getHouseId(), $houseId, 'Folder');
$this->photos->deleteFolder($folderId);
$this->photos->deleteFolder($folderId, $deleteContents, $uid);
return new DataResponse(['success' => true]);
});
}
@@ -287,10 +290,11 @@ final class PhotoController extends OCSController {
#[NoAdminRequired]
public function deletePhoto(int $houseId, int $photoId): DataResponse {
return $this->runAction(function () use ($houseId, $photoId): DataResponse {
$this->auth->requireMember($houseId, $this->requireUid());
$uid = $this->requireUid();
$this->auth->requireMember($houseId, $uid);
$existing = $this->photos->getPhoto($photoId);
$this->assertInHouse($existing->getHouseId(), $houseId, 'Photo');
$this->photos->deletePhoto($photoId);
$this->photos->deletePhoto($photoId, $uid);
return new DataResponse(['success' => true]);
});
}

View File

@@ -57,6 +57,24 @@ class ImageService {
return $file->getId();
}
/**
* Delete a file by its Nextcloud file id.
*
* Silently does nothing if the file does not exist or is not accessible.
*/
public function deleteFile(int $fileId, string $uid): void {
$userFolder = $this->rootFolder->getUserFolder($uid);
$nodes = $userFolder->getById($fileId);
foreach ($nodes as $node) {
try {
$node->delete();
} catch (\Throwable) {
// Best-effort — file may have been removed already.
}
break; // Only need to delete once.
}
}
private function resolvePhotoFolder(string $uid, int $houseId): Folder {
$base = $this->resolveBaseFolder($uid, $houseId);
return $this->getOrCreateSubFolder($base, self::PHOTOS_SUBDIR);

View File

@@ -18,6 +18,7 @@ class PhotoService {
public function __construct(
private PhotoMapper $photoMapper,
private PhotoFolderMapper $folderMapper,
private ImageService $images,
) {
}
@@ -72,10 +73,20 @@ class PhotoService {
return $folder;
}
public function deleteFolder(int $folderId): void {
public function deleteFolder(int $folderId, bool $deleteContents = false, ?string $uid = null): void {
$folder = $this->getFolder($folderId);
// Move all photos in this folder to the board root
$this->photoMapper->moveToRoot($folderId);
if ($deleteContents) {
$photos = $this->photoMapper->findByFolder($folderId);
foreach ($photos as $photo) {
if ($uid !== null) {
$this->images->deleteFile($photo->getFileId(), $uid);
}
$this->photoMapper->delete($photo);
}
} else {
// Move all photos in this folder to the board root
$this->photoMapper->moveToRoot($folderId);
}
$this->folderMapper->delete($folder);
}
@@ -163,8 +174,11 @@ class PhotoService {
return $photo;
}
public function deletePhoto(int $photoId): void {
public function deletePhoto(int $photoId, ?string $uid = null): void {
$photo = $this->getPhoto($photoId);
if ($uid !== null) {
$this->images->deleteFile($photo->getFileId(), $uid);
}
$this->photoMapper->delete($photo);
}

View File

@@ -5178,7 +5178,7 @@
"delete": {
"operationId": "photo-delete-folder",
"summary": "Delete a photo folder",
"description": "Photos in this folder are moved to the board root.",
"description": "When deleteContents is false (default), photos are moved to the board root. When true, the folder and all its photos (including files) are permanently deleted.",
"tags": [
"photo"
],
@@ -5211,6 +5211,15 @@
"format": "int64"
}
},
{
"name": "deleteContents",
"in": "query",
"description": "Whether to also delete photos inside the folder.",
"schema": {
"type": "boolean",
"default": false
}
},
{
"name": "OCS-APIRequest",
"in": "header",

View File

@@ -24,8 +24,14 @@ export async function updateFolder(
return resp.data
}
export async function deleteFolder(houseId: number, folderId: number): Promise<void> {
await ocs.delete(`/houses/${houseId}/photos/folders/${folderId}`)
export async function deleteFolder(
houseId: number,
folderId: number,
deleteContents = false,
): Promise<void> {
await ocs.delete(`/houses/${houseId}/photos/folders/${folderId}`, {
params: deleteContents ? { deleteContents: true } : undefined,
})
}
export async function reorderFolders(

View File

@@ -220,7 +220,7 @@ describe('usePhotos', () => {
})
describe('removeFolder', () => {
it('removes folder and moves its photos to root', async () => {
it('removes folder and moves its photos to root by default', async () => {
mockApi.listPhotos.mockResolvedValue([
makePhoto({ id: 1, folderId: 5 }),
makePhoto({ id: 2, folderId: null }),
@@ -233,9 +233,28 @@ describe('usePhotos', () => {
await board.removeFolder(5)
expect(board.folders.value).toHaveLength(0)
// Photo that was in folder 5 should now have folderId null
expect(board.photos.value[0].folderId).toBeNull()
expect(board.photos.value[1].folderId).toBeNull()
expect(mockApi.deleteFolder).toHaveBeenCalledWith(1, 5, false)
})
it('removes folder and deletes photos when deleteContents is true', async () => {
mockApi.listPhotos.mockResolvedValue([
makePhoto({ id: 1, folderId: 5 }),
makePhoto({ id: 2, folderId: null }),
makePhoto({ id: 3, folderId: 5 }),
])
mockApi.listFolders.mockResolvedValue([makeFolder({ id: 5 })])
mockApi.deleteFolder.mockResolvedValue(undefined)
const board = usePhotos(1)
await board.load()
await board.removeFolder(5, true)
expect(board.folders.value).toHaveLength(0)
expect(board.photos.value).toHaveLength(1)
expect(board.photos.value[0].id).toBe(2)
expect(mockApi.deleteFolder).toHaveBeenCalledWith(1, 5, true)
})
})

View File

@@ -114,11 +114,16 @@ export function usePhotos(houseId: number) {
folders.value = folders.value.map((f) => (f.id === folderId ? updated : f))
}
async function removeFolder(folderId: number): Promise<void> {
await api.deleteFolder(houseId, folderId)
async function removeFolder(folderId: number, deleteContents = false): Promise<void> {
await api.deleteFolder(houseId, folderId, deleteContents)
folders.value = folders.value.filter((f) => f.id !== folderId)
// Photos in the deleted folder move to root
photos.value = photos.value.map((p) => (p.folderId === folderId ? { ...p, folderId: null } : p))
if (deleteContents) {
photos.value = photos.value.filter((p) => p.folderId !== folderId)
} else {
photos.value = photos.value.map((p) =>
p.folderId === folderId ? { ...p, folderId: null } : p,
)
}
}
async function reorderFolders(items: { id: number; sortOrder: number }[]): Promise<void> {

View File

@@ -262,9 +262,35 @@
@update:open="(v) => !v && (deletingFolder = null)"
>
<p>{{ deleteFolderBody }}</p>
<div class="pantry-delete-folder-options">
<NcCheckboxRadioSwitch
v-model="deleteFolderMode"
value="keep"
name="delete-folder-mode"
type="radio"
>
{{ strings.deleteFolderKeepLabel }}
</NcCheckboxRadioSwitch>
<p class="pantry-delete-folder-options__hint">
{{ strings.deleteFolderKeepHint }}
</p>
<NcCheckboxRadioSwitch
v-model="deleteFolderMode"
value="delete"
name="delete-folder-mode"
type="radio"
>
{{ strings.deleteFolderDeleteLabel }}
</NcCheckboxRadioSwitch>
<p class="pantry-delete-folder-options__hint">
{{ strings.deleteFolderDeleteHint }}
</p>
</div>
<template #actions>
<NcButton @click="deletingFolder = null">{{ strings.cancel }}</NcButton>
<NcButton variant="error" @click="submitDeleteFolder">{{ strings.delete }}</NcButton>
<NcButton variant="error" @click="submitDeleteFolder">
{{ strings.delete }}
</NcButton>
</template>
</NcDialog>
</div>
@@ -280,6 +306,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
@@ -563,11 +590,9 @@ const renamingFolder = ref<PhotoFolder | null>(null)
const deletingPhoto = ref<Photo | null>(null)
const deletingFolder = ref<PhotoFolder | null>(null)
const deleteFolderBody = computed(() =>
t(
'pantry',
'Are you sure you want to delete the folder "{name}"? Photos will be moved to the board.',
{ name: deletingFolder.value?.name ?? '' },
),
t('pantry', 'What would you like to do with the folder "{name}"?', {
name: deletingFolder.value?.name ?? '',
}),
)
// ----- Upload -----
@@ -720,13 +745,16 @@ async function submitFolderDialog(name: string) {
}
}
const deleteFolderMode = ref<'keep' | 'delete'>('keep')
function confirmDeleteFolder(folder: PhotoFolder) {
deletingFolder.value = folder
deleteFolderMode.value = 'keep'
}
async function submitDeleteFolder() {
if (!deletingFolder.value) return
await removeFolder(deletingFolder.value.id)
await removeFolder(deletingFolder.value.id, deleteFolderMode.value === 'delete')
deletingFolder.value = null
}
@@ -749,6 +777,10 @@ const strings = {
deletePhotoTitle: t('pantry', 'Delete photo'),
deletePhotoBody: t('pantry', 'Are you sure you want to delete this photo?'),
deleteFolderTitle: t('pantry', 'Delete folder'),
deleteFolderKeepLabel: t('pantry', 'Delete folder only'),
deleteFolderKeepHint: t('pantry', 'Photos will be moved to the board root.'),
deleteFolderDeleteLabel: t('pantry', 'Delete folder and all photos'),
deleteFolderDeleteHint: t('pantry', 'All photos and their files will be permanently deleted.'),
sortLabel: t('pantry', 'Sort order'),
foldersFirst: t('pantry', 'Folders first'),
}
@@ -839,4 +871,14 @@ const strings = {
.pantry-sort-active {
font-weight: 600;
}
.pantry-delete-folder-options {
margin-top: 0.75rem;
&__hint {
margin: 0 0 0.75rem 1.75rem;
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
}
}
</style>

View File

@@ -12,6 +12,7 @@ use OCA\Pantry\Db\PhotoFolder;
use OCA\Pantry\Db\PhotoFolderMapper;
use OCA\Pantry\Db\PhotoMapper;
use OCA\Pantry\Exception\NotFoundException;
use OCA\Pantry\Service\ImageService;
use OCA\Pantry\Service\PhotoService;
use OCP\AppFramework\Db\DoesNotExistException;
use PHPUnit\Framework\MockObject\MockObject;
@@ -22,14 +23,18 @@ class PhotoServiceTest extends TestCase {
private PhotoMapper $photoMapper;
/** @var PhotoFolderMapper&MockObject */
private PhotoFolderMapper $folderMapper;
/** @var ImageService&MockObject */
private ImageService $imageService;
private PhotoService $svc;
protected function setUp(): void {
$this->photoMapper = $this->createMock(PhotoMapper::class);
$this->folderMapper = $this->createMock(PhotoFolderMapper::class);
$this->imageService = $this->createMock(ImageService::class);
$this->svc = new PhotoService(
$this->photoMapper,
$this->folderMapper,
$this->imageService,
);
}