backdraw 0.0.0-stage → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +635 -2
  3. package/README.tr.md +653 -0
  4. package/bin/backdraw.js +60 -0
  5. package/cli.js +105 -0
  6. package/doctor.js +174 -0
  7. package/extension/_locales/en/messages.json +3 -0
  8. package/extension/_locales/tr/messages.json +3 -0
  9. package/extension/aspect.js +25 -0
  10. package/extension/background.js +1015 -0
  11. package/extension/collection.html +45 -0
  12. package/extension/collection.js +62 -0
  13. package/extension/collections.js +409 -0
  14. package/extension/i18n.js +359 -0
  15. package/extension/icons/icon128.png +0 -0
  16. package/extension/icons/icon16.png +0 -0
  17. package/extension/icons/icon32.png +0 -0
  18. package/extension/icons/icon48.png +0 -0
  19. package/extension/icons/mascot-192.png +0 -0
  20. package/extension/icons/mascot-96.png +0 -0
  21. package/extension/inpage.js +624 -0
  22. package/extension/manifest.json +20 -0
  23. package/extension/menus.js +50 -0
  24. package/extension/popup.css +90 -0
  25. package/extension/popup.html +63 -0
  26. package/extension/popup.js +376 -0
  27. package/extension/settings.js +35 -0
  28. package/extension/setup.js +57 -0
  29. package/extension/sidepanel.css +113 -0
  30. package/extension/sidepanel.html +31 -0
  31. package/extension/sidepanel.js +433 -0
  32. package/extension/theme.css +22 -0
  33. package/host.js +664 -0
  34. package/identity.js +21 -0
  35. package/install.js +427 -0
  36. package/lib/args.js +30 -0
  37. package/lib/aspect.js +85 -0
  38. package/lib/browsers.js +74 -0
  39. package/lib/collection-store.js +288 -0
  40. package/lib/collections.js +192 -0
  41. package/lib/doctor.js +143 -0
  42. package/lib/extension-calls.js +59 -0
  43. package/lib/gallery.js +63 -0
  44. package/lib/limits.js +18 -0
  45. package/lib/mcp-clients.js +178 -0
  46. package/lib/naming.js +25 -0
  47. package/lib/png.js +24 -0
  48. package/lib/projects.js +56 -0
  49. package/lib/prompt.js +25 -0
  50. package/lib/protocol.js +50 -0
  51. package/lib/results.js +170 -0
  52. package/lib/term.js +22 -0
  53. package/lib/toml.js +248 -0
  54. package/mcp-server.js +437 -0
  55. package/package.json +52 -4
  56. package/platform/index.js +14 -0
  57. package/platform/win32.js +268 -0
  58. package/setup.js +213 -0
  59. package/shared.js +118 -0
@@ -0,0 +1,624 @@
1
+ // Injected (isolated world) into the ChatGPT tab that the extension opened in the background.
2
+ // It only reads that page and dispatches synthetic DOM events inside it: it never moves the real
3
+ // mouse or keyboard, never focuses a window and never reads cookies or tokens.
4
+ // Re-injected before every call, so it always redefines itself (no stale copies after updates).
5
+ (() => {
6
+ const VERSION = '2.0.0';
7
+ const $ = (sel, root = document) => root.querySelector(sel);
8
+ const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
9
+ const squash = s => String(s || '').replace(/\s+/g, ' ').trim();
10
+ const tidy = s => String(s || '').replace(/[ \t ]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n').trim();
11
+ const shown = el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
12
+ const classOf = el => (typeof el.className === 'string' ? el.className : '');
13
+ const firstOf = sels => {
14
+ for (const s of sels) { try { const el = $(s); if (el) return el; } catch {} }
15
+ return null;
16
+ };
17
+
18
+ const EDITOR = [
19
+ '#prompt-textarea[contenteditable="true"]', 'div.ProseMirror[contenteditable="true"]',
20
+ 'form [contenteditable="true"]', 'textarea#prompt-textarea', 'form textarea',
21
+ ];
22
+ const SEND = ['button[data-testid="send-button"]', 'button#composer-submit-button', 'button[data-testid="composer-submit-button"]'];
23
+ const STOP = ['button[data-testid="stop-button"]'];
24
+
25
+ const editor = () => firstOf(EDITOR);
26
+
27
+ // The send/stop buttons live next to the editor; look there by label when no test id matches.
28
+ function composerRoot(ed) {
29
+ if (!ed) return null;
30
+ return ed.closest('form') || ed.closest('[class*="ComposerLayoutRoot"]') || ed.closest('[class*="Composer"]') ||
31
+ (ed.parentElement && ed.parentElement.parentElement && ed.parentElement.parentElement.parentElement);
32
+ }
33
+ function composerButton(testIds, label) {
34
+ const byId = firstOf(testIds);
35
+ if (byId) return byId;
36
+ const root = composerRoot(editor());
37
+ return root ? $$('button', root).find(b => label.test(b.getAttribute('aria-label') || '')) || null : null;
38
+ }
39
+ const sendButton = () => composerButton(SEND, /send|gönder/i);
40
+ const stopButton = () => composerButton(STOP, /stop|durdur/i);
41
+ const textOf = el => (!el ? '' : el.tagName === 'TEXTAREA' ? el.value : el.innerText || el.textContent || '');
42
+ const isDisabled = b => !!b && (b.disabled || b.getAttribute('aria-disabled') === 'true');
43
+
44
+ // ------------------------------------------------------------ page state
45
+
46
+ function challenge(hasComposer) {
47
+ if (/just a moment|attention required|bir dakika|verify you are human|insan olduğunuzu/i.test(document.title || '')) return 'verification';
48
+ const frames = $$('iframe[src*="challenges.cloudflare.com"], iframe[src*="arkoselabs"], iframe[src*="funcaptcha"], iframe[src*="hcaptcha"], iframe[src*="recaptcha"], iframe[title*="captcha" i]');
49
+ if (frames.some(f => { const r = f.getBoundingClientRect(); return r.width >= 120 && r.height >= 60; })) return 'captcha';
50
+ if (!hasComposer && $('#challenge-form, #cf-challenge-running, #challenge-stage, .cf-turnstile')) return 'verification';
51
+ return null;
52
+ }
53
+
54
+ function loginWall() {
55
+ if (/^auth\d*\./i.test(location.hostname) || /^\/(auth|log-?in)\b/i.test(location.pathname)) return true;
56
+ return !!$('[data-testid="login-button"], [data-testid="signup-button"]');
57
+ }
58
+
59
+ // The conversation (2026 UI): user messages are "group/user-message" blocks, generated images sit
60
+ // in "generated-image-preview" buttons, replies are "MarkdownRoot" blocks, and a role=status
61
+ // aria-busy element shows while ChatGPT is answering. Long chats only render their latest turns,
62
+ // so the reply is always located relative to the LAST user message, never counted from the top.
63
+ const transcriptRoot = () => $('[class*="transcriptContent"]') || $('.thread-scroll-container') || $('main') || document.body;
64
+
65
+ function outermost(list) { return list.filter(el => !list.some(o => o !== el && o.contains(el))); }
66
+
67
+ function userMessages(root) {
68
+ const modern = outermost($$('[class*="group/user-message"]', root));
69
+ return modern.length ? modern : $$('[data-message-author-role="user"]', root);
70
+ }
71
+
72
+ const generatedImages = root => {
73
+ const seen = new Set();
74
+ return $$('[data-testid="generated-image-preview"] img, [data-testid="generated-image-gallery"] img', root)
75
+ .filter(img => !seen.has(img) && seen.add(img));
76
+ };
77
+
78
+ function counts() {
79
+ const root = transcriptRoot();
80
+ return { users: userMessages(root).length, images: generatedImages(root).length };
81
+ }
82
+
83
+ const isAfter = (last, n) => !last || (n !== last && !last.contains(n) && !!(last.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_FOLLOWING));
84
+
85
+ // Elements that come after the last user message, i.e. the reply being written.
86
+ function nodesAfter(after, root) {
87
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
88
+ const out = [];
89
+ let passed = !after;
90
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
91
+ if (!passed) {
92
+ if (!isAfter(after, n)) continue;
93
+ passed = true;
94
+ }
95
+ out.push(n);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function lastUser(root) {
101
+ const users = userMessages(root);
102
+ const last = users[users.length - 1] || null;
103
+ const textEl = last && (last.querySelector('.whitespace-pre-wrap') || last);
104
+ return { users, last, text: textEl ? squash(textEl.innerText || textEl.textContent).slice(0, 400) : '' };
105
+ }
106
+
107
+ const inMarkdown = el => !!el.closest('[class*="MarkdownRoot"], .markdown');
108
+
109
+ // "Görsel oluşturma başarısız oldu [Tekrar dene]" status line under a reply.
110
+ const FAILED_TEXT = /görsel oluşturma başarısız|görüntü oluşturma başarısız|image (generation|creation) failed|couldn.?t (generate|create) (the |this )?image|failed to (generate|create) (the |this )?image/i;
111
+ function failedText(replyEls) {
112
+ for (const el of replyEls) {
113
+ if (inMarkdown(el)) continue;
114
+ const t = squash(el.textContent);
115
+ if (t && t.length < 120 && FAILED_TEXT.test(t)) return t.replace(/\s*(tekrar dene|try again|retry)$/i, '');
116
+ }
117
+ return null;
118
+ }
119
+
120
+ // The reply as ChatGPT wrote it (its markdown blocks), plus a failure line if there is one.
121
+ // Other text after the reply (feedback surveys, buttons, screen-reader labels) is left out.
122
+ function replyText(replyEls) {
123
+ const md = outermost(replyEls.filter(el => /MarkdownRoot|(^|\s)markdown(\s|$)/.test(classOf(el))));
124
+ const parts = md.length
125
+ ? md.map(el => el.innerText || el.textContent)
126
+ : replyEls.filter(el => !el.childElementCount && !el.closest('.sr-only, button, [role="status"], form')).map(el => el.textContent);
127
+ const failed = failedText(replyEls);
128
+ if (failed) parts.push(failed);
129
+ return tidy(parts.join('\n'));
130
+ }
131
+
132
+ const PERCENT = /%\s?\d{1,3}\b|\b\d{1,3}\s?%/;
133
+
134
+ // Progress such as "Eskiz çiziliyor %41" in the reply, or a small "Görsel önizleme %47" popup.
135
+ function progressText(replyEls) {
136
+ for (const el of replyEls) {
137
+ if (el.childElementCount || inMarkdown(el)) continue;
138
+ const t = squash(el.textContent);
139
+ if (t && t.length < 80 && PERCENT.test(t)) return t;
140
+ }
141
+ for (const el of $$('[role="dialog"], [role="status"], [aria-live]')) {
142
+ const t = squash(el.innerText || el.textContent);
143
+ if (t && t.length < 80 && PERCENT.test(t)) return t;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ const IMG_HINT = /estuary\/content|oaiusercontent\.com|\/backend-api\/files|\/file-service|sdmntpr/i;
149
+
150
+ function imageKey(src) {
151
+ if (src.startsWith('blob:')) return src;
152
+ try { const u = new URL(src, location.href); return u.searchParams.get('id') || u.origin + u.pathname; } catch { return src; }
153
+ }
154
+
155
+ function blurred(el) {
156
+ for (let i = 0; el && i < 4; i++, el = el.parentElement) {
157
+ const f = getComputedStyle(el).filter;
158
+ if (f && f.includes('blur')) return true;
159
+ }
160
+ return false;
161
+ }
162
+
163
+ function describeImages(imgs) {
164
+ const map = new Map();
165
+ for (const img of imgs) {
166
+ const src = img.currentSrc || img.src || '';
167
+ if (!src || src.startsWith('data:image/svg') || /favicon|\/s2\/favicons|gravatar|avatar/i.test(src)) continue;
168
+ const r = img.getBoundingClientRect();
169
+ const info = {
170
+ src, key: imageKey(src), alt: squash(img.alt).slice(0, 120), w: img.naturalWidth, h: img.naturalHeight,
171
+ rw: Math.round(r.width), rh: Math.round(r.height), complete: img.complete, blurred: blurred(img),
172
+ };
173
+ const prev = map.get(info.key);
174
+ if (!prev || (prev.blurred && !info.blurred)) map.set(info.key, info);
175
+ }
176
+ return [...map.values()];
177
+ }
178
+
179
+ // Fallback for layouts without the generated-image markers: large or known-host images.
180
+ function looseImages(imgs) {
181
+ return describeImages(imgs.filter(img => {
182
+ const src = img.currentSrc || img.src || '';
183
+ const r = img.getBoundingClientRect();
184
+ return IMG_HINT.test(src) || src.startsWith('blob:') || Math.max(img.naturalWidth, img.naturalHeight, r.width, r.height) >= 200;
185
+ }));
186
+ }
187
+
188
+ // Short status labels ChatGPT shows while it works ("Düşünüyor", "Eskiz çiziliyor %41", "Önizleme").
189
+ const BUSY_TEXT = /creating image|generating image|image is being|thinking|sketch|preview|görsel oluşturuluyor|görüntü oluşturuluyor|resim oluşturuluyor|görsel hazırlanıyor|düşünüyor|eskiz|önizleme/i;
190
+ const BUSY_SEL = '[aria-busy="true"], .pulsing-dot, .loading-shimmer, [class*="shimmer"], [role="progressbar"], .result-streaming';
191
+
192
+ function busyIn(els) {
193
+ return els.some(el => !inMarkdown(el) && (el.matches(BUSY_SEL) ||
194
+ (!el.childElementCount && (el.textContent || '').length < 80 && BUSY_TEXT.test(el.textContent || ''))));
195
+ }
196
+
197
+ const ERROR_TEXT = /something went wrong|an error occurred|error in message stream|network error|bir şeyler ters gitti|bir hata oluştu|hata oluştu|ağ hatası/i;
198
+
199
+ function errorIn(els) {
200
+ for (const el of els) {
201
+ if (inMarkdown(el)) continue;
202
+ const t = squash(el.textContent);
203
+ if (!t) continue;
204
+ if (el.matches('.text-red-500, [class*="text-token-text-error"], [data-testid*="error" i]')) return t.slice(0, 300);
205
+ if (!el.childElementCount && t.length < 300 && ERROR_TEXT.test(t)) return t;
206
+ }
207
+ return null;
208
+ }
209
+
210
+ // baseline: {users, images} counted before our message was sent (null = page state only).
211
+ function probe(baseline) {
212
+ const ed = editor();
213
+ const send = sendButton();
214
+ const root = transcriptRoot();
215
+ const { users, last, text: lastUserText } = lastUser(root);
216
+ const gen = generatedImages(root);
217
+ const replyEls = nodesAfter(last, root);
218
+ const out = {
219
+ url: location.href, title: document.title, ready: document.readyState, hidden: document.hidden,
220
+ viewport: [innerWidth, innerHeight],
221
+ challenge: challenge(!!ed), login: loginWall(),
222
+ composer: !!ed,
223
+ editorKind: !ed ? null : ed.tagName === 'TEXTAREA' ? 'textarea' : ed.classList.contains('ProseMirror') ? 'prosemirror' : 'contenteditable',
224
+ composerText: squash(textOf(ed)).slice(0, 200),
225
+ send: send ? { disabled: isDisabled(send), testid: send.getAttribute('data-testid'), label: send.getAttribute('aria-label') } : null,
226
+ stop: !!stopButton(),
227
+ counts: { users: users.length, images: gen.length },
228
+ lastUserText,
229
+ progress: progressText(replyEls),
230
+ sidebarOpen: (() => { const sb = $('#app-shell-sidebar'); return sb ? sb.getBoundingClientRect().width > 120 : null; })(),
231
+ dialogs: $$('[role="dialog"]').filter(shown).map(d => squash(d.innerText).slice(0, 120)),
232
+ };
233
+ if (baseline && typeof baseline === 'object') {
234
+ out.userTurns = users.length - (baseline.users || 0);
235
+ const mine = gen.filter(img => isAfter(last, img));
236
+ out.images = mine.length ? describeImages(mine)
237
+ : gen.length ? []
238
+ : looseImages(replyEls.filter(el => el.tagName === 'IMG' && !el.closest('form, .composer-attachment-surface')));
239
+ out.text = replyText(replyEls).slice(0, 4000);
240
+ out.failed = failedText(replyEls);
241
+ out.done = replyEls.some(el => classOf(el).includes('turn-action-controls'));
242
+ out.replyCount = out.text || out.images.length ? 1 : 0;
243
+ out.busy = busyIn(replyEls) || !!out.progress;
244
+ out.error = errorIn(replyEls);
245
+ out.sig = `${out.images.map(i => i.key + (i.blurred ? '~b' : '')).join('|')}#${out.text.length}#${out.busy}#${out.stop}#${out.progress}`;
246
+ }
247
+ return out;
248
+ }
249
+
250
+ function composerState() {
251
+ const ed = editor();
252
+ const send = sendButton();
253
+ return {
254
+ composer: !!ed, text: textOf(ed), send: send ? { disabled: isDisabled(send) } : null, stop: !!stopButton(),
255
+ dialogs: $$('[role="dialog"]').filter(shown).length,
256
+ };
257
+ }
258
+
259
+ // ------------------------------------------------------------ actions (synthetic DOM events only)
260
+
261
+ function selectAll(el) {
262
+ const sel = getSelection();
263
+ const range = document.createRange();
264
+ range.selectNodeContents(el);
265
+ sel.removeAllRanges();
266
+ sel.addRange(range);
267
+ }
268
+
269
+ function setNativeValue(el, value) {
270
+ const desc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value') ||
271
+ Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
272
+ desc.set.call(el, value);
273
+ el.dispatchEvent(new Event('input', { bubbles: true }));
274
+ }
275
+
276
+ function clearComposer() {
277
+ const ed = editor();
278
+ if (!ed) return { ok: false, error: 'composer not found' };
279
+ if (ed.tagName === 'TEXTAREA') { setNativeValue(ed, ''); return { ok: true }; }
280
+ ed.focus();
281
+ selectAll(ed);
282
+ document.execCommand('delete', false);
283
+ return { ok: true, left: textOf(ed).length };
284
+ }
285
+
286
+ function setPrompt(text, method) {
287
+ const ed = editor();
288
+ if (!ed) return { ok: false, error: 'composer not found' };
289
+ if (ed.tagName === 'TEXTAREA') { setNativeValue(ed, text); return { ok: true, method: 'textarea' }; }
290
+ ed.focus();
291
+ selectAll(ed);
292
+ if (method === 'insertText') return { ok: document.execCommand('insertText', false, text), method };
293
+ if (method === 'paste') {
294
+ const data = new DataTransfer();
295
+ data.setData('text/plain', text);
296
+ const ev = new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true });
297
+ ed.dispatchEvent(ev);
298
+ return { ok: true, method, handled: ev.defaultPrevented };
299
+ }
300
+ const paragraphs = text.split('\n').map(line => {
301
+ const p = document.createElement('p');
302
+ if (line) p.textContent = line; else p.appendChild(document.createElement('br'));
303
+ return p;
304
+ });
305
+ ed.replaceChildren(...paragraphs);
306
+ ed.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
307
+ return { ok: true, method: 'dom' };
308
+ }
309
+
310
+ function clickSend() {
311
+ const b = sendButton();
312
+ if (!b) return { ok: false, error: 'send button not found' };
313
+ if (isDisabled(b)) return { ok: false, error: 'send button disabled' };
314
+ b.click();
315
+ return { ok: true };
316
+ }
317
+
318
+ function pressEnter() {
319
+ const ed = editor();
320
+ if (!ed) return { ok: false, error: 'composer not found' };
321
+ ed.focus();
322
+ const init = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
323
+ ed.dispatchEvent(new KeyboardEvent('keydown', init));
324
+ ed.dispatchEvent(new KeyboardEvent('keyup', init));
325
+ return { ok: true };
326
+ }
327
+
328
+ function clickStop() {
329
+ const b = stopButton();
330
+ if (!b) return { ok: false };
331
+ b.click();
332
+ return { ok: true };
333
+ }
334
+
335
+ // Closes pop-ups that can sit over the composer. It only uses their close (X) buttons / Escape,
336
+ // never "accept"-style buttons.
337
+ function dismissDialogs() {
338
+ let closed = 0;
339
+ for (const d of $$('[role="dialog"]')) {
340
+ const btn = d.querySelector('button[aria-label*="close" i], button[aria-label*="kapat" i], [data-testid="close-button"]');
341
+ if (btn) { btn.click(); closed++; }
342
+ }
343
+ const esc = { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true };
344
+ (document.activeElement || document.body).dispatchEvent(new KeyboardEvent('keydown', esc));
345
+ return { closed };
346
+ }
347
+
348
+ // ------------------------------------------------------------ attachments (reference images)
349
+
350
+ function b64ToBlob(data, mime) {
351
+ const bin = atob(data);
352
+ const bytes = new Uint8Array(bin.length);
353
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
354
+ return new Blob([bytes], { type: mime || 'image/png' });
355
+ }
356
+
357
+ function fileInputs() {
358
+ const ed = editor();
359
+ const scope = (ed && (ed.closest('form') || composerRoot(ed))) || document;
360
+ return $$('input[type="file"]', scope);
361
+ }
362
+
363
+ // Puts files on the composer's own photo input, exactly like picking them in the file dialog.
364
+ function attachFiles(files) {
365
+ const inputs = fileInputs();
366
+ const input = inputs.find(i => /image/.test(i.accept || '') && !/video/.test(i.accept || '')) ||
367
+ inputs.find(i => /image/.test(i.accept || '')) || inputs.find(i => !i.accept) || inputs[0];
368
+ if (!input) return { ok: false, error: 'file input not found' };
369
+ const data = new DataTransfer();
370
+ for (const f of files) data.items.add(new File([b64ToBlob(f.data, f.mime)], f.name, { type: f.mime }));
371
+ input.files = data.files;
372
+ input.dispatchEvent(new Event('input', { bubbles: true }));
373
+ input.dispatchEvent(new Event('change', { bubbles: true }));
374
+ return { ok: true, input: input.getAttribute('aria-label'), count: input.files ? input.files.length : null };
375
+ }
376
+
377
+ // Attachment chips in the composer ("reference.png" + its remove button) and whether any still uploads.
378
+ function attachmentState() {
379
+ const root = composerRoot(editor());
380
+ const area = root && $('[class*="ComposerLayoutAttachments"]', root);
381
+ const items = area ? outermost($$('[role="button"][aria-label]', area)) : [];
382
+ const uploading = items.some(el => el.matches('[aria-busy="true"]') ||
383
+ !!el.querySelector('[role="progressbar"], [aria-busy="true"], .animate-spin, [class*="spinner"], [class*="loading"]'));
384
+ const failed = items.filter(el => /başarısız|failed|hata|error/i.test(el.textContent || '')).map(el => el.getAttribute('aria-label'));
385
+ return { count: items.length, uploading, failed, names: items.map(el => el.getAttribute('aria-label')) };
386
+ }
387
+
388
+ function removeAttachments() {
389
+ const root = composerRoot(editor());
390
+ if (!root) return { removed: 0 };
391
+ const buttons = $$('button', root).filter(b => /kaldır|remove|delete|sil\b/i.test(b.getAttribute('aria-label') || ''));
392
+ buttons.forEach(b => b.click());
393
+ return { removed: buttons.length, labels: buttons.map(b => b.getAttribute('aria-label')) };
394
+ }
395
+
396
+ // The newest generated image above the last user message (to re-attach it when ChatGPT asks).
397
+ function latestEarlierImage() {
398
+ const root = transcriptRoot();
399
+ const { last } = lastUser(root);
400
+ const earlier = generatedImages(root).filter(img => !isAfter(last, img));
401
+ const img = earlier[earlier.length - 1];
402
+ return img ? { src: img.currentSrc || img.src } : null;
403
+ }
404
+
405
+ // ------------------------------------------------------------ edit check
406
+
407
+ // 32x32 grayscale thumbnail of an <img> or of {data, mime} bytes. Images are decoded from their
408
+ // bytes: img.decode() can stall for minutes in a background tab that is never painted.
409
+ async function signature(source) {
410
+ const blob = source instanceof HTMLImageElement
411
+ ? await (await fetch(source.currentSrc || source.src)).blob()
412
+ : b64ToBlob(source.data, source.mime);
413
+ const bitmap = await createImageBitmap(blob);
414
+ const canvas = document.createElement('canvas');
415
+ canvas.width = 32;
416
+ canvas.height = 32;
417
+ const g = canvas.getContext('2d', { willReadFrequently: true });
418
+ g.imageSmoothingEnabled = true;
419
+ g.imageSmoothingQuality = 'high';
420
+ g.drawImage(bitmap, 0, 0, 32, 32);
421
+ if (bitmap.close) bitmap.close();
422
+ const d = g.getImageData(0, 0, 32, 32).data;
423
+ const gray = new Array(1024);
424
+ for (let i = 0; i < 1024; i++) gray[i] = 0.299 * d[i * 4] + 0.587 * d[i * 4 + 1] + 0.114 * d[i * 4 + 2];
425
+ return gray;
426
+ }
427
+
428
+ // Share of the 64 blocks (4x4 px of the contrast-normalised thumbnail) that stayed the same.
429
+ // Calibrated on real pairs: local edits (mouth, eyes) ~1.00, a redraw of the same character ~0.47,
430
+ // unrelated images ~0.
431
+ function similarity(a, b) {
432
+ const norm = v => {
433
+ const m = v.reduce((s, x) => s + x, 0) / v.length;
434
+ const sd = Math.sqrt(v.reduce((s, x) => s + (x - m) ** 2, 0) / v.length) || 1;
435
+ return v.map(x => (x - m) / sd);
436
+ };
437
+ const za = norm(a);
438
+ const zb = norm(b);
439
+ let same = 0;
440
+ for (let by = 0; by < 8; by++) {
441
+ for (let bx = 0; bx < 8; bx++) {
442
+ let diff = 0;
443
+ for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) { const i = (by * 4 + y) * 32 + bx * 4 + x; diff += Math.abs(za[i] - zb[i]); }
444
+ if (diff / 16 < 0.3) same++;
445
+ }
446
+ }
447
+ return same / 64;
448
+ }
449
+
450
+ // Compares each new image with the reference images and with the images earlier in the chat.
451
+ async function editCheck(refs) {
452
+ const root = transcriptRoot();
453
+ const { last } = lastUser(root);
454
+ const all = generatedImages(root);
455
+ const outputs = describeImages(all.filter(img => isAfter(last, img))).map(i => all.find(img => (img.currentSrc || img.src) === i.src));
456
+ const earlier = all.filter(img => !isAfter(last, img)).slice(-12).reverse();
457
+ const candidates = [
458
+ ...(refs || []).map((r, i) => ({ label: `reference image ${i + 1}${r.name ? ` (${r.name})` : ''}`, source: r })),
459
+ ...earlier.map((img, i) => ({ label: i === 0 ? 'the previous image in the chat' : `an earlier image in the chat (${i + 1} back)`, source: img })),
460
+ ];
461
+ if (!outputs.length || !candidates.length) return null;
462
+ const sigs = [];
463
+ for (const c of candidates) {
464
+ try { sigs.push({ label: c.label, sig: await signature(c.source) }); } catch {}
465
+ }
466
+ const results = [];
467
+ for (const img of outputs) {
468
+ let out;
469
+ try { out = await signature(img); } catch { continue; }
470
+ let best = null;
471
+ for (const c of sigs) {
472
+ const s = similarity(out, c.sig);
473
+ if (!best || s > best.similarity) best = { similarity: Math.round(s * 100) / 100, against: c.label };
474
+ }
475
+ if (best) results.push(best);
476
+ }
477
+ return results.length ? { results, compared: sigs.length } : null;
478
+ }
479
+
480
+ // ------------------------------------------------------------ projects (sidebar)
481
+
482
+ // Sidebar folder rows of the "Projects" section: a row (role=button, aria-expanded) holding the
483
+ // name, followed by a list of the project's chats. The rows have no link of their own; the
484
+ // project id only shows up in the links of the chats inside it.
485
+ function projectRows() {
486
+ const rows = [];
487
+ for (const item of $$('[role="listitem"]')) {
488
+ const head = item.querySelector('[role="button"][aria-expanded]');
489
+ const chats = item.querySelector('[role="list"]');
490
+ if (!head || !chats || !(head.compareDocumentPosition(chats) & Node.DOCUMENT_POSITION_FOLLOWING)) continue;
491
+ const nameEl = head.querySelector('[class*="content-"]') || head;
492
+ const name = squash(nameEl.textContent).split('\n')[0];
493
+ if (name) rows.push({ item, head, chats, name });
494
+ }
495
+ return rows;
496
+ }
497
+
498
+ function listProjects() {
499
+ const found = new Map();
500
+ for (const r of projectRows()) {
501
+ const link = r.chats.querySelector('a[href*="/g/g-p-"]');
502
+ const m = link && link.getAttribute('href').match(/\/g\/(g-p-[0-9a-f]+)/i);
503
+ found.set(r.name, { name: r.name, id: m ? m[1] : null, url: m ? `${location.origin}/g/${m[1]}/project` : null });
504
+ }
505
+ // Older layouts linked the project page directly.
506
+ for (const a of $$('a[href*="/g/g-p-"]')) {
507
+ const m = (a.getAttribute('href') || '').match(/^\/g\/(g-p-[0-9a-f]+)(-[^/]*)?\/project\/?$/i);
508
+ const name = squash((a.innerText || a.getAttribute('aria-label') || '').split('\n')[0]);
509
+ if (m && name && !found.has(name)) found.set(name, { name, id: m[1], url: `${location.origin}/g/${m[1]}${m[2] || ''}/project` });
510
+ }
511
+ return [...found.values()];
512
+ }
513
+
514
+ // Presses a project's "new chat in this project" button; ChatGPT then shows the project page,
515
+ // whose URL carries the project id. (The other row button, the actions menu, has aria-expanded.)
516
+ function openProject(name) {
517
+ const row = projectRows().find(r => r.name === name);
518
+ if (!row) return { ok: false, error: 'project row not found' };
519
+ const btn = $$('button', row.head).find(b => !b.hasAttribute('aria-expanded'));
520
+ press(btn || row.head);
521
+ return { ok: true, via: btn ? 'new-chat-button' : 'row' };
522
+ }
523
+
524
+ // Radix menus open on pointerdown, plain buttons and popovers on click: send both.
525
+ function press(el) {
526
+ el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerType: 'mouse', isPrimary: true }));
527
+ el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, button: 0, pointerType: 'mouse', isPrimary: true }));
528
+ el.click();
529
+ }
530
+
531
+ function clickLabel(label) {
532
+ const want = squash(label).toLowerCase();
533
+ const els = $$('button, a, [role="button"], [role="menuitem"], [role="tab"], [role="option"]');
534
+ const el = els.find(e => squash(e.getAttribute('aria-label')).toLowerCase() === want) ||
535
+ els.find(e => squash(e.innerText).toLowerCase() === want);
536
+ if (!el) return { clicked: false };
537
+ press(el);
538
+ return { clicked: true, tag: el.tagName, label: el.getAttribute('aria-label'), text: squash(el.innerText).slice(0, 60) };
539
+ }
540
+
541
+ function sidebar(open) {
542
+ const b = firstOf(open
543
+ ? ['[data-testid="open-sidebar-button"]', 'button[aria-label="Open sidebar"]', 'button[aria-label="Kenar çubuğunu göster"]', 'button[aria-label*="kenar çubuğunu aç" i]']
544
+ : ['[data-testid="close-sidebar-button"]', 'button[aria-label="Close sidebar"]', 'button[aria-label="Kenar çubuğunu gizle"]', 'button[aria-label*="kenar çubuğunu kapat" i]']);
545
+ if (!b) return { clicked: false };
546
+ b.click();
547
+ return { clicked: true };
548
+ }
549
+
550
+ // ------------------------------------------------------------ image bytes
551
+
552
+ async function blobToResult(blob, via) {
553
+ const bytes = new Uint8Array(await blob.arrayBuffer());
554
+ let bin = '';
555
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
556
+ return { data: btoa(bin), mime: blob.type || '', size: bytes.length, via };
557
+ }
558
+
559
+ // Reads the image bytes inside the page (same origin, so ChatGPT's own blob: and file URLs work).
560
+ // If fetching fails, the already-loaded <img> is copied through a canvas (same-origin, untainted).
561
+ async function fetchImage(src) {
562
+ let fetchError = null;
563
+ try {
564
+ const res = await fetch(src, { credentials: 'include' });
565
+ if (res.ok) return blobToResult(await res.blob(), 'fetch');
566
+ fetchError = `HTTP ${res.status}`;
567
+ } catch (e) {
568
+ fetchError = String(e);
569
+ }
570
+ const img = $$('img').find(i => (i.currentSrc || i.src) === src);
571
+ if (!img) return { error: `${fetchError}; image element not found` };
572
+ if (!img.naturalWidth) return { error: `${fetchError}; image not decoded` };
573
+ const canvas = document.createElement('canvas');
574
+ canvas.width = img.naturalWidth;
575
+ canvas.height = img.naturalHeight;
576
+ canvas.getContext('2d').drawImage(img, 0, 0);
577
+ const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
578
+ if (!blob) return { error: `${fetchError}; canvas export failed` };
579
+ return blobToResult(blob, 'canvas');
580
+ }
581
+
582
+ // ------------------------------------------------------------ diagnostics
583
+
584
+ const ATTRS = ['id', 'data-testid', 'role', 'aria-label', 'data-turn', 'data-message-author-role', 'href', 'src', 'alt',
585
+ 'contenteditable', 'disabled', 'aria-disabled', 'aria-busy', 'aria-expanded', 'title', 'type', 'accept', 'multiple', 'name'];
586
+
587
+ function outline(root, max) {
588
+ const lines = [];
589
+ const walk = (el, depth) => {
590
+ if (lines.length >= max || !el || el.nodeType !== 1) return;
591
+ const tag = el.tagName.toLowerCase();
592
+ if (['script', 'style', 'noscript', 'template', 'path', 'svg'].includes(tag)) return;
593
+ const attrs = [];
594
+ for (const name of ATTRS) {
595
+ const v = el.getAttribute(name);
596
+ if (v !== null) attrs.push(`${name}="${v.slice(0, name === 'src' || name === 'href' ? 100 : 60)}"`);
597
+ }
598
+ if (classOf(el)) attrs.push(`class="${classOf(el).slice(0, 100)}"`);
599
+ let own = '';
600
+ for (const n of el.childNodes) if (n.nodeType === 3) own += n.textContent;
601
+ own = squash(own).slice(0, 80);
602
+ const r = el.getBoundingClientRect();
603
+ const size = r.width || r.height ? ` [${Math.round(r.width)}x${Math.round(r.height)}]` : ' [0]';
604
+ const filter = getComputedStyle(el).filter;
605
+ lines.push(`${' '.repeat(Math.min(depth, 24))}<${tag}${attrs.length ? ` ${attrs.join(' ')}` : ''}>${size}${filter && filter !== 'none' ? ` {filter:${filter}}` : ''}${own ? ` "${own}"` : ''}`);
606
+ for (const c of el.children) walk(c, depth + 1);
607
+ };
608
+ walk(root, 0);
609
+ return lines;
610
+ }
611
+
612
+ function snapshot(selector, max) {
613
+ let root = document.body;
614
+ if (selector === 'transcript') root = transcriptRoot();
615
+ else if (selector) root = $(selector) || document.body;
616
+ return { url: location.href, title: document.title, probe: probe({ users: 0, images: 0 }), lines: outline(root, Math.min(max || 400, 3000)) };
617
+ }
618
+
619
+ globalThis.__cgb = {
620
+ version: VERSION, probe, composerState, clearComposer, setPrompt, clickSend, pressEnter, clickStop,
621
+ dismissDialogs, listProjects, openProject, sidebar, clickLabel, fetchImage, snapshot, counts,
622
+ attachFiles, attachmentState, removeAttachments, latestEarlierImage, editCheck,
623
+ };
624
+ })();
@@ -0,0 +1,20 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Backdraw",
4
+ "short_name": "Backdraw",
5
+ "version": "2.1.1",
6
+ "description": "__MSG_extDescription__",
7
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtu/jyAJ3e3hnNrNGCxfzvHLOp80L9c3j71jmQV2ymY1DbPfZNJUdrszM3dnk6SFlm/X42F/e1Pf9RoQqMKujWr7KRTtH257vBMmLbk8Eo0LytpGP0F8WOQIcp5TxLZgvqSlGTjdbB2QFJt+uisJFE8Y3nWnZpsmejes0m9NObpqBc7Q1tzZrDAnh6gzddyF8fkYU/gS2Qtu5Vd2YbRiVN18tUUh2Bb+QuXlTlzOE1JQ49A9qRTmXejtcn943ZaQ0aucooNQU4hZAFVUT+25UhzuGMZDFzZALqL19hP0EP4Erm1yitjICvR5yK/7nYObGa3LynEeH7dDJ+qoJaf4TEQIDAQAB",
8
+ "default_locale": "en",
9
+ "minimum_chrome_version": "116",
10
+ "permissions": ["nativeMessaging", "scripting", "storage", "alarms", "contextMenus", "sidePanel"],
11
+ "host_permissions": ["https://chatgpt.com/*", "https://*.oaiusercontent.com/*", "<all_urls>"],
12
+ "background": { "service_worker": "background.js" },
13
+ "action": {
14
+ "default_title": "Backdraw",
15
+ "default_popup": "popup.html",
16
+ "default_icon": { "16": "icons/icon16.png", "32": "icons/icon32.png" }
17
+ },
18
+ "side_panel": { "default_path": "sidepanel.html" },
19
+ "icons": { "16": "icons/icon16.png", "32": "icons/icon32.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }
20
+ }