Skip to content

Commit f57b2e4

Browse files
authored
H5P import (#1874)
* fix(thumbnail-upload): upload the thumbnails in parallel * refactor(h5p-service): refactor and simplify the HTML and H5P upload and item creation * feat(graasp-import): import the H5P files
1 parent 72a2aba commit f57b2e4

10 files changed

Lines changed: 92 additions & 74 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ const basePlugin: FastifyPluginAsyncTypebox<GraaspPluginFileOptions> = async (fa
138138
// othwerwise, we save it as a generic file
139139
let item: ItemRaw;
140140
if (getFileExtension(filename) === H5P_FILE_EXTENSION) {
141-
item = await h5pService.createH5PItem(
141+
item = await h5pService.uploadFileAndCreateItem(
142142
tx,
143143
member,
144144
filename,

src/services/item/plugins/html/h5p/h5p.controller.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ import { itemsRawTable } from '../../../../../drizzle/schema';
1919
import { assertIsDefined } from '../../../../../utils/assertions';
2020
import { H5P_LOCAL_CONFIG, H5P_PATH_PREFIX, TMP_FOLDER } from '../../../../../utils/config';
2121
import { H5PItem } from '../../../discrimination';
22-
import { ItemService } from '../../../item.service';
2322
import { HtmlImportError } from '../errors';
2423
import { H5P_FILE_DOT_EXTENSION } from './constants';
2524
import { H5PInvalidManifestError } from './errors';
25+
import { H5PService } from './h5p.service';
2626
import { H5P_PACKAGES } from './test/fixtures';
2727
import { expectH5PFiles, injectH5PImport } from './test/helpers';
2828

@@ -302,9 +302,9 @@ describe('Service plugin', () => {
302302
});
303303
it('returns error and deletes extracted files on item creation failure', async () => {
304304
const { storageRootPath } = H5P_LOCAL_CONFIG.local;
305-
const createItem = jest.spyOn(resolveDependency(ItemService), 'post');
306-
createItem.mockImplementationOnce(() => {
307-
throw new Error('mock error on create item');
305+
const uploadPackage = jest.spyOn(resolveDependency(H5PService), 'uploadPackage');
306+
uploadPackage.mockImplementationOnce(() => {
307+
throw new Error('mock error on HTML package upload');
308308
});
309309

310310
const {

src/services/item/plugins/html/h5p/h5p.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ const plugin: FastifyPluginAsyncTypebox<H5PPluginOptions> = async (fastify) => {
122122

123123
const { filename, file: stream } = h5pFile;
124124

125-
return await h5pService.createH5PItem(
125+
return await h5pService.uploadFileAndCreateItem(
126126
tx,
127127
member,
128128
filename,

src/services/item/plugins/html/h5p/h5p.service.ts

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,25 @@ export class H5PService extends HtmlService {
120120
});
121121
}
122122

123-
async createH5PItem(
123+
/**
124+
* Upload an H5P file.
125+
* @returns The H5P file metadata.
126+
*/
127+
async uploadH5PFile(
128+
dbConnection: DBConnection,
129+
actor: MinimalMember,
130+
filename: string,
131+
stream: Readable,
132+
log?: FastifyBaseLogger,
133+
) {
134+
return await super.uploadFile(dbConnection, actor, filename, stream, log);
135+
}
136+
137+
/**
138+
* Upload the H5P file and create an item associated with it.
139+
* @returns The newly created item.
140+
*/
141+
async uploadFileAndCreateItem(
124142
dbConnection: DBConnection,
125143
actor: MinimalMember,
126144
filename: string,
@@ -129,48 +147,37 @@ export class H5PService extends HtmlService {
129147
previousItemId?: ItemRaw['id'],
130148
log?: FastifyBaseLogger,
131149
): Promise<H5PItem> {
132-
const item = await super.createItem(
150+
const { remoteRootPath, baseName, contentId } = await this.uploadH5PFile(
133151
dbConnection,
134152
actor,
135153
filename,
136154
stream,
137-
this.createItemForH5PFile,
138-
parentId,
139-
previousItemId,
140155
log,
141156
);
142-
if (!isItemType(item, ItemType.H5P)) {
143-
throw new Error('Expected item to be H5P but it was something else');
144-
}
145-
return item;
146-
}
147157

148-
/**
149-
* Creates a Graasp item for the uploaded H5P package
150-
* @param filename Name of the original H5P file WITHOUT EXTENSION
151-
* @param contentId Storage ID of the remote content
152-
* @param remoteRootPath Root path on the remote storage
153-
* @param member Actor member
154-
* @param parentId Optional parent id of the newly created item
155-
* !! it's important to keep this syntax because of the reference to this
156-
*/
157-
private createItemForH5PFile = async (
158-
dbConnection: DBConnection,
159-
member: MinimalMember,
160-
filename: string,
161-
contentId: string,
162-
parentId?: string,
163-
previousItemId?: string,
164-
): Promise<ItemRaw> => {
165158
const metadata = {
166-
name: this.buildH5PPath('', filename),
159+
name: this.buildH5PPath('', baseName),
167160
type: ItemType.H5P,
168-
extra: this.buildH5PExtra(contentId, filename),
161+
extra: this.buildH5PExtra(contentId, baseName),
169162
};
170-
return this.itemService.post(dbConnection, member, {
171-
item: metadata,
172-
parentId,
173-
previousItemId,
174-
});
175-
};
163+
164+
try {
165+
const item = await this.itemService.post(dbConnection, actor, {
166+
item: metadata,
167+
parentId,
168+
previousItemId,
169+
});
170+
171+
if (!isItemType(item, ItemType.H5P)) {
172+
throw new Error('Expected item to be H5P but it was something else');
173+
}
174+
return item;
175+
} catch (error) {
176+
// delete storage folder of this html package if upload or creation fails
177+
await this.fileService.deleteFolder(remoteRootPath);
178+
// rethrow above
179+
log?.error(error);
180+
throw error;
181+
}
182+
}
176183
}

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

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export abstract class HtmlService {
112112
* are uploaded in parallel as soon as possible). Results aggregation can however
113113
* await in parallel (note: await in a map fn does not block the map iteration).
114114
*/
115-
async upload(
115+
async uploadPackage(
116116
member: MinimalMember,
117117
folder: string,
118118
uploadPath: string,
@@ -128,7 +128,7 @@ export abstract class HtmlService {
128128

129129
if (stats.isDirectory()) {
130130
// recursively upload child folder
131-
return await this.upload(member, childPath, childUploadPath, log);
131+
return await this.uploadPackage(member, childPath, childUploadPath, log);
132132
} else {
133133
// ignore this file if extension is not allowed
134134
const ext = path.extname(childPath);
@@ -154,24 +154,16 @@ export abstract class HtmlService {
154154
return (await Promise.all(uploads)).flat();
155155
}
156156

157-
async createItem(
157+
/**
158+
* Upload the HTML file and get the metadata of the file.
159+
*/
160+
async uploadFile(
158161
dbConnection: DBConnection,
159162
actor: MinimalMember,
160163
filename: string,
161164
stream: Readable,
162-
onComplete: (
163-
dbConnection: DBConnection,
164-
actor: MinimalMember,
165-
baseName: string,
166-
contentId: string,
167-
parentId?: ItemRaw['id'],
168-
previousItemId?: ItemRaw['id'],
169-
log?: FastifyBaseLogger,
170-
) => Promise<ItemRaw>,
171-
parentId?: ItemRaw['id'],
172-
previousItemId?: ItemRaw['id'],
173165
log?: FastifyBaseLogger,
174-
): Promise<ItemRaw> {
166+
): Promise<{ remoteRootPath: string; baseName: string; contentId: string }> {
175167
// check member storage limit
176168
await this.storageService.checkRemainingStorage(dbConnection, actor);
177169
const contentId = v4();
@@ -197,17 +189,8 @@ export abstract class HtmlService {
197189
// try-catch block for remote storage cleanup
198190
try {
199191
// upload whole folder to public storage
200-
await this.upload(actor, targetFolder, remoteRootPath, log);
201-
const item = await onComplete(
202-
dbConnection,
203-
actor,
204-
baseName,
205-
contentId,
206-
parentId,
207-
previousItemId,
208-
log,
209-
);
210-
return item;
192+
await this.uploadPackage(actor, targetFolder, remoteRootPath, log);
193+
return { remoteRootPath, baseName, contentId };
211194
} catch (error) {
212195
// delete storage folder of this html package if upload or creation fails
213196
await this.fileService.deleteFolder(remoteRootPath);

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,20 @@ export class ImportExportService {
301301
thumbnail = createReadStream(itemThumbnailPath);
302302
}
303303

304+
// Handle the H5P file upload
305+
if (item.type === ItemType.H5P) {
306+
const pathToGraaspFile = path.join(folderPath, item.id);
307+
const h5pFileStream = createReadStream(pathToGraaspFile);
308+
const h5pFileInfo = await this.h5pService.uploadH5PFile(
309+
dbConnection,
310+
actor,
311+
item.id,
312+
h5pFileStream,
313+
);
314+
315+
extra = h5pFileInfo;
316+
}
317+
304318
// Handle the file upload
305319
if (item.type === ItemType.FILE) {
306320
if (!item.mimetype) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ The `description` field for all items is sanitized before item creation.
3535
- `FOLDER` - The children are recursively imported, if present.
3636
- `LINK` - Not currently supported.
3737
- `FILE` - The file is imported with the same procedure as in the raw file import. The item is then created with the `extra` field based on the properties extracted from the file.
38+
- `H5P` - The file is imported with the same procedure as the normal H5P upload. The `extra` field in the item is based on the properties extracted from the H5P file.
3839
- `SHORTCUT` - Not currently supported.
39-
- `H5P` - Not currently supported.
4040
- `ETHERPAD` - Not currently supported.
4141

4242
# Raw ZIP Import
Binary file not shown.

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ describe('ZIP routes tests', () => {
425425
const pdfName = 'output.pdf';
426426
const pdfSize = 10024;
427427
const pdfContent = 'This is a real document.';
428+
const h5pFilename = 'test-05-collage-54.h5p';
428429

429430
// Create the actor and the parent item
430431
const {
@@ -457,12 +458,14 @@ describe('ZIP routes tests', () => {
457458
const folderItem = itemsInDB[0];
458459
const documentItem = itemsInDB[1];
459460
const fileItem = itemsInDB[2];
461+
const h5pItem = itemsInDB[3];
460462

461463
// Check that all the items have been imported and that their order is correct
462-
expect(itemsInDB.length).toEqual(3);
464+
expect(itemsInDB.length).toEqual(4);
463465
expect(folderItem.type).toEqual(ItemType.FOLDER);
464466
expect(documentItem.type).toEqual(ItemType.DOCUMENT);
465467
expect(fileItem.type).toEqual(ItemType.FILE);
468+
expect(h5pItem.type).toEqual(ItemType.H5P);
466469
expect(Number(itemsInDB[1].order)).toBeLessThan(Number(itemsInDB[2].order));
467470

468471
// Check that all the item properties have been assigned for the document type
@@ -489,6 +492,8 @@ describe('ZIP routes tests', () => {
489492
});
490493

491494
expect(thumbnailURLResponse.statusCode).toBe(StatusCodes.OK);
495+
496+
expect(h5pItem.name).toEqual(h5pFilename);
492497
});
493498
});
494499
});

src/services/thumbnail/thumbnail.service.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import sharp from 'sharp';
33
import { Readable } from 'stream';
44
import { injectable } from 'tsyringe';
55

6+
import { BaseLogger } from '../../logger';
67
import { AuthenticatedUser } from '../../types';
78
import FileService from '../file/file.service';
89
import { THUMBNAIL_FORMAT, THUMBNAIL_MIMETYPE, ThumbnailSizeFormat } from './constants';
@@ -13,10 +14,12 @@ export const ITEM_THUMBNAIL_PREFIX = 'thumbnails';
1314
@injectable()
1415
export class ThumbnailService {
1516
private readonly fileService: FileService;
17+
private readonly logger: BaseLogger;
1618
private _prefix: string = ITEM_THUMBNAIL_PREFIX;
1719

18-
constructor(fileService: FileService) {
20+
constructor(fileService: FileService, log: BaseLogger) {
1921
this.fileService = fileService;
22+
this.logger = log;
2023
}
2124

2225
public set prefix(prefix: string) {
@@ -33,7 +36,7 @@ export class ThumbnailService {
3336

3437
async upload(authenticatedUser: AuthenticatedUser, id: string, file: Readable) {
3538
// upload all thumbnails in parallel
36-
await Promise.all(
39+
const filesToUpload = await Promise.all(
3740
Object.entries(ThumbnailSizeFormat).map(async ([sizeName, width]) => {
3841
// create thumbnail from image stream
3942
const pipeline = sharp().resize({ width }).toFormat(THUMBNAIL_FORMAT);
@@ -47,14 +50,20 @@ export class ThumbnailService {
4750
.on('error', reject),
4851
);
4952

50-
// upload file
51-
await this.fileService.upload(authenticatedUser, {
53+
return {
5254
file: pipeline,
5355
filepath: this.buildFilePath(id, sizeName),
5456
mimetype: THUMBNAIL_MIMETYPE,
55-
});
57+
};
5658
}),
5759
);
60+
61+
// upload the thumbnails
62+
try {
63+
await this.fileService.uploadMany(authenticatedUser, filesToUpload);
64+
} catch (_err) {
65+
this.logger.debug(`Could not upload the ${id} item thumbnails`);
66+
}
5867
}
5968

6069
async getUrl({ id, size }: { size: string; id: string }) {

0 commit comments

Comments
 (0)