Skip to content

Commit

Permalink
Add download functionality to DAM folders (#1230)
Browse files Browse the repository at this point in the history
This PR adds the functionality to download folders recursively in the
DAM as ZIP.

---------

Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com>
Co-authored-by: Thomas Dax <thomas.dax@vivid-planet.com>
  • Loading branch information
3 people authored Feb 12, 2024
1 parent f243d69 commit 9d47b5b
Show file tree
Hide file tree
Showing 9 changed files with 137 additions and 13 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ lang/
.pnp.*
junit.xml
.env.local
**/.idea
22 changes: 16 additions & 6 deletions packages/admin/admin/src/rowActions/RowActionsItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,24 @@ export interface CommonRowActionItemProps {
onClick?: React.MouseEventHandler<HTMLElement>;
}

export type RowActionsItemPropsComponentsProps = RowActionsIconItemComponentsProps & RowActionsListItemComponentsProps;
export type RowActionsItemPropsComponentsProps<T extends React.ElementType = "li"> = RowActionsIconItemComponentsProps &
RowActionsListItemComponentsProps<T>;

export interface RowActionsItemProps extends Omit<RowActionsIconItemProps, "componentsProps">, Omit<RowActionsListItemProps, "componentsProps"> {
componentsProps?: RowActionsItemPropsComponentsProps;
export interface RowActionsItemProps<T extends React.ElementType = "li">
extends Omit<RowActionsIconItemProps, "componentsProps">,
Omit<RowActionsListItemProps<T>, "componentsProps"> {
componentsProps?: RowActionsItemPropsComponentsProps<T>;
children?: React.ReactNode;
}

export const RowActionsItem = ({ icon, children, disabled, onClick, componentsProps, ...restListItemProps }: RowActionsItemProps) => {
export function RowActionsItem<MenuItemComponent extends React.ElementType = "li">({
icon,
children,
disabled,
onClick,
componentsProps,
...restListItemProps
}: RowActionsItemProps<MenuItemComponent>): React.ReactElement<RowActionsItemProps<MenuItemComponent>> {
const { level, closeAllMenus } = React.useContext(RowActionsMenuContext);

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

return (
<RowActionsListItem
<RowActionsListItem<MenuItemComponent>
icon={icon}
disabled={disabled}
onClick={(event) => {
Expand All @@ -50,4 +60,4 @@ export const RowActionsItem = ({ icon, children, disabled, onClick, componentsPr
{children}
</RowActionsListItem>
);
};
}
20 changes: 14 additions & 6 deletions packages/admin/admin/src/rowActions/RowActionsListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,38 @@ import * as React from "react";

import { CommonRowActionItemProps } from "./RowActionsItem";

export type RowActionsListItemComponentsProps = React.PropsWithChildren<{
export type RowActionsListItemComponentsProps<MenuItemComponent extends React.ElementType = "li"> = React.PropsWithChildren<{
listItemIcon?: Partial<ListItemIconProps>;
listItemText?: Partial<ListItemTextProps>;
menuItem?: Partial<MenuItemProps>;
menuItem?: Partial<MenuItemProps<MenuItemComponent> & { component: MenuItemComponent }>;
}>;

export interface RowActionsListItemProps extends CommonRowActionItemProps {
export interface RowActionsListItemProps<MenuItemComponent extends React.ElementType = "li"> extends CommonRowActionItemProps {
textSecondary?: React.ReactNode;
endIcon?: React.ReactNode;
componentsProps?: RowActionsListItemComponentsProps;
componentsProps?: RowActionsListItemComponentsProps<MenuItemComponent>;
children?: React.ReactNode;
}

export const RowActionsListItem = React.forwardRef<HTMLLIElement, RowActionsListItemProps>(function RowActionsListItem(props, ref) {
const RowActionsListItemNoRef = <MenuItemComponent extends React.ElementType = "li">(
props: RowActionsListItemProps<MenuItemComponent>,
ref: React.ForwardedRef<any>,
) => {
const { icon, children, textSecondary, endIcon, componentsProps = {}, ...restMenuItemProps } = props;
const { listItemIcon: listItemIconProps, listItemText: listItemTextProps, menuItem: menuItemProps } = componentsProps;

return (
<MenuItem ref={ref} {...restMenuItemProps} {...menuItemProps}>
{icon !== undefined && <ListItemIcon {...listItemIconProps}>{icon}</ListItemIcon>}
{children !== undefined && <ListItemText primary={children} secondary={textSecondary} {...listItemTextProps} />}
{Boolean(endIcon) && <EndIcon>{endIcon}</EndIcon>}
</MenuItem>
);
});
};

export const RowActionsListItem = React.forwardRef(RowActionsListItemNoRef) as <MenuItemComponent extends React.ElementType = "li">(
props: RowActionsListItemProps<MenuItemComponent> & { ref?: React.ForwardedRef<any> },
) => React.ReactElement;

const EndIcon = styled("div")(({ theme }) => ({
marginLeft: theme.spacing(2),
Expand Down
12 changes: 12 additions & 0 deletions packages/admin/cms-admin/src/dam/DataGrid/DamContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { saveAs } from "file-saver";
import * as React from "react";
import { FormattedMessage } from "react-intl";

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

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

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

const downloadUrl = `${context.damConfig.apiUrl}/dam/folders/${folder.id}/zip`;

return (
<>
<RowActionsMenu>
Expand All @@ -68,6 +72,14 @@ const FolderInnerMenu = ({ folder, openMoveDialog }: FolderInnerMenuProps): Reac
>
<FormattedMessage id="comet.pages.dam.rename" defaultMessage="Rename" />
</RowActionsItem>
<RowActionsItem<"a">
icon={<Download />}
componentsProps={{
menuItem: { component: "a", href: downloadUrl, target: "_blank" },
}}
>
<FormattedMessage id="comet.pages.dam.downloadFolder" defaultMessage="Download folder" />
</RowActionsItem>
<RowActionsItem
icon={<Move />}
onClick={() => {
Expand Down
1 change: 1 addition & 0 deletions packages/api/cms-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"graphql-type-json": "^0.3.2",
"hasha": "^5.2.2",
"jsonwebtoken": "^8.5.1",
"jszip": "^3.10.1",
"jwks-rsa": "^3.0.0",
"lodash.isequal": "^4.0.0",
"mime": "^3.0.0",
Expand Down
3 changes: 2 additions & 1 deletion packages/api/cms-api/src/dam/dam.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { FileValidationService } from "./files/file-validation.service";
import { createFilesController } from "./files/files.controller";
import { createFilesResolver } from "./files/files.resolver";
import { FilesService } from "./files/files.service";
import { FoldersController } from "./files/folders.controller";
import { createFoldersResolver } from "./files/folders.resolver";
import { FoldersService } from "./files/folders.service";
import { CalculateDominantImageColor } from "./images/calculateDominantImageColor.console";
Expand Down Expand Up @@ -120,7 +121,7 @@ export class DamModule {
FileValidationService,
FileUploadService,
],
controllers: [createFilesController({ Scope }), ImagesController],
controllers: [createFilesController({ Scope }), FoldersController, ImagesController],
exports: [ImgproxyService, FilesService, FoldersService, ImagesService, ScaledImagesCacheService, damConfigProvider, FileUploadService],
};
}
Expand Down
34 changes: 34 additions & 0 deletions packages/api/cms-api/src/dam/files/folders.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Controller, ForbiddenException, Get, Inject, NotFoundException, Param, Res } from "@nestjs/common";
import { Response } from "express";

import { CurrentUserInterface } from "../../auth/current-user/current-user";
import { GetCurrentUser } from "../../auth/decorators/get-current-user.decorator";
import { ACCESS_CONTROL_SERVICE } from "../../user-permissions/user-permissions.constants";
import { AccessControlServiceInterface } from "../../user-permissions/user-permissions.types";
import { FoldersService } from "./folders.service";

@Controller("dam/folders")
export class FoldersController {
constructor(
private readonly foldersService: FoldersService,
@Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface,
) {}

@Get("/:folderId/zip")
async createZip(@Param("folderId") folderId: string, @Res() res: Response, @GetCurrentUser() user: CurrentUserInterface): Promise<void> {
const folder = await this.foldersService.findOneById(folderId);
if (!folder) {
throw new NotFoundException("Folder not found");
}

if (folder.scope && !this.accessControlService.isAllowed(user, "dam", folder.scope)) {
throw new ForbiddenException("The current user is not allowed to access this scope and download this folder.");
}

const zipStream = await this.foldersService.createZipStreamFromFolder(folderId);

res.setHeader("Content-Disposition", `attachment; filename="${folder.name}.zip"`);
res.setHeader("Content-Type", "application/zip");
zipStream.pipe(res);
}
}
54 changes: 54 additions & 0 deletions packages/api/cms-api/src/dam/files/folders.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@ import { MikroORM } from "@mikro-orm/core";
import { InjectRepository } from "@mikro-orm/nestjs";
import { EntityRepository, QueryBuilder } from "@mikro-orm/postgresql";
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common";
import JSZip from "jszip";
import isEqual from "lodash.isequal";

import { BlobStorageBackendService } from "../../blob-storage/backends/blob-storage-backend.service";
import { CometEntityNotFoundException } from "../../common/errors/entity-not-found.exception";
import { SortDirection } from "../../common/sorting/sort-direction.enum";
import { DamConfig } from "../dam.config";
import { DAM_CONFIG } from "../dam.constants";
import { DamScopeInterface } from "../types";
import { DamFolderListPositionArgs, FolderArgsInterface } from "./dto/folder.args";
import { UpdateFolderInput } from "./dto/folder.input";
import { FOLDER_TABLE_NAME, FolderInterface } from "./entities/folder.entity";
import { FilesService } from "./files.service";
import { createHashedPath } from "./files.utils";

export const withFoldersSelect = (
qb: QueryBuilder<FolderInterface>,
Expand Down Expand Up @@ -75,6 +80,8 @@ export class FoldersService {
constructor(
@InjectRepository("DamFolder") private readonly foldersRepository: EntityRepository<FolderInterface>,
@Inject(forwardRef(() => FilesService)) private readonly filesService: FilesService,
@Inject(forwardRef(() => BlobStorageBackendService)) private readonly blobStorageBackendService: BlobStorageBackendService,
@Inject(DAM_CONFIG) private readonly config: DamConfig,
private readonly orm: MikroORM,
) {}

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

async createZipStreamFromFolder(folderId: string): Promise<NodeJS.ReadableStream> {
const zip = new JSZip();

await this.addFolderToZip(folderId, zip);

return zip.generateNodeStream({ streamFiles: true });
}

private async addFolderToZip(folderId: string, zip: JSZip): Promise<void> {
const files = await this.filesService.findAll({ folderId: folderId });
const subfolders = await this.findAllByParentId({ parentId: folderId });

for (const file of files) {
const fileStream = await this.blobStorageBackendService.getFile(this.config.filesDirectory, createHashedPath(file.contentHash));

zip.file(file.name, fileStream);
}
const countedSubfolderNames: Record<string, number> = {};

for (const subfolder of subfolders) {
const subfolderName = subfolder.name;
const updatedSubfolderName = this.getUniqueFolderName(subfolderName, countedSubfolderNames);

const subfolderZip = zip.folder(updatedSubfolderName);
if (!subfolderZip) {
throw new Error(`Error while creating zip from folder with id ${folderId}`);
}
await this.addFolderToZip(subfolder.id, subfolderZip);
}
}

private getUniqueFolderName(folderName: string, countedFolderNames: Record<string, number>) {
if (!countedFolderNames[folderName]) {
countedFolderNames[folderName] = 1;
} else {
countedFolderNames[folderName]++;
}

const duplicateCount = countedFolderNames[folderName];

let updatedFolderName = folderName;
if (duplicateCount > 1) {
updatedFolderName = `${folderName} ${duplicateCount}`;
}
return updatedFolderName;
}

private selectQueryBuilder(): QueryBuilder<FolderInterface> {
return this.foldersRepository
.createQueryBuilder("folder")
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 9d47b5b

Please sign in to comment.