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 { useRoomName } from '../../hooks/useRoomMeta';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||||
|
import { RawExportRecord, formatExportBody, resolveMessageEdits } from './exportRoomHistory.utils';
|
||||||
|
|
||||||
type ExportFormat = 'txt' | 'json' | 'html';
|
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 fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
|
||||||
const toTs = toDate ? new Date(`${toDate}T23:59:59`).getTime() : null;
|
const toTs = toDate ? new Date(`${toDate}T23:59:59`).getTime() : null;
|
||||||
|
|
||||||
type MsgRecord = {
|
const rawRecords: RawExportRecord[] = [];
|
||||||
ts: number;
|
// mxc/media-file URL for message events that carry one, keyed by eventId -
|
||||||
sender: string;
|
// surfaced in the JSON export since the export never includes the actual
|
||||||
body: string;
|
// media (see the UI note below the Export button).
|
||||||
eventId: string;
|
const mediaUrlByEventId = new Map<string, string>();
|
||||||
msgtype: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const collected: MsgRecord[] = [];
|
|
||||||
// timeline.getEvents() returns the entire growing window on every call,
|
// timeline.getEvents() returns the entire growing window on every call,
|
||||||
// so we must deduplicate by eventId to avoid re-adding the same events
|
// so we must deduplicate by eventId to avoid re-adding the same events
|
||||||
// on each pagination step.
|
// on each pagination step.
|
||||||
@@ -113,11 +110,39 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
if (fromTs !== null && ts < fromTs) continue;
|
if (fromTs !== null && ts < fromTs) continue;
|
||||||
if (toTs !== null && ts > toTs) continue;
|
if (toTs !== null && ts > toTs) continue;
|
||||||
const content = ev.getContent();
|
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 body: string = content.body ?? '';
|
||||||
const msgtype: string = content.msgtype ?? '';
|
const msgtype: string = content.msgtype ?? '';
|
||||||
if (!body) continue;
|
if (!body) continue;
|
||||||
if (ts < oldestTs) oldestTs = ts;
|
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,
|
ts,
|
||||||
sender: ev.getSender() ?? '',
|
sender: ev.getSender() ?? '',
|
||||||
body,
|
body,
|
||||||
@@ -125,7 +150,9 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
msgtype,
|
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());
|
await addEvents(timeline.getEvents());
|
||||||
@@ -159,6 +186,10 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
await addEvents(timeline.getEvents());
|
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) {
|
if (cancelled) {
|
||||||
setNotice(`Export cancelled after ${collected.length} messages.`);
|
setNotice(`Export cancelled after ${collected.length} messages.`);
|
||||||
return;
|
return;
|
||||||
@@ -186,7 +217,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
const d = new Date(msg.ts);
|
const d = new Date(msg.ts);
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
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())}`;
|
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');
|
content = lines.join('\n');
|
||||||
mimeType = 'text/plain';
|
mimeType = 'text/plain';
|
||||||
@@ -201,6 +232,8 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
body: m.body,
|
body: m.body,
|
||||||
eventId: m.eventId,
|
eventId: m.eventId,
|
||||||
type: m.msgtype,
|
type: m.msgtype,
|
||||||
|
edited: m.edited,
|
||||||
|
mediaUrl: mediaUrlByEventId.get(m.eventId),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
content = JSON.stringify(payload, null, 2);
|
content = JSON.stringify(payload, null, 2);
|
||||||
@@ -220,7 +253,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
const d = new Date(msg.ts);
|
const d = new Date(msg.ts);
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
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())}`;
|
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');
|
.join('\n');
|
||||||
|
|
||||||
@@ -388,6 +421,11 @@ ${msgRows}
|
|||||||
{notice}
|
{notice}
|
||||||
</Text>
|
</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>
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
</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