element-web/src/utils/exportUtils/Exporter.ts

276 lines
9.7 KiB
TypeScript
Raw Normal View History

import streamSaver from "streamsaver";
2021-06-22 05:21:16 +00:00
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { exportOptions, exportTypes } from "./exportUtils";
import { decryptFile } from "../DecryptFile";
import { mediaFromContent } from "../../customisations/Media";
import { formatFullDateNoDay } from "../../DateUtils";
2021-07-19 07:53:55 +00:00
import { Direction, MatrixClient } from "matrix-js-sdk";
2021-06-29 14:13:21 +00:00
import streamToZIP from "./ZipStream";
2021-06-30 08:38:22 +00:00
import * as ponyfill from "web-streams-polyfill/ponyfill";
import "web-streams-polyfill/ponyfill"; // to support streams API for older browsers
2021-07-02 11:22:33 +00:00
import { MutableRefObject } from "react";
2021-05-24 15:18:13 +00:00
type FileStream = {
name: string;
stream(): ReadableStream;
};
2021-06-03 08:09:14 +00:00
export default abstract class Exporter {
protected files: FileStream[];
2021-06-24 12:49:12 +00:00
protected client: MatrixClient;
protected writer: WritableStreamDefaultWriter<any>;
protected fileStream: WritableStream<any>;
2021-06-27 15:25:54 +00:00
protected cancelled: boolean;
protected constructor(
protected room: Room,
protected exportType: exportTypes,
2021-07-02 04:53:25 +00:00
protected exportOptions: exportOptions,
2021-07-02 11:22:33 +00:00
protected exportProgressRef: MutableRefObject<HTMLParagraphElement>,
) {
2021-06-27 15:25:54 +00:00
this.cancelled = false;
this.files = [];
2021-06-24 12:49:12 +00:00
this.client = MatrixClientPeg.get();
window.addEventListener("beforeunload", this.onBeforeUnload);
2021-06-27 15:25:54 +00:00
window.addEventListener("onunload", this.abortWriter);
}
2021-06-29 06:10:26 +00:00
protected onBeforeUnload(e: BeforeUnloadEvent): string {
e.preventDefault();
return e.returnValue = "Are you sure you want to exit during this export?";
}
2021-07-02 04:53:25 +00:00
protected updateProgress(progress: string, log = true, show = true): void {
if (log) console.log(progress);
2021-07-02 11:22:33 +00:00
if (show) this.exportProgressRef.current.innerText = progress;
2021-07-02 04:53:25 +00:00
}
2021-06-29 06:10:26 +00:00
protected addFile(filePath: string, blob: Blob): void {
const file = {
name: filePath,
stream: () => blob.stream(),
2021-06-30 08:38:22 +00:00
};
this.files.push(file);
}
2021-06-29 06:10:26 +00:00
protected async downloadZIP(): Promise<any> {
const filename = `matrix-export-${formatFullDateNoDay(new Date())}.zip`;
2021-06-25 06:22:26 +00:00
// Support for older browsers
2021-06-30 08:38:22 +00:00
streamSaver.WritableStream = ponyfill.WritableStream;
2021-06-25 06:22:26 +00:00
// Create a writable stream to the directory
this.fileStream = streamSaver.createWriteStream(filename);
2021-07-02 04:53:25 +00:00
if (!this.cancelled) this.updateProgress("Generating a ZIP...");
2021-06-27 15:25:54 +00:00
else return this.cleanUp();
2021-06-25 06:22:26 +00:00
this.writer = this.fileStream.getWriter();
const files = this.files;
const readableZipStream = streamToZIP({
start(ctrl) {
for (const file of files) ctrl.enqueue(file);
ctrl.close();
},
});
2021-06-27 15:25:54 +00:00
if (this.cancelled) return this.cleanUp();
2021-07-02 04:53:25 +00:00
this.updateProgress("Writing to the file system...");
2021-06-30 08:38:22 +00:00
const reader = readableZipStream.getReader();
await this.pumpToFileStream(reader);
}
2021-06-29 06:10:26 +00:00
protected cleanUp(): string {
2021-06-27 15:25:54 +00:00
console.log("Cleaning up...");
window.removeEventListener("beforeunload", this.onBeforeUnload);
window.removeEventListener("onunload", this.abortWriter);
return "";
}
2021-06-29 06:10:26 +00:00
public async cancelExport(): Promise<void> {
2021-06-27 15:25:54 +00:00
console.log("Cancelling export...");
this.cancelled = true;
await this.abortWriter();
}
protected async downloadPlainText(fileName: string, text: string): Promise<any> {
this.fileStream = streamSaver.createWriteStream(fileName);
2021-06-30 08:38:22 +00:00
this.writer = this.fileStream.getWriter();
const data = new TextEncoder().encode(text);
2021-06-27 15:25:54 +00:00
if (this.cancelled) return this.cleanUp();
await this.writer.write(data);
await this.writer.close();
}
2021-06-27 15:25:54 +00:00
protected async abortWriter(): Promise<void> {
2021-06-25 06:22:26 +00:00
await this.fileStream?.abort();
await this.writer?.abort();
}
2021-06-29 06:10:26 +00:00
protected async pumpToFileStream(reader: ReadableStreamDefaultReader): Promise<void> {
const res = await reader.read();
if (res.done) await this.writer.close();
else {
await this.writer.write(res.value);
await this.pumpToFileStream(reader);
}
}
2021-06-03 07:51:56 +00:00
2021-06-29 06:10:26 +00:00
protected setEventMetadata(event: MatrixEvent): MatrixEvent {
2021-06-24 12:49:12 +00:00
const roomState = this.client.getRoom(this.room.roomId).currentState;
event.sender = roomState.getSentinelMember(
event.getSender(),
);
if (event.getType() === "m.room.member") {
event.target = roomState.getSentinelMember(
event.getStateKey(),
);
2021-06-04 09:38:17 +00:00
}
return event;
2021-06-04 09:38:17 +00:00
}
2021-06-29 06:10:26 +00:00
protected getLimit(): number {
let limit: number;
switch (this.exportType) {
case exportTypes.LAST_N_MESSAGES:
limit = this.exportOptions.numberOfMessages;
break;
case exportTypes.TIMELINE:
limit = 40;
break;
default:
2021-06-25 09:19:01 +00:00
limit = 10**8;
}
return limit;
}
2021-06-30 08:38:22 +00:00
protected async getRequiredEvents(): Promise<MatrixEvent[]> {
2021-06-24 12:49:12 +00:00
const eventMapper = this.client.getEventMapper();
2021-06-04 09:38:17 +00:00
let prevToken: string|null = null;
let limit = this.getLimit();
2021-07-19 07:30:37 +00:00
const events: MatrixEvent[] = [];
2021-06-04 09:38:17 +00:00
while (limit) {
const eventsPerCrawl = Math.min(limit, 1000);
2021-07-19 07:53:55 +00:00
const res: any = await this.client.createMessagesRequest(this.room.roomId, prevToken, eventsPerCrawl, Direction.Backward);
2021-06-04 09:38:17 +00:00
2021-06-27 15:25:54 +00:00
if (this.cancelled) {
this.cleanUp();
return [];
}
2021-06-04 09:38:17 +00:00
if (res.chunk.length === 0) break;
limit -= res.chunk.length;
2021-06-04 09:38:17 +00:00
const matrixEvents: MatrixEvent[] = res.chunk.map(eventMapper);
for (const mxEv of matrixEvents) {
if (this.exportOptions.startDate && mxEv.getTs() < this.exportOptions.startDate) {
// Once the last message received is older than the start date, we break out of both the loops
limit = 0;
break;
}
events.push(mxEv);
}
2021-07-02 04:53:25 +00:00
this.updateProgress(
("Fetched " + events.length + " events ") + (this.exportType === exportTypes.LAST_N_MESSAGES
? `out of ${this.exportOptions.numberOfMessages}...`
: "so far..."),
);
2021-06-04 09:38:17 +00:00
prevToken = res.end;
}
// Reverse the events so that we preserve the order
2021-07-19 07:30:37 +00:00
for (let i = 0; i < Math.floor(events.length/2); i++) {
[events[i], events[events.length - i - 1]] = [events[events.length - i - 1], events[i]];
}
2021-06-04 09:38:17 +00:00
const decryptionPromises = events
.filter(event => event.isEncrypted())
.map(event => {
2021-06-24 12:49:12 +00:00
return this.client.decryptEventIfNeeded(event, {
isRetry: true,
emit: false,
2021-06-04 09:38:17 +00:00
});
});
// Wait for all the events to get decrypted.
2021-06-04 09:38:17 +00:00
await Promise.all(decryptionPromises);
2021-06-07 09:17:27 +00:00
for (let i = 0; i < events.length; i++) this.setEventMetadata(events[i]);
2021-06-04 09:38:17 +00:00
return events;
2021-06-04 09:38:17 +00:00
}
2021-06-29 06:10:26 +00:00
protected async getMediaBlob(event: MatrixEvent): Promise<Blob> {
2021-06-14 12:36:40 +00:00
let blob: Blob;
try {
const isEncrypted = event.isEncrypted();
const content = event.getContent();
const shouldDecrypt = isEncrypted && !content.hasOwnProperty("org.matrix.msc1767.file")
&& event.getType() !== "m.sticker";
if (shouldDecrypt) {
blob = await decryptFile(content.file);
} else {
const media = mediaFromContent(event.getContent());
const image = await fetch(media.srcHttp);
blob = await image.blob();
}
} catch (err) {
console.log("Error decrypting media");
}
return blob;
}
2021-06-29 06:10:26 +00:00
protected splitFileName(file: string): string[] {
2021-06-14 12:36:40 +00:00
const lastDot = file.lastIndexOf('.');
if (lastDot === -1) return [file, ""];
const fileName = file.slice(0, lastDot);
const ext = file.slice(lastDot + 1);
return [fileName, '.' + ext];
}
2021-06-29 06:10:26 +00:00
protected getFilePath(event: MatrixEvent): string {
2021-06-14 12:36:40 +00:00
const mediaType = event.getContent().msgtype;
let fileDirectory: string;
switch (mediaType) {
case "m.image":
fileDirectory = "images";
break;
case "m.video":
fileDirectory = "videos";
break;
case "m.audio":
fileDirectory = "audio";
break;
default:
fileDirectory = event.getType() === "m.sticker" ? "stickers" : "files";
}
const fileDate = formatFullDateNoDay(new Date(event.getTs()));
let [fileName, fileExt] = this.splitFileName(event.getContent().body);
if (event.getType() === "m.sticker") fileExt = ".png";
2021-06-14 12:36:40 +00:00
return fileDirectory + "/" + fileName + '-' + fileDate + fileExt;
}
2021-06-29 06:10:26 +00:00
protected isReply(event: MatrixEvent): boolean {
const isEncrypted = event.isEncrypted();
// If encrypted, in_reply_to lies in event.event.content
const content = isEncrypted ? event.event.content : event.getContent();
const relatesTo = content["m.relates_to"];
return !!(relatesTo && relatesTo["m.in_reply_to"]);
}
2021-06-29 06:10:26 +00:00
protected isAttachment(mxEv: MatrixEvent): boolean {
2021-06-14 12:36:40 +00:00
const attachmentTypes = ["m.sticker", "m.image", "m.file", "m.video", "m.audio"];
return mxEv.getType() === attachmentTypes[0] || attachmentTypes.includes(mxEv.getContent().msgtype);
}
abstract export(): Promise<any>;
2021-05-24 15:18:13 +00:00
}