From 2b2c736311551b0ac92e5c5fecec1d4916cd4b53 Mon Sep 17 00:00:00 2001 From: N-Pex Date: Wed, 2 Jul 2025 15:40:43 +0200 Subject: [PATCH] More work on export --- src/components/RoomExport.vue | 530 +++++++++--------- src/components/chatMixin.js | 1 - .../composition/MessageThreadExport.vue | 50 +- src/models/attachmentManager.ts | 32 +- src/models/eventAttachment.ts | 13 +- 5 files changed, 327 insertions(+), 299 deletions(-) diff --git a/src/components/RoomExport.vue b/src/components/RoomExport.vue index 2afe1f6..8f88d8d 100644 --- a/src/components/RoomExport.vue +++ b/src/components/RoomExport.vue @@ -27,14 +27,16 @@
@@ -100,6 +102,7 @@ import { EventTimelineSet } from "matrix-js-sdk"; import axios from "axios"; import "../services/jszip.min"; import "../services/filesaver.cjs"; +import { toRaw } from "vue"; export default { name: "RoomExport", @@ -180,21 +183,9 @@ export default { }, }, methods: { - componentForEventForExport(event, forExport) { - const comp = this.componentForEvent(event, forExport); - const self = this; - if (comp) { - if (!comp.mixins) { - comp.mixins = []; - } - comp.mixins.push({ - created: function() { - console.error("Created", this.name); - self.exportComponents.push(this); - } - }); - } - return comp; + addComponent(item) { + //console.log('Add component of type', item.type.__file); + this.exportComponents.push(item); }, cancelExport() { this.cancelled = true; @@ -242,297 +233,292 @@ export default { } return fetchedEvents; }, - doExport() { + async doExport() { var zip = null; var currentMediaSize = 0; var maxMediaSize = 1024 * 1024 * 1024; // 1GB - this.exportComponents = []; + try { + this.exportComponents = []; - this.getEvents() - .then((events) => { - var decryptionPromises = []; - for (const event of this.events) { - if (event.isEncrypted()) { - decryptionPromises.push( - this.$matrix.matrixClient.decryptEventIfNeeded(event, { - isRetry: true, - emit: false, - }) - ); - } + const events = await this.getEvents(); + + let decryptionPromises = []; + for (const event of events) { + if (event.isEncrypted()) { + decryptionPromises.push( + this.$matrix.matrixClient.decryptEventIfNeeded(event, { + isRetry: true, + emit: false, + }) + ); } - return Promise.all(decryptionPromises).then(() => { - return events; + } + await Promise.all(decryptionPromises); + + // Create a timeline and add the events to that, so that relations etc are aggregated correctly! + this.timelineSet = new EventTimelineSet(null, { unstableClientRelationAggregation: true }); + this.timelineSet.addEventsToTimeline(events.reverse(), true, false, this.timelineSet.getLiveTimeline(), ""); + this.events = events; + + // Need to set thread root events and replyEvents so stuff is rendered correctly. + this.events + .filter((event) => event.threadRootId && !event.parentThread) + .forEach((event) => { + const parentEvent = + this.timelineSet.findEventById(event.threadRootId) || this.room.findEventById(event.threadRootId); + if (parentEvent) { + parentEvent["isMxThread"] = true; + event["parentThread"] = parentEvent; + } }); - }) - .then((events) => { - // Create a timeline and add the events to that, so that relations etc are aggregated correctly! - this.timelineSet = new EventTimelineSet(null, { unstableClientRelationAggregation: true }); - this.timelineSet.addEventsToTimeline(events.reverse(), true, false, this.timelineSet.getLiveTimeline(), ""); - this.events = events; - - // Need to set thread root events and replyEvents so stuff is rendered correctly. - this.events - .filter((event) => event.threadRootId && !event.parentThread) - .forEach((event) => { - const parentEvent = - this.timelineSet.findEventById(event.threadRootId) || this.room.findEventById(event.threadRootId); - if (parentEvent) { - parentEvent["isMxThread"] = true; - event["parentThread"] = parentEvent; - } - }); - this.events - .filter((event) => event.replyEventId && !event.replyEvent) - .forEach((event) => { - const parentEvent = - this.timelineSet.findEventById(event.replyEventId) || this.room.findEventById(event.replyEventId); - if (parentEvent) { - event["replyEvent"] = parentEvent; - } - }); - - // Wait a tick so UI is updated. - return new Promise((resolve, ignoredReject) => { - this.$nextTick(() => { - resolve(true); - }); + this.events + .filter((event) => event.replyEventId && !event.replyEvent) + .forEach((event) => { + const parentEvent = + this.timelineSet.findEventById(event.replyEventId) || this.room.findEventById(event.replyEventId); + if (parentEvent) { + event["replyEvent"] = parentEvent; + } }); - }) - .then(() => { - this.totalEvents = this.exportComponents.length; - // UI updated, start processing events - zip = new JSZip(); - var avatarFolder = zip.folder("avatars"); - var imageFolder = zip.folder("images"); - var audioFolder = zip.folder("audio"); - var videoFolder = zip.folder("video"); - var filesFolder = zip.folder("files"); + // Wait a tick so UI is updated. + await new Promise((resolve, ignoredReject) => { + this.$nextTick(() => { + resolve(true); + }); + }); - var downloadPromises = []; + this.totalEvents = this.exportComponents.length; - for (const comp of this.exportComponents.filter((c) => c.event != undefined)) { - // Avatars need downloading? - if (comp.$el && comp.$el.nodeType == 1) { - const avatars = comp.$el.getElementsByClassName("v-avatar"); - if (avatars && avatars.length > 0) { - const member = this.room.getMember(comp.event.getSender()); - if (member) { - const fileName = comp.event.getSender() + ".png"; + let beforeExportPromises = this.exportComponents.filter((c) => c.component.exposed?.beforeExport !== undefined).map((c) => c.component.exposed.beforeExport()); + await Promise.all(beforeExportPromises); - const setSource = (fileName) => { - for (let avatarIndex = 0; avatarIndex < avatars.length; avatarIndex++) { - const avatarElement = avatars[avatarIndex]; - const images = avatarElement.getElementsByTagName("img"); - for (let imageIndex = 0; imageIndex < images.length; imageIndex++) { - const img = images[imageIndex]; - img.onerror = undefined; - img.removeAttribute("src"); - img.setAttribute("data-exported-src", "./avatars/" + fileName); - } + // UI updated, start processing events + zip = new JSZip(); + var avatarFolder = zip.folder("avatars"); + var imageFolder = zip.folder("images"); + var audioFolder = zip.folder("audio"); + var videoFolder = zip.folder("video"); + var filesFolder = zip.folder("files"); + + var downloadPromises = []; + + for (const comp of this.exportComponents.filter((c) => c.props.originalEvent != undefined)) { + const event = comp.props.originalEvent; + + // Avatars need downloading? + if (comp.el && comp.el.nodeType == 1) { + const avatars = comp.el.getElementsByClassName("v-avatar"); + if (avatars && avatars.length > 0) { + const member = this.room.getMember(event.getSender()); + if (member) { + const fileName = event.getSender() + ".png"; + + const setSource = (fileName) => { + for (let avatarIndex = 0; avatarIndex < avatars.length; avatarIndex++) { + const avatarElement = avatars[avatarIndex]; + const images = avatarElement.getElementsByTagName("img"); + for (let imageIndex = 0; imageIndex < images.length; imageIndex++) { + const img = images[imageIndex]; + img.onerror = undefined; + img.removeAttribute("src"); + img.setAttribute("data-exported-src", "./avatars/" + fileName); } - }; - - if (!avatarFolder.file(fileName)) { - const url = member.getAvatarUrl( - this.$matrix.matrixClient.getHomeserverUrl(), - 40, - 40, - "scale", - true, - false, - this.$matrix.useAuthedMedia - ); - if (url) { - avatarFolder.file(fileName, "empty"); - downloadPromises.push( - axios - .get(url, { - responseType: "blob", - headers: this.$matrix.useAuthedMedia - ? { - Authorization: `Bearer ${this.$matrix.matrixClient.getAccessToken()}`, - } - : undefined, - }) - .then((result) => { - if (result.data) { - avatarFolder.file(fileName, result.data); - setSource(fileName); - } - }) - .catch((err) => { - console.error("Download error: ", err); - avatarFolder.remove(fileName); - }) - ); - } - } else { - setSource(fileName); } + }; + + if (!avatarFolder.file(fileName)) { + const url = member.getAvatarUrl( + this.$matrix.matrixClient.getHomeserverUrl(), + 40, + 40, + "scale", + true, + false, + this.$matrix.useAuthedMedia + ); + if (url) { + avatarFolder.file(fileName, "empty"); + downloadPromises.push( + axios + .get(url, { + responseType: "blob", + headers: this.$matrix.useAuthedMedia + ? { + Authorization: `Bearer ${this.$matrix.matrixClient.getAccessToken()}`, + } + : undefined, + }) + .then((result) => { + if (result.data) { + avatarFolder.file(fileName, result.data); + setSource(fileName); + } + }) + .catch((err) => { + console.error("Download error: ", err); + avatarFolder.remove(fileName); + }) + ); + } + } else { + setSource(fileName); } } } + } - let attachment = - comp.event && comp.event.getId - ? this.$matrix.attachmentManager.getEventAttachment(comp.event) - : undefined; - let componentClass = comp.$options - ? comp.$options.__file.split("/").reverse()[0].split(".")[0] - : "invalid_component"; - console.error("Processi", componentClass, comp.event, comp.originalEvent, attachment); + let attachment = + event && event.getId ? this.$matrix.attachmentManager.getEventAttachment(event) : undefined; + let componentClass = comp.type + ? comp.type.__file.split("/").reverse()[0].split(".")[0] + : "invalid_component"; + if (attachment && (attachment.srcSize = 0 || currentMediaSize + attachment.srcSize <= maxMediaSize)) { + downloadPromises.push( + attachment + .loadBlob() + .then((res) => { + const blob = res.data; + if (currentMediaSize + blob.size <= maxMediaSize) { + currentMediaSize += blob.size; - if (attachment && (attachment.srcSize = 0 || currentMediaSize + attachment.srcSize <= maxMediaSize)) { - downloadPromises.push( - attachment - .loadSrc({ asBlob: true }) - .then((res) => { - const blob = res.data; - if (currentMediaSize + blob.size <= maxMediaSize) { - currentMediaSize += blob.size; - - switch (componentClass) { - case "MessageIncomingImageExport": - case "MessageOutgoingImageExport": - { - let mime = blob.type; - var extension = ".png"; - switch (mime) { - case "image/jpeg": - case "image/jpg": - extension = ".jpg"; - break; - case "image/gif": - extension = ".gif"; - } - - let fileName = comp.event.getId() + extension; - imageFolder.file(fileName, blob); // TODO calc bytes - - // Update source - const images = comp.$el.getElementsByTagName("img"); - for (let imageIndex = 0; imageIndex < images.length; imageIndex++) { - const img = images[imageIndex]; - img.removeAttribute("src"); - img.setAttribute("data-exported-src", "./images/" + fileName); - } - this.processedEvents += 1; + switch (componentClass) { + case "MessageIncomingImageExport": + case "MessageOutgoingImageExport": + { + let mime = blob.type; + var extension = ".png"; + switch (mime) { + case "image/jpeg": + case "image/jpg": + extension = ".jpg"; + break; + case "image/gif": + extension = ".gif"; } - break; - case "MessageIncomingAudioExport": - case "MessageOutgoingAudioExport": - { - var extension = ".webm"; - let fileName = comp.event.getId() + extension; - audioFolder.file(fileName, blob); // TODO calc bytes - let elements = comp.$el.getElementsByTagName("audio"); - let element = elements && elements[0]; - if (element) { - element.setAttribute("data-exported-src", "./audio/" + fileName); - } - this.processedEvents += 1; - } - break; + let fileName = event.getId() + extension; + imageFolder.file(fileName, blob); // TODO calc bytes - case "MessageIncomingVideoExport": - case "MessageOutgoingVideoExport": - { - var extension = ".mp4"; - let fileName = comp.event.getId() + extension; - videoFolder.file(fileName, blob); // TODO calc bytes - // comp.src = "./video/" + fileName; - let elements = comp.$el.getElementsByTagName("video"); - let element = elements && elements[0]; - if (element) { - element.setAttribute("data-exported-src", "./video/" + fileName); - } - this.processedEvents += 1; + // Update source + const images = comp.el.getElementsByTagName("img"); + for (let imageIndex = 0; imageIndex < images.length; imageIndex++) { + const img = images[imageIndex]; + img.removeAttribute("src"); + img.setAttribute("data-exported-src", "./images/" + fileName); } - break; + } + break; - case "MessageIncomingFileExport": - case "MessageOutgoingFileExport": - { - var extension = util.getFileExtension(comp.event); - let fileName = comp.event.getId() + extension; - filesFolder.file(fileName, blob); - comp.href = "./files/" + fileName; - this.processedEvents += 1; + case "MessageIncomingAudioExport": + case "MessageOutgoingAudioExport": + { + var extension = ".webm"; + let fileName = event.getId() + extension; + audioFolder.file(fileName, blob); // TODO calc bytes + let elements = comp.el.getElementsByTagName("audio"); + let element = elements && elements[0]; + if (element) { + element.setAttribute("data-exported-src", "./audio/" + fileName); } - break; - } - this.processedEvents += 1; - return true; - } else { - this.processedEvents += 1; - return false; + } + break; + + case "MessageIncomingVideoExport": + case "MessageOutgoingVideoExport": + { + var extension = ".mp4"; + let fileName = event.getId() + extension; + videoFolder.file(fileName, blob); // TODO calc bytes + // comp.src = "./video/" + fileName; + let elements = comp.el.getElementsByTagName("video"); + let element = elements && elements[0]; + if (element) { + element.setAttribute("data-exported-src", "./video/" + fileName); + } + } + break; + + case "MessageIncomingFileExport": + case "MessageOutgoingFileExport": + { + var extension = util.getFileExtension(event); + let fileName = event.getId() + extension; + filesFolder.file(fileName, blob); + comp.component.data.href = "./files/" + fileName; + } + break; } - }) - .catch((ignoredErr) => { this.processedEvents += 1; - }) - ); - } else { - this.processedEvents += 1; + return true; + } else { + this.processedEvents += 1; + return false; + } + }) + .catch((ignoredErr) => { + this.processedEvents += 1; + }) + ); + } else { + this.processedEvents += 1; + } + } + + await Promise.all(downloadPromises); + + console.log("All media added, total size: " + currentMediaSize); + + let root = this.$refs.exportRoot; + + var doc = '\n\n\n'; + + for (const sheet of document.styleSheets) { + doc += "\n"; + } + doc += + "
"; + const getCssRules = function (el) { + if (el.classList.contains("op-button")) { + el.innerHTML = ""; + } else { + for (let i = 0; i < el.children.length; i++) { + getCssRules(el.children[i]); } - doc += "\n"; } - doc += - "
"; - const getCssRules = function (el) { - if (el.classList.contains("op-button")) { - el.innerHTML = ""; - } else { - for (let i = 0; i < el.children.length; i++) { - getCssRules(el.children[i]); - } - } - }; - getCssRules(root); + }; + getCssRules(root); - this.$nextTick(() => { - const contentHtml = this.$refs.exportRoot.outerHTML; - doc += contentHtml.replaceAll("data-exported-src=", "src="); - doc += "
"; + this.$nextTick(() => { + const contentHtml = this.$refs.exportRoot.outerHTML; + doc += contentHtml.replaceAll("data-exported-src=", "src="); + doc += "
"; - zip.file("chat.html", doc); - zip.generateAsync({ type: "blob" }).then((content) => { - saveAs( - content, - this.$t("room_export.export_filename", { date: util.formatDay(Date.now().valueOf()) }) + ".zip" - ); - this.status = ""; - this.$emit("close"); - }); + zip.file("chat.html", doc); + zip.generateAsync({ type: "blob" }).then((content) => { + saveAs( + content, + this.$t("room_export.export_filename", { date: util.formatDay(Date.now().valueOf()) }) + ".zip" + ); + this.status = ""; + this.$emit("close"); }); - }) - .catch((err) => { - console.error("Failed to export:", err); - this.$emit("close"); + this.events = []; }); + } catch (error) { + console.error("Failed to export:", error); + this.$emit("close"); + this.events = []; + } }, onLayoutChange(event) { const { action, element } = event; diff --git a/src/components/chatMixin.js b/src/components/chatMixin.js index fe349e4..1c3af1d 100644 --- a/src/components/chatMixin.js +++ b/src/components/chatMixin.js @@ -129,7 +129,6 @@ export default { componentForEvent(event, isForExport = false) { let component = this.componentForEventInternal(event, isForExport); if (component) { - console.error("COMPONENT", isForExport, component.name); return markRaw(component); } return component; diff --git a/src/components/messages/composition/MessageThreadExport.vue b/src/components/messages/composition/MessageThreadExport.vue index 9dd1add..776e567 100644 --- a/src/components/messages/composition/MessageThreadExport.vue +++ b/src/components/messages/composition/MessageThreadExport.vue @@ -4,8 +4,23 @@ :class="isIncoming ? 'messageIn-thread' : 'messageOut-thread'" ref="root" v-bind="{ ...$props, ...$attrs }" + @vue:mounted=" + (item) => { + emits('componentMounted', item); + } + " > - + @@ -24,7 +44,7 @@ import MessageIncomingText from "../MessageIncomingText.vue"; import MessageOutgoingText from "../MessageOutgoingText.vue"; import { MessageEmits, MessageProps, useMessage } from "./useMessage"; import util from "@/plugins/utils"; -import { computed, inject, onBeforeUnmount, ref, Ref, useTemplateRef, watch } from "vue"; +import { computed, inject, onBeforeUnmount, ref, Ref, watch, useAttrs } from "vue"; import { EventAttachment } from "../../../models/eventAttachment"; import { useI18n } from "vue-i18n"; import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk"; @@ -33,12 +53,21 @@ const { t } = useI18n(); const $matrix: any = inject("globalMatrix"); const emits = defineEmits< - MessageEmits & { (event: "layout-change", value: { element: Element | undefined; action: () => void }): void } + MessageEmits & { (event: "layout-change", value: { element: Element | undefined; action: () => void }): void } & { + (event: "componentMounted", value: any): void; + } >(); const items: Ref = ref([]); const props = defineProps(); +let beforeExportPromiseResolve: ((value: boolean) => void) | undefined = undefined; +let beforeExportPromiseReject: ((value: boolean) => void) | undefined = undefined; +const beforeExportPromise = new Promise((resolve, reject) => { + beforeExportPromiseResolve = resolve; + beforeExportPromiseReject = reject; +}); + const processThread = () => { if (!event.value?.isRedacted()) { _processThread(); @@ -96,15 +125,22 @@ const _processThread = () => { const eventItems = props.timelineSet.relations .getAllChildEventsForEvent(event.value?.getId() ?? "") .filter((e: MatrixEvent) => !e.isRedacted() && util.downloadableTypes().includes(e.getContent().msgtype)); - - console.log("EVENT ITEMS", eventItems); items.value = eventItems.map((e: MatrixEvent) => { let ea = $matrix.attachmentManager.getEventAttachment(e); - ea.loadThumbnail(); return ea; }); - console.log("MAPPED", items.value); + if (beforeExportPromiseResolve) { + beforeExportPromiseResolve(true); + } }; + +const beforeExport = () => { + return beforeExportPromise; +}; + +defineExpose({ + beforeExport, +});