Skip to content

Commit 9d47b5b

Browse files
jomunkerjohnnyomairthomasdax98
authored
Add download functionality to DAM folders (#1230)
This PR adds the functionality to download folders recursively in the DAM as ZIP. --------- Co-authored-by: Johannes Obermair <[email protected]> Co-authored-by: Thomas Dax <[email protected]>
1 parent f243d69 commit 9d47b5b

File tree

9 files changed

+137
-13
lines changed

9 files changed

+137
-13
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ lang/
99
.pnp.*
1010
junit.xml
1111
.env.local
12+
**/.idea

packages/admin/admin/src/rowActions/RowActionsItem.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,24 @@ export interface CommonRowActionItemProps {
1010
onClick?: React.MouseEventHandler<HTMLElement>;
1111
}
1212

13-
export type RowActionsItemPropsComponentsProps = RowActionsIconItemComponentsProps & RowActionsListItemComponentsProps;
13+
export type RowActionsItemPropsComponentsProps<T extends React.ElementType = "li"> = RowActionsIconItemComponentsProps &
14+
RowActionsListItemComponentsProps<T>;
1415

15-
export interface RowActionsItemProps extends Omit<RowActionsIconItemProps, "componentsProps">, Omit<RowActionsListItemProps, "componentsProps"> {
16-
componentsProps?: RowActionsItemPropsComponentsProps;
16+
export interface RowActionsItemProps<T extends React.ElementType = "li">
17+
extends Omit<RowActionsIconItemProps, "componentsProps">,
18+
Omit<RowActionsListItemProps<T>, "componentsProps"> {
19+
componentsProps?: RowActionsItemPropsComponentsProps<T>;
1720
children?: React.ReactNode;
1821
}
1922

20-
export const RowActionsItem = ({ icon, children, disabled, onClick, componentsProps, ...restListItemProps }: RowActionsItemProps) => {
23+
export function RowActionsItem<MenuItemComponent extends React.ElementType = "li">({
24+
icon,
25+
children,
26+
disabled,
27+
onClick,
28+
componentsProps,
29+
...restListItemProps
30+
}: RowActionsItemProps<MenuItemComponent>): React.ReactElement<RowActionsItemProps<MenuItemComponent>> {
2131
const { level, closeAllMenus } = React.useContext(RowActionsMenuContext);
2232

2333
if (level === 1) {
@@ -33,7 +43,7 @@ export const RowActionsItem = ({ icon, children, disabled, onClick, componentsPr
3343
}
3444

3545
return (
36-
<RowActionsListItem
46+
<RowActionsListItem<MenuItemComponent>
3747
icon={icon}
3848
disabled={disabled}
3949
onClick={(event) => {
@@ -50,4 +60,4 @@ export const RowActionsItem = ({ icon, children, disabled, onClick, componentsPr
5060
{children}
5161
</RowActionsListItem>
5262
);
53-
};
63+
}

packages/admin/admin/src/rowActions/RowActionsListItem.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,38 @@ import * as React from "react";
44

55
import { CommonRowActionItemProps } from "./RowActionsItem";
66

7-
export type RowActionsListItemComponentsProps = React.PropsWithChildren<{
7+
export type RowActionsListItemComponentsProps<MenuItemComponent extends React.ElementType = "li"> = React.PropsWithChildren<{
88
listItemIcon?: Partial<ListItemIconProps>;
99
listItemText?: Partial<ListItemTextProps>;
10-
menuItem?: Partial<MenuItemProps>;
10+
menuItem?: Partial<MenuItemProps<MenuItemComponent> & { component: MenuItemComponent }>;
1111
}>;
1212

13-
export interface RowActionsListItemProps extends CommonRowActionItemProps {
13+
export interface RowActionsListItemProps<MenuItemComponent extends React.ElementType = "li"> extends CommonRowActionItemProps {
1414
textSecondary?: React.ReactNode;
1515
endIcon?: React.ReactNode;
16-
componentsProps?: RowActionsListItemComponentsProps;
16+
componentsProps?: RowActionsListItemComponentsProps<MenuItemComponent>;
1717
children?: React.ReactNode;
1818
}
1919

20-
export const RowActionsListItem = React.forwardRef<HTMLLIElement, RowActionsListItemProps>(function RowActionsListItem(props, ref) {
20+
const RowActionsListItemNoRef = <MenuItemComponent extends React.ElementType = "li">(
21+
props: RowActionsListItemProps<MenuItemComponent>,
22+
ref: React.ForwardedRef<any>,
23+
) => {
2124
const { icon, children, textSecondary, endIcon, componentsProps = {}, ...restMenuItemProps } = props;
2225
const { listItemIcon: listItemIconProps, listItemText: listItemTextProps, menuItem: menuItemProps } = componentsProps;
26+
2327
return (
2428
<MenuItem ref={ref} {...restMenuItemProps} {...menuItemProps}>
2529
{icon !== undefined && <ListItemIcon {...listItemIconProps}>{icon}</ListItemIcon>}
2630
{children !== undefined && <ListItemText primary={children} secondary={textSecondary} {...listItemTextProps} />}
2731
{Boolean(endIcon) && <EndIcon>{endIcon}</EndIcon>}
2832
</MenuItem>
2933
);
30-
});
34+
};
35+
36+
export const RowActionsListItem = React.forwardRef(RowActionsListItemNoRef) as <MenuItemComponent extends React.ElementType = "li">(
37+
props: RowActionsListItemProps<MenuItemComponent> & { ref?: React.ForwardedRef<any> },
38+
) => React.ReactElement;
3139

3240
const EndIcon = styled("div")(({ theme }) => ({
3341
marginLeft: theme.spacing(2),

packages/admin/cms-admin/src/dam/DataGrid/DamContextMenu.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { saveAs } from "file-saver";
66
import * as React from "react";
77
import { FormattedMessage } from "react-intl";
88

9+
import { useCmsBlockContext } from "../../blocks/useCmsBlockContext";
910
import { UnknownError } from "../../common/errors/errorMessages";
1011
import { GQLDamFile, GQLDamFolder } from "../../graphql.generated";
1112
import { ConfirmDeleteDialog } from "../FileActions/ConfirmDeleteDialog";
@@ -30,6 +31,7 @@ const FolderInnerMenu = ({ folder, openMoveDialog }: FolderInnerMenuProps): Reac
3031
const editDialogApi = useEditDialogApi();
3132
const errorDialog = useErrorDialog();
3233
const apolloClient = useApolloClient();
34+
const context = useCmsBlockContext();
3335

3436
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState<boolean>(false);
3537

@@ -56,6 +58,8 @@ const FolderInnerMenu = ({ folder, openMoveDialog }: FolderInnerMenuProps): Reac
5658
}
5759
};
5860

61+
const downloadUrl = `${context.damConfig.apiUrl}/dam/folders/${folder.id}/zip`;
62+
5963
return (
6064
<>
6165
<RowActionsMenu>
@@ -68,6 +72,14 @@ const FolderInnerMenu = ({ folder, openMoveDialog }: FolderInnerMenuProps): Reac
6872
>
6973
<FormattedMessage id="comet.pages.dam.rename" defaultMessage="Rename" />
7074
</RowActionsItem>
75+
<RowActionsItem<"a">
76+
icon={<Download />}
77+
componentsProps={{
78+
menuItem: { component: "a", href: downloadUrl, target: "_blank" },
79+
}}
80+
>
81+
<FormattedMessage id="comet.pages.dam.downloadFolder" defaultMessage="Download folder" />
82+
</RowActionsItem>
7183
<RowActionsItem
7284
icon={<Move />}
7385
onClick={() => {

packages/api/cms-api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"graphql-type-json": "^0.3.2",
5555
"hasha": "^5.2.2",
5656
"jsonwebtoken": "^8.5.1",
57+
"jszip": "^3.10.1",
5758
"jwks-rsa": "^3.0.0",
5859
"lodash.isequal": "^4.0.0",
5960
"mime": "^3.0.0",

packages/api/cms-api/src/dam/dam.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { FileValidationService } from "./files/file-validation.service";
1818
import { createFilesController } from "./files/files.controller";
1919
import { createFilesResolver } from "./files/files.resolver";
2020
import { FilesService } from "./files/files.service";
21+
import { FoldersController } from "./files/folders.controller";
2122
import { createFoldersResolver } from "./files/folders.resolver";
2223
import { FoldersService } from "./files/folders.service";
2324
import { CalculateDominantImageColor } from "./images/calculateDominantImageColor.console";
@@ -120,7 +121,7 @@ export class DamModule {
120121
FileValidationService,
121122
FileUploadService,
122123
],
123-
controllers: [createFilesController({ Scope }), ImagesController],
124+
controllers: [createFilesController({ Scope }), FoldersController, ImagesController],
124125
exports: [ImgproxyService, FilesService, FoldersService, ImagesService, ScaledImagesCacheService, damConfigProvider, FileUploadService],
125126
};
126127
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Controller, ForbiddenException, Get, Inject, NotFoundException, Param, Res } from "@nestjs/common";
2+
import { Response } from "express";
3+
4+
import { CurrentUserInterface } from "../../auth/current-user/current-user";
5+
import { GetCurrentUser } from "../../auth/decorators/get-current-user.decorator";
6+
import { ACCESS_CONTROL_SERVICE } from "../../user-permissions/user-permissions.constants";
7+
import { AccessControlServiceInterface } from "../../user-permissions/user-permissions.types";
8+
import { FoldersService } from "./folders.service";
9+
10+
@Controller("dam/folders")
11+
export class FoldersController {
12+
constructor(
13+
private readonly foldersService: FoldersService,
14+
@Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface,
15+
) {}
16+
17+
@Get("/:folderId/zip")
18+
async createZip(@Param("folderId") folderId: string, @Res() res: Response, @GetCurrentUser() user: CurrentUserInterface): Promise<void> {
19+
const folder = await this.foldersService.findOneById(folderId);
20+
if (!folder) {
21+
throw new NotFoundException("Folder not found");
22+
}
23+
24+
if (folder.scope && !this.accessControlService.isAllowed(user, "dam", folder.scope)) {
25+
throw new ForbiddenException("The current user is not allowed to access this scope and download this folder.");
26+
}
27+
28+
const zipStream = await this.foldersService.createZipStreamFromFolder(folderId);
29+
30+
res.setHeader("Content-Disposition", `attachment; filename="${folder.name}.zip"`);
31+
res.setHeader("Content-Type", "application/zip");
32+
zipStream.pipe(res);
33+
}
34+
}

packages/api/cms-api/src/dam/files/folders.service.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@ import { MikroORM } from "@mikro-orm/core";
22
import { InjectRepository } from "@mikro-orm/nestjs";
33
import { EntityRepository, QueryBuilder } from "@mikro-orm/postgresql";
44
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common";
5+
import JSZip from "jszip";
56
import isEqual from "lodash.isequal";
67

8+
import { BlobStorageBackendService } from "../../blob-storage/backends/blob-storage-backend.service";
79
import { CometEntityNotFoundException } from "../../common/errors/entity-not-found.exception";
810
import { SortDirection } from "../../common/sorting/sort-direction.enum";
11+
import { DamConfig } from "../dam.config";
12+
import { DAM_CONFIG } from "../dam.constants";
913
import { DamScopeInterface } from "../types";
1014
import { DamFolderListPositionArgs, FolderArgsInterface } from "./dto/folder.args";
1115
import { UpdateFolderInput } from "./dto/folder.input";
1216
import { FOLDER_TABLE_NAME, FolderInterface } from "./entities/folder.entity";
1317
import { FilesService } from "./files.service";
18+
import { createHashedPath } from "./files.utils";
1419

1520
export const withFoldersSelect = (
1621
qb: QueryBuilder<FolderInterface>,
@@ -75,6 +80,8 @@ export class FoldersService {
7580
constructor(
7681
@InjectRepository("DamFolder") private readonly foldersRepository: EntityRepository<FolderInterface>,
7782
@Inject(forwardRef(() => FilesService)) private readonly filesService: FilesService,
83+
@Inject(forwardRef(() => BlobStorageBackendService)) private readonly blobStorageBackendService: BlobStorageBackendService,
84+
@Inject(DAM_CONFIG) private readonly config: DamConfig,
7885
private readonly orm: MikroORM,
7986
) {}
8087

@@ -342,6 +349,53 @@ export class FoldersService {
342349
return mpath.map((id) => folders.find((folder) => folder.id === id) as FolderInterface);
343350
}
344351

352+
async createZipStreamFromFolder(folderId: string): Promise<NodeJS.ReadableStream> {
353+
const zip = new JSZip();
354+
355+
await this.addFolderToZip(folderId, zip);
356+
357+
return zip.generateNodeStream({ streamFiles: true });
358+
}
359+
360+
private async addFolderToZip(folderId: string, zip: JSZip): Promise<void> {
361+
const files = await this.filesService.findAll({ folderId: folderId });
362+
const subfolders = await this.findAllByParentId({ parentId: folderId });
363+
364+
for (const file of files) {
365+
const fileStream = await this.blobStorageBackendService.getFile(this.config.filesDirectory, createHashedPath(file.contentHash));
366+
367+
zip.file(file.name, fileStream);
368+
}
369+
const countedSubfolderNames: Record<string, number> = {};
370+
371+
for (const subfolder of subfolders) {
372+
const subfolderName = subfolder.name;
373+
const updatedSubfolderName = this.getUniqueFolderName(subfolderName, countedSubfolderNames);
374+
375+
const subfolderZip = zip.folder(updatedSubfolderName);
376+
if (!subfolderZip) {
377+
throw new Error(`Error while creating zip from folder with id ${folderId}`);
378+
}
379+
await this.addFolderToZip(subfolder.id, subfolderZip);
380+
}
381+
}
382+
383+
private getUniqueFolderName(folderName: string, countedFolderNames: Record<string, number>) {
384+
if (!countedFolderNames[folderName]) {
385+
countedFolderNames[folderName] = 1;
386+
} else {
387+
countedFolderNames[folderName]++;
388+
}
389+
390+
const duplicateCount = countedFolderNames[folderName];
391+
392+
let updatedFolderName = folderName;
393+
if (duplicateCount > 1) {
394+
updatedFolderName = `${folderName} ${duplicateCount}`;
395+
}
396+
return updatedFolderName;
397+
}
398+
345399
private selectQueryBuilder(): QueryBuilder<FolderInterface> {
346400
return this.foldersRepository
347401
.createQueryBuilder("folder")

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)