diff --git a/lib/Controller/PhotoController.php b/lib/Controller/PhotoController.php index eea196c..c4682ad 100644 --- a/lib/Controller/PhotoController.php +++ b/lib/Controller/PhotoController.php @@ -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 * @@ -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]); }); } diff --git a/lib/Service/ImageService.php b/lib/Service/ImageService.php index 55c5b85..77d2023 100644 --- a/lib/Service/ImageService.php +++ b/lib/Service/ImageService.php @@ -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); diff --git a/lib/Service/PhotoService.php b/lib/Service/PhotoService.php index f362cac..52b2407 100644 --- a/lib/Service/PhotoService.php +++ b/lib/Service/PhotoService.php @@ -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); } diff --git a/openapi.json b/openapi.json index c2daa5e..62c84ba 100644 --- a/openapi.json +++ b/openapi.json @@ -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", diff --git a/src/api/photos.ts b/src/api/photos.ts index 7693eac..0e49353 100644 --- a/src/api/photos.ts +++ b/src/api/photos.ts @@ -24,8 +24,14 @@ export async function updateFolder( return resp.data } -export async function deleteFolder(houseId: number, folderId: number): Promise { - await ocs.delete(`/houses/${houseId}/photos/folders/${folderId}`) +export async function deleteFolder( + houseId: number, + folderId: number, + deleteContents = false, +): Promise { + await ocs.delete(`/houses/${houseId}/photos/folders/${folderId}`, { + params: deleteContents ? { deleteContents: true } : undefined, + }) } export async function reorderFolders( diff --git a/src/composables/usePhotos.test.ts b/src/composables/usePhotos.test.ts index 7e5ad1c..6966c5c 100644 --- a/src/composables/usePhotos.test.ts +++ b/src/composables/usePhotos.test.ts @@ -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) }) }) diff --git a/src/composables/usePhotos.ts b/src/composables/usePhotos.ts index 01d4966..0d61ed3 100644 --- a/src/composables/usePhotos.ts +++ b/src/composables/usePhotos.ts @@ -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 { - await api.deleteFolder(houseId, folderId) + async function removeFolder(folderId: number, deleteContents = false): Promise { + 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 { diff --git a/src/views/PhotosView.vue b/src/views/PhotosView.vue index 3adf7c3..6260df5 100644 --- a/src/views/PhotosView.vue +++ b/src/views/PhotosView.vue @@ -262,9 +262,35 @@ @update:open="(v) => !v && (deletingFolder = null)" >

{{ deleteFolderBody }}

+
+ + {{ strings.deleteFolderKeepLabel }} + +

+ {{ strings.deleteFolderKeepHint }} +

+ + {{ strings.deleteFolderDeleteLabel }} + +

+ {{ strings.deleteFolderDeleteHint }} +

+
@@ -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(null) const deletingPhoto = ref(null) const deletingFolder = ref(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); + } +} diff --git a/tests/unit/Service/PhotoServiceTest.php b/tests/unit/Service/PhotoServiceTest.php index 7718922..e66f51c 100644 --- a/tests/unit/Service/PhotoServiceTest.php +++ b/tests/unit/Service/PhotoServiceTest.php @@ -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, ); }