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.
- package/LICENSE +21 -0
- package/README.md +606 -2
- package/README.tr.md +624 -0
- package/bin/backdraw.js +56 -0
- package/cli.js +105 -0
- package/doctor.js +174 -0
- package/extension/_locales/en/messages.json +3 -0
- package/extension/_locales/tr/messages.json +3 -0
- package/extension/aspect.js +25 -0
- package/extension/background.js +1015 -0
- package/extension/collection.html +45 -0
- package/extension/collection.js +62 -0
- package/extension/collections.js +409 -0
- package/extension/i18n.js +359 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon32.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/icons/mascot-192.png +0 -0
- package/extension/icons/mascot-96.png +0 -0
- package/extension/inpage.js +624 -0
- package/extension/manifest.json +20 -0
- package/extension/menus.js +50 -0
- package/extension/popup.css +90 -0
- package/extension/popup.html +63 -0
- package/extension/popup.js +376 -0
- package/extension/settings.js +35 -0
- package/extension/setup.js +57 -0
- package/extension/sidepanel.css +113 -0
- package/extension/sidepanel.html +31 -0
- package/extension/sidepanel.js +433 -0
- package/extension/theme.css +22 -0
- package/host.js +664 -0
- package/identity.js +21 -0
- package/install.js +427 -0
- package/lib/args.js +30 -0
- package/lib/aspect.js +85 -0
- package/lib/browsers.js +74 -0
- package/lib/collection-store.js +288 -0
- package/lib/collections.js +192 -0
- package/lib/doctor.js +143 -0
- package/lib/extension-calls.js +59 -0
- package/lib/gallery.js +63 -0
- package/lib/limits.js +18 -0
- package/lib/mcp-clients.js +178 -0
- package/lib/naming.js +25 -0
- package/lib/png.js +24 -0
- package/lib/projects.js +56 -0
- package/lib/prompt.js +25 -0
- package/lib/protocol.js +50 -0
- package/lib/results.js +170 -0
- package/lib/term.js +22 -0
- package/lib/toml.js +248 -0
- package/mcp-server.js +437 -0
- package/package.json +54 -4
- package/platform/index.js +14 -0
- package/platform/win32.js +268 -0
- package/setup.js +212 -0
- package/shared.js +118 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The right-click menu on images: its items for a collection list, what a clicked item id means, and
|
|
2
|
+
// where the "New collection…" window goes. No chrome.* calls here, so the tests can load it too.
|
|
3
|
+
(() => {
|
|
4
|
+
const ROOT = 'backdraw';
|
|
5
|
+
const ADD = 'backdraw:add:'; // + the collection name, as the host lists it
|
|
6
|
+
const NEW = 'backdraw:new';
|
|
7
|
+
const SEP = 'backdraw:sep';
|
|
8
|
+
const OFF = 'backdraw:off';
|
|
9
|
+
const GALLERY_ID = 'backdraw:gallery'; // on the toolbar icon; parseMenuId leaves it to background.js
|
|
10
|
+
// Images on web pages and local files only: the extension's own pages show thumbnails of images
|
|
11
|
+
// that are already in a collection.
|
|
12
|
+
const where = { contexts: ['image'], documentUrlPatterns: ['http://*/*', 'https://*/*', 'file:///*'] };
|
|
13
|
+
|
|
14
|
+
// collections: [{ name, count }] from the host, or null while it is not connected. msg(key) gives
|
|
15
|
+
// the localized title.
|
|
16
|
+
function menuItems(collections, msg) {
|
|
17
|
+
const items = [{ id: ROOT, title: msg('menuAdd'), ...where }];
|
|
18
|
+
if (!collections) return [...items, { id: OFF, parentId: ROOT, title: msg('menuOffline'), ...where, enabled: false }];
|
|
19
|
+
for (const c of collections) items.push({ id: ADD + c.name, parentId: ROOT, title: `${c.name} (${c.count})`, ...where });
|
|
20
|
+
if (collections.length) items.push({ id: SEP, parentId: ROOT, type: 'separator', ...where });
|
|
21
|
+
items.push({ id: NEW, parentId: ROOT, title: msg('menuNew'), ...where });
|
|
22
|
+
return items;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// { kind: 'add', name } | { kind: 'new' } | null for anything else.
|
|
26
|
+
function parseMenuId(id) {
|
|
27
|
+
const s = String(id ?? '');
|
|
28
|
+
if (s === NEW) return { kind: 'new' };
|
|
29
|
+
if (s.startsWith(ADD) && s.length > ADD.length) return { kind: 'add', name: s.slice(ADD.length) };
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// left/top that put a width x height window in the middle of win (a chrome.windows.Window); nothing
|
|
34
|
+
// when win is unknown, so Chrome picks the place. Not clamped: a screen left of the main one has
|
|
35
|
+
// negative coordinates.
|
|
36
|
+
function centreOver(win, width, height) {
|
|
37
|
+
if (!win || ![win.left, win.top, win.width, win.height].every(Number.isFinite)) return {};
|
|
38
|
+
return { left: Math.round(win.left + (win.width - width) / 2), top: Math.round(win.top + (win.height - height) / 2) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
// The toolbar icon's own right-click menu (the icon itself keeps opening the popup).
|
|
43
|
+
function actionItems(msg) {
|
|
44
|
+
return [{ id: GALLERY_ID, title: msg('menuGallery'), contexts: ['action'] }];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const api = { menuItems, parseMenuId, centreOver, actionItems, GALLERY_ID };
|
|
48
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
49
|
+
else globalThis.backdrawMenus = api;
|
|
50
|
+
})();
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
* { box-sizing: border-box; }
|
|
2
|
+
[hidden] { display: none !important; }
|
|
3
|
+
body { margin: 0; width: 348px; background: var(--bg); color: var(--fg); font: 13px/1.45 "Segoe UI", system-ui, sans-serif; }
|
|
4
|
+
button, select, input { font: inherit; color: inherit; }
|
|
5
|
+
button { cursor: pointer; }
|
|
6
|
+
|
|
7
|
+
header { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
|
8
|
+
.mascot { flex: none; width: 36px; height: 36px; border-radius: 10px; }
|
|
9
|
+
.brand { flex: 1; display: flex; flex-direction: column; line-height: 1.25; }
|
|
10
|
+
.brand b { font-size: 15px; }
|
|
11
|
+
.brand small { color: var(--muted); font-size: 11px; }
|
|
12
|
+
.icon { flex: none; display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--muted); }
|
|
13
|
+
.icon:hover, .icon[aria-expanded="true"] { background: var(--surface); color: var(--fg); }
|
|
14
|
+
|
|
15
|
+
section { padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
|
16
|
+
h2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin: 0 0 10px; font-size: 13px; }
|
|
17
|
+
h2 small { color: var(--muted); font-size: 11px; font-weight: 400; }
|
|
18
|
+
|
|
19
|
+
.settings { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; background: var(--surface); }
|
|
20
|
+
.settings label { display: flex; flex-direction: column; gap: 3px; color: var(--muted); font-size: 11px; }
|
|
21
|
+
select { padding: 5px 6px; border: 1px solid var(--line); border-radius: 7px; background: var(--field); color: var(--fg); font-size: 12px; }
|
|
22
|
+
|
|
23
|
+
.btn { padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--fg); overflow-wrap: anywhere; }
|
|
24
|
+
.btn:hover:not(:disabled) { border-color: var(--accent); }
|
|
25
|
+
.btn.primary { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); font-weight: 600; }
|
|
26
|
+
.btn.primary:hover:not(:disabled) { background: var(--accent-hover); }
|
|
27
|
+
.btn.danger { border-color: var(--bad); color: var(--bad); }
|
|
28
|
+
.btn:disabled { opacity: .55; cursor: default; }
|
|
29
|
+
.link { padding: 0; border: 0; background: none; color: var(--link); font-weight: 600; }
|
|
30
|
+
|
|
31
|
+
/* setup checklist */
|
|
32
|
+
.steps { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; }
|
|
33
|
+
.step { display: flex; gap: 10px; }
|
|
34
|
+
.mark { flex: none; display: grid; place-items: center; width: 20px; height: 20px; border: 1px solid var(--line); border-radius: 50%;
|
|
35
|
+
background: var(--surface); color: var(--muted); font-size: 11px; font-weight: 700; }
|
|
36
|
+
.step.done .mark { border-color: var(--ok); background: var(--ok); color: #fff; }
|
|
37
|
+
.step.todo .mark { border-color: var(--bad); background: var(--bad); color: #fff; }
|
|
38
|
+
.step-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
|
39
|
+
.step-title { font-weight: 600; line-height: 20px; }
|
|
40
|
+
.step.wait .step-title { color: var(--muted); }
|
|
41
|
+
.note { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
42
|
+
.note.err { color: var(--bad); }
|
|
43
|
+
.actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
44
|
+
.copy { display: flex; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); overflow: hidden; }
|
|
45
|
+
.copy code { flex: 1; min-width: 0; padding: 6px 8px; font: 12px/1.4 Consolas, "Cascadia Mono", monospace; overflow-wrap: anywhere; }
|
|
46
|
+
.copy button { flex: none; padding: 0 10px; border: 0; border-left: 1px solid var(--line); background: transparent; color: var(--link); font-size: 12px; font-weight: 600; }
|
|
47
|
+
.projects { display: flex; flex-direction: column; gap: 4px; max-height: 168px; overflow-y: auto; }
|
|
48
|
+
.projects .btn { text-align: left; }
|
|
49
|
+
.spinner { display: inline-block; flex: none; width: 11px; height: 11px; margin-right: 6px; border: 2px solid var(--accent-soft); border-top-color: var(--accent);
|
|
50
|
+
border-radius: 50%; vertical-align: -1px; animation: spin .8s linear infinite; }
|
|
51
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
52
|
+
|
|
53
|
+
/* status */
|
|
54
|
+
.rows { display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; margin: 0; }
|
|
55
|
+
.rows dt { color: var(--muted); }
|
|
56
|
+
.rows dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
|
|
57
|
+
.ok { color: var(--ok); font-weight: 600; }
|
|
58
|
+
.bad { color: var(--bad); font-weight: 600; }
|
|
59
|
+
.rows .link { margin-left: 6px; font-size: 12px; }
|
|
60
|
+
#pick { width: 100%; margin-top: 10px; }
|
|
61
|
+
details { margin-top: 10px; }
|
|
62
|
+
summary { color: var(--muted); font-size: 12px; cursor: pointer; }
|
|
63
|
+
details .steps { margin-top: 10px; }
|
|
64
|
+
|
|
65
|
+
/* reference collections */
|
|
66
|
+
#colList { display: flex; flex-direction: column; gap: 4px; max-height: 240px; overflow-y: auto; }
|
|
67
|
+
#colList .btn { display: flex; justify-content: space-between; gap: 8px; text-align: left; }
|
|
68
|
+
#colList .btn span:first-child { min-width: 0; }
|
|
69
|
+
#colList .count { flex: none; }
|
|
70
|
+
#colList .count, #colPath { color: var(--muted); }
|
|
71
|
+
#colHead { display: flex; align-items: center; gap: 8px; }
|
|
72
|
+
#colName { flex: 1; min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
|
|
73
|
+
#colPath { margin: 4px 0 8px; font-size: 11px; overflow-wrap: anywhere; }
|
|
74
|
+
#colDetail .actions, #colEdit { margin-bottom: 8px; }
|
|
75
|
+
#colEdit { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
76
|
+
#colEdit p { width: 100%; margin: 0; overflow-wrap: anywhere; }
|
|
77
|
+
#colEdit input { flex: 1; min-width: 0; padding: 5px 7px; border: 1px solid var(--line); border-radius: 7px; background: var(--field); }
|
|
78
|
+
select:focus-visible, #colEdit input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
79
|
+
#colGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 280px; overflow-y: auto; }
|
|
80
|
+
.tile { position: relative; aspect-ratio: 1; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; background: var(--surface); }
|
|
81
|
+
.tile img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
82
|
+
.tile .noimg { padding: 4px; color: var(--muted); font-size: 10px; overflow-wrap: anywhere; }
|
|
83
|
+
.tile button { position: absolute; top: 3px; right: 3px; padding: 0 6px; border: 0; border-radius: 10px; background: rgba(0, 0, 0, .55); color: #fff; line-height: 18px; }
|
|
84
|
+
.tile button.armed { background: var(--bad); }
|
|
85
|
+
#colMsg { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
|
|
86
|
+
#colMsg:empty { display: none; }
|
|
87
|
+
#colMsg.bad { color: var(--bad); font-weight: 600; }
|
|
88
|
+
|
|
89
|
+
footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 14px; color: var(--muted); font-size: 11px; }
|
|
90
|
+
footer .link { flex: none; font-size: 12px; }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Backdraw</title>
|
|
6
|
+
<link rel="stylesheet" href="theme.css">
|
|
7
|
+
<link rel="stylesheet" href="popup.css">
|
|
8
|
+
<script src="i18n.js"></script>
|
|
9
|
+
<script src="settings.js"></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<header>
|
|
13
|
+
<img class="mascot" src="icons/mascot-96.png" srcset="icons/mascot-96.png 1x, icons/mascot-192.png 2x" width="36" height="36" alt="">
|
|
14
|
+
<div class="brand"><b>Backdraw</b><small><span data-i18n="version">Version</span> <span id="ver">?</span></small></div>
|
|
15
|
+
<button id="settingsBtn" class="icon" aria-expanded="false" aria-controls="settings" data-i18n-title="settings">
|
|
16
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
17
|
+
<path d="M4 6h9M19 6h1M4 12h3M13 12h7M4 18h11M21 18h-1"/><circle cx="16" cy="6" r="2.2"/><circle cx="10" cy="12" r="2.2"/><circle cx="18" cy="18" r="2.2"/>
|
|
18
|
+
</svg>
|
|
19
|
+
</button>
|
|
20
|
+
</header>
|
|
21
|
+
<section id="settings" class="settings" hidden>
|
|
22
|
+
<label><span data-i18n="language">Language</span>
|
|
23
|
+
<select id="language">
|
|
24
|
+
<option value="auto" data-i18n="optAuto">Auto</option><option value="en" data-i18n="langEnglish">English</option><option value="tr" data-i18n="langTurkish">Türkçe</option>
|
|
25
|
+
</select>
|
|
26
|
+
</label>
|
|
27
|
+
<label><span data-i18n="theme">Theme</span>
|
|
28
|
+
<select id="theme">
|
|
29
|
+
<option value="auto" data-i18n="optAuto">Auto</option><option value="light" data-i18n="themeLight">Light</option><option value="dark" data-i18n="themeDark">Dark</option>
|
|
30
|
+
</select>
|
|
31
|
+
</label>
|
|
32
|
+
</section>
|
|
33
|
+
<section id="wizard" hidden>
|
|
34
|
+
<h2><span data-i18n="setupTitle">Set up Backdraw</span><small id="setupCount"></small></h2>
|
|
35
|
+
<ol id="steps" class="steps"></ol>
|
|
36
|
+
</section>
|
|
37
|
+
<section id="overview" hidden>
|
|
38
|
+
<dl class="rows">
|
|
39
|
+
<dt data-i18n="bridge">Bridge</dt><dd id="conn">…</dd>
|
|
40
|
+
<dt data-i18n="chatgpt">ChatGPT</dt><dd id="chat">…</dd>
|
|
41
|
+
<dt data-i18n="project">Project</dt><dd id="project">…</dd>
|
|
42
|
+
<dt data-i18n="job">Job</dt><dd id="job">…</dd>
|
|
43
|
+
</dl>
|
|
44
|
+
<button id="pick" class="btn primary" hidden></button>
|
|
45
|
+
<details id="checklist"><summary data-i18n="setupChecklist">Setup</summary><ol id="doneSteps" class="steps"></ol></details>
|
|
46
|
+
</section>
|
|
47
|
+
<section id="collections" hidden>
|
|
48
|
+
<h2 data-i18n="collections">Collections</h2>
|
|
49
|
+
<div id="colList"></div>
|
|
50
|
+
<div id="colDetail" hidden>
|
|
51
|
+
<div id="colHead"><button id="colBack" class="btn" data-i18n="back">‹ Back</button><span id="colName"></span></div>
|
|
52
|
+
<div id="colPath"></div>
|
|
53
|
+
<div class="actions"><button id="colRename" class="btn" data-i18n="rename">Rename</button><button id="colDelete" class="btn danger" data-i18n="deleteCollection">Delete collection</button></div>
|
|
54
|
+
<div id="colEdit" hidden></div>
|
|
55
|
+
<div id="colGrid"></div>
|
|
56
|
+
</div>
|
|
57
|
+
<p id="colMsg"></p>
|
|
58
|
+
</section>
|
|
59
|
+
<footer><span data-i18n="footerNote">Images are made in a background tab; your active tab does not change.</span><button id="gallery" class="link" hidden data-i18n="openGallery">Open gallery</button></footer>
|
|
60
|
+
<script src="setup.js"></script>
|
|
61
|
+
<script src="popup.js"></script>
|
|
62
|
+
</body>
|
|
63
|
+
</html>
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
const $ = id => document.getElementById(id);
|
|
2
|
+
const { t } = i18n;
|
|
3
|
+
const { STEP_TITLES, setupSteps, projectFromTab, sameProject, jobText, chatgptText } = backdrawSetup;
|
|
4
|
+
const ACK_KEY = 'setupDone'; // chrome.storage.local: the user finished the checklist once
|
|
5
|
+
const MARKS = { done: '✓', todo: '✗', wait: '…' };
|
|
6
|
+
|
|
7
|
+
let status = null; // the worker's last popup:status answer
|
|
8
|
+
let acknowledged = false;
|
|
9
|
+
let tabProject = null; // a ChatGPT project open in the active tab
|
|
10
|
+
let windowId = null;
|
|
11
|
+
let shownSig = '';
|
|
12
|
+
let shownJob = null;
|
|
13
|
+
let collectionsLoaded = false;
|
|
14
|
+
// What was started from this popup: the project list, a project being resolved, a failed check.
|
|
15
|
+
const ui = { projects: null, projectsError: '', resolving: '', checkError: '' };
|
|
16
|
+
|
|
17
|
+
function el(tag, props = {}, ...children) {
|
|
18
|
+
const e = Object.assign(document.createElement(tag), props);
|
|
19
|
+
e.append(...children.filter(c => c !== null && c !== undefined));
|
|
20
|
+
return e;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const hideHome = text => i18n.shortPath(text, status && status.home);
|
|
24
|
+
const note = (text, cls = '') => el('p', { className: `note ${cls}` }, text);
|
|
25
|
+
const busyNote = text => el('p', { className: 'note' }, el('span', { className: 'spinner' }), text);
|
|
26
|
+
const button = (text, onclick, cls = 'btn') => el('button', { className: cls, textContent: text, onclick });
|
|
27
|
+
const primary = (text, onclick) => button(text, onclick, 'btn primary');
|
|
28
|
+
const actions = (...buttons) => el('div', { className: 'actions' }, ...buttons);
|
|
29
|
+
|
|
30
|
+
async function ask(type, extra = {}) {
|
|
31
|
+
return chrome.runtime.sendMessage({ type, ...extra }).catch(e => ({ ok: false, error: e.message }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function copyBox(text) {
|
|
35
|
+
const btn = el('button', { textContent: t('copy') });
|
|
36
|
+
btn.onclick = () => navigator.clipboard.writeText(text).then(() => {
|
|
37
|
+
btn.textContent = t('copied');
|
|
38
|
+
setTimeout(() => { btn.textContent = t('copy'); }, 1500);
|
|
39
|
+
}, () => {});
|
|
40
|
+
return el('div', { className: 'copy' }, el('code', { textContent: text }), btn);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------- setup steps
|
|
44
|
+
|
|
45
|
+
function bridgeBody(state) {
|
|
46
|
+
if (state === 'done') return [note(t('bridgeOk'))];
|
|
47
|
+
return [
|
|
48
|
+
note(t('bridgeMissing')),
|
|
49
|
+
status && status.lastError ? note(t('chromeSays', { error: hideHome(status.lastError) }), 'err') : null,
|
|
50
|
+
note(t('bridgeFix')),
|
|
51
|
+
copyBox('node install.js'),
|
|
52
|
+
note(t('bridgeReload')),
|
|
53
|
+
actions(button(t('retry'), async () => { await ask('popup:reconnect'); refresh(); })),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function chatgptBody(state) {
|
|
58
|
+
const c = (status && status.chatgpt) || {};
|
|
59
|
+
if (c.checking) return [busyNote(t('chatgptChecking'))];
|
|
60
|
+
const error = ui.checkError ? note(ui.checkError, 'err') : null;
|
|
61
|
+
const open = label => primary(label, () => chrome.tabs.create({ url: 'https://chatgpt.com/' }));
|
|
62
|
+
if (state === 'done') return [note(t('chatgptIn'))];
|
|
63
|
+
if (c.challenge) return [note(t('chatgptChallenge')), error, actions(open(t('openChatgpt')), button(t('checkAgain'), checkChatgpt))];
|
|
64
|
+
if (c.loggedIn === false) return [note(t('chatgptOut')), error, actions(open(t('openChatgptSignIn')), button(t('checkAgain'), checkChatgpt))];
|
|
65
|
+
return [note(t('chatgptUnknown')), error, actions(primary(t('checkNow'), checkChatgpt))];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function projectList() {
|
|
69
|
+
if (ui.resolving) return busyNote(ui.resolving);
|
|
70
|
+
if (ui.projects === 'loading') return busyNote(t('loadingProjects'));
|
|
71
|
+
if (!Array.isArray(ui.projects)) return null;
|
|
72
|
+
if (!ui.projects.length) return note(t('noProjects'));
|
|
73
|
+
return el('div', { className: 'projects' }, ...ui.projects.map(p => button(p.name, () => useProject(p))));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function projectBody(state) {
|
|
77
|
+
if (state === 'done') return [note(t('projectUsing', { name: status.project.name }))];
|
|
78
|
+
if (state === 'wait') return [note(t('projectNeedsBridge'))];
|
|
79
|
+
return [
|
|
80
|
+
note(t('projectGuide')),
|
|
81
|
+
actions(tabProject ? primary(t('useProject', { name: tabProject.name }), () => useProject(tabProject)) : null,
|
|
82
|
+
ui.projects ? null : button(t('chooseProject'), loadProjects)),
|
|
83
|
+
projectList(),
|
|
84
|
+
ui.projectsError ? note(ui.projectsError, 'err') : null,
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readyBody(state) {
|
|
89
|
+
if (state === 'wait') return [note(t('readyWaiting'))];
|
|
90
|
+
return [
|
|
91
|
+
note(t('readyIntro')),
|
|
92
|
+
copyBox(t('examplePrompt')),
|
|
93
|
+
note(t('readyCollections')),
|
|
94
|
+
state === 'todo' ? actions(primary(t('gotIt'), acknowledge)) : null,
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const BODIES = { bridge: bridgeBody, chatgpt: chatgptBody, project: projectBody, ready: readyBody };
|
|
99
|
+
|
|
100
|
+
function stepItem({ id, state }) {
|
|
101
|
+
return el('li', { className: `step ${state}` },
|
|
102
|
+
el('span', { className: 'mark', textContent: MARKS[state] }),
|
|
103
|
+
el('div', { className: 'step-body' }, el('div', { className: 'step-title', textContent: t(STEP_TITLES[id]) }), ...BODIES[id](state)));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------- what the buttons do
|
|
107
|
+
|
|
108
|
+
// The worker opens chatgpt.com in a background tab, looks at the page (never at cookies) and closes it.
|
|
109
|
+
async function checkChatgpt() {
|
|
110
|
+
ui.checkError = '';
|
|
111
|
+
status = { ...status, chatgpt: { ...(status && status.chatgpt), checking: true } };
|
|
112
|
+
render();
|
|
113
|
+
const r = await ask('popup:check-chatgpt');
|
|
114
|
+
if (!r || !r.ok) ui.checkError = hideHome((r && r.error) || t('errChatgptCheck'));
|
|
115
|
+
refresh();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function loadProjects() {
|
|
119
|
+
Object.assign(ui, { projects: 'loading', projectsError: '' });
|
|
120
|
+
render();
|
|
121
|
+
const r = await ask('popup:projects');
|
|
122
|
+
Object.assign(ui, r && r.ok ? { projects: r.projects } : { projects: null, projectsError: hideHome((r && r.error) || t('selectFailed')) });
|
|
123
|
+
render();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A project from the sidebar may come without its link: the worker opens it once to read it.
|
|
127
|
+
async function useProject(p) {
|
|
128
|
+
let project = p.url ? { name: p.name, url: p.url } : null;
|
|
129
|
+
ui.projectsError = '';
|
|
130
|
+
if (!project) {
|
|
131
|
+
ui.resolving = `${p.name}…`;
|
|
132
|
+
render();
|
|
133
|
+
const r = await ask('popup:resolve-project', { name: p.name });
|
|
134
|
+
ui.resolving = '';
|
|
135
|
+
if (r && r.ok) project = { name: r.project.name, url: r.project.url };
|
|
136
|
+
else ui.projectsError = hideHome((r && r.error) || t('selectFailed'));
|
|
137
|
+
}
|
|
138
|
+
if (project) {
|
|
139
|
+
const r = await ask('popup:select', { project });
|
|
140
|
+
if (r && r.ok) ui.projects = null;
|
|
141
|
+
else ui.projectsError = hideHome((r && r.error) || t('selectFailed'));
|
|
142
|
+
}
|
|
143
|
+
setTimeout(refresh, 300);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function acknowledge() {
|
|
147
|
+
acknowledged = true;
|
|
148
|
+
await chrome.storage.local.set({ [ACK_KEY]: true }).catch(() => {});
|
|
149
|
+
render();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------- the popup
|
|
153
|
+
|
|
154
|
+
function renderStatus(s) {
|
|
155
|
+
const chat = s.chatgpt || {};
|
|
156
|
+
$('conn').textContent = s.connected ? t('connected') : t('notConnected');
|
|
157
|
+
$('conn').className = s.connected ? 'ok' : 'bad';
|
|
158
|
+
$('chat').className = chat.challenge || chat.loggedIn === false ? 'bad' : chat.loggedIn ? 'ok' : '';
|
|
159
|
+
$('chat').replaceChildren(...(chat.checking ? [el('span', { className: 'spinner' }), t('chatgptChecking')]
|
|
160
|
+
: [chatgptText(chat, t), chat.loggedIn === true && !chat.challenge ? null : el('button', { className: 'link', textContent: t('checkNow'), onclick: checkChatgpt })]
|
|
161
|
+
.filter(Boolean)));
|
|
162
|
+
$('project').textContent = s.project ? s.project.name : t('notSelected');
|
|
163
|
+
$('project').title = s.project ? s.project.url : '';
|
|
164
|
+
const pick = $('pick');
|
|
165
|
+
pick.hidden = !(s.connected && tabProject && !sameProject(s.project, tabProject));
|
|
166
|
+
pick.textContent = tabProject ? t('useProject', { name: tabProject.name }) : '';
|
|
167
|
+
pick.onclick = () => useProject(tabProject);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function render() {
|
|
171
|
+
const s = status || {};
|
|
172
|
+
const setup = setupSteps(status, acknowledged);
|
|
173
|
+
const chat = s.chatgpt || {};
|
|
174
|
+
$('ver').textContent = s.version || '?';
|
|
175
|
+
$('wizard').hidden = !setup.wizard;
|
|
176
|
+
$('overview').hidden = setup.wizard;
|
|
177
|
+
// Rebuilt only when something shown changed, so a list or a "Copied" note isn't reset by the polling.
|
|
178
|
+
const sig = JSON.stringify([s.connected, s.lastError, s.project, chat.loggedIn, chat.challenge, chat.checking, s.home,
|
|
179
|
+
tabProject, acknowledged, ui, i18n.lang()]);
|
|
180
|
+
if (sig !== shownSig) {
|
|
181
|
+
shownSig = sig;
|
|
182
|
+
$('setupCount').textContent = t('setupCount', { done: setup.done, total: setup.steps.length });
|
|
183
|
+
$(setup.wizard ? 'steps' : 'doneSteps').replaceChildren(...setup.steps.map(stepItem));
|
|
184
|
+
$(setup.wizard ? 'doneSteps' : 'steps').replaceChildren();
|
|
185
|
+
renderStatus(s);
|
|
186
|
+
}
|
|
187
|
+
// Rebuilt only when its text changes, so the spinner keeps turning between polls.
|
|
188
|
+
const jobLine = s.current ? jobText(s.current, t) : t('idle');
|
|
189
|
+
if (jobLine !== shownJob) {
|
|
190
|
+
shownJob = jobLine;
|
|
191
|
+
$('job').replaceChildren(...(s.current ? [el('span', { className: 'spinner' })] : []), jobLine);
|
|
192
|
+
}
|
|
193
|
+
$('collections').hidden = !s.connected;
|
|
194
|
+
if (s.connected && !collectionsLoaded) {
|
|
195
|
+
collectionsLoaded = true;
|
|
196
|
+
showCollections();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function refresh() {
|
|
201
|
+
try { status = await chrome.runtime.sendMessage({ type: 'popup:status' }); } catch { status = null; }
|
|
202
|
+
render();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function setupSettings(prefs) {
|
|
206
|
+
$('language').value = i18n.PREFERENCES.includes(prefs[i18n.LANGUAGE_KEY]) ? prefs[i18n.LANGUAGE_KEY] : 'auto';
|
|
207
|
+
$('theme').value = backdrawSettings.theme();
|
|
208
|
+
$('settingsBtn').onclick = () => {
|
|
209
|
+
const open = $('settings').hidden;
|
|
210
|
+
$('settings').hidden = !open;
|
|
211
|
+
$('settingsBtn').setAttribute('aria-expanded', String(open));
|
|
212
|
+
};
|
|
213
|
+
$('language').onchange = () => backdrawSettings.set(i18n.LANGUAGE_KEY, $('language').value);
|
|
214
|
+
$('theme').onchange = () => {
|
|
215
|
+
backdrawSettings.applyTheme($('theme').value);
|
|
216
|
+
backdrawSettings.set(backdrawSettings.THEME_KEY, $('theme').value);
|
|
217
|
+
};
|
|
218
|
+
i18n.watch(() => {
|
|
219
|
+
document.documentElement.lang = i18n.lang();
|
|
220
|
+
i18n.apply();
|
|
221
|
+
render();
|
|
222
|
+
if (collectionsLoaded && !openCol) showCollections();
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The side panel (gallery) only exists when the manifest declares one.
|
|
227
|
+
function setupGallery() {
|
|
228
|
+
if (!chrome.sidePanel || !chrome.runtime.getManifest().side_panel || windowId === null) return;
|
|
229
|
+
$('gallery').hidden = false;
|
|
230
|
+
// Opened straight from the click: Chrome opens a side panel only for a user gesture.
|
|
231
|
+
$('gallery').onclick = () => chrome.sidePanel.open({ windowId }).then(() => window.close(), () => {});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function start() {
|
|
235
|
+
await backdrawSettings.ready;
|
|
236
|
+
const prefs = await chrome.storage.local.get([i18n.LANGUAGE_KEY, ACK_KEY]).catch(() => ({}));
|
|
237
|
+
acknowledged = !!prefs[ACK_KEY];
|
|
238
|
+
document.documentElement.lang = i18n.lang();
|
|
239
|
+
i18n.apply();
|
|
240
|
+
setupSettings(prefs);
|
|
241
|
+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }).catch(() => []);
|
|
242
|
+
tabProject = projectFromTab(tab);
|
|
243
|
+
windowId = tab ? tab.windowId : null;
|
|
244
|
+
setupGallery();
|
|
245
|
+
await refresh();
|
|
246
|
+
setInterval(refresh, 1500);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
start();
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------- reference collections
|
|
252
|
+
|
|
253
|
+
let openCol = null; // the collection shown in detail, or null for the list
|
|
254
|
+
|
|
255
|
+
async function hostCall(method, params = {}) {
|
|
256
|
+
const r = await chrome.runtime.sendMessage({ type: 'host:call', method, params }).catch(e => ({ ok: false, error: e.message }));
|
|
257
|
+
if (!r || !r.ok) throw new Error(hideHome((r && r.error) || t('errNotConnected')));
|
|
258
|
+
return r.result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function colMessage(text, bad) {
|
|
262
|
+
$('colMsg').textContent = text || '';
|
|
263
|
+
$('colMsg').className = bad ? 'bad' : '';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function showCollections(text, bad) {
|
|
267
|
+
openCol = null;
|
|
268
|
+
$('colDetail').hidden = true;
|
|
269
|
+
$('colList').hidden = false;
|
|
270
|
+
let list;
|
|
271
|
+
try {
|
|
272
|
+
list = await hostCall('list_collections', { summary: true });
|
|
273
|
+
} catch (e) {
|
|
274
|
+
$('colList').replaceChildren();
|
|
275
|
+
return colMessage(e.message, true);
|
|
276
|
+
}
|
|
277
|
+
$('colList').replaceChildren(...list.map(c => el('button', { className: 'btn', onclick: () => showCollection(c) },
|
|
278
|
+
el('span', { textContent: c.name }), el('span', { className: 'count', textContent: i18n.images(c.count) }))));
|
|
279
|
+
colMessage(text || (list.length ? '' : t('noCollections')), bad);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function showCollection(c) {
|
|
283
|
+
openCol = c;
|
|
284
|
+
$('colList').hidden = true;
|
|
285
|
+
$('colDetail').hidden = false;
|
|
286
|
+
$('colEdit').hidden = true;
|
|
287
|
+
$('colName').textContent = c.name;
|
|
288
|
+
$('colPath').textContent = hideHome(c.path || '');
|
|
289
|
+
$('colGrid').replaceChildren();
|
|
290
|
+
colMessage(t('loading'));
|
|
291
|
+
try {
|
|
292
|
+
// Thumbnails come a page at a time (each answer from the host has to stay under 1 MB).
|
|
293
|
+
for (let offset = 0; offset !== null && openCol === c;) {
|
|
294
|
+
const page = await hostCall('collection_thumbs', { name: c.name, offset });
|
|
295
|
+
if (openCol !== c) return;
|
|
296
|
+
$('colGrid').append(...page.thumbs.map(th => thumbTile(c, th)));
|
|
297
|
+
offset = Number.isInteger(page.next) ? page.next : null;
|
|
298
|
+
}
|
|
299
|
+
colMessage('');
|
|
300
|
+
} catch (e) {
|
|
301
|
+
colMessage(e.message, true);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// The first click on × arms it; a second one within 3 s deletes the file.
|
|
306
|
+
function thumbTile(c, th) {
|
|
307
|
+
const pic = th.data ? el('img', { src: `data:${th.mime};base64,${th.data}`, alt: th.file, title: th.file })
|
|
308
|
+
: el('div', { className: 'noimg', textContent: `${th.file} (${t('noThumb')})` });
|
|
309
|
+
const del = el('button', { textContent: '×', title: t('removeImage') });
|
|
310
|
+
const tile = el('div', { className: 'tile' }, pic, del);
|
|
311
|
+
let armed = null;
|
|
312
|
+
del.onclick = async () => {
|
|
313
|
+
if (!armed) {
|
|
314
|
+
del.classList.add('armed');
|
|
315
|
+
del.title = t('confirmRemoveImage');
|
|
316
|
+
armed = setTimeout(() => { armed = null; del.classList.remove('armed'); del.title = t('removeImage'); }, 3000);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
clearTimeout(armed);
|
|
320
|
+
del.disabled = true;
|
|
321
|
+
try {
|
|
322
|
+
const r = await hostCall('delete_collection_image', { name: c.name, file: th.file });
|
|
323
|
+
c.count = r.count;
|
|
324
|
+
tile.remove();
|
|
325
|
+
colMessage('');
|
|
326
|
+
} catch (e) {
|
|
327
|
+
del.disabled = false;
|
|
328
|
+
colMessage(e.message, true);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
return tile;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
$('colBack').onclick = () => showCollections();
|
|
335
|
+
|
|
336
|
+
$('colRename').onclick = () => {
|
|
337
|
+
const c = openCol;
|
|
338
|
+
const input = el('input', { value: c.name });
|
|
339
|
+
const save = el('button', { className: 'btn primary', textContent: t('save') });
|
|
340
|
+
const cancel = el('button', { className: 'btn', textContent: t('cancel'), onclick: () => { $('colEdit').hidden = true; } });
|
|
341
|
+
save.onclick = async () => {
|
|
342
|
+
try {
|
|
343
|
+
const r = await hostCall('rename_collection', { name: c.name, newName: input.value });
|
|
344
|
+
Object.assign(c, { name: r.name, path: r.path });
|
|
345
|
+
$('colName').textContent = r.name;
|
|
346
|
+
$('colPath').textContent = hideHome(r.path);
|
|
347
|
+
$('colEdit').hidden = true;
|
|
348
|
+
colMessage('');
|
|
349
|
+
} catch (e) {
|
|
350
|
+
colMessage(e.message, true);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
input.onkeydown = e => { if (e.key === 'Enter') save.click(); else if (e.key === 'Escape') cancel.click(); };
|
|
354
|
+
$('colEdit').replaceChildren(input, save, cancel);
|
|
355
|
+
$('colEdit').hidden = false;
|
|
356
|
+
input.select();
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
$('colDelete').onclick = () => {
|
|
360
|
+
const c = openCol;
|
|
361
|
+
const yes = el('button', { className: 'btn danger', textContent: t('delete') });
|
|
362
|
+
const no = el('button', { className: 'btn', textContent: t('cancel'), onclick: () => { $('colEdit').hidden = true; } });
|
|
363
|
+
yes.onclick = async () => {
|
|
364
|
+
yes.disabled = true;
|
|
365
|
+
try {
|
|
366
|
+
const r = await hostCall('delete_collection', { name: c.name });
|
|
367
|
+
const left = r.failed.length ? r.failed : r.left;
|
|
368
|
+
showCollections(left.length ? t('leftover', { files: left.join(', ') }) : '', left.length > 0);
|
|
369
|
+
} catch (e) {
|
|
370
|
+
yes.disabled = false;
|
|
371
|
+
colMessage(e.message, true);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
$('colEdit').replaceChildren(el('p', { textContent: t('confirmDelete', { name: c.name, images: i18n.images(c.count) }) }), yes, no);
|
|
375
|
+
$('colEdit').hidden = false;
|
|
376
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The theme and language picked in the popup (chrome.storage.local "theme" and "language"), applied
|
|
2
|
+
// to an extension page. Loaded in <head> after i18n.js, so the colours are set before the first paint.
|
|
3
|
+
(() => {
|
|
4
|
+
const THEMES = ['auto', 'light', 'dark'];
|
|
5
|
+
const THEME_KEY = 'theme';
|
|
6
|
+
const COPY = 'backdraw.theme'; // chrome.storage answers after the first paint; localStorage doesn't
|
|
7
|
+
const root = document.documentElement;
|
|
8
|
+
|
|
9
|
+
function applyTheme(theme) {
|
|
10
|
+
root.dataset.theme = THEMES.includes(theme) ? theme : 'auto';
|
|
11
|
+
try { localStorage.setItem(COPY, root.dataset.theme); } catch {}
|
|
12
|
+
return root.dataset.theme;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let early = null;
|
|
16
|
+
try { early = localStorage.getItem(COPY); } catch {}
|
|
17
|
+
root.dataset.theme = THEMES.includes(early) ? early : 'auto';
|
|
18
|
+
|
|
19
|
+
const ready = Promise.all([
|
|
20
|
+
chrome.storage.local.get(THEME_KEY).then(r => r[THEME_KEY], () => undefined),
|
|
21
|
+
i18n.load(),
|
|
22
|
+
]).then(([theme]) => {
|
|
23
|
+
applyTheme(theme);
|
|
24
|
+
root.lang = i18n.lang();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
chrome.storage.onChanged.addListener((changes, area) => {
|
|
28
|
+
if (area === 'local' && changes[THEME_KEY]) applyTheme(changes[THEME_KEY].newValue);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const set = (key, value) => chrome.storage.local.set({ [key]: value });
|
|
32
|
+
const theme = () => root.dataset.theme;
|
|
33
|
+
|
|
34
|
+
globalThis.backdrawSettings = { THEMES, THEME_KEY, ready, set, theme, applyTheme };
|
|
35
|
+
})();
|