diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js
index 1d8943336b..2d3164b174 100644
--- a/client/modules/IDE/actions/project.js
+++ b/client/modules/IDE/actions/project.js
@@ -262,6 +262,14 @@ export function exportProjectAsZip(projectId) {
win.focus();
}
+export function exportCollectionAsZip(collectionId) {
+ const win = window.open(
+ `${ROOT_URL}/downloadCollection/${collectionId}`,
+ '_blank'
+ );
+ win.focus();
+}
+
export function resetProject() {
return {
type: ActionTypes.RESET_PROJECT
diff --git a/client/modules/User/components/CollectionShareButton.jsx b/client/modules/User/components/CollectionShareButton.jsx
index c4fd06bcb6..fd513bd796 100644
--- a/client/modules/User/components/CollectionShareButton.jsx
+++ b/client/modules/User/components/CollectionShareButton.jsx
@@ -6,13 +6,18 @@ import Button from '../../../common/Button';
import { DropdownArrowIcon } from '../../../common/icons';
import useModalClose from '../../../common/useModalClose';
import CopyableInput from '../../IDE/components/CopyableInput';
+import { exportCollectionAsZip } from '../../IDE/actions/project';
const ShareURL = ({ value }) => {
const [showURL, setShowURL] = useState(false);
const { t } = useTranslation();
const close = useCallback(() => setShowURL(false), [setShowURL]);
const ref = useModalClose(close);
-
+ function downloadCollection() {
+ const urlArray = value.split('/');
+ const collectionId = urlArray[urlArray.length - 1];
+ exportCollectionAsZip(collectionId);
+ }
return (
diff --git a/client/styles/components/_collection.scss b/client/styles/components/_collection.scss
index a616154ba5..d09b155903 100644
--- a/client/styles/components/_collection.scss
+++ b/client/styles/components/_collection.scss
@@ -104,6 +104,7 @@
@extend %dropdown-open-right;
padding: #{20 / $base-font-size}rem;
width: #{350 / $base-font-size}rem;
+ gap: 1rem;
}
.collection-content {
diff --git a/server/controllers/collection.controller/downloadCollection.js b/server/controllers/collection.controller/downloadCollection.js
new file mode 100644
index 0000000000..4b9202aee2
--- /dev/null
+++ b/server/controllers/collection.controller/downloadCollection.js
@@ -0,0 +1,104 @@
+import JSZip from 'jszip';
+import format from 'date-fns/format';
+import isUrl from 'is-url';
+import { JSDOM } from 'jsdom';
+import slugify from 'slugify';
+import { addFileToZip } from '../project.controller';
+import Collection from '../../models/collection';
+import generateFileSystemSafeName from '../../utils/generateFileSystemSafeName';
+
+function formatCollection(collection) {
+ const { items, name, owner } = collection;
+ const folder = {
+ name: 'root',
+ fileType: 'folder',
+ children: []
+ };
+
+ const formattedCollection = {
+ name,
+ owner: owner.username,
+ files: [folder]
+ };
+
+ items.forEach((item) => {
+ const { project } = item;
+ const rootFile = project.files.find((file) => file.name === 'root');
+ rootFile.name = project.name;
+ formattedCollection.files.push(...project.files);
+ folder.children.push(rootFile.id);
+ });
+
+ return formattedCollection;
+}
+
+function bundleExternalLibsForCollection(collection) {
+ const { files } = collection;
+ const htmlFiles = [];
+ const projectFolders = [];
+ files.forEach((file) => {
+ if (file.name === 'index.html') htmlFiles.push(file);
+ else if (file.fileType === 'folder' && file.name !== 'root')
+ projectFolders.push(file);
+ });
+ htmlFiles.forEach((indexHtml) => {
+ const { window } = new JSDOM(indexHtml.content);
+ const scriptTags = window.document.getElementsByTagName('script');
+
+ const parentFolder = projectFolders.filter((folder) =>
+ folder.children.includes(indexHtml.id)
+ );
+
+ Object.values(scriptTags).forEach(async ({ src }, i) => {
+ if (!isUrl(src)) return;
+
+ const path = src.split('/');
+ const filename = path[path.length - 1];
+ const libId = `${filename}-${parentFolder[0].name}`;
+
+ collection.files.push({
+ name: filename,
+ url: src,
+ id: libId
+ });
+
+ parentFolder[0].children.push(libId);
+ });
+ });
+}
+
+async function buildCollectionZip(collection, req, res) {
+ try {
+ const zip = new JSZip();
+ const currentTime = format(new Date(), 'yyyy_MM_dd_HH_mm_ss');
+ collection.slug = slugify(`${collection.name} by ${collection.owner}`, '_');
+ const zipFileName = `${generateFileSystemSafeName(
+ collection.slug
+ )}_${currentTime}.zip`;
+ const { files } = collection;
+ const root = files.find((file) => file.name === 'root');
+
+ bundleExternalLibsForCollection(collection);
+ await addFileToZip(root, files, zip);
+
+ const base64 = await zip.generateAsync({ type: 'base64' });
+ const buff = Buffer.from(base64, 'base64');
+ res.writeHead(200, {
+ 'Content-Type': 'application/zip',
+ 'Content-disposition': `attachment; filename=${zipFileName}`
+ });
+ res.end(buff);
+ } catch (err) {
+ console.error(err);
+ res.status(500).send(err);
+ }
+}
+
+export default async function downloadCollection(req, res) {
+ const { id } = req.params;
+ const collection = await Collection.findById(id)
+ .populate('items.project')
+ .populate('owner');
+ const formattedCollection = formatCollection(collection);
+ buildCollectionZip(formattedCollection, req, res);
+}
diff --git a/server/controllers/collection.controller/index.js b/server/controllers/collection.controller/index.js
index 8cb9e368d7..90f0538354 100644
--- a/server/controllers/collection.controller/index.js
+++ b/server/controllers/collection.controller/index.js
@@ -5,3 +5,4 @@ export { default as listCollections } from './listCollections';
export { default as removeCollection } from './removeCollection';
export { default as removeProjectFromCollection } from './removeProjectFromCollection';
export { default as updateCollection } from './updateCollection';
+export { default as downloadCollection } from './downloadCollection';
diff --git a/server/controllers/project.controller.js b/server/controllers/project.controller.js
index fd48b7558d..e658bed9fb 100644
--- a/server/controllers/project.controller.js
+++ b/server/controllers/project.controller.js
@@ -215,7 +215,7 @@ function bundleExternalLibs(project) {
});
}
-function addFileToZip(file, files, zip, path = '') {
+export function addFileToZip(file, files, zip, path = '') {
return new Promise((resolve, reject) => {
if (file.fileType === 'folder') {
const newPath = file.name === 'root' ? path : `${path}${file.name}/`;
diff --git a/server/routes/collection.routes.js b/server/routes/collection.routes.js
index 31316d6f5b..297557a0fd 100644
--- a/server/routes/collection.routes.js
+++ b/server/routes/collection.routes.js
@@ -40,5 +40,5 @@ router.delete(
isAuthenticated,
CollectionController.removeProjectFromCollection
);
-
+router.get('/downloadCollection/:id', CollectionController.downloadCollection);
export default router;
diff --git a/translations/locales/en-US/translations.json b/translations/locales/en-US/translations.json
index a3dde037de..344e576169 100644
--- a/translations/locales/en-US/translations.json
+++ b/translations/locales/en-US/translations.json
@@ -434,6 +434,7 @@
"AnothersTitle": "p5.js Web Editor | {{anotheruser}}'s collections",
"Share": "Share",
"URLLink": "Link to Collection",
+ "Download": "Download Collection",
"AddSketch": "Add Sketch",
"DeleteFromCollection": "Are you sure you want to remove {{name_sketch}} from this collection?",
"SketchDeleted": "Sketch deleted",