Skip to content

Commit 0c0eb5a

Browse files
authored
feat(graasp-import): export and import the item thumbnails (#1868)
* feat(graasp-import): export and import the item thumbnails * fix(graasp-import): pull request review fixes * fix(graasp-import): correct the test fixture * fix(graasp-import): correct test * refactor(graasp-import): refactor the thumbnail function * fix(graasp-import): correct the graasp import test * fix(s3-file): check the fetch response before starting the file stream
1 parent 9c48b77 commit 0c0eb5a

13 files changed

Lines changed: 98 additions & 39 deletions

File tree

src/services/file/file.service.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ describe('FileService', () => {
145145
const downloadMock = jest
146146
.spyOn(s3Repository, 'getFile')
147147
.mockImplementation(async () => returnValue);
148-
expect(await s3FileService.getFile(member, downloadPayload)).toBeTruthy();
148+
expect(await s3FileService.getFile(downloadPayload)).toBeTruthy();
149149
expect(downloadMock).toHaveBeenCalled();
150150
});
151151

@@ -154,7 +154,7 @@ describe('FileService', () => {
154154
const downloadMock = jest
155155
.spyOn(s3Repository, 'getFile')
156156
.mockImplementation(async () => returnValue);
157-
expect(await s3FileService.getFile(undefined, downloadPayload)).toBeTruthy();
157+
expect(await s3FileService.getFile(downloadPayload)).toBeTruthy();
158158
expect(downloadMock).toHaveBeenCalled();
159159
});
160160
});

src/services/file/file.service.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,10 @@ class FileService {
104104
return file;
105105
}
106106

107-
async getFile(_actor: MaybeUser, data: { id?: string; path?: string }): Promise<Readable> {
108-
const { id, path: filepath } = data;
109-
if (!filepath || !id) {
110-
throw new DownloadFileInvalidParameterError();
111-
}
112-
107+
async getFile({ id, path }: { id: string; path: string }): Promise<Readable> {
113108
return this.repository.getFile(
114109
{
115-
filepath,
110+
filepath: path,
116111
id,
117112
},
118113
this.logger,

src/services/file/repositories/s3.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ export class S3FileRepository implements FileRepository {
188188
// return readstream of the file saved at given filepath
189189
// fetch and save file in temporary path
190190
const res = await fetch(url);
191+
192+
if (!res.ok) {
193+
throw new S3FileNotFound();
194+
}
195+
191196
const fileStream = fs.createWriteStream(filepath);
192197
await new Promise<void>((resolve, reject) => {
193198
res.body.pipe(fileStream);

src/services/item/plugins/file/itemFile.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ class FileItemService extends ItemService {
208208
item,
209209
);
210210
const extraData = item.extra[ItemType.FILE] as FileItemProperties;
211-
const result = await this.fileService.getFile(actor, {
211+
const result = await this.fileService.getFile({
212212
id: itemId,
213213
...extraData,
214214
});

src/services/item/plugins/importExport/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export const ROOT_PATH = './';
1010

1111
export const GRAASP_MANIFEST_FILENAME = 'graasp-manifest.json';
1212

13+
export const GRAASP_ARCHIVE_THUMBNAIL_SUFFIX = '-thumbnail';
14+
1315
export const DESCRIPTION_EXTENSION = '.description.html';
1416

1517
export const GRAASP_DOCUMENT_EXTENSION = '.graasp';

src/services/item/plugins/importExport/service.ts

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs, { existsSync } from 'fs';
2+
import { createReadStream, exists } from 'fs-extra';
23
import { readFile } from 'fs/promises';
34
import mimetics from 'mimetics';
45
import fetch from 'node-fetch';
@@ -14,21 +15,22 @@ import {
1415
ItemSettings,
1516
ItemType,
1617
ItemTypeUnion,
18+
ThumbnailSize,
1719
getMimetype,
1820
} from '@graasp/sdk';
1921

2022
import { type DBConnection } from '../../../../drizzle/db';
2123
import { type ItemRaw } from '../../../../drizzle/types';
2224
import { BaseLogger } from '../../../../logger';
2325
import { MaybeUser, MinimalMember } from '../../../../types';
24-
import FileService from '../../../file/file.service';
2526
import { UploadEmptyFileError } from '../../../file/utils/errors';
2627
import { BasicItemService } from '../../basic.service';
2728
import { isItemType } from '../../discrimination';
2829
import { ItemService } from '../../item.service';
2930
import { EtherpadItemService } from '../etherpad/etherpad.service';
3031
import FileItemService from '../file/itemFile.service';
3132
import { H5PService } from '../html/h5p/h5p.service';
33+
import { ItemThumbnailService } from '../thumbnail/itemThumbnail.service';
3234
import {
3335
DESCRIPTION_EXTENSION,
3436
GRAASP_DOCUMENT_EXTENSION,
@@ -39,7 +41,7 @@ import {
3941
URL_PREFIX,
4042
} from './constants';
4143
import { GraaspExportInvalidFileError, UnexpectedExportError } from './errors';
42-
import { buildTextContent, getFilenameFromItem } from './utils';
44+
import { buildTextContent, generateThumbnailFilename, getFilenameFromItem } from './utils';
4345

4446
/**
4547
* Defines the properties of an individual item in the graasp export format.
@@ -60,28 +62,28 @@ export type GraaspExportItem = {
6062

6163
@singleton()
6264
export class ImportExportService {
63-
private readonly fileService: FileService;
6465
private readonly fileItemService: FileItemService;
6566
private readonly h5pService: H5PService;
6667
private readonly itemService: ItemService;
6768
private readonly basicItemService: BasicItemService;
6869
private readonly etherpadService: EtherpadItemService;
70+
private readonly itemThumbnailService: ItemThumbnailService;
6971
private readonly log: BaseLogger;
7072

7173
constructor(
72-
fileService: FileService,
7374
fileItemService: FileItemService,
7475
itemService: ItemService,
7576
h5pService: H5PService,
7677
etherpadService: EtherpadItemService,
78+
itemThumbnailService: ItemThumbnailService,
7779
basicItemService: BasicItemService,
7880
log: BaseLogger,
7981
) {
80-
this.fileService = fileService;
8182
this.fileItemService = fileItemService;
8283
this.h5pService = h5pService;
8384
this.itemService = itemService;
8485
this.etherpadService = etherpadService;
86+
this.itemThumbnailService = itemThumbnailService;
8587
this.basicItemService = basicItemService;
8688
this.log = log;
8789
}
@@ -292,6 +294,13 @@ export class ImportExportService {
292294
extra = { [ItemType.DOCUMENT]: { content: sanitizedContent } };
293295
}
294296

297+
// Find and upload the thumbnail
298+
let thumbnail: Readable | undefined = undefined;
299+
const itemThumbnailPath = path.join(folderPath, generateThumbnailFilename(item.id));
300+
if (await exists(itemThumbnailPath)) {
301+
thumbnail = createReadStream(itemThumbnailPath);
302+
}
303+
295304
// Handle the file upload
296305
if (item.type === ItemType.FILE) {
297306
if (!item.mimetype) {
@@ -312,7 +321,7 @@ export class ImportExportService {
312321

313322
const augmentedItem = { ...item, description: sanitizedDescription, extra };
314323

315-
return { item: augmentedItem, thumbnail: undefined };
324+
return { item: augmentedItem, thumbnail };
316325
}),
317326
);
318327

@@ -411,6 +420,15 @@ export class ImportExportService {
411420
const exportItemId = v4();
412421
const itemPath = path.join(path.dirname('./'), exportItemId);
413422

423+
// add the thumbnail to export, if present
424+
const thumbnailFilename = await this.getAndWriteThumbnail(
425+
dbConnection,
426+
actor,
427+
item.id,
428+
exportItemId,
429+
archive,
430+
);
431+
414432
// TODO EXPORT treat the shortcut items correctly
415433
// ignore the shortcuts for now
416434
if (isItemType(item, ItemType.SHORTCUT)) {
@@ -436,6 +454,7 @@ export class ImportExportService {
436454
type: item.type,
437455
settings: item.settings,
438456
extra: item.extra,
457+
thumbnailFilename,
439458
children: childrenManifest,
440459
});
441460
return itemManifest;
@@ -451,12 +470,39 @@ export class ImportExportService {
451470
type: item.type,
452471
settings: item.settings,
453472
extra: item.extra,
473+
thumbnailFilename,
454474
mimetype,
455475
});
456476
archive.addReadStream(stream, itemPath);
457477
return itemManifest;
458478
}
459479

480+
/**
481+
* Try and get the item thumbnail and write it to the zip archive.
482+
* @returns Thumbnail filename, undefined if the thumbnail was not found.
483+
*/
484+
private async getAndWriteThumbnail(
485+
dbConnection: DBConnection,
486+
actor: MaybeUser,
487+
itemId: string,
488+
exportItemId: string,
489+
archive: ZipFile,
490+
) {
491+
const filename = generateThumbnailFilename(exportItemId);
492+
const itemThumbnailPath = path.join(path.dirname('./'), filename);
493+
try {
494+
const thumbnailStream = await this.itemThumbnailService.getFile(dbConnection, actor, {
495+
size: ThumbnailSize.Original,
496+
itemId,
497+
});
498+
499+
archive.addReadStream(thumbnailStream, itemThumbnailPath);
500+
return filename;
501+
} catch (_err) {
502+
this.log.debug(`Thumbnail not found for item ${itemId}`);
503+
}
504+
}
505+
460506
/**
461507
* Export the items recursively
462508
* @param item The root item

src/services/item/plugins/importExport/specifications.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@ The export operation produces a single zip file. This file is a simple zip file
1313
- description (item description in HTML)
1414
- settings (item settings)
1515
- extra (item extras)
16-
- thumbnailFilename (item thumbnail in the original size, if present) - COMING SOON
16+
- thumbnailFilename (item thumbnail in the original size, if present)
1717
- children (item children, in case of a folder item)
1818
- mimetype (item file mimetype, in case there's a file attached to the item)
1919
```
2020

21+
### Files and thumbnails
22+
23+
Files are stored in the top level of the zip. The filename is the same as the item ID in the manifest file. The thumbnails are also stored on the top-level and are named using the `{ID}-thumbnail` convention.
24+
2125
## Import
2226

2327
Upon the import, the uploaded ZIP file is scanned for the presence of a `graasp-manifest.json` file. If the file is present, it is scanned and then the items and their children are recursively imported, respecting the item order in the manifest file.
Binary file not shown.

src/services/item/plugins/importExport/test/index.test.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import waitForExpect from 'wait-for-expect';
99

1010
import { FastifyInstance } from 'fastify';
1111

12-
import { FileItemProperties, HttpMethod, ItemType, MimeTypes } from '@graasp/sdk';
12+
import { FileItemProperties, HttpMethod, ItemType, MimeTypes, ThumbnailSize } from '@graasp/sdk';
1313

1414
import build, {
1515
clearDatabase,
@@ -21,6 +21,7 @@ import { db } from '../../../../../drizzle/db';
2121
import { isDescendantOrSelf, isDirectChild } from '../../../../../drizzle/operations';
2222
import { itemsRawTable } from '../../../../../drizzle/schema';
2323
import { assertIsDefined } from '../../../../../utils/assertions';
24+
import { ITEMS_ROUTE_PREFIX, THUMBNAILS_ROUTE_PREFIX } from '../../../../../utils/config';
2425
import { LocalFileRepository } from '../../../../file/repositories/local';
2526
import { GRAASP_MANIFEST_FILENAME } from '../constants';
2627
import { GraaspExportItem } from '../service';
@@ -451,7 +452,7 @@ describe('ZIP routes tests', () => {
451452
isDescendantOrSelf(itemsRawTable.path, parentItem.path),
452453
ne(itemsRawTable.id, parentItem.id),
453454
),
454-
orderBy: asc(itemsRawTable.order),
455+
orderBy: [asc(itemsRawTable.order), asc(itemsRawTable.path)],
455456
});
456457
const folderItem = itemsInDB[0];
457458
const documentItem = itemsInDB[1];
@@ -472,17 +473,22 @@ describe('ZIP routes tests', () => {
472473
expect(documentItem.extra[ItemType.DOCUMENT].content).toEqual(documentContent);
473474

474475
// Check that all the item properties have been assigned for the file type
475-
let fileItemProperties: FileItemProperties;
476-
if (fileItem.extra[ItemType.FILE]) {
477-
fileItemProperties = fileItem.extra[ItemType.FILE] as FileItemProperties;
478-
} else {
479-
fileItemProperties = fileItem.extra[ItemType.LOCAL_FILE] as FileItemProperties;
480-
}
476+
const fileItemProperties = fileItem.extra[ItemType.FILE] as FileItemProperties;
481477
expect(fileItemProperties).toBeDefined();
482478
expect(fileItemProperties.name).toEqual(pdfName);
483479
expect(fileItemProperties.path).toBeDefined();
484480
expect(fileItemProperties.mimetype).toEqual(MimeTypes.PDF);
485481
expect(fileItemProperties.content).toEqual(pdfContent);
482+
483+
// Check the the file item thumbnail has been imported correctly
484+
expect(fileItem.settings.hasThumbnail).toBeTruthy();
485+
486+
const thumbnailURLResponse = await app.inject({
487+
method: HttpMethod.Get,
488+
url: `${ITEMS_ROUTE_PREFIX}/${fileItem.id}${THUMBNAILS_ROUTE_PREFIX}/${ThumbnailSize.Original}`,
489+
});
490+
491+
expect(thumbnailURLResponse.statusCode).toBe(StatusCodes.OK);
486492
});
487493
});
488494
});

src/services/item/plugins/importExport/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,7 @@ export const getFilenameFromItem = (item: ItemRaw): string => {
9999
return item.name;
100100
}
101101
};
102+
103+
export const generateThumbnailFilename = (id: string): string => {
104+
return `${id}-thumbnail`;
105+
};

0 commit comments

Comments
 (0)