Skip to content

Commit 37eab43

Browse files
authored
feat(graasp-export): export in graasp format (#1796)
* feat(graasp-export): export in graasp format * feat(graasp-export): add comments
1 parent a3aa31d commit 37eab43

6 files changed

Lines changed: 301 additions & 50 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export const TMP_IMPORT_ZIP_FOLDER_PATH = path.join(TMP_FOLDER, 'zip-import');
88

99
export const ROOT_PATH = './';
1010

11+
export const GRAASP_MANIFEST_FILENAME = 'graasp-manifest.json';
12+
1113
export const DESCRIPTION_EXTENSION = '.description.html';
1214

1315
export const GRAASP_DOCUMENT_EXTENSION = '.graasp';

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

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
8787
},
8888
);
8989

90-
// download item
90+
// export item as a zip containing raw files
9191
fastify.get(
9292
'/:itemId/export',
9393
{
@@ -132,15 +132,41 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
132132
}
133133

134134
// generate archive stream
135-
const archiveStream = await importExportService.export(
136-
member,
137-
repositories,
138-
{
139-
item,
140-
reply,
141-
},
142-
log,
143-
);
135+
const archiveStream = await importExportService.exportRaw(member, repositories, item);
136+
137+
try {
138+
reply.raw.setHeader('Content-Disposition', `filename="${encodeFilename(item.name)}.zip"`);
139+
} catch (e) {
140+
// TODO: send sentry error
141+
log?.error(e);
142+
reply.raw.setHeader('Content-Disposition', 'filename="download.zip"');
143+
}
144+
reply.type('application/octet-stream');
145+
return archiveStream.outputStream;
146+
},
147+
);
148+
149+
// export item in graasp format
150+
fastify.get(
151+
'/:itemId/graasp-export',
152+
{
153+
schema: zipExport,
154+
preHandler: optionalIsAuthenticated,
155+
},
156+
async (request, reply) => {
157+
const {
158+
user,
159+
params: { itemId },
160+
} = request;
161+
const member = user?.account;
162+
const repositories = buildRepositories();
163+
const item = await itemService.get(member, repositories, itemId);
164+
165+
// allow browser to access content disposition
166+
reply.header('Access-Control-Expose-Headers', 'Content-Disposition');
167+
168+
// generate archive stream
169+
const archiveStream = await importExportService.exportGraasp(member, repositories, item);
144170

145171
try {
146172
reply.raw.setHeader('Content-Disposition', `filename="${encodeFilename(item.name)}.zip"`);

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import * as nodeFetch from 'node-fetch';
22
import { ZipFile } from 'yazl';
33

4-
import { FastifyInstance, FastifyReply } from 'fastify';
4+
import { FastifyInstance } from 'fastify';
55

66
import { ItemType, ItemVisibilityType } from '@graasp/sdk';
77

88
import build, {
9-
MOCK_LOGGER,
109
clearDatabase,
1110
mockAuthenticate,
1211
unmockAuthenticate,
@@ -70,8 +69,7 @@ describe('ZIP routes tests', () => {
7069
resolveDependency(BaseLogger),
7170
);
7271
const repositories = buildRepositories();
73-
const reply = {} as unknown as FastifyReply;
74-
await importExportService.export(actor, repositories, { item, reply }, MOCK_LOGGER);
72+
await importExportService.exportRaw(actor, repositories, item);
7573

7674
// called for parent and one child
7775
expect(mock).toHaveBeenCalledTimes(2);

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

Lines changed: 133 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@ import sanitize from 'sanitize-html';
77
import { Readable } from 'stream';
88
import { DataSource } from 'typeorm';
99
import util from 'util';
10+
import { v4 } from 'uuid';
1011
import { ZipFile } from 'yazl';
1112

12-
import { FastifyReply } from 'fastify';
13-
14-
import { ItemType, getMimetype } from '@graasp/sdk';
13+
import { ItemSettings, ItemType, ItemTypeUnion, getMimetype } from '@graasp/sdk';
1514

1615
import { BaseLogger } from '../../../../logger';
1716
import { Repositories, buildRepositories } from '../../../../utils/repositories';
@@ -25,6 +24,7 @@ import { H5PService } from '../html/h5p/service';
2524
import {
2625
DESCRIPTION_EXTENSION,
2726
GRAASP_DOCUMENT_EXTENSION,
27+
GRAASP_MANIFEST_FILENAME,
2828
HTML_EXTENSION,
2929
LINK_EXTENSION,
3030
TXT_EXTENSION,
@@ -33,6 +33,22 @@ import {
3333
import { UnexpectedExportError } from './errors';
3434
import { buildTextContent, getFilenameFromItem } from './utils';
3535

36+
/**
37+
* Defines the properties of an individual item in the graasp export format.
38+
* @property children Children items, if the item if of type FOLDER.
39+
* @property mimetype Mimetype of the item. Present if the item is not of type FOLDER.
40+
*/
41+
export type GraaspExportItem = {
42+
id: string;
43+
name: string;
44+
type: ItemTypeUnion;
45+
description: string | null;
46+
settings: ItemSettings;
47+
thumbnailFilename?: string;
48+
children?: GraaspExportItem[];
49+
mimetype?: string;
50+
};
51+
3652
const magic = new Magic(MAGIC_MIME_TYPE);
3753
const asyncDetectFile = util.promisify(magic.detectFile.bind(magic));
3854

@@ -281,14 +297,12 @@ export class ImportExportService {
281297
actor: Actor,
282298
repositories: Repositories,
283299
args: {
284-
reply;
285300
item: Item;
286301
archiveRootPath: string;
287302
archive: ZipFile;
288303
},
289-
logger: BaseLogger,
290304
) {
291-
const { item, archiveRootPath, archive, reply } = args;
305+
const { item, archiveRootPath, archive } = args;
292306

293307
// save description in file
294308
if (item.description) {
@@ -304,17 +318,11 @@ export class ImportExportService {
304318
const children = await this.itemService.getChildren(actor, repositories, item.id);
305319
const result = await Promise.all(
306320
children.map((child) =>
307-
this._addItemToZip(
308-
actor,
309-
repositories,
310-
{
311-
item: child,
312-
archiveRootPath: folderPath,
313-
archive,
314-
reply,
315-
},
316-
logger,
317-
),
321+
this._addItemToZip(actor, repositories, {
322+
item: child,
323+
archiveRootPath: folderPath,
324+
archive,
325+
}),
318326
),
319327
);
320328
// add empty folder
@@ -329,40 +337,133 @@ export class ImportExportService {
329337
return archive.addReadStream(stream, path.join(archiveRootPath, name));
330338
}
331339

332-
async export(
340+
/**
341+
* Recursively add items to the Graasp export file.
342+
* Note that the shortcut items are excluded for now, they will be included in a later release.
343+
* @param args item - the item to add
344+
* archive - reference to the zip file to which the files will be written
345+
* itemManifest - reference to the item manifest list
346+
* @returns A full manifest promise for the given item
347+
*/
348+
private async addItemToGraaspExport(
333349
actor: Actor,
334350
repositories: Repositories,
335-
{ item, reply }: { item: Item; reply: FastifyReply },
336-
logger: BaseLogger,
351+
args: {
352+
item: Item;
353+
archive: ZipFile;
354+
itemManifest: GraaspExportItem[];
355+
},
337356
) {
357+
const { item, archive, itemManifest } = args;
358+
359+
// assign the uuid to the exported items
360+
const exportItemId = v4();
361+
const itemPath = path.join(path.dirname('./'), exportItemId);
362+
363+
// TODO EXPORT treat the shortcut items correctly
364+
// ignore the shortcuts
365+
if (isItemType(item, ItemType.SHORTCUT)) {
366+
return itemManifest;
367+
}
368+
369+
// treat folder items recursively
370+
const childrenManifest: GraaspExportItem[] = [];
371+
if (isItemType(item, ItemType.FOLDER)) {
372+
const childrenItems = await this.itemService.getChildren(actor, repositories, item.id, {
373+
ordered: true,
374+
});
375+
for (const child of childrenItems) {
376+
await this.addItemToGraaspExport(actor, repositories, {
377+
item: child,
378+
archive,
379+
itemManifest: childrenManifest,
380+
});
381+
}
382+
383+
itemManifest.push({
384+
id: exportItemId,
385+
name: item.name,
386+
description: item.description,
387+
type: item.type,
388+
settings: item.settings,
389+
children: childrenManifest,
390+
});
391+
return itemManifest;
392+
}
393+
394+
// treat single items
395+
const { stream, name, mimetype } = await this.fetchItemData(actor, repositories, item);
396+
397+
itemManifest.push({
398+
id: exportItemId,
399+
name,
400+
description: item.description,
401+
type: item.type,
402+
settings: item.settings,
403+
mimetype,
404+
});
405+
archive.addReadStream(stream, itemPath);
406+
return itemManifest;
407+
}
408+
409+
/**
410+
* Export the items recursively
411+
* @param item The root item
412+
* @returns A zip file promise
413+
*/
414+
async exportRaw(actor: Actor, repositories: Repositories, item: Item) {
338415
// init archive
339416
const archive = new ZipFile();
340417
archive.outputStream.on('error', function (err) {
341418
throw new UnexpectedExportError(err);
342419
});
343-
344420
// path used to index files in archive
345421
const rootPath = path.dirname('./');
346422

347423
// import items in zip recursively
348-
await this._addItemToZip(
349-
actor,
350-
repositories,
351-
{
352-
item,
353-
reply,
354-
archiveRootPath: rootPath,
355-
archive,
356-
},
357-
logger,
358-
).catch((error) => {
424+
await this._addItemToZip(actor, repositories, {
425+
item,
426+
archiveRootPath: rootPath,
427+
archive,
428+
}).catch((error) => {
359429
throw new UnexpectedExportError(error);
360430
});
361431

362432
archive.end();
363433
return archive;
364434
}
365435

436+
/**
437+
* Export the items recursively in the Graasp export format
438+
* @param item The root item
439+
* @returns A zip file promise
440+
*/
441+
async exportGraasp(actor: Actor, repositories: Repositories, item: Item) {
442+
// init archive
443+
const archive = new ZipFile();
444+
archive.outputStream.on('error', function (err) {
445+
throw new UnexpectedExportError(err);
446+
});
447+
// path used to index files in archive
448+
const rootPath = path.dirname('./');
449+
450+
const manifest = await this.addItemToGraaspExport(actor, repositories, {
451+
item,
452+
archive,
453+
itemManifest: [],
454+
}).catch((error) => {
455+
throw new UnexpectedExportError(error);
456+
});
457+
458+
archive.addReadStream(
459+
Readable.from(JSON.stringify(manifest)),
460+
path.join(rootPath, GRAASP_MANIFEST_FILENAME),
461+
);
462+
463+
archive.end();
464+
return archive;
465+
}
466+
366467
/**
367468
* Util recursive function that create graasp item given folder content
368469
* @param actor

0 commit comments

Comments
 (0)