浏览代码

feat(server/web): download entire album as zip archive (#897)

* feat(server/web): download entire album as zip archive

* fix: remove duplicate API call

* disable ZIP compression (images are already compressed)
Fynn Petersen-Frey 2 年之前
父节点
当前提交
dc2c92e721

+ 1 - 0
mobile/openapi/README.md

@@ -69,6 +69,7 @@ Class | Method | HTTP request | Description
 *AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users | 
 *AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users | 
 *AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album | 
 *AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album | 
 *AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} | 
 *AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} | 
+*AlbumApi* | [**downloadArchive**](doc//AlbumApi.md#downloadarchive) | **GET** /album/{albumId}/download | 
 *AlbumApi* | [**getAlbumCountByUserId**](doc//AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id | 
 *AlbumApi* | [**getAlbumCountByUserId**](doc//AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id | 
 *AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} | 
 *AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} | 
 *AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /album | 
 *AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /album | 

+ 48 - 0
mobile/openapi/doc/AlbumApi.md

@@ -13,6 +13,7 @@ Method | HTTP request | Description
 [**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users | 
 [**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users | 
 [**createAlbum**](AlbumApi.md#createalbum) | **POST** /album | 
 [**createAlbum**](AlbumApi.md#createalbum) | **POST** /album | 
 [**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} | 
 [**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} | 
+[**downloadArchive**](AlbumApi.md#downloadarchive) | **GET** /album/{albumId}/download | 
 [**getAlbumCountByUserId**](AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id | 
 [**getAlbumCountByUserId**](AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id | 
 [**getAlbumInfo**](AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} | 
 [**getAlbumInfo**](AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} | 
 [**getAllAlbums**](AlbumApi.md#getallalbums) | **GET** /album | 
 [**getAllAlbums**](AlbumApi.md#getallalbums) | **GET** /album | 
@@ -212,6 +213,53 @@ void (empty response body)
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
+# **downloadArchive**
+> Object downloadArchive(albumId)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AlbumApi();
+final albumId = albumId_example; // String | 
+
+try {
+    final result = api_instance.downloadArchive(albumId);
+    print(result);
+} catch (e) {
+    print('Exception when calling AlbumApi->downloadArchive: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **albumId** | **String**|  | 
+
+### Return type
+
+[**Object**](Object.md)
+
+### Authorization
+
+[bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **getAlbumCountByUserId**
 # **getAlbumCountByUserId**
 > AlbumCountResponseDto getAlbumCountByUserId()
 > AlbumCountResponseDto getAlbumCountByUserId()
 
 

+ 48 - 0
mobile/openapi/lib/api/album_api.dart

@@ -207,6 +207,54 @@ class AlbumApi {
     }
     }
   }
   }
 
 
+  /// Performs an HTTP 'GET /album/{albumId}/download' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] albumId (required):
+  Future<Response> downloadArchiveWithHttpInfo(String albumId,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/album/{albumId}/download'
+      .replaceAll('{albumId}', albumId);
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] albumId (required):
+  Future<Object?> downloadArchive(String albumId,) async {
+    final response = await downloadArchiveWithHttpInfo(albumId,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'GET /album/count-by-user-id' operation and returns the [Response].
   /// Performs an HTTP 'GET /album/count-by-user-id' operation and returns the [Response].
   Future<Response> getAlbumCountByUserIdWithHttpInfo() async {
   Future<Response> getAlbumCountByUserIdWithHttpInfo() async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations

+ 11 - 0
server/apps/immich/src/api-v1/album/album.controller.ts

@@ -10,6 +10,7 @@ import {
   ParseUUIDPipe,
   ParseUUIDPipe,
   Put,
   Put,
   Query,
   Query,
+  Response,
 } from '@nestjs/common';
 } from '@nestjs/common';
 import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
 import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
 import { AlbumService } from './album.service';
 import { AlbumService } from './album.service';
@@ -25,6 +26,7 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
 import { AlbumResponseDto } from './response-dto/album-response.dto';
 import { AlbumResponseDto } from './response-dto/album-response.dto';
 import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
 import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
 import {AddAssetsResponseDto} from "./response-dto/add-assets-response.dto";
 import {AddAssetsResponseDto} from "./response-dto/add-assets-response.dto";
+import { Response as Res } from 'express';
 
 
 // TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
 // TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
 @Authenticated()
 @Authenticated()
@@ -112,4 +114,13 @@ export class AlbumController {
   ) {
   ) {
     return this.albumService.updateAlbumInfo(authUser, updateAlbumInfoDto, albumId);
     return this.albumService.updateAlbumInfo(authUser, updateAlbumInfoDto, albumId);
   }
   }
+
+  @Get('/:albumId/download')
+  async downloadArchive(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
+    @Response({ passthrough: true }) res: Res,
+  ): Promise<any> {
+    return this.albumService.downloadArchive(authUser, albumId, res);
+  }
 }
 }

+ 31 - 4
server/apps/immich/src/api-v1/album/album.service.ts

@@ -1,4 +1,12 @@
-import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
+import {
+  BadRequestException,
+  Inject,
+  Injectable,
+  NotFoundException,
+  ForbiddenException,
+  Logger,
+  InternalServerErrorException,
+} from '@nestjs/common';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { CreateAlbumDto } from './dto/create-album.dto';
 import { CreateAlbumDto } from './dto/create-album.dto';
 import { AlbumEntity } from '@app/database/entities/album.entity';
 import { AlbumEntity } from '@app/database/entities/album.entity';
@@ -10,8 +18,10 @@ import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response
 import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
 import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
 import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
 import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
 import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
 import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
-import { AddAssetsResponseDto } from "./response-dto/add-assets-response.dto";
-import {AddAssetsDto} from "./dto/add-assets.dto";
+import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
+import { AddAssetsDto } from './dto/add-assets.dto';
+import { Response as Res } from 'express';
+import archiver from 'archiver';
 
 
 @Injectable()
 @Injectable()
 export class AlbumService {
 export class AlbumService {
@@ -116,7 +126,7 @@ export class AlbumService {
 
 
     return {
     return {
       ...result,
       ...result,
-      album: mapAlbum(newAlbum)
+      album: mapAlbum(newAlbum),
     };
     };
   }
   }
 
 
@@ -139,6 +149,23 @@ export class AlbumService {
     return this._albumRepository.getCountByUserId(authUser.id);
     return this._albumRepository.getCountByUserId(authUser.id);
   }
   }
 
 
+  async downloadArchive(authUser: AuthUserDto, albumId: string, res: Res) {
+    try {
+      const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
+      const archive = archiver('zip', { store: true });
+      res.attachment(`${album.albumName}.zip`);
+      archive.pipe(res);
+      album.assets?.forEach((a) => {
+        const name = `${a.assetInfo.exifInfo?.imageName || a.assetInfo.id}.${a.assetInfo.originalPath.split('.')[1]}`;
+        archive.file(a.assetInfo.originalPath, { name });
+      });
+      return archive.finalize();
+    } catch (e) {
+      Logger.error(`Error downloading album ${e}`, 'downloadArchive');
+      throw new InternalServerErrorException(`Failed to download album ${e}`, 'DownloadArchive');
+    }
+  }
+
   async _checkValidThumbnail(album: AlbumEntity): Promise<AlbumEntity> {
   async _checkValidThumbnail(album: AlbumEntity): Promise<AlbumEntity> {
     const assetId = album.albumThumbnailAssetId;
     const assetId = album.albumThumbnailAssetId;
     if (assetId) {
     if (assetId) {

文件差异内容过多而无法显示
+ 0 - 0
server/immich-openapi-specs.json


+ 425 - 7
server/package-lock.json

@@ -23,6 +23,7 @@
         "@nestjs/typeorm": "^8.1.4",
         "@nestjs/typeorm": "^8.1.4",
         "@nestjs/websockets": "^8.4.7",
         "@nestjs/websockets": "^8.4.7",
         "@socket.io/redis-adapter": "^7.1.0",
         "@socket.io/redis-adapter": "^7.1.0",
+        "archiver": "^5.3.1",
         "axios": "^0.26.0",
         "axios": "^0.26.0",
         "bcrypt": "^5.0.1",
         "bcrypt": "^5.0.1",
         "bull": "^4.4.0",
         "bull": "^4.4.0",
@@ -58,6 +59,7 @@
         "@nestjs/schematics": "^8.0.11",
         "@nestjs/schematics": "^8.0.11",
         "@nestjs/testing": "^8.4.7",
         "@nestjs/testing": "^8.4.7",
         "@openapitools/openapi-generator-cli": "2.5.1",
         "@openapitools/openapi-generator-cli": "2.5.1",
+        "@types/archiver": "^5.3.1",
         "@types/bcrypt": "^5.0.0",
         "@types/bcrypt": "^5.0.0",
         "@types/bull": "^3.15.9",
         "@types/bull": "^3.15.9",
         "@types/cookie-parser": "^1.4.3",
         "@types/cookie-parser": "^1.4.3",
@@ -2278,6 +2280,15 @@
         "url": "https://opencollective.com/turf"
         "url": "https://opencollective.com/turf"
       }
       }
     },
     },
+    "node_modules/@types/archiver": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.1.tgz",
+      "integrity": "sha512-wKYZaSXaDvTZuInAWjCeGG7BEAgTWG2zZW0/f7IYFcoHB2X2d9lkVFnrOlXl3W6NrvO6Ml3FLLu8Uksyymcpnw==",
+      "dev": true,
+      "dependencies": {
+        "@types/glob": "*"
+      }
+    },
     "node_modules/@types/babel__core": {
     "node_modules/@types/babel__core": {
       "version": "7.1.18",
       "version": "7.1.18",
       "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.18.tgz",
       "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.18.tgz",
@@ -2455,6 +2466,16 @@
         "@types/node": "*"
         "@types/node": "*"
       }
       }
     },
     },
+    "node_modules/@types/glob": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.0.0.tgz",
+      "integrity": "sha512-l6NQsDDyQUVeoTynNpC9uRvCUint/gSUXQA2euwmTuWGvPY5LSDUu6tkCtJB2SvGQlJQzLaKqcGZP4//7EDveA==",
+      "dev": true,
+      "dependencies": {
+        "@types/minimatch": "*",
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/graceful-fs": {
     "node_modules/@types/graceful-fs": {
       "version": "4.1.5",
       "version": "4.1.5",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
@@ -2554,6 +2575,12 @@
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
       "dev": true
       "dev": true
     },
     },
+    "node_modules/@types/minimatch": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
+      "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==",
+      "dev": true
+    },
     "node_modules/@types/multer": {
     "node_modules/@types/multer": {
       "version": "1.4.7",
       "version": "1.4.7",
       "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
       "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
@@ -3271,6 +3298,70 @@
       "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
       "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
       "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
       "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
     },
     },
+    "node_modules/archiver": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.1.tgz",
+      "integrity": "sha512-8KyabkmbYrH+9ibcTScQ1xCJC/CGcugdVIwB+53f5sZziXgwUh3iXlAlANMxcZyDEfTHMe6+Z5FofV8nopXP7w==",
+      "dependencies": {
+        "archiver-utils": "^2.1.0",
+        "async": "^3.2.3",
+        "buffer-crc32": "^0.2.1",
+        "readable-stream": "^3.6.0",
+        "readdir-glob": "^1.0.0",
+        "tar-stream": "^2.2.0",
+        "zip-stream": "^4.1.0"
+      },
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/archiver-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz",
+      "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
+      "dependencies": {
+        "glob": "^7.1.4",
+        "graceful-fs": "^4.2.0",
+        "lazystream": "^1.0.0",
+        "lodash.defaults": "^4.2.0",
+        "lodash.difference": "^4.5.0",
+        "lodash.flatten": "^4.4.0",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.union": "^4.6.0",
+        "normalize-path": "^3.0.0",
+        "readable-stream": "^2.0.0"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/archiver-utils/node_modules/readable-stream": {
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+      "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/archiver-utils/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
+    "node_modules/archiver-utils/node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/are-we-there-yet": {
     "node_modules/are-we-there-yet": {
       "version": "1.1.7",
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
       "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
@@ -3700,6 +3791,14 @@
         "ieee754": "^1.1.13"
         "ieee754": "^1.1.13"
       }
       }
     },
     },
+    "node_modules/buffer-crc32": {
+      "version": "0.2.13",
+      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+      "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/buffer-equal-constant-time": {
     "node_modules/buffer-equal-constant-time": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -4139,6 +4238,20 @@
       "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
       "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
       "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg=="
       "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg=="
     },
     },
+    "node_modules/compress-commons": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.1.tgz",
+      "integrity": "sha512-QLdDLCKNV2dtoTorqgxngQCMA+gWXkM/Nwu7FpeBhk/RdkzimqC3jueb/FDmaZeXh+uby1jkBqE3xArsLBE5wQ==",
+      "dependencies": {
+        "buffer-crc32": "^0.2.13",
+        "crc32-stream": "^4.0.2",
+        "normalize-path": "^3.0.0",
+        "readable-stream": "^3.6.0"
+      },
+      "engines": {
+        "node": ">= 10"
+      }
+    },
     "node_modules/concat-map": {
     "node_modules/concat-map": {
       "version": "0.0.1",
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4368,6 +4481,29 @@
         "node": ">=10"
         "node": ">=10"
       }
       }
     },
     },
+    "node_modules/crc-32": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+      "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+      "bin": {
+        "crc32": "bin/crc32.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/crc32-stream": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.2.tgz",
+      "integrity": "sha512-DxFZ/Hk473b/muq1VJ///PMNLj0ZMnzye9thBpmjpJKCc5eMgB95aK8zCGrGfQ90cWo561Te6HK9D+j4KPdM6w==",
+      "dependencies": {
+        "crc-32": "^1.2.0",
+        "readable-stream": "^3.4.0"
+      },
+      "engines": {
+        "node": ">= 10"
+      }
+    },
     "node_modules/create-require": {
     "node_modules/create-require": {
       "version": "1.1.1",
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
       "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -6090,8 +6226,7 @@
     "node_modules/graceful-fs": {
     "node_modules/graceful-fs": {
       "version": "4.2.9",
       "version": "4.2.9",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
-      "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==",
-      "dev": true
+      "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ=="
     },
     },
     "node_modules/har-schema": {
     "node_modules/har-schema": {
       "version": "2.0.0",
       "version": "2.0.0",
@@ -7618,6 +7753,44 @@
         "node": ">=6"
         "node": ">=6"
       }
       }
     },
     },
+    "node_modules/lazystream": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
+      "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
+      "dependencies": {
+        "readable-stream": "^2.0.5"
+      },
+      "engines": {
+        "node": ">= 0.6.3"
+      }
+    },
+    "node_modules/lazystream/node_modules/readable-stream": {
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+      "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/lazystream/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
+    "node_modules/lazystream/node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/leven": {
     "node_modules/leven": {
       "version": "3.1.0",
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
       "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -7706,6 +7879,11 @@
       "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
       "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
       "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
       "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
     },
     },
+    "node_modules/lodash.difference": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
+      "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="
+    },
     "node_modules/lodash.flatten": {
     "node_modules/lodash.flatten": {
       "version": "4.4.0",
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
       "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
@@ -7763,6 +7941,11 @@
       "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
       "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
       "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
       "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
     },
     },
+    "node_modules/lodash.union": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
+      "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="
+    },
     "node_modules/log-symbols": {
     "node_modules/log-symbols": {
       "version": "4.1.0",
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
       "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -8219,7 +8402,6 @@
       "version": "3.0.0",
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
       "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
       "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
       "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
-      "dev": true,
       "engines": {
       "engines": {
         "node": ">=0.10.0"
         "node": ">=0.10.0"
       }
       }
@@ -9032,6 +9214,33 @@
         "node": ">= 6"
         "node": ">= 6"
       }
       }
     },
     },
+    "node_modules/readdir-glob": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.2.tgz",
+      "integrity": "sha512-6RLVvwJtVwEDfPdn6X6Ille4/lxGl0ATOY4FN/B9nxQcgOazvvI0nodiD19ScKq0PvA/29VpaOQML36o5IzZWA==",
+      "dependencies": {
+        "minimatch": "^5.1.0"
+      }
+    },
+    "node_modules/readdir-glob/node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/readdir-glob/node_modules/minimatch": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz",
+      "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/readdirp": {
     "node_modules/readdirp": {
       "version": "3.6.0",
       "version": "3.6.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -11269,6 +11478,19 @@
       "engines": {
       "engines": {
         "node": ">=6"
         "node": ">=6"
       }
       }
+    },
+    "node_modules/zip-stream": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.0.tgz",
+      "integrity": "sha512-zshzwQW7gG7hjpBlgeQP9RuyPGNxvJdzR8SUM3QhxCnLjWN2E7j3dOvpeDcQoETfHx0urRS7EtmVToql7YpU4A==",
+      "dependencies": {
+        "archiver-utils": "^2.1.0",
+        "compress-commons": "^4.1.0",
+        "readable-stream": "^3.6.0"
+      },
+      "engines": {
+        "node": ">= 10"
+      }
     }
     }
   },
   },
   "dependencies": {
   "dependencies": {
@@ -12866,6 +13088,15 @@
         "@turf/helpers": "^6.5.0"
         "@turf/helpers": "^6.5.0"
       }
       }
     },
     },
+    "@types/archiver": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.1.tgz",
+      "integrity": "sha512-wKYZaSXaDvTZuInAWjCeGG7BEAgTWG2zZW0/f7IYFcoHB2X2d9lkVFnrOlXl3W6NrvO6Ml3FLLu8Uksyymcpnw==",
+      "dev": true,
+      "requires": {
+        "@types/glob": "*"
+      }
+    },
     "@types/babel__core": {
     "@types/babel__core": {
       "version": "7.1.18",
       "version": "7.1.18",
       "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.18.tgz",
       "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.18.tgz",
@@ -13043,6 +13274,16 @@
         "@types/node": "*"
         "@types/node": "*"
       }
       }
     },
     },
+    "@types/glob": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.0.0.tgz",
+      "integrity": "sha512-l6NQsDDyQUVeoTynNpC9uRvCUint/gSUXQA2euwmTuWGvPY5LSDUu6tkCtJB2SvGQlJQzLaKqcGZP4//7EDveA==",
+      "dev": true,
+      "requires": {
+        "@types/minimatch": "*",
+        "@types/node": "*"
+      }
+    },
     "@types/graceful-fs": {
     "@types/graceful-fs": {
       "version": "4.1.5",
       "version": "4.1.5",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
       "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
@@ -13142,6 +13383,12 @@
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
       "dev": true
       "dev": true
     },
     },
+    "@types/minimatch": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
+      "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==",
+      "dev": true
+    },
     "@types/multer": {
     "@types/multer": {
       "version": "1.4.7",
       "version": "1.4.7",
       "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
       "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
@@ -13709,6 +13956,66 @@
       "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
       "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
       "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
       "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
     },
     },
+    "archiver": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.1.tgz",
+      "integrity": "sha512-8KyabkmbYrH+9ibcTScQ1xCJC/CGcugdVIwB+53f5sZziXgwUh3iXlAlANMxcZyDEfTHMe6+Z5FofV8nopXP7w==",
+      "requires": {
+        "archiver-utils": "^2.1.0",
+        "async": "^3.2.3",
+        "buffer-crc32": "^0.2.1",
+        "readable-stream": "^3.6.0",
+        "readdir-glob": "^1.0.0",
+        "tar-stream": "^2.2.0",
+        "zip-stream": "^4.1.0"
+      }
+    },
+    "archiver-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz",
+      "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
+      "requires": {
+        "glob": "^7.1.4",
+        "graceful-fs": "^4.2.0",
+        "lazystream": "^1.0.0",
+        "lodash.defaults": "^4.2.0",
+        "lodash.difference": "^4.5.0",
+        "lodash.flatten": "^4.4.0",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.union": "^4.6.0",
+        "normalize-path": "^3.0.0",
+        "readable-stream": "^2.0.0"
+      },
+      "dependencies": {
+        "readable-stream": {
+          "version": "2.3.7",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+          "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+          "requires": {
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.3",
+            "isarray": "~1.0.0",
+            "process-nextick-args": "~2.0.0",
+            "safe-buffer": "~5.1.1",
+            "string_decoder": "~1.1.1",
+            "util-deprecate": "~1.0.1"
+          }
+        },
+        "safe-buffer": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+          "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+        },
+        "string_decoder": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+          "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+          "requires": {
+            "safe-buffer": "~5.1.0"
+          }
+        }
+      }
+    },
     "are-we-there-yet": {
     "are-we-there-yet": {
       "version": "1.1.7",
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
       "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
@@ -14051,6 +14358,11 @@
         "ieee754": "^1.1.13"
         "ieee754": "^1.1.13"
       }
       }
     },
     },
+    "buffer-crc32": {
+      "version": "0.2.13",
+      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+      "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="
+    },
     "buffer-equal-constant-time": {
     "buffer-equal-constant-time": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -14393,6 +14705,17 @@
       "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
       "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
       "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg=="
       "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg=="
     },
     },
+    "compress-commons": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.1.tgz",
+      "integrity": "sha512-QLdDLCKNV2dtoTorqgxngQCMA+gWXkM/Nwu7FpeBhk/RdkzimqC3jueb/FDmaZeXh+uby1jkBqE3xArsLBE5wQ==",
+      "requires": {
+        "buffer-crc32": "^0.2.13",
+        "crc32-stream": "^4.0.2",
+        "normalize-path": "^3.0.0",
+        "readable-stream": "^3.6.0"
+      }
+    },
     "concat-map": {
     "concat-map": {
       "version": "0.0.1",
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -14588,6 +14911,20 @@
         "yaml": "^1.10.0"
         "yaml": "^1.10.0"
       }
       }
     },
     },
+    "crc-32": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+      "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="
+    },
+    "crc32-stream": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.2.tgz",
+      "integrity": "sha512-DxFZ/Hk473b/muq1VJ///PMNLj0ZMnzye9thBpmjpJKCc5eMgB95aK8zCGrGfQ90cWo561Te6HK9D+j4KPdM6w==",
+      "requires": {
+        "crc-32": "^1.2.0",
+        "readable-stream": "^3.4.0"
+      }
+    },
     "create-require": {
     "create-require": {
       "version": "1.1.1",
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
       "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -15899,8 +16236,7 @@
     "graceful-fs": {
     "graceful-fs": {
       "version": "4.2.9",
       "version": "4.2.9",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
-      "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==",
-      "dev": true
+      "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ=="
     },
     },
     "har-schema": {
     "har-schema": {
       "version": "2.0.0",
       "version": "2.0.0",
@@ -17074,6 +17410,43 @@
       "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
       "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
       "dev": true
       "dev": true
     },
     },
+    "lazystream": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
+      "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
+      "requires": {
+        "readable-stream": "^2.0.5"
+      },
+      "dependencies": {
+        "readable-stream": {
+          "version": "2.3.7",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+          "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+          "requires": {
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.3",
+            "isarray": "~1.0.0",
+            "process-nextick-args": "~2.0.0",
+            "safe-buffer": "~5.1.1",
+            "string_decoder": "~1.1.1",
+            "util-deprecate": "~1.0.1"
+          }
+        },
+        "safe-buffer": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+          "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+        },
+        "string_decoder": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+          "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+          "requires": {
+            "safe-buffer": "~5.1.0"
+          }
+        }
+      }
+    },
     "leven": {
     "leven": {
       "version": "3.1.0",
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
       "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -17146,6 +17519,11 @@
       "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
       "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
       "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
       "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
     },
     },
+    "lodash.difference": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
+      "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="
+    },
     "lodash.flatten": {
     "lodash.flatten": {
       "version": "4.4.0",
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
       "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
@@ -17203,6 +17581,11 @@
       "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
       "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
       "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
       "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
     },
     },
+    "lodash.union": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
+      "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="
+    },
     "log-symbols": {
     "log-symbols": {
       "version": "4.1.0",
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
       "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -17557,8 +17940,7 @@
     "normalize-path": {
     "normalize-path": {
       "version": "3.0.0",
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
       "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
-      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
-      "dev": true
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
     },
     },
     "notepack.io": {
     "notepack.io": {
       "version": "2.2.0",
       "version": "2.2.0",
@@ -18152,6 +18534,32 @@
         "util-deprecate": "^1.0.1"
         "util-deprecate": "^1.0.1"
       }
       }
     },
     },
+    "readdir-glob": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.2.tgz",
+      "integrity": "sha512-6RLVvwJtVwEDfPdn6X6Ille4/lxGl0ATOY4FN/B9nxQcgOazvvI0nodiD19ScKq0PvA/29VpaOQML36o5IzZWA==",
+      "requires": {
+        "minimatch": "^5.1.0"
+      },
+      "dependencies": {
+        "brace-expansion": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+          "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+          "requires": {
+            "balanced-match": "^1.0.0"
+          }
+        },
+        "minimatch": {
+          "version": "5.1.0",
+          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz",
+          "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==",
+          "requires": {
+            "brace-expansion": "^2.0.1"
+          }
+        }
+      }
+    },
     "readdirp": {
     "readdirp": {
       "version": "3.6.0",
       "version": "3.6.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -19731,6 +20139,16 @@
       "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
       "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
       "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
       "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
       "devOptional": true
       "devOptional": true
+    },
+    "zip-stream": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.0.tgz",
+      "integrity": "sha512-zshzwQW7gG7hjpBlgeQP9RuyPGNxvJdzR8SUM3QhxCnLjWN2E7j3dOvpeDcQoETfHx0urRS7EtmVToql7YpU4A==",
+      "requires": {
+        "archiver-utils": "^2.1.0",
+        "compress-commons": "^4.1.0",
+        "readable-stream": "^3.6.0"
+      }
     }
     }
   }
   }
 }
 }

+ 2 - 0
server/package.json

@@ -42,6 +42,7 @@
     "@nestjs/typeorm": "^8.1.4",
     "@nestjs/typeorm": "^8.1.4",
     "@nestjs/websockets": "^8.4.7",
     "@nestjs/websockets": "^8.4.7",
     "@socket.io/redis-adapter": "^7.1.0",
     "@socket.io/redis-adapter": "^7.1.0",
+    "archiver": "^5.3.1",
     "axios": "^0.26.0",
     "axios": "^0.26.0",
     "bcrypt": "^5.0.1",
     "bcrypt": "^5.0.1",
     "bull": "^4.4.0",
     "bull": "^4.4.0",
@@ -77,6 +78,7 @@
     "@nestjs/schematics": "^8.0.11",
     "@nestjs/schematics": "^8.0.11",
     "@nestjs/testing": "^8.4.7",
     "@nestjs/testing": "^8.4.7",
     "@openapitools/openapi-generator-cli": "2.5.1",
     "@openapitools/openapi-generator-cli": "2.5.1",
+    "@types/archiver": "^5.3.1",
     "@types/bcrypt": "^5.0.0",
     "@types/bcrypt": "^5.0.0",
     "@types/bull": "^3.15.9",
     "@types/bull": "^3.15.9",
     "@types/cookie-parser": "^1.4.3",
     "@types/cookie-parser": "^1.4.3",

+ 5 - 2
web/package-lock.json

@@ -52,6 +52,7 @@
 				"svelte": "^3.44.0",
 				"svelte": "^3.44.0",
 				"svelte-check": "^2.7.1",
 				"svelte-check": "^2.7.1",
 				"svelte-jester": "^2.3.2",
 				"svelte-jester": "^2.3.2",
+				"svelte-keydown": "^0.5.0",
 				"svelte-preprocess": "^4.10.7",
 				"svelte-preprocess": "^4.10.7",
 				"tailwindcss": "^3.0.24",
 				"tailwindcss": "^3.0.24",
 				"tslib": "^2.3.1",
 				"tslib": "^2.3.1",
@@ -10344,7 +10345,8 @@
 		"node_modules/svelte-keydown": {
 		"node_modules/svelte-keydown": {
 			"version": "0.5.0",
 			"version": "0.5.0",
 			"resolved": "https://registry.npmjs.org/svelte-keydown/-/svelte-keydown-0.5.0.tgz",
 			"resolved": "https://registry.npmjs.org/svelte-keydown/-/svelte-keydown-0.5.0.tgz",
-			"integrity": "sha512-DgY6AYlKbBocSvjC3kUeNPcStJQOTOCxAGG9ymVHzJdsQ1hRJuB8pcnB4UFH8uH3bAPdYyXXa3LwenLDL41eqQ=="
+			"integrity": "sha512-DgY6AYlKbBocSvjC3kUeNPcStJQOTOCxAGG9ymVHzJdsQ1hRJuB8pcnB4UFH8uH3bAPdYyXXa3LwenLDL41eqQ==",
+			"dev": true
 		},
 		},
 		"node_modules/svelte-material-icons": {
 		"node_modules/svelte-material-icons": {
 			"version": "2.0.4",
 			"version": "2.0.4",
@@ -18639,7 +18641,8 @@
 		"svelte-keydown": {
 		"svelte-keydown": {
 			"version": "0.5.0",
 			"version": "0.5.0",
 			"resolved": "https://registry.npmjs.org/svelte-keydown/-/svelte-keydown-0.5.0.tgz",
 			"resolved": "https://registry.npmjs.org/svelte-keydown/-/svelte-keydown-0.5.0.tgz",
-			"integrity": "sha512-DgY6AYlKbBocSvjC3kUeNPcStJQOTOCxAGG9ymVHzJdsQ1hRJuB8pcnB4UFH8uH3bAPdYyXXa3LwenLDL41eqQ=="
+			"integrity": "sha512-DgY6AYlKbBocSvjC3kUeNPcStJQOTOCxAGG9ymVHzJdsQ1hRJuB8pcnB4UFH8uH3bAPdYyXXa3LwenLDL41eqQ==",
+			"dev": true
 		},
 		},
 		"svelte-material-icons": {
 		"svelte-material-icons": {
 			"version": "2.0.4",
 			"version": "2.0.4",

+ 1 - 0
web/package.json

@@ -49,6 +49,7 @@
 		"svelte": "^3.44.0",
 		"svelte": "^3.44.0",
 		"svelte-check": "^2.7.1",
 		"svelte-check": "^2.7.1",
 		"svelte-jester": "^2.3.2",
 		"svelte-jester": "^2.3.2",
+		"svelte-keydown": "^0.5.0",
 		"svelte-preprocess": "^4.10.7",
 		"svelte-preprocess": "^4.10.7",
 		"tailwindcss": "^3.0.24",
 		"tailwindcss": "^3.0.24",
 		"tslib": "^2.3.1",
 		"tslib": "^2.3.1",

+ 67 - 0
web/src/api/open-api/api.ts

@@ -1749,6 +1749,43 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
 
 
 
 
     
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {string} albumId 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        downloadArchive: async (albumId: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'albumId' is not null or undefined
+            assertParamExists('downloadArchive', 'albumId', albumId)
+            const localVarPath = `/album/{albumId}/download`
+                .replace(`{${"albumId"}}`, encodeURIComponent(String(albumId)));
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -2050,6 +2087,16 @@ export const AlbumApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAlbum(albumId, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAlbum(albumId, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {string} albumId 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async downloadArchive(albumId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(albumId, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * 
          * 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
@@ -2161,6 +2208,15 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
         deleteAlbum(albumId: string, options?: any): AxiosPromise<void> {
         deleteAlbum(albumId: string, options?: any): AxiosPromise<void> {
             return localVarFp.deleteAlbum(albumId, options).then((request) => request(axios, basePath));
             return localVarFp.deleteAlbum(albumId, options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {string} albumId 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        downloadArchive(albumId: string, options?: any): AxiosPromise<object> {
+            return localVarFp.downloadArchive(albumId, options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * 
          * 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
@@ -2274,6 +2330,17 @@ export class AlbumApi extends BaseAPI {
         return AlbumApiFp(this.configuration).deleteAlbum(albumId, options).then((request) => request(this.axios, this.basePath));
         return AlbumApiFp(this.configuration).deleteAlbum(albumId, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
+    /**
+     * 
+     * @param {string} albumId 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AlbumApi
+     */
+    public downloadArchive(albumId: string, options?: AxiosRequestConfig) {
+        return AlbumApiFp(this.configuration).downloadArchive(albumId, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * 
      * 
      * @param {*} [options] Override http request option.
      * @param {*} [options] Override http request option.

+ 55 - 0
web/src/lib/components/album-page/album-viewer.svelte

@@ -16,6 +16,8 @@
 	import CircleIconButton from '../shared-components/circle-icon-button.svelte';
 	import CircleIconButton from '../shared-components/circle-icon-button.svelte';
 	import Close from 'svelte-material-icons/Close.svelte';
 	import Close from 'svelte-material-icons/Close.svelte';
 	import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
 	import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
+	import FolderDownloadOutline from 'svelte-material-icons/FolderDownloadOutline.svelte';
+	import { downloadAssets } from '$lib/stores/download';
 	import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
 	import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
 	import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
 	import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
 	import MenuOption from '../shared-components/context-menu/menu-option.svelte';
 	import MenuOption from '../shared-components/context-menu/menu-option.svelte';
@@ -309,6 +311,53 @@
 		}
 		}
 	};
 	};
 
 
+	const downloadAlbum = async () => {
+		try {
+			const fileName = album.albumName + '.zip';
+
+			// If assets is already download -> return;
+			if ($downloadAssets[fileName]) {
+				return;
+			}
+
+			$downloadAssets[fileName] = 0;
+
+			const { data, status } = await api.albumApi.downloadArchive(album.id, {
+				responseType: 'blob'
+			});
+
+			if (!(data instanceof Blob)) {
+				return;
+			}
+
+			if (status === 200) {
+				const fileUrl = URL.createObjectURL(data);
+				const anchor = document.createElement('a');
+				anchor.href = fileUrl;
+				anchor.download = fileName;
+
+				document.body.appendChild(anchor);
+				anchor.click();
+				document.body.removeChild(anchor);
+
+				URL.revokeObjectURL(fileUrl);
+
+				// Remove item from download list
+				setTimeout(() => {
+					const copy = $downloadAssets;
+					delete copy[fileName];
+					$downloadAssets = copy;
+				}, 2000);
+			}
+		} catch (e) {
+			console.error('Error downloading file ', e);
+			notificationController.show({
+				type: NotificationType.Error,
+				message: 'Error downloading file, check console for more details.'
+			});
+		}
+	};
+
 	const showAlbumOptionsMenu = (event: CustomEvent) => {
 	const showAlbumOptionsMenu = (event: CustomEvent) => {
 		contextMenuPosition = {
 		contextMenuPosition = {
 			x: event.detail.mouseEvent.x,
 			x: event.detail.mouseEvent.x,
@@ -382,6 +431,12 @@
 						<CircleIconButton title="Remove album" on:click={removeAlbum} logo={DeleteOutline} />
 						<CircleIconButton title="Remove album" on:click={removeAlbum} logo={DeleteOutline} />
 					{/if}
 					{/if}
 
 
+					<CircleIconButton
+						title="Download"
+						on:click={() => downloadAlbum()}
+						logo={FolderDownloadOutline}
+					/>
+
 					<CircleIconButton
 					<CircleIconButton
 						title="Album options"
 						title="Album options"
 						on:click={(event) => showAlbumOptionsMenu(event)}
 						on:click={(event) => showAlbumOptionsMenu(event)}

部分文件因为文件数量过多而无法显示