backdraw 0.0.0-stage → 2.1.0

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 +606 -2
  3. package/README.tr.md +624 -0
  4. package/bin/backdraw.js +56 -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 +54 -4
  56. package/platform/index.js +14 -0
  57. package/platform/win32.js +268 -0
  58. package/setup.js +212 -0
  59. package/shared.js +118 -0
@@ -0,0 +1,57 @@
1
+ // The popup's setup checklist and status texts, worked out from the worker's popup:status answer.
2
+ // No chrome.* calls here, so the tests can load it too.
3
+ (() => {
4
+ const STEP_TITLES = { bridge: 'stepBridge', chatgpt: 'stepChatgpt', project: 'stepProject', ready: 'stepReady' };
5
+ // The steps of a running job (background.js ctx.progress) and their texts in i18n.js.
6
+ const JOB_STEPS = {
7
+ starting: 'jobStarting', opening: 'jobOpening', attaching: 'jobAttaching', typing: 'jobTyping', sending: 'jobSending',
8
+ generating: 'jobGenerating', reattaching: 'jobReattaching', verifying: 'jobVerifying', downloading: 'jobDownloading',
9
+ needs_human: 'jobNeedsHuman', resuming: 'jobResuming',
10
+ };
11
+
12
+ // s: the popup:status answer (null when the worker did not answer); acknowledged: the user pressed
13
+ // Done on the last step once. A step is 'done', 'todo' (the user has something to do) or 'wait'
14
+ // (it can't be told yet, or an earlier step comes first). The checklist is shown until it was
15
+ // finished once, and again whenever a step needs the user.
16
+ function setupSteps(s, acknowledged) {
17
+ const connected = !!(s && s.connected);
18
+ const chat = (s && s.chatgpt) || {};
19
+ const bridge = connected ? 'done' : 'todo';
20
+ const chatgpt = chat.challenge ? 'todo' : chat.loggedIn === true ? 'done' : chat.loggedIn === false ? 'todo' : 'wait';
21
+ const project = s && s.project ? 'done' : connected ? 'todo' : 'wait';
22
+ const ready = [bridge, chatgpt, project].every(x => x === 'done') ? (acknowledged ? 'done' : 'todo') : 'wait';
23
+ const steps = [{ id: 'bridge', state: bridge }, { id: 'chatgpt', state: chatgpt }, { id: 'project', state: project }, { id: 'ready', state: ready }];
24
+ return { steps, wizard: !acknowledged || steps.some(x => x.state === 'todo'), done: steps.filter(x => x.state === 'done').length };
25
+ }
26
+
27
+ // A ChatGPT project page (or a chat inside a project) in the active tab can be picked directly.
28
+ function projectFromTab(tab) {
29
+ const m = /^https:\/\/chatgpt\.com\/g\/(g-p-[0-9a-f]+)(-[^/?#]*)?(\/project)?/i.exec((tab && tab.url) || '');
30
+ if (!m) return null;
31
+ const fromTitle = m[3] ? String(tab.title || '').replace(/\s*[-|–]\s*ChatGPT\s*$/i, '').replace(/^ChatGPT\s*[-|–]\s*/i, '').trim() : '';
32
+ return { url: `https://chatgpt.com/g/${m[1]}${m[2] || ''}/project`, name: fromTitle || (m[2] || '').slice(1) || m[1] };
33
+ }
34
+
35
+ // A project's link may carry its name (…/g/g-p-abc-images/project) or not (one picked from the list).
36
+ const projectId = url => ((/\/g\/(g-p-[0-9a-f]+)/i.exec(String(url || '')) || [])[1] || '').toLowerCase();
37
+ const sameProject = (a, b) => !!(a && b && projectId(a.url)) && projectId(a.url) === projectId(b.url);
38
+
39
+ // The running job in the popup's language; ChatGPT's own progress (a percentage) is kept.
40
+ function jobText(current, t) {
41
+ if (!current) return t('idle');
42
+ const key = JOB_STEPS[current.step];
43
+ const text = key ? t(key) : current.message || current.step || '';
44
+ const pct = (/(\d{1,3})%/.exec(current.message || '') || [])[1];
45
+ return pct ? t('jobPercent', { step: text, pct }) : text;
46
+ }
47
+
48
+ // The ChatGPT row of the status list.
49
+ function chatgptText(chat, t) {
50
+ const c = chat || {};
51
+ return c.challenge ? t('verification') : c.loggedIn === true ? t('signedIn') : c.loggedIn === false ? t('signedOut') : t('notChecked');
52
+ }
53
+
54
+ const api = { STEP_TITLES, JOB_STEPS, setupSteps, projectFromTab, sameProject, jobText, chatgptText };
55
+ if (typeof module === 'object' && module.exports) module.exports = api;
56
+ else globalThis.backdrawSetup = api;
57
+ })();
@@ -0,0 +1,113 @@
1
+ /* The popup's palette. A stored theme (data-theme on <html>) wins over the system setting. */
2
+ :root {
3
+ color-scheme: light;
4
+ --bg: #ffffff; --fg: #1d1d1f; --muted: #6e6e73; --line: #e5e5ea; --card: #ffffff; --soft: #f5f5f7;
5
+ --ok: #1a7f37; --bad: #c62828; --warn: #9a5b00; --accent: #7c3aed; --accent-fg: #ffffff; --accent-soft: rgba(124, 58, 237, .1);
6
+ --shadow: 0 1px 2px rgba(0, 0, 0, .05);
7
+ }
8
+ @media (prefers-color-scheme: dark) {
9
+ :root:not([data-theme="light"]) {
10
+ color-scheme: dark;
11
+ --bg: #1c1c1e; --fg: #f2f2f7; --muted: #a1a1a6; --line: #38383a; --card: #242426; --soft: #2c2c2e;
12
+ --ok: #3fb950; --bad: #ff6b6b; --warn: #f0b429; --accent-soft: rgba(167, 139, 250, .16); --shadow: none;
13
+ }
14
+ }
15
+ :root[data-theme="dark"] {
16
+ color-scheme: dark;
17
+ --bg: #1c1c1e; --fg: #f2f2f7; --muted: #a1a1a6; --line: #38383a; --card: #242426; --soft: #2c2c2e;
18
+ --ok: #3fb950; --bad: #ff6b6b; --warn: #f0b429; --accent-soft: rgba(167, 139, 250, .16); --shadow: none;
19
+ }
20
+
21
+ * { box-sizing: border-box; }
22
+ body { margin: 0; min-width: 0; background: var(--bg); color: var(--fg); font: 13px/1.45 "Segoe UI", system-ui, sans-serif; }
23
+ [hidden] { display: none !important; }
24
+ button { font: inherit; color: inherit; }
25
+
26
+ .top { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 8px; padding: 10px 14px;
27
+ border-bottom: 1px solid var(--line); background: var(--bg); }
28
+ .top img { width: 20px; height: 20px; }
29
+ .top h1 { flex: 1; margin: 0; font-size: 14px; font-weight: 600; }
30
+ .top h1 span { color: var(--muted); font-weight: 400; }
31
+
32
+ .icon-btn { display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid var(--line);
33
+ border-radius: 7px; background: transparent; cursor: pointer; }
34
+ .icon-btn:hover:not(:disabled) { background: var(--soft); }
35
+ .icon-btn:disabled { opacity: .4; cursor: default; }
36
+ .icon-btn.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
37
+ .icon-btn svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
38
+ button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
39
+
40
+ .now { position: relative; display: flex; align-items: center; gap: 10px; margin: 12px 14px 4px; padding: 10px 12px;
41
+ border: 1px solid var(--line); border-radius: 10px; background: var(--soft); overflow: hidden; }
42
+ .now .dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
43
+ .now-text { min-width: 0; }
44
+ #nowTitle { font-weight: 600; overflow-wrap: anywhere; }
45
+ #nowMeta { color: var(--muted); font-size: 11.5px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
46
+ #nowMeta:empty { display: none; }
47
+ .now.offline .dot { background: var(--bad); }
48
+ .now.offline #nowTitle { color: var(--bad); }
49
+ .now.human { border-color: var(--warn); }
50
+ .now.human .dot { background: var(--warn); }
51
+ .now.running .dot { background: var(--accent); animation: pulse 1.4s ease-in-out infinite; }
52
+ .now.running::after { content: ""; position: absolute; left: 0; bottom: 0; width: 35%; height: 2px; background: var(--accent);
53
+ animation: slide 1.8s ease-in-out infinite; }
54
+ @keyframes pulse { 50% { opacity: .35; } }
55
+ @keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(290%); } }
56
+
57
+ main { padding: 6px 14px 18px; }
58
+ .head { display: flex; align-items: baseline; gap: 8px; }
59
+ .head h2 { margin: 8px 0; font-size: 13px; }
60
+ #count { color: var(--muted); font-size: 12px; }
61
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(136px, 1fr)); gap: 10px; }
62
+
63
+ .card { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--line); border-radius: 10px;
64
+ background: var(--card); box-shadow: var(--shadow); overflow: hidden; }
65
+ .card.open { grid-column: 1 / -1; }
66
+ .thumb { position: relative; display: grid; place-items: center; aspect-ratio: 1; max-height: 260px; background: var(--soft); overflow: hidden; }
67
+ /* Out of the flow, so the box clamped by max-height is what the image is fitted into. */
68
+ .thumb img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
69
+ .thumb .ph { padding: 6px; color: var(--muted); font-size: 11px; text-align: center; }
70
+ .thumb.loading { background: linear-gradient(100deg, var(--soft) 30%, var(--line) 50%, var(--soft) 70%) 0 0 / 300% 100%;
71
+ animation: shimmer 1.4s linear infinite; }
72
+ @keyframes shimmer { to { background-position: -150% 0; } }
73
+ .card.missing .thumb img { opacity: .45; filter: grayscale(1); }
74
+ .body { display: flex; flex-direction: column; gap: 6px; padding: 8px; }
75
+ .prompt { margin: 0; min-height: 2.7em; font-size: 12.5px; line-height: 1.35; overflow: hidden; overflow-wrap: anywhere;
76
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
77
+ .prompt.none { color: var(--muted); font-style: italic; }
78
+ .meta { display: flex; flex-wrap: wrap; gap: 2px 8px; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
79
+ .pill { padding: 0 6px; border: 1px solid var(--line); border-radius: 999px; }
80
+ .actions { display: flex; gap: 4px; }
81
+ .actions .icon-btn { flex: 1 1 0; width: auto; min-width: 0; max-width: 40px; }
82
+ .note { margin: 0; color: var(--muted); font-size: 11.5px; overflow-wrap: anywhere; }
83
+ .note:empty { display: none; }
84
+ .note.ok { color: var(--ok); }
85
+ .note.bad { color: var(--bad); font-weight: 600; }
86
+ .note.warn { color: var(--warn); }
87
+
88
+ .adder { display: flex; flex-direction: column; gap: 4px; padding-top: 6px; border-top: 1px dashed var(--line); }
89
+ .adder .col { display: flex; justify-content: space-between; gap: 8px; width: 100%; padding: 5px 8px; border: 1px solid var(--line);
90
+ border-radius: 7px; background: transparent; text-align: left; cursor: pointer; }
91
+ .adder .col:hover:not(:disabled) { border-color: var(--accent); }
92
+ .adder .col:disabled { opacity: .5; cursor: default; }
93
+ .adder .col span:first-child { overflow-wrap: anywhere; }
94
+ .adder .col span + span { flex: none; color: var(--muted); }
95
+ .adder .col.new { color: var(--accent); font-weight: 600; }
96
+ .adder .list { display: flex; flex-direction: column; gap: 4px; max-height: 210px; overflow-y: auto; }
97
+ .adder form { display: flex; flex-wrap: wrap; gap: 4px; }
98
+ .adder input { flex: 1 1 140px; min-width: 0; padding: 5px 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg); color: var(--fg); font: inherit; }
99
+ .btn { padding: 5px 10px; border: 1px solid var(--line); border-radius: 7px; background: transparent; cursor: pointer; }
100
+ .btn.primary { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); font-weight: 600; }
101
+ .btn:disabled { opacity: .5; cursor: default; }
102
+
103
+ .msg { margin: 14px 0 0; color: var(--muted); text-align: center; }
104
+ .msg:empty { display: none; }
105
+ .msg.bad { color: var(--bad); font-weight: 600; }
106
+ .more { display: block; margin: 14px auto 0; padding: 7px 18px; border: 1px solid var(--line); border-radius: 999px;
107
+ background: var(--card); font-weight: 600; cursor: pointer; }
108
+ .more:hover:not(:disabled) { border-color: var(--accent); }
109
+ .more:disabled { opacity: .5; cursor: default; }
110
+
111
+ @media (prefers-reduced-motion: reduce) {
112
+ .now.running .dot, .now.running::after, .thumb.loading { animation: none; }
113
+ }
@@ -0,0 +1,31 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Backdraw</title>
7
+ <link rel="stylesheet" href="sidepanel.css">
8
+ </head>
9
+ <body>
10
+ <header class="top">
11
+ <img src="icons/icon32.png" alt="">
12
+ <h1>Backdraw <span data-i18n="galleryTitle">Gallery</span></h1>
13
+ <button id="refresh" type="button" class="icon-btn" data-i18n-title="refresh" title="Refresh"></button>
14
+ </header>
15
+ <section id="now" class="now loading" aria-live="polite">
16
+ <span class="dot" aria-hidden="true"></span>
17
+ <div class="now-text">
18
+ <div id="nowTitle">…</div>
19
+ <div id="nowMeta"></div>
20
+ </div>
21
+ </section>
22
+ <main>
23
+ <div class="head"><h2 data-i18n="galleryTitle">Gallery</h2><span id="count"></span></div>
24
+ <div id="grid" class="grid"></div>
25
+ <p id="msg" class="msg" role="status"></p>
26
+ <button id="more" type="button" class="more" data-i18n="loadMore" hidden>Load more</button>
27
+ </main>
28
+ <script src="i18n.js"></script>
29
+ <script src="sidepanel.js"></script>
30
+ </body>
31
+ </html>
@@ -0,0 +1,433 @@
1
+ // Gallery side panel: the job in progress (asked from the service worker every 2 s while the panel is
2
+ // visible) and the images Backdraw saved, newest first. An image is named by its job id and number
3
+ // only; the host finds the file in its own records, so no path ever goes from here to the host.
4
+ const $ = id => document.getElementById(id);
5
+ const PAGE = 12;
6
+ const MAX_LIST = 100; // lib/gallery.js PAGE_MAX
7
+ const POLL_MS = 2000;
8
+ const THUMB_PX = 256; // like the thumbnail of a right-click add (collections.js)
9
+ const CHAT = /^https:\/\/chatgpt\.com\//i;
10
+ const STEPS = {
11
+ queued: 'stepQueued', starting: 'stepStarting', opening: 'stepOpening', attaching: 'stepAttaching', typing: 'stepTyping',
12
+ sending: 'stepSending', generating: 'stepGenerating', verifying: 'stepVerifying', downloading: 'stepDownloading',
13
+ reattaching: 'stepReattaching', needs_human: 'stepNeedsHuman', resuming: 'stepResuming',
14
+ };
15
+ const ICONS = {
16
+ chat: '<path d="M14 4h6v6M20 4l-9 9"/><path d="M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/>',
17
+ folder: '<path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2h7.5A2.5 2.5 0 0 1 21 9.5v7a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 16.5z"/>',
18
+ copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
19
+ add: '<path d="M12 5v14M5 12h14"/>',
20
+ refresh: '<path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/>',
21
+ };
22
+
23
+ let language = i18n.lang();
24
+ const t = (key, vars) => i18n.translate(language, key, vars);
25
+ const imageCount = n => t(n === 1 ? 'imageCountOne' : 'imageCount', { count: n });
26
+ const keyOf = it => `${it.jobId}:${it.n}`;
27
+
28
+ let status; // the worker's last answer to panel:status; undefined before the first one
29
+ let polling = false;
30
+ let lastJobId = null;
31
+ let loaded = false;
32
+ let items = []; // the gallery entries shown, as the host listed them
33
+ let total = 0;
34
+ let nextOffset = null;
35
+ let generation = 0; // a newer listing makes the answers to older ones stale
36
+ const thumbs = new Map(); // key -> promise of a blob: URL (null: no preview)
37
+
38
+ async function hostCall(method, params = {}) {
39
+ const r = await chrome.runtime.sendMessage({ type: 'panel:call', method, params }).catch(e => ({ ok: false, error: e.message }));
40
+ if (!r || !r.ok) throw new Error((r && r.error) || t('errNotConnected'));
41
+ return r.result;
42
+ }
43
+
44
+ function el(tag, props = {}, ...children) {
45
+ const e = Object.assign(document.createElement(tag), props);
46
+ e.append(...children);
47
+ return e;
48
+ }
49
+
50
+ function icon(name) {
51
+ const tpl = document.createElement('template');
52
+ tpl.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`; // constant markup only
53
+ return tpl.content.firstChild;
54
+ }
55
+
56
+ function iconButton(name, label, onclick) {
57
+ const b = el('button', { type: 'button', className: 'icon-btn', title: label, onclick }, icon(name));
58
+ b.setAttribute('aria-label', label);
59
+ return b;
60
+ }
61
+
62
+ function showMessage(text, bad) {
63
+ $('msg').textContent = text || '';
64
+ $('msg').className = bad ? 'msg bad' : 'msg';
65
+ }
66
+
67
+ // ---------------------------------------------------------------- settings (theme, language)
68
+
69
+ // The popup stores them when the user picks them: theme 'auto' | 'light' | 'dark', language 'auto' | 'en' | 'tr'.
70
+ function applySettings(s) {
71
+ const root = document.documentElement;
72
+ if (s.theme === 'light' || s.theme === 'dark') root.dataset.theme = s.theme;
73
+ else delete root.dataset.theme;
74
+ language = typeof s.language === 'string' && Object.hasOwn(i18n.STRINGS, s.language) ? s.language : i18n.lang();
75
+ root.lang = language;
76
+ for (const e of document.querySelectorAll('[data-i18n]')) e.textContent = t(e.dataset.i18n);
77
+ for (const e of document.querySelectorAll('[data-i18n-title]')) {
78
+ e.title = t(e.dataset.i18nTitle);
79
+ e.setAttribute('aria-label', e.title);
80
+ }
81
+ }
82
+
83
+ async function loadSettings() {
84
+ let s = {};
85
+ try { s = (await chrome.storage.local.get(['theme', 'language'])) || {}; } catch {}
86
+ applySettings(s);
87
+ }
88
+
89
+ chrome.storage.onChanged.addListener((changes, area) => {
90
+ if (area !== 'local' || !(changes.theme || changes.language)) return;
91
+ loadSettings().then(() => {
92
+ renderNow();
93
+ if (loaded) renderGallery();
94
+ });
95
+ });
96
+
97
+ // ---------------------------------------------------------------- the job in progress
98
+
99
+ function clock(ms) {
100
+ const s = Math.max(0, Math.floor(ms / 1000));
101
+ const p = n => String(n).padStart(2, '0');
102
+ return s >= 3600 ? `${Math.floor(s / 3600)}:${p(Math.floor(s / 60) % 60)}:${p(s % 60)}` : `${Math.floor(s / 60)}:${p(s % 60)}`;
103
+ }
104
+
105
+ function renderNow() {
106
+ const job = status && status.current;
107
+ const state = status === undefined ? 'loading' : !status || !status.connected ? 'offline'
108
+ : !job ? 'idle' : job.step === 'needs_human' ? 'human' : 'running';
109
+ let title = '…';
110
+ let meta = '';
111
+ if (state === 'offline') {
112
+ title = t('notConnected');
113
+ meta = t('noHost', { error: status && status.lastError ? ` (${status.lastError})` : '' });
114
+ } else if (state === 'idle') {
115
+ title = t('idle');
116
+ } else if (job) {
117
+ const pct = (String(job.message || '').match(/\((\d{1,3})%\)/) || [])[1];
118
+ title = `${STEPS[job.step] ? t(STEPS[job.step]) : job.message || job.step}${pct ? ` · ${pct}%` : ''}`;
119
+ meta = [job.id, job.startedAt ? t('elapsed', { time: clock(Date.now() - job.startedAt) }) : ''].filter(Boolean).join(' · ');
120
+ }
121
+ $('now').className = `now ${state}`;
122
+ $('nowTitle').textContent = title;
123
+ $('nowTitle').title = (job && job.message) || '';
124
+ $('nowMeta').textContent = meta;
125
+ }
126
+
127
+ // Returns whether it started a new listing.
128
+ async function poll() {
129
+ if (document.hidden || polling) return false;
130
+ polling = true;
131
+ let s = null;
132
+ try { s = await chrome.runtime.sendMessage({ type: 'panel:status' }); } catch {}
133
+ polling = false;
134
+ const wasConnected = !!(status && status.connected);
135
+ status = s && typeof s === 'object' ? s : null;
136
+ renderNow();
137
+ const id = status && status.current ? status.current.id : null;
138
+ const ended = lastJobId !== null && id !== lastJobId;
139
+ lastJobId = id;
140
+ // A job that just ended may have saved images; a bridge that just connected can list them now.
141
+ if (status && status.connected && (ended || !wasConnected)) {
142
+ refresh();
143
+ return true;
144
+ }
145
+ if (!loaded && !(status && status.connected)) showMessage(t('errNotConnected'), true);
146
+ return false;
147
+ }
148
+
149
+ // ---------------------------------------------------------------- the gallery
150
+
151
+ function formatDate(iso) {
152
+ const d = new Date(iso || '');
153
+ if (Number.isNaN(d.getTime())) return '';
154
+ const ui = chrome.i18n.getUILanguage();
155
+ try {
156
+ return d.toLocaleString(ui.toLowerCase().startsWith(language) ? ui : language, { dateStyle: 'medium', timeStyle: 'short' });
157
+ } catch {
158
+ return d.toLocaleString();
159
+ }
160
+ }
161
+
162
+ const listSig = list => list.map(it => `${keyOf(it)}${it.missing ? '!' : ''}`).join();
163
+
164
+ async function refresh() {
165
+ const gen = ++generation;
166
+ $('refresh').disabled = true;
167
+ try {
168
+ // As many as are shown, which "Load more" can take past one listing's MAX_LIST: page by page.
169
+ const want = Math.max(PAGE, items.length);
170
+ let list = [];
171
+ let page = { next: 0 };
172
+ while (page.next !== null && list.length < want) {
173
+ page = await hostCall('gallery_list', { offset: page.next, limit: Math.min(MAX_LIST, want - list.length) });
174
+ if (gen !== generation) return;
175
+ const seen = new Set(list.map(keyOf)); // a job that finished in between shifts the offsets
176
+ list = list.concat(page.items.filter(it => !seen.has(keyOf(it))));
177
+ }
178
+ const changed = !loaded || listSig(list) !== listSig(items);
179
+ items = list;
180
+ total = page.total;
181
+ nextOffset = page.next;
182
+ loaded = true;
183
+ // An unchanged list keeps its cards (and an open "Add to collection").
184
+ if (changed) renderGallery(); else renderFooter();
185
+ } catch (e) {
186
+ if (gen === generation) showMessage(e.message, true);
187
+ } finally {
188
+ if (gen === generation) $('refresh').disabled = false;
189
+ }
190
+ }
191
+
192
+ async function loadMore() {
193
+ const gen = generation;
194
+ $('more').disabled = true;
195
+ try {
196
+ const page = await hostCall('gallery_list', { offset: nextOffset, limit: PAGE });
197
+ if (gen !== generation) return;
198
+ const seen = new Set(items.map(keyOf)); // a job that finished meanwhile shifts the offsets
199
+ const fresh = page.items.filter(it => !seen.has(keyOf(it)));
200
+ items = items.concat(fresh);
201
+ total = page.total;
202
+ nextOffset = page.next;
203
+ $('grid').append(...fresh.map(card));
204
+ renderFooter();
205
+ } catch (e) {
206
+ showMessage(e.message, true);
207
+ } finally {
208
+ $('more').disabled = false;
209
+ }
210
+ }
211
+
212
+ function renderGallery() {
213
+ $('grid').replaceChildren(...items.map(card));
214
+ renderFooter();
215
+ }
216
+
217
+ function renderFooter() {
218
+ $('count').textContent = total ? imageCount(total) : '';
219
+ $('more').hidden = nextOffset === null;
220
+ showMessage(total ? '' : t('galleryEmpty'));
221
+ }
222
+
223
+ function card(it) {
224
+ const box = el('article', { className: it.missing ? 'card missing' : 'card' });
225
+ const pic = el('div', { className: 'thumb', title: String(it.path || '').split(/[\\/]/).pop() });
226
+ if (it.width > 0 && it.height > 0) pic.style.aspectRatio = String(Math.min(2, Math.max(0.5, it.width / it.height)));
227
+ showThumb(it, pic);
228
+ const note = el('p', { className: 'note' });
229
+ const say = (text, kind) => {
230
+ note.textContent = text || '';
231
+ note.className = kind ? `note ${kind}` : 'note';
232
+ };
233
+ const meta = el('div', { className: 'meta' }, el('time', { dateTime: it.createdAt || '', textContent: formatDate(it.createdAt) }));
234
+ if (it.width && it.height) meta.append(el('span', { textContent: `${it.width}×${it.height}` }));
235
+ if (it.aspect) meta.append(el('span', { className: 'pill', textContent: it.aspect }));
236
+
237
+ const hasChat = CHAT.test(it.conversationUrl || '');
238
+ const chat = iconButton('chat', t(hasChat ? 'openChat' : 'noChatLink'), () => chrome.tabs.create({ url: it.conversationUrl }));
239
+ chat.disabled = !hasChat;
240
+ const reveal = iconButton('folder', t('showInFolder'), async () => {
241
+ reveal.disabled = true;
242
+ try {
243
+ await hostCall('reveal_image', { jobId: it.jobId, n: it.n });
244
+ say('');
245
+ } catch (e) {
246
+ say(e.message, 'bad');
247
+ }
248
+ reveal.disabled = false;
249
+ });
250
+ const copy = iconButton('copy', t('copyPath'), async () => {
251
+ if (await copyText(it.path)) say(t('pathCopied'), 'ok');
252
+ else say(t('copyFailed'), 'bad');
253
+ });
254
+ const adder = el('div', { className: 'adder', hidden: true });
255
+ const toggle = () => {
256
+ const open = adder.hidden;
257
+ adder.hidden = !open;
258
+ box.classList.toggle('open', open);
259
+ add.classList.toggle('on', open);
260
+ if (open) fillAdder(it, adder, say, () => pic.querySelector('img'), () => { if (!adder.hidden) toggle(); });
261
+ };
262
+ const add = iconButton('add', t('addToCollection'), toggle);
263
+ if (it.missing) {
264
+ reveal.disabled = add.disabled = true;
265
+ say(t('fileMissing'), 'warn');
266
+ }
267
+ const prompt = el('p', { className: it.prompt ? 'prompt' : 'prompt none', textContent: it.prompt || t('galleryFetched'), title: it.prompt || '' });
268
+ box.append(pic, el('div', { className: 'body' }, prompt, meta, el('div', { className: 'actions' }, chat, reveal, copy, add), adder, note));
269
+ return box;
270
+ }
271
+
272
+ // ---------------------------------------------------------------- thumbnails
273
+
274
+ let thumbsBusy = 0;
275
+ const thumbsWaiting = [];
276
+
277
+ // At most two previews on their way at a time (each can be over half a megabyte).
278
+ function limited(fn) {
279
+ return new Promise(resolve => {
280
+ const go = () => {
281
+ thumbsBusy++;
282
+ fn().then(resolve, () => resolve(undefined)).finally(() => {
283
+ thumbsBusy--;
284
+ if (thumbsWaiting.length) thumbsWaiting.shift()();
285
+ });
286
+ };
287
+ if (thumbsBusy < 2) go(); else thumbsWaiting.push(go);
288
+ });
289
+ }
290
+
291
+ function b64Blob(b64, type) {
292
+ const bin = atob(b64);
293
+ const bytes = new Uint8Array(bin.length);
294
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
295
+ return new Blob([bytes], { type });
296
+ }
297
+
298
+ // null: the image has no preview (any more). A preview that could not be asked for (undefined) is
299
+ // asked for again with the next listing.
300
+ function previewOf(it) {
301
+ if (!it.preview) return Promise.resolve(null);
302
+ const key = keyOf(it);
303
+ if (!thumbs.has(key)) {
304
+ const p = limited(async () => {
305
+ const r = await hostCall('gallery_thumb', { jobId: it.jobId, n: it.n });
306
+ return r && r.data ? URL.createObjectURL(b64Blob(r.data, r.mime || 'image/jpeg')) : null;
307
+ });
308
+ thumbs.set(key, p);
309
+ p.then(url => { if (url === undefined) thumbs.delete(key); });
310
+ }
311
+ return thumbs.get(key);
312
+ }
313
+
314
+ function showThumb(it, pic) {
315
+ pic.classList.add('loading');
316
+ previewOf(it).then(url => {
317
+ pic.classList.remove('loading');
318
+ pic.replaceChildren(url ? el('img', { src: url, alt: '', decoding: 'async' }) : el('span', { className: 'ph', textContent: t('noThumb') }));
319
+ });
320
+ }
321
+
322
+ // The card's preview as an image: the one shown, or the same preview once it has arrived (they load
323
+ // two at a time, so Add can come first).
324
+ async function previewImage(it, shown) {
325
+ if (shown && shown.naturalWidth) return shown;
326
+ const url = await previewOf(it);
327
+ if (!url) return null;
328
+ const img = new Image();
329
+ img.src = url;
330
+ return img.decode().then(() => img, () => null);
331
+ }
332
+
333
+ // A small JPEG of the preview, as a right-click add makes one; the host checks it like those.
334
+ function smallJpeg(img) {
335
+ if (!img || !img.naturalWidth) return undefined;
336
+ const scale = Math.min(1, THUMB_PX / Math.max(img.naturalWidth, img.naturalHeight));
337
+ const canvas = el('canvas', { width: Math.max(1, Math.round(img.naturalWidth * scale)), height: Math.max(1, Math.round(img.naturalHeight * scale)) });
338
+ try {
339
+ canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
340
+ return canvas.toDataURL('image/jpeg', 0.8).split(',')[1];
341
+ } catch {
342
+ return undefined;
343
+ }
344
+ }
345
+
346
+ // ---------------------------------------------------------------- actions
347
+
348
+ async function copyText(text) {
349
+ try {
350
+ await navigator.clipboard.writeText(text);
351
+ return true;
352
+ } catch {}
353
+ const area = el('textarea', { value: text });
354
+ area.style.cssText = 'position:fixed;top:-200px;opacity:0';
355
+ document.body.append(area);
356
+ area.select();
357
+ try { return document.execCommand('copy'); } catch { return false; } finally { area.remove(); }
358
+ }
359
+
360
+ // The collections, and "New collection…", which asks for the name in place. The host copies the image
361
+ // file itself; only the job id and number go to it.
362
+ async function fillAdder(it, box, say, picture, close) {
363
+ box.replaceChildren(el('p', { className: 'note', textContent: t('loading') }));
364
+ let list;
365
+ try {
366
+ list = await hostCall('list_collections', { summary: true });
367
+ } catch (e) {
368
+ box.replaceChildren();
369
+ say(e.message, 'bad');
370
+ return;
371
+ }
372
+ const controls = on => { for (const c of box.querySelectorAll('button, input')) c.disabled = !on; };
373
+ const addTo = async (name, create) => {
374
+ controls(false);
375
+ say(t('saving'));
376
+ try {
377
+ const thumb = smallJpeg(await previewImage(it, picture()));
378
+ const r = await hostCall('collection_add_job_image', { collection: name, create, jobId: it.jobId, n: it.n, thumb });
379
+ say(t(r.duplicate ? 'toastDuplicate' : 'toastAdded', { name: r.name, images: imageCount(r.count) }), 'ok');
380
+ close();
381
+ } catch (e) {
382
+ controls(true);
383
+ say(e.message, 'bad');
384
+ }
385
+ };
386
+ const fresh = el('button', { type: 'button', className: 'col new', textContent: t('newCollectionItem') });
387
+ fresh.onclick = () => {
388
+ const form = nameForm(list, say, name => addTo(name, true), close);
389
+ fresh.replaceWith(form);
390
+ form.querySelector('input').focus();
391
+ };
392
+ const rows = list.map(c => el('button', { type: 'button', className: 'col', onclick: () => addTo(c.name, false) },
393
+ el('span', { textContent: c.name }), el('span', { textContent: imageCount(c.count) })));
394
+ box.replaceChildren(...(rows.length ? [el('div', { className: 'list' }, ...rows)] : []), fresh);
395
+ say('');
396
+ }
397
+
398
+ function nameForm(list, say, submit, close) {
399
+ const input = el('input', { placeholder: t('namePlaceholder'), autocomplete: 'off', spellcheck: false });
400
+ input.setAttribute('aria-label', t('nameLabel'));
401
+ const form = el('form', {}, input, el('button', { type: 'submit', className: 'btn primary', textContent: t('add') }),
402
+ el('button', { type: 'button', className: 'btn', textContent: t('cancel'), onclick: close }));
403
+ const tidy = () => input.value.replace(/\s+/g, ' ').trim();
404
+ input.oninput = () => {
405
+ const wanted = tidy().toLowerCase();
406
+ say(wanted && list.some(c => c.name.toLowerCase() === wanted) ? t('nameExists') : '');
407
+ };
408
+ input.onkeydown = e => { if (e.key === 'Escape') close(); };
409
+ form.onsubmit = e => {
410
+ e.preventDefault();
411
+ const name = tidy();
412
+ if (!name || Array.from(name).length > 64) say(t('nameInvalid'), 'bad');
413
+ else submit(name);
414
+ };
415
+ return form;
416
+ }
417
+
418
+ // ---------------------------------------------------------------- start
419
+
420
+ $('refresh').append(icon('refresh'));
421
+ $('refresh').onclick = () => refresh();
422
+ $('more').onclick = loadMore;
423
+ document.addEventListener('visibilitychange', () => {
424
+ if (!document.hidden) poll().then(started => { if (!started && loaded) refresh(); });
425
+ });
426
+ setInterval(poll, POLL_MS);
427
+ setInterval(() => { if (!document.hidden && status && status.current) renderNow(); }, 1000);
428
+ showMessage(t('loading'));
429
+ loadSettings().then(() => {
430
+ showMessage(t('loading'));
431
+ renderNow();
432
+ poll();
433
+ });
@@ -0,0 +1,22 @@
1
+ /* Colours of the extension's pages. data-theme on <html> is the user's choice (settings.js):
2
+ "light", "dark", or "auto", which follows the system. Dark is the brand look. */
3
+ :root {
4
+ color-scheme: light;
5
+ --bg: #ffffff; --surface: #f6f5fa; --fg: #1c1a24; --muted: #6b6878; --line: #e6e3ef; --field: #ffffff;
6
+ --accent: #7c3aed; --accent-hover: #6d28d9; --accent-fg: #ffffff; --accent-soft: #efe8fd; --link: #6d28d9;
7
+ --ok: #1a7f37; --bad: #c62828; --warn: #a15c00;
8
+ }
9
+ :root[data-theme="dark"] {
10
+ color-scheme: dark;
11
+ --bg: #0e0d12; --surface: #17151f; --fg: #f2f0f7; --muted: #a29eb2; --line: #2a2736; --field: #17151f;
12
+ --accent: #7c3aed; --accent-hover: #8b5cf6; --accent-fg: #ffffff; --accent-soft: rgba(124, 58, 237, .2); --link: #a78bfa;
13
+ --ok: #3fb950; --bad: #ff6b6b; --warn: #e3a33b;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root:not([data-theme="light"]) {
17
+ color-scheme: dark;
18
+ --bg: #0e0d12; --surface: #17151f; --fg: #f2f0f7; --muted: #a29eb2; --line: #2a2736; --field: #17151f;
19
+ --accent: #7c3aed; --accent-hover: #8b5cf6; --accent-fg: #ffffff; --accent-soft: rgba(124, 58, 237, .2); --link: #a78bfa;
20
+ --ok: #3fb950; --bad: #ff6b6b; --warn: #e3a33b;
21
+ }
22
+ }