fix(export): resolve edits into the original row; mark media rows
m.replace events no longer appear as their own "* text" line — the original row gets m.new_content and an (edited) marker. Media rows carry a [Image]/[Video]/[Audio]/[File] prefix and the JSON export includes mediaUrl; the UI notes attachments aren't included. Unit-tested. Fixes #50 Fixes #81 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -7,6 +7,7 @@ import { useRoom } from '../../hooks/useRoom';
|
||||
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||
import { RawExportRecord, formatExportBody, resolveMessageEdits } from './exportRoomHistory.utils';
|
||||
|
||||
type ExportFormat = 'txt' | 'json' | 'html';
|
||||
|
||||
@@ -63,15 +64,11 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
|
||||
const toTs = toDate ? new Date(`${toDate}T23:59:59`).getTime() : null;
|
||||
|
||||
type MsgRecord = {
|
||||
ts: number;
|
||||
sender: string;
|
||||
body: string;
|
||||
eventId: string;
|
||||
msgtype: string;
|
||||
};
|
||||
|
||||
const collected: MsgRecord[] = [];
|
||||
const rawRecords: RawExportRecord[] = [];
|
||||
// mxc/media-file URL for message events that carry one, keyed by eventId -
|
||||
// surfaced in the JSON export since the export never includes the actual
|
||||
// media (see the UI note below the Export button).
|
||||
const mediaUrlByEventId = new Map<string, string>();
|
||||
// timeline.getEvents() returns the entire growing window on every call,
|
||||
// so we must deduplicate by eventId to avoid re-adding the same events
|
||||
// on each pagination step.
|
||||
@@ -113,11 +110,39 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
if (fromTs !== null && ts < fromTs) continue;
|
||||
if (toTs !== null && ts > toTs) continue;
|
||||
const content = ev.getContent();
|
||||
|
||||
// m.replace (edit) events must not become their own row — collect
|
||||
// them separately and substitute the new content into the original
|
||||
// event's row below, instead of adding a stale original + a garbled
|
||||
// "* new text" duplicate line.
|
||||
const relatesTo = content['m.relates_to'] as
|
||||
| { rel_type?: string; event_id?: string }
|
||||
| undefined;
|
||||
if (relatesTo?.rel_type === 'm.replace' && relatesTo.event_id) {
|
||||
const newContent = content['m.new_content'] as { body?: string } | undefined;
|
||||
const newBody = newContent?.body ?? '';
|
||||
if (!newBody) continue;
|
||||
rawRecords.push({
|
||||
eventId: evId,
|
||||
ts,
|
||||
sender: ev.getSender() ?? '',
|
||||
body: '',
|
||||
msgtype: '',
|
||||
editsEventId: relatesTo.event_id,
|
||||
newBody,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const body: string = content.body ?? '';
|
||||
const msgtype: string = content.msgtype ?? '';
|
||||
if (!body) continue;
|
||||
if (ts < oldestTs) oldestTs = ts;
|
||||
collected.push({
|
||||
const mediaUrl =
|
||||
(content.url as string | undefined) ??
|
||||
(content.file as { url?: string } | undefined)?.url;
|
||||
if (mediaUrl) mediaUrlByEventId.set(evId, mediaUrl);
|
||||
rawRecords.push({
|
||||
ts,
|
||||
sender: ev.getSender() ?? '',
|
||||
body,
|
||||
@@ -125,7 +150,9 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
msgtype,
|
||||
});
|
||||
}
|
||||
setExportCount(collected.length);
|
||||
// Approximate progress — excludes edit rows, which never become their
|
||||
// own line in the final (edit-resolved) output.
|
||||
setExportCount(rawRecords.filter((r) => !r.editsEventId).length);
|
||||
};
|
||||
|
||||
await addEvents(timeline.getEvents());
|
||||
@@ -159,6 +186,10 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
await addEvents(timeline.getEvents());
|
||||
}
|
||||
|
||||
// Resolve m.replace edits against their target row (drops the edit rows,
|
||||
// substitutes m.new_content into the original) before sorting/rendering.
|
||||
const collected = resolveMessageEdits(rawRecords);
|
||||
|
||||
if (cancelled) {
|
||||
setNotice(`Export cancelled after ${collected.length} messages.`);
|
||||
return;
|
||||
@@ -186,7 +217,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
const d = new Date(msg.ts);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
lines.push(`[${dateLabel}] ${msg.sender}: ${msg.body}`);
|
||||
lines.push(`[${dateLabel}] ${msg.sender}: ${formatExportBody(msg)}`);
|
||||
}
|
||||
content = lines.join('\n');
|
||||
mimeType = 'text/plain';
|
||||
@@ -201,6 +232,8 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
body: m.body,
|
||||
eventId: m.eventId,
|
||||
type: m.msgtype,
|
||||
edited: m.edited,
|
||||
mediaUrl: mediaUrlByEventId.get(m.eventId),
|
||||
})),
|
||||
};
|
||||
content = JSON.stringify(payload, null, 2);
|
||||
@@ -220,7 +253,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
const d = new Date(msg.ts);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
return `<div class="msg"><span class="ts">[${esc(dateLabel)}]</span> <span class="sender">${esc(msg.sender)}</span><span class="body">: ${esc(msg.body)}</span></div>`;
|
||||
return `<div class="msg"><span class="ts">[${esc(dateLabel)}]</span> <span class="sender">${esc(msg.sender)}</span><span class="body">: ${esc(formatExportBody(msg))}</span></div>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
@@ -388,6 +421,11 @@ ${msgRows}
|
||||
{notice}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="T200" priority="300">
|
||||
Attachments (images, videos, audio, files) are not included in the export -
|
||||
media messages are marked with a type label (e.g. "[Image]") but only
|
||||
their filename is exported, not the file itself.
|
||||
</Text>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user