Skip to content

Commit a2d1333

Browse files
committed
feat: support deleting photo folders with content
1 parent 4384b29 commit a2d1333

9 files changed

Lines changed: 149 additions & 27 deletions

File tree

lib/Controller/PhotoController.php

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,23 +121,26 @@ public function updateFolder(int $houseId, int $folderId, ?string $name = null,
121121
/**
122122
* Delete a photo folder
123123
*
124-
* Photos in this folder are moved to the board root.
124+
* When deleteContents is false (default), photos are moved to the board root.
125+
* When true, the folder and all its photos (including files) are permanently deleted.
125126
*
126127
* @param int $houseId House id.
127128
* @param int $folderId Folder id.
129+
* @param bool $deleteContents Whether to also delete photos inside the folder.
128130
*
129131
* @return DataResponse<Http::STATUS_OK, PantrySuccess, array{}>
130132
*
131133
* 200: Folder deleted
132134
*/
133135
#[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/photos/folders/{folderId}')]
134136
#[NoAdminRequired]
135-
public function deleteFolder(int $houseId, int $folderId): DataResponse {
136-
return $this->runAction(function () use ($houseId, $folderId): DataResponse {
137-
$this->auth->requireMember($houseId, $this->requireUid());
137+
public function deleteFolder(int $houseId, int $folderId, bool $deleteContents = false): DataResponse {
138+
return $this->runAction(function () use ($houseId, $folderId, $deleteContents): DataResponse {
139+
$uid = $this->requireUid();
140+
$this->auth->requireMember($houseId, $uid);
138141
$existing = $this->photos->getFolder($folderId);
139142
$this->assertInHouse($existing->getHouseId(), $houseId, 'Folder');
140-
$this->photos->deleteFolder($folderId);
143+
$this->photos->deleteFolder($folderId, $deleteContents, $uid);
141144
return new DataResponse(['success' => true]);
142145
});
143146
}
@@ -287,10 +290,11 @@ public function updatePhoto(int $houseId, int $photoId, ?string $caption = null,
287290
#[NoAdminRequired]
288291
public function deletePhoto(int $houseId, int $photoId): DataResponse {
289292
return $this->runAction(function () use ($houseId, $photoId): DataResponse {
290-
$this->auth->requireMember($houseId, $this->requireUid());
293+
$uid = $this->requireUid();
294+
$this->auth->requireMember($houseId, $uid);
291295
$existing = $this->photos->getPhoto($photoId);
292296
$this->assertInHouse($existing->getHouseId(), $houseId, 'Photo');
293-
$this->photos->deletePhoto($photoId);
297+
$this->photos->deletePhoto($photoId, $uid);
294298
return new DataResponse(['success' => true]);
295299
});
296300
}

lib/Service/ImageService.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,24 @@ public function uploadPhoto(string $uid, int $houseId, string $originalName, str
5757
return $file->getId();
5858
}
5959

60+
/**
61+
* Delete a file by its Nextcloud file id.
62+
*
63+
* Silently does nothing if the file does not exist or is not accessible.
64+
*/
65+
public function deleteFile(int $fileId, string $uid): void {
66+
$userFolder = $this->rootFolder->getUserFolder($uid);
67+
$nodes = $userFolder->getById($fileId);
68+
foreach ($nodes as $node) {
69+
try {
70+
$node->delete();
71+
} catch (\Throwable) {
72+
// Best-effort — file may have been removed already.
73+
}
74+
break; // Only need to delete once.
75+
}
76+
}
77+
6078
private function resolvePhotoFolder(string $uid, int $houseId): Folder {
6179
$base = $this->resolveBaseFolder($uid, $houseId);
6280
return $this->getOrCreateSubFolder($base, self::PHOTOS_SUBDIR);

lib/Service/PhotoService.php

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class PhotoService {
1818
public function __construct(
1919
private PhotoMapper $photoMapper,
2020
private PhotoFolderMapper $folderMapper,
21+
private ImageService $images,
2122
) {
2223
}
2324

@@ -72,10 +73,20 @@ public function updateFolder(int $folderId, array $patch): PhotoFolder {
7273
return $folder;
7374
}
7475

75-
public function deleteFolder(int $folderId): void {
76+
public function deleteFolder(int $folderId, bool $deleteContents = false, ?string $uid = null): void {
7677
$folder = $this->getFolder($folderId);
77-
// Move all photos in this folder to the board root
78-
$this->photoMapper->moveToRoot($folderId);
78+
if ($deleteContents) {
79+
$photos = $this->photoMapper->findByFolder($folderId);
80+
foreach ($photos as $photo) {
81+
if ($uid !== null) {
82+
$this->images->deleteFile($photo->getFileId(), $uid);
83+
}
84+
$this->photoMapper->delete($photo);
85+
}
86+
} else {
87+
// Move all photos in this folder to the board root
88+
$this->photoMapper->moveToRoot($folderId);
89+
}
7990
$this->folderMapper->delete($folder);
8091
}
8192

@@ -163,8 +174,11 @@ public function updatePhoto(int $photoId, array $patch): Photo {
163174
return $photo;
164175
}
165176

166-
public function deletePhoto(int $photoId): void {
177+
public function deletePhoto(int $photoId, ?string $uid = null): void {
167178
$photo = $this->getPhoto($photoId);
179+
if ($uid !== null) {
180+
$this->images->deleteFile($photo->getFileId(), $uid);
181+
}
168182
$this->photoMapper->delete($photo);
169183
}
170184

openapi.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5178,7 +5178,7 @@
51785178
"delete": {
51795179
"operationId": "photo-delete-folder",
51805180
"summary": "Delete a photo folder",
5181-
"description": "Photos in this folder are moved to the board root.",
5181+
"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.",
51825182
"tags": [
51835183
"photo"
51845184
],
@@ -5211,6 +5211,15 @@
52115211
"format": "int64"
52125212
}
52135213
},
5214+
{
5215+
"name": "deleteContents",
5216+
"in": "query",
5217+
"description": "Whether to also delete photos inside the folder.",
5218+
"schema": {
5219+
"type": "boolean",
5220+
"default": false
5221+
}
5222+
},
52145223
{
52155224
"name": "OCS-APIRequest",
52165225
"in": "header",

src/api/photos.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@ export async function updateFolder(
2424
return resp.data
2525
}
2626

27-
export async function deleteFolder(houseId: number, folderId: number): Promise<void> {
28-
await ocs.delete(`/houses/${houseId}/photos/folders/${folderId}`)
27+
export async function deleteFolder(
28+
houseId: number,
29+
folderId: number,
30+
deleteContents = false,
31+
): Promise<void> {
32+
await ocs.delete(`/houses/${houseId}/photos/folders/${folderId}`, {
33+
params: deleteContents ? { deleteContents: true } : undefined,
34+
})
2935
}
3036

3137
export async function reorderFolders(

src/composables/usePhotos.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ describe('usePhotos', () => {
220220
})
221221

222222
describe('removeFolder', () => {
223-
it('removes folder and moves its photos to root', async () => {
223+
it('removes folder and moves its photos to root by default', async () => {
224224
mockApi.listPhotos.mockResolvedValue([
225225
makePhoto({ id: 1, folderId: 5 }),
226226
makePhoto({ id: 2, folderId: null }),
@@ -233,9 +233,28 @@ describe('usePhotos', () => {
233233
await board.removeFolder(5)
234234

235235
expect(board.folders.value).toHaveLength(0)
236-
// Photo that was in folder 5 should now have folderId null
237236
expect(board.photos.value[0].folderId).toBeNull()
238237
expect(board.photos.value[1].folderId).toBeNull()
238+
expect(mockApi.deleteFolder).toHaveBeenCalledWith(1, 5, false)
239+
})
240+
241+
it('removes folder and deletes photos when deleteContents is true', async () => {
242+
mockApi.listPhotos.mockResolvedValue([
243+
makePhoto({ id: 1, folderId: 5 }),
244+
makePhoto({ id: 2, folderId: null }),
245+
makePhoto({ id: 3, folderId: 5 }),
246+
])
247+
mockApi.listFolders.mockResolvedValue([makeFolder({ id: 5 })])
248+
mockApi.deleteFolder.mockResolvedValue(undefined)
249+
250+
const board = usePhotos(1)
251+
await board.load()
252+
await board.removeFolder(5, true)
253+
254+
expect(board.folders.value).toHaveLength(0)
255+
expect(board.photos.value).toHaveLength(1)
256+
expect(board.photos.value[0].id).toBe(2)
257+
expect(mockApi.deleteFolder).toHaveBeenCalledWith(1, 5, true)
239258
})
240259
})
241260

src/composables/usePhotos.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,16 @@ export function usePhotos(houseId: number) {
114114
folders.value = folders.value.map((f) => (f.id === folderId ? updated : f))
115115
}
116116

117-
async function removeFolder(folderId: number): Promise<void> {
118-
await api.deleteFolder(houseId, folderId)
117+
async function removeFolder(folderId: number, deleteContents = false): Promise<void> {
118+
await api.deleteFolder(houseId, folderId, deleteContents)
119119
folders.value = folders.value.filter((f) => f.id !== folderId)
120-
// Photos in the deleted folder move to root
121-
photos.value = photos.value.map((p) => (p.folderId === folderId ? { ...p, folderId: null } : p))
120+
if (deleteContents) {
121+
photos.value = photos.value.filter((p) => p.folderId !== folderId)
122+
} else {
123+
photos.value = photos.value.map((p) =>
124+
p.folderId === folderId ? { ...p, folderId: null } : p,
125+
)
126+
}
122127
}
123128

124129
async function reorderFolders(items: { id: number; sortOrder: number }[]): Promise<void> {

src/views/PhotosView.vue

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -262,9 +262,35 @@
262262
@update:open="(v) => !v && (deletingFolder = null)"
263263
>
264264
<p>{{ deleteFolderBody }}</p>
265+
<div class="pantry-delete-folder-options">
266+
<NcCheckboxRadioSwitch
267+
v-model="deleteFolderMode"
268+
value="keep"
269+
name="delete-folder-mode"
270+
type="radio"
271+
>
272+
{{ strings.deleteFolderKeepLabel }}
273+
</NcCheckboxRadioSwitch>
274+
<p class="pantry-delete-folder-options__hint">
275+
{{ strings.deleteFolderKeepHint }}
276+
</p>
277+
<NcCheckboxRadioSwitch
278+
v-model="deleteFolderMode"
279+
value="delete"
280+
name="delete-folder-mode"
281+
type="radio"
282+
>
283+
{{ strings.deleteFolderDeleteLabel }}
284+
</NcCheckboxRadioSwitch>
285+
<p class="pantry-delete-folder-options__hint">
286+
{{ strings.deleteFolderDeleteHint }}
287+
</p>
288+
</div>
265289
<template #actions>
266290
<NcButton @click="deletingFolder = null">{{ strings.cancel }}</NcButton>
267-
<NcButton variant="error" @click="submitDeleteFolder">{{ strings.delete }}</NcButton>
291+
<NcButton variant="error" @click="submitDeleteFolder">
292+
{{ strings.delete }}
293+
</NcButton>
268294
</template>
269295
</NcDialog>
270296
</div>
@@ -280,6 +306,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
280306
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
281307
import NcDialog from '@nextcloud/vue/components/NcDialog'
282308
import NcTextField from '@nextcloud/vue/components/NcTextField'
309+
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
283310
import NcActions from '@nextcloud/vue/components/NcActions'
284311
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
285312
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
@@ -563,11 +590,9 @@ const renamingFolder = ref<PhotoFolder | null>(null)
563590
const deletingPhoto = ref<Photo | null>(null)
564591
const deletingFolder = ref<PhotoFolder | null>(null)
565592
const deleteFolderBody = computed(() =>
566-
t(
567-
'pantry',
568-
'Are you sure you want to delete the folder "{name}"? Photos will be moved to the board.',
569-
{ name: deletingFolder.value?.name ?? '' },
570-
),
593+
t('pantry', 'What would you like to do with the folder "{name}"?', {
594+
name: deletingFolder.value?.name ?? '',
595+
}),
571596
)
572597
573598
// ----- Upload -----
@@ -720,13 +745,16 @@ async function submitFolderDialog(name: string) {
720745
}
721746
}
722747
748+
const deleteFolderMode = ref<'keep' | 'delete'>('keep')
749+
723750
function confirmDeleteFolder(folder: PhotoFolder) {
724751
deletingFolder.value = folder
752+
deleteFolderMode.value = 'keep'
725753
}
726754
727755
async function submitDeleteFolder() {
728756
if (!deletingFolder.value) return
729-
await removeFolder(deletingFolder.value.id)
757+
await removeFolder(deletingFolder.value.id, deleteFolderMode.value === 'delete')
730758
deletingFolder.value = null
731759
}
732760
@@ -749,6 +777,10 @@ const strings = {
749777
deletePhotoTitle: t('pantry', 'Delete photo'),
750778
deletePhotoBody: t('pantry', 'Are you sure you want to delete this photo?'),
751779
deleteFolderTitle: t('pantry', 'Delete folder'),
780+
deleteFolderKeepLabel: t('pantry', 'Delete folder only'),
781+
deleteFolderKeepHint: t('pantry', 'Photos will be moved to the board root.'),
782+
deleteFolderDeleteLabel: t('pantry', 'Delete folder and all photos'),
783+
deleteFolderDeleteHint: t('pantry', 'All photos and their files will be permanently deleted.'),
752784
sortLabel: t('pantry', 'Sort order'),
753785
foldersFirst: t('pantry', 'Folders first'),
754786
}
@@ -839,4 +871,14 @@ const strings = {
839871
.pantry-sort-active {
840872
font-weight: 600;
841873
}
874+
875+
.pantry-delete-folder-options {
876+
margin-top: 0.75rem;
877+
878+
&__hint {
879+
margin: 0 0 0.75rem 1.75rem;
880+
font-size: 0.85rem;
881+
color: var(--color-text-maxcontrast);
882+
}
883+
}
842884
</style>

tests/unit/Service/PhotoServiceTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use OCA\Pantry\Db\PhotoFolderMapper;
1313
use OCA\Pantry\Db\PhotoMapper;
1414
use OCA\Pantry\Exception\NotFoundException;
15+
use OCA\Pantry\Service\ImageService;
1516
use OCA\Pantry\Service\PhotoService;
1617
use OCP\AppFramework\Db\DoesNotExistException;
1718
use PHPUnit\Framework\MockObject\MockObject;
@@ -22,14 +23,18 @@ class PhotoServiceTest extends TestCase {
2223
private PhotoMapper $photoMapper;
2324
/** @var PhotoFolderMapper&MockObject */
2425
private PhotoFolderMapper $folderMapper;
26+
/** @var ImageService&MockObject */
27+
private ImageService $imageService;
2528
private PhotoService $svc;
2629

2730
protected function setUp(): void {
2831
$this->photoMapper = $this->createMock(PhotoMapper::class);
2932
$this->folderMapper = $this->createMock(PhotoFolderMapper::class);
33+
$this->imageService = $this->createMock(ImageService::class);
3034
$this->svc = new PhotoService(
3135
$this->photoMapper,
3236
$this->folderMapper,
37+
$this->imageService,
3338
);
3439
}
3540

0 commit comments

Comments
 (0)