Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 121 additions & 3 deletions src/features/export/services/DOMContentExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ export class DOMContentExtractor {

// Extract text from query-text-line paragraphs
const textLines = element.querySelectorAll('.query-text-line');
const hasGeminiUserStructure =
images.length > 0 ||
textLines.length > 0 ||
element.querySelector('user-query-file-preview') !== null;
if (!hasGeminiUserStructure) {
// ChatGPT and other hosts use ordinary semantic HTML rather than Gemini's
// query-text-line/file-preview elements. Reuse the standards-aware rich
// fallback so paragraphs, links, images, and file pills are retained.
const extracted = this.extractAssistantContent(element);
extracted.attachments = attachments;
return extracted;
Comment on lines +90 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include mixed ChatGPT attachments in delegated exports

When a ChatGPT user turn contains both prompt text in a nested .markdown container and a sibling [data-testid="file-attachment"] pill, extractAssistantContent() narrows extraction to the Markdown container and excludes the pill. This branch then records the attachment only in extracted.attachments, but the Markdown/JSON exporters consume text and the PDF exporter consumes html, so the uploaded filename disappears from every format. Fresh evidence after the earlier attachment review is that the new ChatGPT selector populates this otherwise-unserialized array; append the detected attachments to the delegated text and HTML as the Gemini path does.

Useful? React with 👍 / 👎.

}
const textParts: string[] = [];
textLines.forEach((line) => {
const el = line as HTMLElement;
Expand Down Expand Up @@ -325,7 +337,9 @@ export class DOMContentExtractor {
uploadedFiles.length > 0
? uploadedFiles
: Array.from(
element.querySelectorAll<HTMLElement>('user-query-file-preview .new-file-preview-file'),
element.querySelectorAll<HTMLElement>(
'user-query-file-preview .new-file-preview-file, [data-testid="file-attachment"]',
),
);
const attachments: ExportAttachment[] = [];
const seen = new Set<string>();
Expand Down Expand Up @@ -360,6 +374,35 @@ export class DOMContentExtractor {
return attachments;
}

private static resolveExportLink(link: HTMLAnchorElement): string | null {
const rawHref = link.getAttribute('href')?.trim();
if (!rawHref) return null;

try {
const destination = new URL(rawHref, link.ownerDocument.baseURI);
return destination.protocol === 'http:' || destination.protocol === 'https:'
? destination.href
: null;
} catch {
return null;
}
}

private static extractLinkLabel(
link: HTMLAnchorElement,
fallback: string,
): {
html: string;
text: string;
} {
const inline = this.processInlineContent(link);
const text = inline.text || this.normalizeText(link.textContent || '') || fallback;
return {
html: inline.html || this.escapeHtml(text),
text,
};
}

/**
* Process DOM nodes recursively
*/
Expand All @@ -369,7 +412,7 @@ export class DOMContentExtractor {
textParts: string[],
flags: Pick<ExtractedContent, 'hasImages' | 'hasFormulas' | 'hasTables' | 'hasCode'>,
): void {
const children = Array.from(container.children);
const children = Array.from(container.childNodes);
if (this.DEBUG)
console.log(
`[DOMContentExtractor] processNodes: ${children.length} children in`,
Expand All @@ -385,7 +428,18 @@ export class DOMContentExtractor {
this.processNodes(shadowRoot as unknown as Element, htmlParts, textParts, flags);
}

for (const child of children) {
for (const node of children) {
if (node.nodeType === Node.TEXT_NODE) {
const text = (node.textContent || '').replace(/\s+/g, ' ');
if (text.trim()) {
htmlParts.push(this.escapeHtml(text));
textParts.push(text);
}
continue;
}
if (node.nodeType !== Node.ELEMENT_NODE) continue;

const child = node as Element;
const tagName = child.tagName.toLowerCase();
if (this.DEBUG)
console.log('[DOMContentExtractor] Processing child:', tagName, child.className);
Expand Down Expand Up @@ -447,6 +501,21 @@ export class DOMContentExtractor {
}
}

// Standard Markdown renderers, including ChatGPT, emit bare <pre><code>
// blocks rather than Gemini's code-block custom element. Consume the code
// here and mark it so the later compatibility scan cannot emit it twice.
if (tagName === 'pre') {
const codeElement = child.querySelector<HTMLElement>(':scope > code');
if (codeElement) {
const extracted = this.extractCodeFromCodeElement(codeElement);
(codeElement as Element & { processedByGV?: boolean }).processedByGV = true;
flags.hasCode = true;
htmlParts.push(extracted.html);
textParts.push(`\n${extracted.text}\n`);
continue;
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const exportCodeBlocks = this.findExportCodeBlocks(child);
const directExportCodeBlock = exportCodeBlocks.find(({ element }) => element === child);
if (directExportCodeBlock) {
Expand Down Expand Up @@ -586,6 +655,22 @@ export class DOMContentExtractor {
continue;
}

// Standalone links (for example ChatGPT attachment pills) are block-level
// children in some message layouts, so preserve their destination here.
if (tagName === 'a') {
const link = child as HTMLAnchorElement;
const href = this.resolveExportLink(link);
const label = this.extractLinkLabel(link, href || '');
if (href) {
htmlParts.push(`<a href="${this.escapeHtmlAttribute(href)}">${label.html}</a>`);
textParts.push(`[${label.text}](${href.replace(/\)/g, '\\)')})`);
} else {
htmlParts.push(label.html);
textParts.push(label.text);
}
continue;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Paragraph with possible inline formulas
if (tagName === 'p') {
const processed = this.processInlineContent(child as HTMLElement);
Expand Down Expand Up @@ -830,6 +915,21 @@ export class DOMContentExtractor {
return;
}

// Links and linked attachment labels
if (el.tagName === 'A') {
const link = el as HTMLAnchorElement;
const href = this.resolveExportLink(link);
const label = this.extractLinkLabel(link, href || '');
if (href) {
htmlParts.push(`<a href="${this.escapeHtmlAttribute(href)}">${label.html}</a>`);
textParts.push(`[${label.text}](${href.replace(/\)/g, '\\)')})`);
} else {
htmlParts.push(label.html);
textParts.push(label.text);
}
return;
}

// Inline images
if (el.tagName === 'IMG') {
const imgEl = el as HTMLImageElement;
Expand Down Expand Up @@ -1103,6 +1203,24 @@ export class DOMContentExtractor {
return;
}

if (child.tagName === 'PRE') {
const codeElement = child.querySelector<HTMLElement>(':scope > code');
if (codeElement) {
flushProse();
const extracted = this.extractCodeFromCodeElement(codeElement);
(codeElement as Element & { processedByGV?: boolean }).processedByGV = true;
ensureItemMarker();
hasCode = true;
textLines.push(
extracted.text
.split('\n')
.map((line) => continuationIndent + line)
.join('\n'),
);
return;
}
}

const exportCodeBlocks = this.findExportCodeBlocks(child);
const directExportCodeBlock = exportCodeBlocks.find(({ element }) => element === child);
if (directExportCodeBlock) {
Expand Down
Loading
Loading