Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"build": "node scripts/build.mjs",
"check": "node scripts/check-extension.mjs",
"test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs"
"test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs tests/chatgpt-summary-handoff.test.mjs"
},
"dependencies": {
"pdfjs-dist": "6.3.289",
Expand Down
144 changes: 107 additions & 37 deletions src/content/chatgpt-summary.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@ function requestIdFromLocation() {
}

function findComposer() {
const composer = (
document.querySelector("#prompt-textarea") ||
document.querySelector('[contenteditable="true"][data-lexical-editor="true"]') ||
document.querySelector('[contenteditable="true"][role="textbox"]') ||
document.querySelector("textarea")
);
if (!composer || !composer.isConnected || !composer.getClientRects().length) return null;
if (composer instanceof HTMLTextAreaElement) {
return !composer.disabled && !composer.readOnly ? composer : null;
}
return composer.isContentEditable ? composer : null;
const candidates = [
document.querySelector("#prompt-textarea"),
document.querySelector('[contenteditable="true"][data-lexical-editor="true"]'),
document.querySelector('[contenteditable="true"][role="textbox"]'),
document.querySelector("textarea"),
].filter((composer) => composer && composer.isConnected && (
composer instanceof HTMLTextAreaElement
? !composer.disabled && !composer.readOnly
: composer.isContentEditable
));
// The active tab has a visible editor. When the tab/window is backgrounded,
// layout rects may not be available, but the connected editor is still usable.
return candidates.find((composer) => composer.getClientRects().length) || candidates[0] || null;
}

function composerContainsPrompt(composer, prompt) {
const value = composer instanceof HTMLTextAreaElement ? composer.value : composer.textContent;
return value === prompt || (composer.innerText || "").replace(/\r\n/g, "\n") === prompt;
}

function findSendButton() {
Expand Down Expand Up @@ -66,18 +73,35 @@ function waitFor(getValue, timeoutMs) {
return new Promise((resolve) => {
let observer;
let timeout;
let finished = false;
const finish = (value) => {
observer?.disconnect();
if (finished) return;
finished = true;
observer.disconnect();
window.clearTimeout(timeout);
document.removeEventListener("visibilitychange", check);
window.removeEventListener("focus", check);
resolve(value);
};
const check = () => {
if (finished) return;
const value = getValue();
if (value) finish(value);
};
const expire = () => {
// A background tab can be throttled before ChatGPT hydrates. Do not
// discard the pending prompt just because the user switched away.
if (document.visibilityState === "hidden" || document.hasFocus?.() === false) {
timeout = window.setTimeout(expire, timeoutMs);
} else {
finish(null);
}
};
observer = new MutationObserver(check);
observer.observe(document, { childList: true, subtree: true, attributes: true });
timeout = window.setTimeout(() => finish(null), timeoutMs);
document.addEventListener("visibilitychange", check);
window.addEventListener("focus", check);
timeout = window.setTimeout(expire, timeoutMs);
check();
});
}
Expand All @@ -89,6 +113,43 @@ function clearRequestHash() {
}


function currentConversationUrl() {
const url = new URL(window.location.href);
return url.origin === "https://chatgpt.com" && /^\/c\/[a-z0-9-]+\/?$/i.test(url.pathname)
? url.origin + url.pathname.replace(/\/$/, "") : null;
}

// A fresh ChatGPT conversation obtains its /c/ URL after send. Record it only
// once it exists; subsequent summary requests reuse the attached PDF there.
async function rememberConversation(conversationKey) {
const url = await new Promise((resolve) => {
let elapsed = 0;
const check = () => {
const found = currentConversationUrl();
if (found || elapsed >= 10 * 60 * 1000) {
clearInterval(interval);
resolve(found);
}
};
const interval = setInterval(() => {
elapsed += 1000;
check();
}, 1000);
check();
});
if (url) {
await chrome.storage.local.set({
[conversationKey]: { status: "complete", url, updatedAt: Date.now() },
});
} else {
// Do not leave an indefinitely pending entry after navigation fails.
const stored = await chrome.storage.local.get(conversationKey);
if (stored[conversationKey]?.status === "pending") {
await chrome.storage.local.remove(conversationKey);
}
}
}

function findPdfInput() {
return Array.from(document.querySelectorAll('input[type="file"]')).find((input) => {
const accepts = (input.accept || "").toLowerCase();
Expand Down Expand Up @@ -151,19 +212,17 @@ async function attachPdf(file) {
data.items.add(file);
input.files = data.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
// An existing file selection is not enough: wait for ChatGPT's attachment
// chip before allowing the summary to be submitted.
const acknowledged = await waitFor(() => {
const displayed = Array.from(document.querySelectorAll(
'[data-testid*="attachment"], [data-testid*="file"], [title], [aria-label]',
)).some((element) => (
(element.textContent || "").includes(file.name) ||
(element.getAttribute("title") || "").includes(file.name) ||
(element.getAttribute("aria-label") || "").includes(file.name)
));
return displayed && !document.querySelector('[role="progressbar"]');
}, 90000);
if (!acknowledged) throw new Error("ChatGPT did not confirm the PDF attachment");
// Wait only until ChatGPT registers the attachment. Its upload can remain
// in progress: ChatGPT queues the message and sends it once the PDF is ready.
// Restrict this check to attachment UI, not arbitrary labels or prompt text.
const acknowledged = await waitFor(() => Array.from(document.querySelectorAll(
'[data-testid*="attachment"], [data-testid*="file"]',
)).some((element) => (
(element.textContent || "").includes(file.name) ||
(element.getAttribute("title") || "").includes(file.name) ||
(element.getAttribute("aria-label") || "").includes(file.name)
)), 90000);
if (!acknowledged) throw new Error("ChatGPT did not register the PDF attachment");
}

async function submitPendingSummary() {
Expand All @@ -173,40 +232,51 @@ async function submitPendingSummary() {
const stored = await chrome.storage.local.get(storageKey);
const payload = stored[storageKey];
if (!payload?.prompt) return;
let composer = await waitFor(findComposer, 20000);
if (!composer) {
await chrome.storage.local.remove(storageKey);
clearRequestHash();
return;
}
let composer;
let port;
try {
composer = await waitFor(findComposer, 90000);
if (!composer) throw new Error("ChatGPT composer unavailable");
// Compose immediately, before transferring the PDF or waiting for its
// upload. A backgrounded or hydrated page must not lose the prompt.
setComposerText(composer, payload.prompt);
if (payload.attachmentRequired) {
const pdf = await receivePdf(requestId);
port = pdf.port;
await attachPdf(pdf.file);
}
setComposerText(composer, payload.prompt);
const sendButton = await waitFor(() => {
const current = findComposer();
if (!current) return null;
if (current !== composer) {
if (current !== composer || !composerContainsPrompt(current, payload.prompt)) {
composer = current;
setComposerText(composer, payload.prompt);
return null;
}
const button = findSendButton();
return button && !button.disabled && button.getClientRects().length ? button : null;
}, 30000);
return button && !button.disabled && button.isConnected !== false &&
(button.getClientRects().length || document.visibilityState === "hidden")
? button : null;
}, 90000);
if (!sendButton) throw new Error("ChatGPT send button unavailable");
sendButton.click();
if (payload.attachmentRequired && payload.conversationKey) {
void rememberConversation(payload.conversationKey).catch(() => {});
}
port?.postMessage({ type: "finish" });
} catch (error) {
port?.postMessage({ type: "cancel" });
if (payload.attachmentRequired && payload.conversationKey) {
const current = await chrome.storage.local.get(payload.conversationKey);
if (current[payload.conversationKey]?.status === "pending") {
await chrome.storage.local.remove(payload.conversationKey);
}
}
// Fail visibly and leave an unsent draft; never summarize just a file path.
const draft = "PDF not attached automatically (" + error.message +
"). Attach it manually before sending.\n\n" + payload.prompt;
setComposerText(findComposer() || composer, draft);
const failedComposer = findComposer() || composer;
if (failedComposer) setComposerText(failedComposer, draft);
} finally {
port?.disconnect();
await chrome.storage.local.remove(storageKey);
Expand Down
2 changes: 1 addition & 1 deletion src/viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
</div>
</div>
<div class="search-actions" role="group" aria-label="Actions">
<button id="summarize-chatgpt" class="mark-button" type="button" aria-label="Summarize with ChatGPT" title="Summarize with ChatGPT">
<button id="summarize-chatgpt" class="mark-button" type="button" aria-label="Summarize with ChatGPT (Shift-click for new chat)" title="Summarize with ChatGPT (Shift-click for new chat)">
<img src="assets/sweetie-bot.png" alt="" aria-hidden="true" />
</button>
</div>
Expand Down
92 changes: 78 additions & 14 deletions src/viewer/sharing/summarize-with-chatgpt.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { pdfDocumentSessionReady } from "../pdf-document-session.js";

const summarizeButton = document.querySelector("#summarize-chatgpt");
const STORAGE_PREFIX = "pdf-viewer-chatgpt-summary:";
const CONVERSATION_PREFIX = "pdf-viewer-chatgpt-conversation:";
const PENDING_MAX_AGE_MS = 10 * 60 * 1000;
const CHATGPT_URL = "https://chatgpt.com/";
const defaultTitle = summarizeButton.title;
// Resolve PDF metadata while the viewer loads, before the user asks for a summary.
Expand Down Expand Up @@ -69,7 +71,7 @@ function registerPdfTransfer(requestId, filename) {
const port = chrome.runtime.connect({ name: "pdf-summary-source:" + requestId });
let stopped = false;
let awaitingAck;
const expire = setTimeout(() => port.disconnect(), 120000);
const expire = setTimeout(() => port.disconnect(), PENDING_MAX_AGE_MS);
port.onDisconnect.addListener(() => {
stopped = true;
if (awaitingAck) awaitingAck.reject(new Error("PDF transfer disconnected"));
Expand Down Expand Up @@ -118,11 +120,46 @@ function registerPdfTransfer(requestId, filename) {
return port;
}

async function openChatGPTSummary() {
function savedConversationUrl(value) {
try {
const url = new URL(value);
return url.origin === "https://chatgpt.com" && /^\/c\/[a-z0-9-]+\/?$/i.test(url.pathname)
? url.origin + url.pathname.replace(/\/$/, "") : null;
} catch {
return null;
}
}

async function pdfContentHash() {
const session = await pdfDocumentSessionReady;
if (!session?.document) throw new Error("PDF has not finished loading");
const bytes = await session.document.getData();
if (!bytes?.length || bytes.length > 512 * 1024 * 1024) {
throw new Error("PDF empty or over 512 MB attachment limit");
}
// Hash bytes, not the filename or URL: changed PDFs must not reuse an old attachment.
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}

async function focusPendingSummary(entry) {
if (entry?.status !== "pending" || Date.now() - entry.createdAt > PENDING_MAX_AGE_MS) {
return false;
}
try {
const tab = await chrome.tabs.get(entry.tabId);
if (!tab) return false;
await chrome.windows.update(tab.windowId, { focused: true });
await chrome.tabs.update(tab.id, { active: true });
return true;
} catch {
return false; // The previous popup was closed.
}
}

async function openChatGPTSummary({ freshChat = false } = {}) {
const requestId = crypto.randomUUID();
const storageKey = `${STORAGE_PREFIX}${requestId}`;
const chatgptUrl = new URL(CHATGPT_URL);
chatgptUrl.hash = `pdf-viewer-summary=${encodeURIComponent(requestId)}`;

const screenWidth = window.screen.availWidth || window.screen.width || 1440;
const screenHeight = window.screen.availHeight || window.screen.height || 900;
Expand All @@ -148,6 +185,8 @@ async function openChatGPTSummary() {
}

let sourcePort;
let conversationKey;
let reserved = false;
try {
const fileUrl = await documentReferenceUrl;
if (!fileUrl) {
Expand All @@ -156,29 +195,54 @@ async function openChatGPTSummary() {
return;
}
const filename = pdfFilename(fileUrl);
await chrome.storage.local.set({
[storageKey]: {
prompt: summaryPrompt(fileUrl, filename),
createdAt: Date.now(),
attachmentRequired: true,
},
});
sourcePort = registerPdfTransfer(requestId, filename);
let existingConversation = null;
if (!freshChat) {
conversationKey = CONVERSATION_PREFIX + await pdfContentHash();
const stored = await chrome.storage.local.get(conversationKey);
existingConversation = stored[conversationKey];
if (await focusPendingSummary(existingConversation)) {
await chrome.tabs.remove(summaryTab.id);
return; // Focus the active upload rather than initiating another one.
}
}
const previousUrl = savedConversationUrl(existingConversation?.url);
const reuse = !freshChat && existingConversation?.status === "complete" && previousUrl;
const chatgptUrl = new URL(reuse || CHATGPT_URL);
chatgptUrl.hash = `pdf-viewer-summary=${encodeURIComponent(requestId)}`;
const payload = {
prompt: summaryPrompt(fileUrl, filename),
createdAt: Date.now(),
attachmentRequired: !reuse,
conversationKey: conversationKey || null,
};
const changes = { [storageKey]: payload };
if (conversationKey && !reuse) {
changes[conversationKey] = { status: "pending", tabId: summaryTab.id, createdAt: Date.now() };
reserved = true;
}
await chrome.storage.local.set(changes);
if (!reuse) sourcePort = registerPdfTransfer(requestId, filename);
await chrome.tabs.update(summaryTab.id, { url: chatgptUrl.href });
} catch (error) {
sourcePort?.disconnect();
await chrome.storage.local.remove(storageKey);
if (reserved) {
const stored = await chrome.storage.local.get(conversationKey);
if (stored[conversationKey]?.tabId === summaryTab.id) {
await chrome.storage.local.remove(conversationKey);
}
}
await chrome.tabs.remove(summaryTab.id).catch(() => {});
throw error;
}
}

summarizeButton.addEventListener("click", async () => {
summarizeButton.addEventListener("click", async (event) => {
dismissSummaryTooltip();
summarizeButton.disabled = true;

try {
await openChatGPTSummary();
await openChatGPTSummary({ freshChat: Boolean(event?.shiftKey) });
} catch {
showTemporaryTitle("Could not open ChatGPT");
} finally {
Expand Down
Loading
Loading