mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat: support deleting photo folders with content
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
11
openapi.json
11
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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user