Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add download functionality to DAM folders #1230

Merged
merged 18 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
648162c
chore(api): add jszip library
jomunker Aug 16, 2023
6c942a7
feat(FoldersController): add a folders controller with an endpoint wh…
jomunker Aug 16, 2023
f619788
chore(gitignore): add .idea folder to .gitignore
jomunker Aug 16, 2023
b33aaf3
feat(FoldersService): handle duplicate folder names
jomunker Aug 16, 2023
9f20a98
fix(FoldersController): wrap filename property in double quotes to av…
jomunker Aug 16, 2023
e6ee99d
feat(FoldersService): throw error on empty subfolder zip
jomunker Aug 16, 2023
7c6a104
refactor(FoldersController): rename zipBuffer to zipStream
jomunker Aug 16, 2023
6f72a32
feat(DamContextMenu): add download link button
jomunker Aug 17, 2023
92348e7
refactor(RowActionsItem): use more descriptive name for generic
jomunker Aug 17, 2023
2a57696
refactor(cms-api): remove console log from folders.controller.ts
jomunker Sep 11, 2023
38f3aed
refactor(cms-api): throw not found error instead of using fallback fo…
jomunker Sep 11, 2023
017d90d
Merge branch 'main' into dam-download-folders
jomunker Oct 23, 2023
f0b672e
Merge branch 'main' into dam-download-folders
jomunker Dec 6, 2023
770de6c
feat(folders.controller): add user scope check to createZip function
jomunker Jan 3, 2024
d2feb34
Merge branch 'main' into dam-download-folders
jomunker Jan 17, 2024
7d94b72
refactor(RowActionsListItem): use any as generic in ForwardedRef
jomunker Feb 6, 2024
2048789
Merge branch 'main' into dam-download-folders
thomasdax98 Feb 8, 2024
2494ca9
Replace ContentScopeService with accessControlService
thomasdax98 Feb 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
jomunker marked this conversation as resolved.
Show resolved Hide resolved
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>(
props: RowActionsListItemProps<MenuItemComponent>,
ref: React.ForwardedRef<HTMLLIElement>,
jomunker marked this conversation as resolved.
Show resolved Hide resolved
) => {
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>(
johnnyomair marked this conversation as resolved.
Show resolved Hide resolved
props: RowActionsListItemProps<MenuItemComponent> & { ref?: React.ForwardedRef<HTMLLIElement> },
) => 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";
johnnyomair marked this conversation as resolved.
Show resolved Hide resolved
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">
johnnyomair marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -17,6 +17,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 @@ -118,7 +119,7 @@ export class DamModule {
CalculateDominantImageColor,
FileValidationService,
],
controllers: [createFilesController({ Scope }), ImagesController],
controllers: [createFilesController({ Scope }), FoldersController, ImagesController],
exports: [ImgproxyService, FilesService, FoldersService, ImagesService, ScaledImagesCacheService, damConfigProvider],
};
}
Expand Down
22 changes: 22 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,22 @@
import { Controller, Get, NotFoundException, Param, Res } from "@nestjs/common";
import { Response } from "express";

import { FoldersService } from "./folders.service";

@Controller("dam/folders")
export class FoldersController {
constructor(private readonly foldersService: FoldersService) {}

@Get("/:folderId/zip")
async createZip(@Param("folderId") folderId: string, @Res() res: Response): Promise<void> {
jomunker marked this conversation as resolved.
Show resolved Hide resolved
const folder = await this.foldersService.findOneById(folderId);
jomunker marked this conversation as resolved.
Show resolved Hide resolved
if (!folder) {
throw new NotFoundException("Folder not found");
}
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.