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>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
resolveMessageEdits,
|
||||
formatExportBody,
|
||||
RawExportRecord,
|
||||
ResolvedExportRecord,
|
||||
} from './exportRoomHistory.utils';
|
||||
|
||||
const msg = (over: Partial<RawExportRecord>): RawExportRecord => ({
|
||||
eventId: '$1',
|
||||
ts: 1000,
|
||||
sender: '@alice:example.org',
|
||||
body: 'hello',
|
||||
msgtype: 'm.text',
|
||||
...over,
|
||||
});
|
||||
|
||||
test('resolveMessageEdits leaves unedited messages untouched', () => {
|
||||
const out = resolveMessageEdits([msg({ eventId: '$1', body: 'hi' })]);
|
||||
assert.deepEqual(out, [
|
||||
{
|
||||
eventId: '$1',
|
||||
ts: 1000,
|
||||
sender: '@alice:example.org',
|
||||
body: 'hi',
|
||||
msgtype: 'm.text',
|
||||
edited: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveMessageEdits substitutes the new content into the original row and drops the edit row', () => {
|
||||
const out = resolveMessageEdits([
|
||||
msg({ eventId: '$1', body: 'origianl typo' }),
|
||||
msg({ eventId: '$2', editsEventId: '$1', newBody: 'original fixed' }),
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].eventId, '$1');
|
||||
assert.equal(out[0].body, 'original fixed');
|
||||
assert.equal(out[0].edited, true);
|
||||
});
|
||||
|
||||
test('resolveMessageEdits works regardless of whether the edit appears before its target', () => {
|
||||
const out = resolveMessageEdits([
|
||||
msg({ eventId: '$2', editsEventId: '$1', newBody: 'fixed' }),
|
||||
msg({ eventId: '$1', body: 'orig' }),
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].body, 'fixed');
|
||||
assert.equal(out[0].edited, true);
|
||||
});
|
||||
|
||||
test('resolveMessageEdits ignores an edit with no matching target (target never collected)', () => {
|
||||
const out = resolveMessageEdits([msg({ eventId: '$2', editsEventId: '$1', newBody: 'fixed' })]);
|
||||
assert.deepEqual(out, []);
|
||||
});
|
||||
|
||||
test('formatExportBody prefixes media messages with a type marker', () => {
|
||||
const record: ResolvedExportRecord = {
|
||||
eventId: '$1',
|
||||
ts: 1000,
|
||||
sender: '@alice:example.org',
|
||||
body: 'photo.jpg',
|
||||
msgtype: 'm.image',
|
||||
edited: false,
|
||||
};
|
||||
assert.equal(formatExportBody(record), '[Image] photo.jpg');
|
||||
});
|
||||
|
||||
test('formatExportBody appends an (edited) suffix', () => {
|
||||
const record: ResolvedExportRecord = {
|
||||
eventId: '$1',
|
||||
ts: 1000,
|
||||
sender: '@alice:example.org',
|
||||
body: 'fixed text',
|
||||
msgtype: 'm.text',
|
||||
edited: true,
|
||||
};
|
||||
assert.equal(formatExportBody(record), 'fixed text (edited)');
|
||||
});
|
||||
|
||||
test('formatExportBody combines media prefix and edited suffix', () => {
|
||||
const record: ResolvedExportRecord = {
|
||||
eventId: '$1',
|
||||
ts: 1000,
|
||||
sender: '@alice:example.org',
|
||||
body: 'photo.jpg',
|
||||
msgtype: 'm.file',
|
||||
edited: true,
|
||||
};
|
||||
assert.equal(formatExportBody(record), '[File] photo.jpg (edited)');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// Pure helpers for ExportRoomHistory.tsx, kept SDK/DOM-free so they're easy to
|
||||
// unit test in isolation.
|
||||
|
||||
export type RawExportRecord = {
|
||||
eventId: string;
|
||||
ts: number;
|
||||
sender: string;
|
||||
body: string;
|
||||
msgtype: string;
|
||||
// Set when this record IS an m.replace edit event, to the event id it targets.
|
||||
editsEventId?: string;
|
||||
// The m.new_content.body carried by an m.replace edit event.
|
||||
newBody?: string;
|
||||
};
|
||||
|
||||
export type ResolvedExportRecord = {
|
||||
eventId: string;
|
||||
ts: number;
|
||||
sender: string;
|
||||
body: string;
|
||||
msgtype: string;
|
||||
edited: boolean;
|
||||
};
|
||||
|
||||
// Human-readable label for media msgtypes, used to prefix txt/html export rows
|
||||
// so a media message isn't indistinguishable from a plain-text one.
|
||||
export const MEDIA_TYPE_LABELS: Partial<Record<string, string>> = {
|
||||
'm.image': 'Image',
|
||||
'm.video': 'Video',
|
||||
'm.audio': 'Audio',
|
||||
'm.file': 'File',
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves m.replace (edit) events against the original message they target:
|
||||
* the edit event is dropped from the output (it should not appear as its own
|
||||
* row), and the original message's body is replaced with the edit's
|
||||
* `m.new_content.body`, marked `edited: true`.
|
||||
*
|
||||
* Order-independent: edits may appear before or after their target in the
|
||||
* input (e.g. across separate back-pagination batches).
|
||||
*/
|
||||
export function resolveMessageEdits(records: RawExportRecord[]): ResolvedExportRecord[] {
|
||||
const edits = new Map<string, string>();
|
||||
for (const record of records) {
|
||||
if (record.editsEventId && record.newBody) {
|
||||
edits.set(record.editsEventId, record.newBody);
|
||||
}
|
||||
}
|
||||
|
||||
const resolved: ResolvedExportRecord[] = [];
|
||||
for (const record of records) {
|
||||
// Edit events never become their own row.
|
||||
if (record.editsEventId) continue;
|
||||
const editedBody = edits.get(record.eventId);
|
||||
resolved.push({
|
||||
eventId: record.eventId,
|
||||
ts: record.ts,
|
||||
sender: record.sender,
|
||||
body: editedBody ?? record.body,
|
||||
msgtype: record.msgtype,
|
||||
edited: editedBody !== undefined,
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Prefixes a media message's body with a "[Image]"-style marker for txt/html
|
||||
// export output, and appends an "(edited)" suffix when applicable. Plain-text
|
||||
// messages are returned unchanged (aside from the edited suffix).
|
||||
export function formatExportBody(record: ResolvedExportRecord): string {
|
||||
const label = MEDIA_TYPE_LABELS[record.msgtype];
|
||||
const mediaPrefix = label ? `[${label}] ` : '';
|
||||
const editedSuffix = record.edited ? ' (edited)' : '';
|
||||
return `${mediaPrefix}${record.body}${editedSuffix}`;
|
||||
}
|
||||
Reference in New Issue
Block a user