From 9f8fae964de8ed5654e84735453bd8fc4ca519d6 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 12 Sep 2026 20:28:42 -0400 Subject: [PATCH] fix(export): resolve edits into the original row; mark media rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../room-settings/ExportRoomHistory.tsx | 64 ++++++++++--- .../exportRoomHistory.utils.test.ts | 93 +++++++++++++++++++ .../room-settings/exportRoomHistory.utils.ts | 76 +++++++++++++++ 3 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 src/app/features/room-settings/exportRoomHistory.utils.test.ts create mode 100644 src/app/features/room-settings/exportRoomHistory.utils.ts diff --git a/src/app/features/room-settings/ExportRoomHistory.tsx b/src/app/features/room-settings/ExportRoomHistory.tsx index da3a295c7..22dda55af 100644 --- a/src/app/features/room-settings/ExportRoomHistory.tsx +++ b/src/app/features/room-settings/ExportRoomHistory.tsx @@ -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(); // 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 `
[${esc(dateLabel)}] ${esc(msg.sender)}: ${esc(msg.body)}
`; + return `
[${esc(dateLabel)}] ${esc(msg.sender)}: ${esc(formatExportBody(msg))}
`; }) .join('\n'); @@ -388,6 +421,11 @@ ${msgRows} {notice} )} + + 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. + diff --git a/src/app/features/room-settings/exportRoomHistory.utils.test.ts b/src/app/features/room-settings/exportRoomHistory.utils.test.ts new file mode 100644 index 000000000..bdc1970bc --- /dev/null +++ b/src/app/features/room-settings/exportRoomHistory.utils.test.ts @@ -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 => ({ + 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)'); +}); diff --git a/src/app/features/room-settings/exportRoomHistory.utils.ts b/src/app/features/room-settings/exportRoomHistory.utils.ts new file mode 100644 index 000000000..3d10c1e0d --- /dev/null +++ b/src/app/features/room-settings/exportRoomHistory.utils.ts @@ -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> = { + '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(); + 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}`; +}