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,288 @@
1
+ 'use strict';
2
+ // Reference collections on disk. Every function takes the collections folder (root) first, so the
3
+ // tests can use a temporary one. Layout: <root>/<folder>/collection.json, the images next to it and
4
+ // thumbs/<file>.jpg. Folder and file names are always made here, never taken from a request, and
5
+ // every path is checked to stay inside root.
6
+ const crypto = require('crypto');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { sniffExt } = require('./naming');
10
+ const { MAX_COLLECTION_BYTES } = require('./limits');
11
+ const c = require('./collections');
12
+
13
+ const MANIFEST = 'collection.json';
14
+ const THUMBS = 'thumbs';
15
+ const MAX_THUMB_BYTES = 256 * 1024;
16
+ const SMALL_ORIGINAL = 200 * 1024; // shown as its own thumbnail when it has none
17
+ const THUMB_PAGE = 600000; // base64 chars of thumbnails per answer: Chrome caps host->extension messages at 1 MB
18
+ const MIME = { png: 'image/png', jpg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' };
19
+ const PNG_HEAD = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
20
+
21
+ function storeError(message, code) { return Object.assign(new Error(message), { code }); }
22
+
23
+ function inside(root, ...parts) {
24
+ const p = path.join(root, ...parts);
25
+ if (!c.isInside(root, p)) throw storeError('That path is outside the collections folder.', 'BAD_REQUEST');
26
+ return p;
27
+ }
28
+
29
+ function readManifest(dir) {
30
+ try {
31
+ const m = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST), 'utf8'));
32
+ return m && typeof m === 'object' && !Array.isArray(m) ? m : null;
33
+ } catch { return null; }
34
+ }
35
+
36
+ function writeManifest(dir, m) {
37
+ const file = path.join(dir, MANIFEST);
38
+ const tmp = `${file}.${process.pid}.tmp`;
39
+ fs.writeFileSync(tmp, JSON.stringify(m, null, 2));
40
+ fs.renameSync(tmp, file);
41
+ }
42
+
43
+ // Entries a hand-edited manifest may hold that point anywhere else are left out everywhere.
44
+ const entries = m => (Array.isArray(m.images) ? m.images : []).filter(im => im && c.isPlainFile(im.file));
45
+ // The folder sits in Pictures, so an image may be deleted by hand: such an entry is not listed,
46
+ // counted or attached any more.
47
+ const present = (dir, m) => entries(m).filter(im => fs.existsSync(path.join(dir, im.file)));
48
+ const dim = v => (Number.isInteger(v) && v > 0 && v <= 100000 ? v : null);
49
+ // data: URLs can be megabytes long; only their type is worth keeping.
50
+ const cleanUrl = u => (typeof u !== 'string' || !u ? null : /^data:/i.test(u) ? u.slice(0, Math.min(u.indexOf(',') + 1 || 64, 64)) : u.slice(0, 2048));
51
+
52
+ function view(root, folder, m) {
53
+ const dir = path.join(root, folder);
54
+ const images = c.newestFirst(present(dir, m)).map(im => ({
55
+ file: im.file, path: path.join(dir, im.file), sourceUrl: im.sourceUrl || null, pageUrl: im.pageUrl || null,
56
+ addedAt: im.addedAt || null, width: im.width || null, height: im.height || null, bytes: im.bytes || null,
57
+ }));
58
+ return {
59
+ name: c.validateName(m.name).name || folder, folder, path: dir, count: images.length,
60
+ createdAt: m.createdAt || null, updatedAt: m.updatedAt || m.createdAt || null, images,
61
+ };
62
+ }
63
+
64
+ // Every collection, most recently changed first; images newest first.
65
+ function list(root) {
66
+ let dirs;
67
+ try { dirs = fs.readdirSync(root, { withFileTypes: true }); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
68
+ return dirs.filter(d => d.isDirectory())
69
+ .map(d => { const m = readManifest(path.join(root, d.name)); return m && view(root, d.name, m); })
70
+ .filter(Boolean)
71
+ .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')) || a.name.localeCompare(b.name));
72
+ }
73
+
74
+ // loose: also match a loosely written name (for generating); changes only take an exact or
75
+ // differently-cased name.
76
+ function find(root, name, loose = true) {
77
+ const all = list(root);
78
+ const { collection, error, ties } = c.findCollection(all, name, { loose });
79
+ if (collection) return collection;
80
+ if (error === 'AMBIGUOUS') throw storeError(`"${name}" matches several collections: ${ties.map(x => `"${x.name}"`).join(', ')}. Use the exact name.`, 'AMBIGUOUS');
81
+ throw storeError(`No reference collection matches "${name}". Available collections: ${all.map(x => `"${x.name}"`).join(', ') || '(none)'}`, 'NOT_FOUND');
82
+ }
83
+
84
+ function uniqueFolder(root, name) {
85
+ const base = c.safeFolderName(name);
86
+ for (let n = 1; ; n++) {
87
+ const folder = n === 1 ? base : c.safeFolderName(`${c.cut(base, c.MAX_NAME - String(n).length - 1)} ${n}`);
88
+ if (!fs.existsSync(inside(root, folder))) return folder;
89
+ }
90
+ }
91
+
92
+ // The number after the highest one in the collection, and never a file that is already there.
93
+ function nextFile(dir, images, srcUrl, ext, name) {
94
+ let n = images.reduce((max, im) => Math.max(max, parseInt(im.file, 10) || 0), 0);
95
+ for (;;) {
96
+ const file = c.fileNameFor(++n, srcUrl, ext, name);
97
+ if (!fs.existsSync(path.join(dir, file))) return file;
98
+ }
99
+ }
100
+
101
+ // A full signature, not just the first two or three bytes (lib/naming.js sniffExt is enough for
102
+ // images ChatGPT made, not for bytes from any web page).
103
+ function imageExt(b) {
104
+ const at = (from, to) => b.subarray(from, to).toString('latin1');
105
+ if (b.subarray(0, 16).equals(PNG_HEAD)) return 'png'; // signature and the IHDR chunk that must come first
106
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpg';
107
+ if (at(0, 6) === 'GIF87a' || at(0, 6) === 'GIF89a') return 'gif';
108
+ if (at(0, 4) === 'RIFF' && at(8, 12) === 'WEBP' && /^VP8[ LX]$/.test(at(12, 16))) return 'webp';
109
+ return null;
110
+ }
111
+
112
+ // The bytes decide what the file is, whatever mime the caller claims.
113
+ function decodeImage(data) {
114
+ const tooLarge = storeError(`The image is larger than ${MAX_COLLECTION_BYTES / 1024 / 1024} MB.`, 'TOO_LARGE');
115
+ if (typeof data !== 'string' || !data) throw storeError('No image data was sent.', 'BAD_REQUEST');
116
+ if (data.length > Math.ceil(MAX_COLLECTION_BYTES / 3) * 4 + 4) throw tooLarge;
117
+ const buf = Buffer.from(data, 'base64');
118
+ if (buf.length > MAX_COLLECTION_BYTES) throw tooLarge;
119
+ if (buf.length < 24 || !imageExt(buf)) throw storeError('Only png, jpeg, webp and gif images can be saved.', 'BAD_REQUEST');
120
+ return buf;
121
+ }
122
+
123
+ function decodeThumb(data) {
124
+ if (typeof data !== 'string' || !data || data.length > Math.ceil(MAX_THUMB_BYTES / 3) * 4) return null;
125
+ const buf = Buffer.from(data, 'base64');
126
+ return buf.length >= 24 && imageExt(buf) === 'jpg' ? buf : null;
127
+ }
128
+
129
+ // p: { collection, create, data (base64), sourceUrl, pageUrl, thumb (base64 jpeg), width, height }.
130
+ // The same bytes twice in one collection are kept once (duplicate: true). fileName: the host's own
131
+ // name for the file (outside p, so a request from the extension can never set it).
132
+ function add(root, p, now = new Date(), { fileName = '' } = {}) {
133
+ const { name, error } = c.validateName(p.collection);
134
+ if (error) throw storeError(error, 'BAD_REQUEST');
135
+ const buf = decodeImage(p.data);
136
+ const found = c.findCollection(list(root), name, { loose: false });
137
+ if (found.error === 'AMBIGUOUS') throw storeError(`"${name}" matches several collections; use the exact name.`, 'AMBIGUOUS');
138
+ if (found.error && !p.create) throw storeError(`There is no collection named "${name}".`, 'NOT_FOUND');
139
+ const stamp = now.toISOString();
140
+ const folder = found.collection ? found.collection.folder : uniqueFolder(root, name);
141
+ const dir = inside(root, folder);
142
+ let m = found.collection && readManifest(dir);
143
+ if (!m) {
144
+ fs.mkdirSync(dir, { recursive: true });
145
+ m = { name, createdAt: stamp, updatedAt: stamp, images: [] };
146
+ }
147
+ if (!Array.isArray(m.images)) m.images = [];
148
+ const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
149
+ const same = present(dir, m).find(im => im.sha256 === sha256);
150
+ if (same) return { name: m.name, folder, file: same.file, count: present(dir, m).length, created: false, duplicate: true };
151
+ const file = nextFile(dir, entries(m), p.sourceUrl, imageExt(buf), fileName);
152
+ fs.writeFileSync(inside(root, folder, file), buf, { flag: 'wx' });
153
+ const thumb = decodeThumb(p.thumb);
154
+ if (thumb) {
155
+ fs.mkdirSync(inside(root, folder, THUMBS), { recursive: true });
156
+ fs.writeFileSync(inside(root, folder, THUMBS, `${file}.jpg`), thumb);
157
+ }
158
+ m.images.push({
159
+ file, sourceUrl: cleanUrl(p.sourceUrl), pageUrl: cleanUrl(p.pageUrl), addedAt: stamp,
160
+ bytes: buf.length, width: dim(p.width), height: dim(p.height), sha256,
161
+ });
162
+ m.updatedAt = stamp;
163
+ writeManifest(dir, m);
164
+ return { name: m.name, folder, file, count: present(dir, m).length, created: !found.collection, duplicate: false };
165
+ }
166
+
167
+ // Renames the folder too; refuses a name another collection already has.
168
+ function rename(root, name, newName, now = new Date()) {
169
+ const v = c.validateName(newName);
170
+ if (v.error) throw storeError(v.error, 'BAD_REQUEST');
171
+ const col = find(root, name, false);
172
+ const taken = c.findCollection(list(root).filter(x => x.folder !== col.folder), v.name, { loose: false });
173
+ if (!taken.error || taken.error === 'AMBIGUOUS') throw storeError(`A collection named "${v.name}" already exists.`, 'EXISTS');
174
+ let folder = col.folder;
175
+ const wanted = c.safeFolderName(v.name);
176
+ if (wanted !== folder) {
177
+ // A change of case only is the same folder on Windows: rename it in place.
178
+ folder = wanted.toLowerCase() === folder.toLowerCase() ? wanted : uniqueFolder(root, v.name);
179
+ fs.renameSync(inside(root, col.folder), inside(root, folder));
180
+ }
181
+ const dir = inside(root, folder);
182
+ const m = readManifest(dir) || { images: [] };
183
+ Object.assign(m, { name: v.name, updatedAt: now.toISOString() });
184
+ writeManifest(dir, m);
185
+ return { name: v.name, oldName: col.name, folder, path: dir };
186
+ }
187
+
188
+ function unlinkIfThere(file) {
189
+ try { fs.unlinkSync(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
190
+ }
191
+
192
+ function rmdirIfEmpty(dir) {
193
+ try { if (!fs.readdirSync(dir).length) fs.rmdirSync(dir); } catch {}
194
+ }
195
+
196
+ // Deletes only what the collection itself wrote (its images, their thumbnails and the manifest), then
197
+ // the folders if they are empty. Anything else is left and reported. Images that could not be
198
+ // deleted (e.g. open in another program) stay in the manifest; a thumbnail that stays is only reported.
199
+ function remove(root, name) {
200
+ const col = find(root, name, false);
201
+ const dir = inside(root, col.folder);
202
+ const m = readManifest(dir) || {};
203
+ let deleted = 0;
204
+ const failed = [];
205
+ const kept = [];
206
+ for (const im of entries(m)) {
207
+ try {
208
+ if (unlinkIfThere(inside(root, col.folder, im.file))) deleted++;
209
+ } catch (e) {
210
+ failed.push(`${im.file} (${e.code || e.message})`);
211
+ kept.push(im);
212
+ continue; // its thumbnail stays with it
213
+ }
214
+ try { unlinkIfThere(inside(root, col.folder, THUMBS, `${im.file}.jpg`)); } catch (e) { failed.push(`${THUMBS}/${im.file}.jpg (${e.code || e.message})`); }
215
+ }
216
+ if (kept.length) {
217
+ m.images = kept;
218
+ writeManifest(dir, m);
219
+ } else {
220
+ rmdirIfEmpty(inside(root, col.folder, THUMBS));
221
+ unlinkIfThere(inside(root, col.folder, MANIFEST));
222
+ rmdirIfEmpty(dir);
223
+ }
224
+ let left = [];
225
+ try { left = fs.readdirSync(dir); } catch {}
226
+ return { name: col.name, folder: col.folder, path: dir, deleted, left, failed };
227
+ }
228
+
229
+ function removeImage(root, name, file, now = new Date()) {
230
+ const col = find(root, name, false);
231
+ const dir = inside(root, col.folder);
232
+ const m = readManifest(dir) || {};
233
+ const images = Array.isArray(m.images) ? m.images : [];
234
+ const i = c.isPlainFile(file) ? images.findIndex(im => im && im.file === file) : -1;
235
+ if (i < 0) throw storeError(`Collection "${col.name}" has no image "${file}".`, 'NOT_FOUND');
236
+ unlinkIfThere(inside(root, col.folder, file));
237
+ try { unlinkIfThere(inside(root, col.folder, THUMBS, `${file}.jpg`)); } catch {}
238
+ images.splice(i, 1);
239
+ m.images = images;
240
+ m.updatedAt = now.toISOString();
241
+ writeManifest(dir, m);
242
+ return { name: col.name, file, count: present(dir, m).length };
243
+ }
244
+
245
+ function readThumb(root, folder, im) {
246
+ try {
247
+ const buf = fs.readFileSync(inside(root, folder, THUMBS, `${im.file}.jpg`));
248
+ if (buf.length <= MAX_THUMB_BYTES) return { data: buf.toString('base64'), mime: 'image/jpeg' };
249
+ } catch {}
250
+ try {
251
+ const file = inside(root, folder, im.file);
252
+ if (fs.statSync(file).size <= SMALL_ORIGINAL) {
253
+ const buf = fs.readFileSync(file);
254
+ const ext = sniffExt(buf);
255
+ if (MIME[ext]) return { data: buf.toString('base64'), mime: MIME[ext] };
256
+ }
257
+ } catch {}
258
+ return { data: null, mime: null };
259
+ }
260
+
261
+ // Thumbnails newest first, a page at a time (next: the offset to ask for, or null at the end).
262
+ function thumbs(root, name, offset = 0, budget = THUMB_PAGE) {
263
+ const col = find(root, name, false);
264
+ const out = [];
265
+ let size = 0;
266
+ let i = Math.max(0, Math.floor(Number(offset) || 0));
267
+ for (; i < col.images.length; i++) {
268
+ const im = col.images[i];
269
+ const t = readThumb(root, col.folder, im);
270
+ const len = t.data ? t.data.length : 0;
271
+ if (out.length && size + len > budget) break;
272
+ size += len;
273
+ out.push({ file: im.file, width: im.width, height: im.height, ...t });
274
+ }
275
+ return { name: col.name, total: col.images.length, thumbs: out, next: i < col.images.length ? i : null };
276
+ }
277
+
278
+ // An image of a collection (from list/find) as a reference for a job.
279
+ function readImage(root, col, im) {
280
+ const file = inside(root, col.folder, im.file);
281
+ let buf;
282
+ try { buf = fs.readFileSync(file); } catch (e) {
283
+ throw storeError(`Cannot read ${im.file} from collection "${col.name}" (${e.code || e.message}).`, 'NOT_FOUND');
284
+ }
285
+ return { name: im.file, mime: MIME[sniffExt(buf)] || 'image/png', data: buf.toString('base64') };
286
+ }
287
+
288
+ module.exports = { MANIFEST, THUMBS, THUMB_PAGE, list, find, add, rename, remove, removeImage, thumbs, readImage };
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+ // Reference collections, the pure part: names, folder and file names, path checks, lookups, which
3
+ // images a job attaches and the MCP listing. The file system side is lib/collection-store.js.
4
+ const path = require('path');
5
+ const { slugify } = require('./naming');
6
+ const { normName } = require('./projects');
7
+ const { MAX_REFS, MAX_REF_BYTES, MAX_REFS_TOTAL_BYTES } = require('./limits');
8
+
9
+ const MAX_NAME = 64;
10
+ const IMAGE_EXTS = ['png', 'jpg', 'webp', 'gif'];
11
+ // The only host methods the extension may call over its native port ({type: 'call'}).
12
+ const EXTENSION_METHODS = new Set([
13
+ 'list_collections', 'collection_add', 'rename_collection', 'delete_collection', 'delete_collection_image', 'collection_thumbs',
14
+ // The side panel's gallery (lib/gallery.js): images named by job id and number, never by a path.
15
+ 'gallery_list', 'gallery_thumb', 'reveal_image', 'collection_add_job_image',
16
+ ]);
17
+ // Windows device names; "CON.txt" or "con .x" open the device too.
18
+ const RESERVED = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$|clock\$)(?=\s*(\.|$))/i;
19
+
20
+ const chars = s => Array.from(String(s)); // code points, so an emoji is never cut in half
21
+ const cut = (s, n) => chars(s).slice(0, n).join('');
22
+ const tidyName = s => String(s ?? '').replace(/\s/g, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').replace(/ +/g, ' ').trim();
23
+ const stripEnds = s => s.replace(/^[\s.]+|[\s.]+$/g, '');
24
+
25
+ // A collection name: 1-64 visible characters once control characters and extra spaces are gone.
26
+ function validateName(name) {
27
+ const clean = tidyName(name);
28
+ const n = chars(clean).length;
29
+ if (!n) return { error: 'The collection name is empty.' };
30
+ if (n > MAX_NAME) return { error: `The collection name is longer than ${MAX_NAME} characters.` };
31
+ return { name: clean };
32
+ }
33
+
34
+ // The folder a collection gets. It never holds a path separator, never starts or ends with a dot or
35
+ // space and is never a device name, so it always stays a plain folder inside the collections folder.
36
+ function safeFolderName(name) {
37
+ let s = stripEnds(cut(stripEnds(tidyName(String(name ?? '').replace(/[<>:"/\\|?*]/g, ''))), MAX_NAME));
38
+ if (RESERVED.test(s)) s = stripEnds(cut(s.replace(RESERVED, '$1_'), MAX_NAME));
39
+ return s || 'collection';
40
+ }
41
+
42
+ // A bare file name as the host writes them (no folder part, no device name, nothing Windows refuses).
43
+ function isPlainFile(f) {
44
+ return typeof f === 'string' && /^[^<>:"/\\|?*\u0000-\u001f]{1,200}$/.test(f) && !/^\.+$/.test(f) && !/[ .]$/.test(f) && !RESERVED.test(f);
45
+ }
46
+
47
+ function isInside(root, p) {
48
+ const rel = path.relative(path.resolve(root), path.resolve(p));
49
+ return rel !== '' && !path.isAbsolute(rel) && rel.split(/[\\/]/)[0] !== '..';
50
+ }
51
+
52
+ // Exact name first, then ignoring case, then (loose) the normalized name used for ChatGPT projects.
53
+ // Returns { collection } or { error: 'NOT_FOUND' | 'AMBIGUOUS', ties }.
54
+ function findCollection(list, name, { loose = true } = {}) {
55
+ const wanted = tidyName(name);
56
+ const steps = [c => c.name === wanted, c => c.name.toLowerCase() === wanted.toLowerCase()];
57
+ if (loose && normName(wanted)) steps.push(c => normName(c.name) === normName(wanted));
58
+ for (const step of steps) {
59
+ const hits = list.filter(step);
60
+ if (hits.length === 1) return { collection: hits[0] };
61
+ if (hits.length > 1) return { error: 'AMBIGUOUS', ties: hits };
62
+ }
63
+ return { error: 'NOT_FOUND' };
64
+ }
65
+
66
+ // Ties (the same or no addedAt, e.g. a hand-edited manifest) go by the file number, which grows with
67
+ // every image added, so sorting a sorted list again never changes it.
68
+ function newestFirst(images) {
69
+ const num = im => parseInt(im.file, 10) || 0;
70
+ return images.slice().sort((a, b) => String(b.addedAt || '').localeCompare(String(a.addedAt || '')) || num(b) - num(a) ||
71
+ String(b.file).localeCompare(String(a.file)));
72
+ }
73
+
74
+ const fileOf = f => String(f).split(/[\\/]/).pop().trim(); // a path works as its file name
75
+
76
+ // The images a job attaches: the files the caller picked (in that order), else the newest ones.
77
+ // unknown lists picked names the collection doesn't have.
78
+ function pickRefs(images, max, chosenFiles) {
79
+ const room = Math.max(0, max);
80
+ const picked = (chosenFiles || []).map(fileOf).filter(Boolean);
81
+ if (!picked.length) return { used: newestFirst(images).slice(0, room), unknown: [] };
82
+ const byName = new Map(images.map(im => [im.file.toLowerCase(), im]));
83
+ return {
84
+ used: [...new Set(picked.map(f => byName.get(f.toLowerCase())).filter(Boolean))].slice(0, room),
85
+ unknown: picked.filter(f => !byName.has(f.toLowerCase())),
86
+ };
87
+ }
88
+
89
+ const mb = n => `${n / 1048576} MB`;
90
+ const refBytes = r => String((r && r.data) || '').length * 0.75; // base64, measured like the host's createJob
91
+ const refError = message => Object.assign(new Error(message), { code: 'BAD_REQUEST' });
92
+
93
+ // generate with reference_collection: the request with the collection's images after the caller's own
94
+ // references, and what was used (job.collection). find(name) gives the collection (store.find) and
95
+ // read(col, im) one image as a reference ({ name, mime, data }); both throw. The newest images fill the
96
+ // free slots: one that is too large or can't be read is left out for the next one, and adding stops
97
+ // before the references together pass the total limit. Images picked with reference_files are all
98
+ // attached, or the request is refused.
99
+ function withCollection(p, { find, read }, limits = {}) {
100
+ const { max = MAX_REFS, maxBytes = MAX_REF_BYTES, maxTotal = MAX_REFS_TOTAL_BYTES } = limits;
101
+ const files = [].concat(p.referenceFiles ?? []).map(fileOf).filter(Boolean);
102
+ if (!p.referenceCollection) {
103
+ if (files.length) throw refError('reference_files needs reference_collection.');
104
+ return { params: p, collection: null };
105
+ }
106
+ let col;
107
+ try { col = find(String(p.referenceCollection)); } catch (e) { throw refError(e.message); }
108
+ const own = Array.isArray(p.references) ? p.references : [];
109
+ const room = max - own.length;
110
+ if (room < 1) throw refError(`reference_images already use all ${max} reference slots, so no collection image fits.`);
111
+ let budget = maxTotal - own.reduce((sum, r) => sum + refBytes(r), 0);
112
+ if (budget <= 0) throw refError(`reference_images together already reach ${mb(maxTotal)}, so no collection image fits.`);
113
+ const { used: wanted, unknown } = pickRefs(col.images, Infinity, files);
114
+ if (unknown.length) {
115
+ throw refError(`Collection "${col.name}" has no ${unknown.map(f => `"${f}"`).join(', ')}. ` +
116
+ `Its files (newest first): ${col.images.slice(0, 40).map(im => im.file).join(', ') || '(none)'}`);
117
+ }
118
+ if (!col.images.length) throw refError(`Collection "${col.name}" has no images yet.`);
119
+ const picked = files.length > 0;
120
+ if (picked && wanted.length > room) throw refError(`reference_files picks ${wanted.length} images, but only ${room} fit (${max} references per request).`);
121
+ const refs = [];
122
+ const used = [];
123
+ const skipped = [];
124
+ for (const im of wanted) {
125
+ if (refs.length >= room) break;
126
+ let ref;
127
+ try { ref = read(col, im); } catch (e) {
128
+ if (picked) throw refError(e.message);
129
+ skipped.push(`${im.file} (cannot be read)`);
130
+ continue;
131
+ }
132
+ const size = refBytes(ref);
133
+ if (size > maxBytes) {
134
+ if (picked) throw refError(`"${im.file}" in collection "${col.name}" is larger than ${mb(maxBytes)}, the most one reference may be.`);
135
+ skipped.push(`${im.file} (larger than ${mb(maxBytes)})`);
136
+ continue;
137
+ }
138
+ if (size > budget) {
139
+ if (picked) throw refError(`The picked images and reference_images together are larger than ${mb(maxTotal)}; pick fewer.`);
140
+ skipped.push(`${im.file} (all references together would pass ${mb(maxTotal)})`);
141
+ break;
142
+ }
143
+ budget -= size;
144
+ refs.push(ref);
145
+ used.push(im.file);
146
+ }
147
+ if (!refs.length) throw refError(`No image of collection "${col.name}" can be attached: ${skipped.join(', ')}.`);
148
+ return { params: { ...p, references: [...own, ...refs] }, collection: { name: col.name, used, total: col.count, skipped } };
149
+ }
150
+
151
+ // NNN_<slug of the source file name>.<ext>; nothing of it is taken from the request as is. name (the
152
+ // host's own, e.g. a generated image's file name) is used instead of the source URL's when given.
153
+ function fileNameFor(index, srcUrl, ext, name = '') {
154
+ let base = String(name || '');
155
+ try {
156
+ const u = new URL(String(srcUrl));
157
+ if (!base && /^https?:$/.test(u.protocol)) base = decodeURIComponent(u.pathname.split('/').pop() || '');
158
+ } catch {}
159
+ base = base.replace(/\.[a-z0-9]{1,5}$/i, '');
160
+ return `${String(index).padStart(3, '0')}_${slugify(base)}.${IMAGE_EXTS.includes(ext) ? ext : 'png'}`;
161
+ }
162
+
163
+ function siteOf(url) {
164
+ try {
165
+ const u = new URL(String(url));
166
+ return /^https?:$/.test(u.protocol) ? u.hostname.replace(/^www\./, '') : u.protocol.replace(':', '');
167
+ } catch { return null; }
168
+ }
169
+
170
+ // The list_collections tool text.
171
+ function describeCollections(list, maxRefs, maxFiles = 100) {
172
+ if (!list.length) {
173
+ return 'No reference collections yet. The user makes them in Chrome: right-click an image, "Add to Backdraw", "New collection…".';
174
+ }
175
+ const lines = [`Reference collections (${list.length}, most recently changed first):`];
176
+ for (const c of list) {
177
+ lines.push(`- "${c.name}": ${c.count} image(s), folder ${c.path}`);
178
+ for (const im of c.images.slice(0, maxFiles)) {
179
+ const site = siteOf(im.pageUrl) || siteOf(im.sourceUrl) || 'unknown source';
180
+ lines.push(` - ${im.file} (${site}${im.width ? `, ${im.width}x${im.height}` : ''}, added ${String(im.addedAt || '').slice(0, 10) || '?'})`);
181
+ }
182
+ if (c.images.length > maxFiles) lines.push(` - … ${c.images.length - maxFiles} more in the folder`);
183
+ }
184
+ lines.push(`Use one with generate_image(reference_collection="<name>"): its newest images are attached after any reference_images, up to ${maxRefs} references in total. ` +
185
+ 'Pass reference_files=[file names above] to pick specific images instead.');
186
+ return lines.join('\n');
187
+ }
188
+
189
+ module.exports = {
190
+ MAX_NAME, IMAGE_EXTS, EXTENSION_METHODS, chars, cut, validateName, safeFolderName, isPlainFile, isInside,
191
+ findCollection, newestFirst, pickRefs, withCollection, fileNameFor, siteOf, describeCollections,
192
+ };
package/lib/doctor.js ADDED
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+ // `backdraw doctor`: turns the facts doctor.js gathered into checks (✓ / ⚠ / ✗, each with a one-line
3
+ // fix) and prints them so the output can be pasted into an issue: the home folder, which holds the
4
+ // user name, is shown as "~", and project names, prompts and chat links are never printed.
5
+ const path = require('path');
6
+ const { policyProblem } = require('./browsers');
7
+ const { painter, line } = require('./term');
8
+
9
+ const SETUP = 'npx backdraw@latest setup';
10
+ const FIX = 'npx backdraw@latest doctor --fix';
11
+
12
+ // Compares "2.1.0"-style versions: > 0 when a is newer.
13
+ function compareVersions(a, b) {
14
+ const pa = String(a).split('.').map(Number);
15
+ const pb = String(b).split('.').map(Number);
16
+ for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
17
+ return 0;
18
+ }
19
+
20
+ const samePath = (a, b) => !!a && !!b && path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
21
+
22
+ // facts: see gather() in doctor.js. Returns [{ status, label, detail, fix, fixable }].
23
+ function evaluate(f) {
24
+ const checks = [];
25
+ const add = (status, label, detail, fix = '', fixable = false) => checks.push({ status, label, detail, fix, fixable });
26
+
27
+ const major = Number((/^v?(\d+)/.exec(f.node) || [])[1]);
28
+ if (major >= 20) add('ok', 'Node.js', f.node);
29
+ else add('fail', 'Node.js', `${f.node} is too old`, 'Install Node.js 20 or newer (nodejs.org).');
30
+
31
+ const inst = f.install;
32
+ if (!inst.exists) add('fail', 'Install folder', `${inst.dir} is missing`, `Run: ${SETUP}`);
33
+ else if (inst.missing.length) add('fail', 'Install folder', `${inst.dir} lacks ${inst.missing.join(', ')}`, `Run: ${SETUP}`);
34
+ else add('ok', 'Install folder', `${inst.dir} (${inst.version})`);
35
+
36
+ if (inst.exists && inst.version) {
37
+ if (inst.extensionVersion && inst.extensionVersion !== inst.version) {
38
+ add('fail', 'Version', `program ${inst.version}, extension files ${inst.extensionVersion}`, `Run: ${SETUP}`);
39
+ } else if (compareVersions(f.self.version, inst.version) > 0) {
40
+ add('warn', 'Version', `${inst.version} is installed; this is ${f.self.version}`, `Update: ${SETUP}`);
41
+ } else {
42
+ add('ok', 'Version', inst.version);
43
+ }
44
+ }
45
+
46
+ const hm = f.hostManifest;
47
+ if (inst.exists) {
48
+ if (!hm.exists) add('fail', 'Host manifest', 'missing', FIX, true);
49
+ else if (hm.problem) add('fail', 'Host manifest', hm.problem, FIX, true);
50
+ else add('ok', 'Host manifest', hm.file);
51
+ }
52
+
53
+ // target: a browser Backdraw registers for (Chrome always, the others when installed).
54
+ if (f.registryError) add('warn', 'Browsers', `couldn't read the registry (${f.registryError})`);
55
+ for (const b of f.registryError ? [] : f.browsers.filter(x => x.target)) {
56
+ const reg = b.registration;
57
+ if (!reg) add(b.id === 'chrome' ? 'fail' : 'warn', b.name, 'the native host is not registered', FIX, inst.exists);
58
+ else if (!samePath(reg.path, hm.file)) add('fail', b.name, `the native host points to ${reg.path}`, FIX, inst.exists);
59
+ else add('ok', b.name, `native host registered (${reg.key} key)`);
60
+ const blocked = policyProblem(b.policies, f.hostName);
61
+ if (blocked) add('fail', `${b.name} policy`, blocked, `Backdraw can't run in ${b.name} until an administrator allows it.`);
62
+ }
63
+
64
+ const br = f.bridge;
65
+ if (!br.ok) {
66
+ add('fail', 'Bridge', br.code === 'NOT_RUNNING' ? 'not running: the browser is closed, or the extension is not loaded or turned on' : br.error,
67
+ `Open Chrome. Not loaded yet? Run ${SETUP}; turned off? Turn Backdraw on at chrome://extensions.`);
68
+ } else {
69
+ add('ok', 'Bridge', `host ${br.status.hostVersion} answers on the named pipe`);
70
+ }
71
+
72
+ if (br.ok) {
73
+ const ext = br.status.extension;
74
+ if (!ext) add('fail', 'Extension', 'not connected', 'Turn Backdraw on at chrome://extensions, or reload it there.');
75
+ else if (inst.version && ext.version !== inst.version) add('warn', 'Extension', `${ext.version} is running; the files are ${inst.version}`, 'Reload Backdraw at chrome://extensions.');
76
+ else add('ok', 'Extension', `${ext.version} connected`);
77
+
78
+ const chat = f.chatgpt || {};
79
+ if (chat.error) add('warn', 'ChatGPT', `couldn't ask the extension (${chat.error})`, 'Click the Backdraw icon in Chrome to see the sign-in state.');
80
+ else if (chat.challenge) add('fail', 'ChatGPT', 'shows a human verification page', 'Open chatgpt.com in Chrome and complete it yourself.');
81
+ else if (chat.loggedIn === false) add('fail', 'ChatGPT', 'signed out', 'Sign in at chatgpt.com in Chrome.');
82
+ else if (chat.loggedIn === true) add('ok', 'ChatGPT', 'signed in');
83
+ else add('warn', 'ChatGPT', 'not checked yet', 'Click the Backdraw icon in Chrome and press "Check now".');
84
+
85
+ if (br.status.project) add('ok', 'Project', 'a ChatGPT Project is selected');
86
+ else add('fail', 'Project', 'no ChatGPT Project is selected', 'Click the Backdraw icon in Chrome and choose a Project.');
87
+ } else {
88
+ for (const label of ['Extension', 'ChatGPT', 'Project']) add('info', label, 'not checked (the bridge is not running)');
89
+ }
90
+
91
+ for (const c of f.clients) {
92
+ if (c.error) add('warn', c.label, c.error);
93
+ else if (c.entry && c.problem) add('fail', c.label, c.problem, FIX, true);
94
+ else if (c.entry) add('ok', c.label, 'set up for this install');
95
+ else if (c.detected) add('warn', c.label, 'Backdraw is not added', `Run: ${SETUP} --clients=${c.id}`);
96
+ }
97
+
98
+ const d = f.desktop;
99
+ for (const file of d.files) {
100
+ // The Store (MSIX) app keeps its own copy of the config next to the classic one.
101
+ const label = d.files.length > 1 ? `Claude desktop (${/\\Packages\\/i.test(file.file) ? 'Store' : 'classic'})` : 'Claude desktop app';
102
+ if (file.error) add('warn', label, file.error);
103
+ else if (file.entry && file.problem) {
104
+ add('fail', label, file.problem, d.running === false ? FIX : `Quit Claude completely (also from the system tray), then run: ${FIX}`, d.running === false);
105
+ } else if (file.entry) add('ok', label, 'set up for this install');
106
+ else add('warn', label, 'Backdraw is not added', `Quit Claude completely (also from the system tray), then run: ${SETUP} --clients=claude-desktop`);
107
+ }
108
+ if (d.files.length && d.running) add('info', 'Claude desktop app', 'running (Backdraw only changes its config while it is closed)');
109
+
110
+ return checks;
111
+ }
112
+
113
+ // The user's home folder (in the forms it is printed in) as "~", and the pipe's user tag.
114
+ function mask(text, home, userTag) {
115
+ let out = String(text);
116
+ if (home) {
117
+ const forms = new Set([home, home.replace(/\\/g, '/'), home.replace(/\\/g, '\\\\')]);
118
+ for (const form of [...forms].sort((a, b) => b.length - a.length)) {
119
+ out = out.replace(new RegExp(form.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), '~');
120
+ }
121
+ }
122
+ if (userTag) out = out.split(`backdraw-${userTag}`).join('backdraw-~');
123
+ return out;
124
+ }
125
+
126
+ function format(checks, { header, color = false } = {}) {
127
+ const paint = painter(color);
128
+ const width = Math.max(...checks.map(c => c.label.length));
129
+ const lines = header ? [header, ''] : [];
130
+ for (const c of checks) {
131
+ lines.push(line(paint, c.status, c.label, c.detail, width));
132
+ if (c.fix) lines.push(` ${' '.repeat(width)} → ${c.fix}`);
133
+ }
134
+ const fails = checks.filter(c => c.status === 'fail').length;
135
+ const warns = checks.filter(c => c.status === 'warn').length;
136
+ const fixable = checks.filter(c => c.fixable).length;
137
+ lines.push('', fails || warns
138
+ ? `${fails} problem${fails === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'}.${fixable ? ` "${FIX}" can repair ${fixable} of them.` : ''}`
139
+ : 'Everything looks good.');
140
+ return lines.join('\n');
141
+ }
142
+
143
+ module.exports = { compareVersions, evaluate, mask, format, SETUP, FIX };