element-portable/src/utils/exportUtils/Exporter.ts
David Langley 491f0cd08a
Change license (#13)
* Copyright headers 1

* Licence headers 2

* Copyright Headers 3

* Copyright Headers 4

* Copyright Headers 5

* Copyright Headers 6

* Copyright headers 7

* Add copyright headers for html and config file

* Replace license files and update package.json

* Update with CLA

* lint
2024-09-09 13:57:16 +00:00

288 lines
11 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Direction, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { MediaEventContent } from "matrix-js-sdk/src/types";
import { saveAs } from "file-saver";
import { logger } from "matrix-js-sdk/src/logger";
import sanitizeFilename from "sanitize-filename";
import { ExportType, IExportOptions } from "./exportUtils";
import { decryptFile } from "../DecryptFile";
import { mediaFromContent } from "../../customisations/Media";
import { formatFullDateNoDay, formatFullDateNoDayISO } from "../../DateUtils";
import { isVoiceMessage } from "../EventUtils";
import { _t } from "../../languageHandler";
import SdkConfig from "../../SdkConfig";
type BlobFile = {
name: string;
blob: Blob;
};
export default abstract class Exporter {
protected files: BlobFile[] = [];
protected cancelled = false;
protected constructor(
protected room: Room,
protected exportType: ExportType,
protected exportOptions: IExportOptions,
protected setProgressText: React.Dispatch<React.SetStateAction<string>>,
) {
if (
exportOptions.maxSize < 1 * 1024 * 1024 || // Less than 1 MB
exportOptions.maxSize > 8000 * 1024 * 1024 || // More than 8 GB
(!!exportOptions.numberOfMessages && exportOptions.numberOfMessages > 10 ** 8) ||
(exportType === ExportType.LastNMessages && !exportOptions.numberOfMessages)
) {
throw new Error("Invalid export options");
}
window.addEventListener("beforeunload", this.onBeforeUnload);
}
public get destinationFileName(): string {
return this.makeFileNameNoExtension(SdkConfig.get().brand) + ".zip";
}
protected onBeforeUnload(e: BeforeUnloadEvent): string {
e.preventDefault();
return (e.returnValue = _t("export_chat|unload_confirm"));
}
protected updateProgress(progress: string, log = true, show = true): void {
if (log) logger.log(progress);
if (show) this.setProgressText(progress);
}
protected addFile(filePath: string, blob: Blob): void {
const file = {
name: filePath,
blob,
};
this.files.push(file);
}
protected makeFileNameNoExtension(brand = "matrix"): string {
// First try to use the real name of the room, then a translated copy of a generic name,
// then finally hardcoded default to guarantee we'll have a name.
const safeRoomName = sanitizeFilename(this.room.name ?? _t("common|unnamed_room")).trim() || "Unnamed Room";
const safeDate = formatFullDateNoDayISO(new Date()).replace(/:/g, "-"); // ISO format automatically removes a lot of stuff for us
const safeBrand = sanitizeFilename(brand);
return `${safeBrand} - ${safeRoomName} - Chat Export - ${safeDate}`;
}
protected async downloadZIP(): Promise<string | void> {
const filename = this.destinationFileName;
const filenameWithoutExt = filename.substring(0, filename.lastIndexOf(".")); // take off the extension
const { default: JSZip } = await import("jszip");
const zip = new JSZip();
// Create a writable stream to the directory
if (!this.cancelled) this.updateProgress(_t("export_chat|generating_zip"));
else return this.cleanUp();
for (const file of this.files) zip.file(filenameWithoutExt + "/" + file.name, file.blob);
const content = await zip.generateAsync({ type: "blob" });
saveAs(content, filenameWithoutExt + ".zip");
}
protected cleanUp(): string {
logger.log("Cleaning up...");
window.removeEventListener("beforeunload", this.onBeforeUnload);
return "";
}
public async cancelExport(): Promise<void> {
logger.log("Cancelling export...");
this.cancelled = true;
}
protected downloadPlainText(fileName: string, text: string): void {
const content = new Blob([text], { type: "text/plain" });
saveAs(content, fileName);
}
protected setEventMetadata(event: MatrixEvent): MatrixEvent {
const roomState = this.room.currentState;
const sender = event.getSender();
event.sender = (!!sender && roomState?.getSentinelMember(sender)) || null;
if (event.getType() === "m.room.member") {
event.target = roomState?.getSentinelMember(event.getStateKey()!) ?? null;
}
return event;
}
public getLimit(): number {
let limit: number;
switch (this.exportType) {
case ExportType.LastNMessages:
// validated in constructor that numberOfMessages is defined
// when export type is LastNMessages
limit = this.exportOptions.numberOfMessages!;
break;
default:
limit = 10 ** 8;
}
return limit;
}
protected async getRequiredEvents(): Promise<MatrixEvent[]> {
const eventMapper = this.room.client.getEventMapper();
let prevToken: string | null = null;
let events: MatrixEvent[] = [];
if (this.exportType === ExportType.Timeline) {
events = this.room.getLiveTimeline().getEvents();
} else {
let limit = this.getLimit();
while (limit) {
const eventsPerCrawl = Math.min(limit, 1000);
const res = await this.room.client.createMessagesRequest(
this.room.roomId,
prevToken,
eventsPerCrawl,
Direction.Backward,
);
if (this.cancelled) {
this.cleanUp();
return [];
}
if (res.chunk.length === 0) break;
limit -= res.chunk.length;
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);
}
if (this.exportType === ExportType.LastNMessages) {
this.updateProgress(
_t("export_chat|fetched_n_events_with_total", {
count: events.length,
total: this.exportOptions.numberOfMessages,
}),
);
} else {
this.updateProgress(
_t("export_chat|fetched_n_events", {
count: events.length,
}),
);
}
prevToken = res.end ?? null;
}
// Reverse the events so that we preserve the order
events.reverse();
}
const decryptionPromises = events
.filter((event) => event.isEncrypted())
.map((event) => {
return this.room.client.decryptEventIfNeeded(event, { emit: false });
});
// Wait for all the events to get decrypted.
await Promise.all(decryptionPromises);
for (let i = 0; i < events.length; i++) this.setEventMetadata(events[i]);
return events;
}
/**
* Decrypts if necessary, and fetches media from a matrix event
* @param event - matrix event with media event content
* @resolves when media has been fetched
* @throws if media was unable to be fetched
*/
protected async getMediaBlob(event: MatrixEvent): Promise<Blob> {
let blob: Blob | undefined = undefined;
try {
const isEncrypted = event.isEncrypted();
const content = event.getContent<MediaEventContent>();
const shouldDecrypt = isEncrypted && content.hasOwnProperty("file") && event.getType() !== "m.sticker";
if (shouldDecrypt) {
blob = await decryptFile(content.file);
} else {
const media = mediaFromContent(content);
if (!media.srcHttp) {
throw new Error("Cannot fetch without srcHttp");
}
const image = await fetch(media.srcHttp);
blob = await image.blob();
}
} catch (err) {
logger.log("Error decrypting media");
}
if (!blob) {
throw new Error("Unable to fetch file");
}
return blob;
}
public splitFileName(file: string): string[] {
const lastDot = file.lastIndexOf(".");
if (lastDot === -1) return [file, ""];
const fileName = file.slice(0, lastDot);
const ext = file.slice(lastDot + 1);
return [fileName, "." + ext];
}
public getFilePath(event: MatrixEvent): string {
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";
if (isVoiceMessage(event)) fileExt = ".ogg";
return fileDirectory + "/" + fileName + "-" + fileDate + fileExt;
}
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"]);
}
protected isAttachment(mxEv: MatrixEvent): boolean {
const attachmentTypes = ["m.sticker", "m.image", "m.file", "m.video", "m.audio"];
return mxEv.getType() === attachmentTypes[0] || attachmentTypes.includes(mxEv.getContent().msgtype!);
}
public abstract export(): Promise<void>;
}