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
package/lib/results.js ADDED
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+ // The text lines of a tool result for a job. Pure (no file or bridge access) so every status can
3
+ // be tested; mcp-server.js adds the preview images.
4
+ const { intArg } = require('./args');
5
+ // pct: the warning quotes the same rounded shares as the prompt ChatGPT got.
6
+ const { TOLERANCE, aspectOption, safeBand, pct: percent } = require('./aspect');
7
+
8
+ const FINAL = new Set(['succeeded', 'failed', 'cancelled', 'needs_reference', 'text_reply']);
9
+ // Each tool call returns within this many seconds by default: some clients that reach the PC from
10
+ // the cloud give up on a tool call after 60 s, and generating takes 1-3 minutes.
11
+ const DEFAULT_WAIT = 45;
12
+ // How long a tool call may wait for its job: wait_seconds as whole seconds within 0-900.
13
+ const waitSeconds = args => intArg(args.wait_seconds, 0, 900, DEFAULT_WAIT);
14
+ const checkAgain = id => `Call job_status with job_id="${id}" and wait_seconds=${DEFAULT_WAIT} again (repeat until it finishes), or cancel_job to stop it.`;
15
+ // A prompt that asks to change an existing image rather than draw a new one.
16
+ const EDIT_INTENT = /\b(edit(s|ed|ing)?|modif(y|ies|ied|ying)|retouch(es|ed|ing)?|inpaint(s|ed|ing)?|change only|only change|change [a-z ]{1,30} only|keep (everything|it|the rest)|pixel-identical|same (pose|image|drawing|character))\b|düzenle|değiştir|aynı (kalsın|bırak|tut)|koru(yarak)?\b/i;
17
+
18
+ const neutral = (pct, against) => `Similarity to ${against}: ${pct}% (a local edit keeps ~95-100%, a redraw is usually under 60%).`;
19
+
20
+ function editCheckLines(job) {
21
+ const check = job.editCheck;
22
+ if (!check || !check.results || !check.results.length) return [];
23
+ // Each result is an output image's closest match; judge by the least similar output.
24
+ const worst = check.results.reduce((a, b) => (b.similarity < a.similarity ? b : a));
25
+ // Judged on the shown percentage, so the verdict never contradicts the number next to it.
26
+ const pct = Math.round(worst.similarity * 100);
27
+ // Only collection images attached: they are style or character references, so a new picture is
28
+ // what was asked for, not a failed edit.
29
+ const col = job.collection;
30
+ if (col && (job.references || []).length <= col.used.length) return [neutral(pct, worst.against)];
31
+ if (pct >= 75) return [`Edit check: ${pct}% of the image is unchanged compared with ${worst.against} — this looks like a real edit (edited: true).`];
32
+ if (EDIT_INTENT.test(job.prompt || '')) {
33
+ return [`⚠ EDIT CHECK (edited: false): only ${pct}% of the image matches ${worst.against}. ChatGPT most likely drew a NEW picture instead of editing (a real local edit keeps ~95-100%). ` +
34
+ 'Tell the user; if an exact edit is needed, call generate_image again WITHOUT conversation_url and with reference_images=[the original image file].'];
35
+ }
36
+ return [neutral(pct, worst.against)];
37
+ }
38
+
39
+ // Collection images come after the caller's own reference images in job.references.
40
+ function referenceLines(job) {
41
+ const refs = job.references || [];
42
+ const col = job.collection;
43
+ const own = col ? refs.slice(0, Math.max(0, refs.length - col.used.length)) : refs;
44
+ const lines = own.length ? [`Reference images attached: ${own.map(r => r.name).join(', ')}`] : [];
45
+ if (col) {
46
+ const left = col.skipped && col.skipped.length ? `; left out: ${col.skipped.join(', ')}` : '';
47
+ lines.push(`References: collection "${col.name}": used ${col.used.length} of ${col.total} (${col.used.join(', ')})${left}`);
48
+ }
49
+ return lines;
50
+ }
51
+
52
+ const MISSING = '⚠ TRANSPARENT BACKGROUND MISSING';
53
+ const RETRY = 'Tell the user; retry once in a fresh chat or remove the background with another tool.';
54
+ const KINDS = { png: 'PNG', jpg: 'JPEG', jpeg: 'JPEG', webp: 'WebP', gif: 'GIF' };
55
+ const COLOR_TYPES = { 0: 'grayscale', 2: 'RGB', 3: 'palette' };
56
+
57
+ // ChatGPT was told what the crop may cut: nothing for a ratio it draws itself, the edges outside the
58
+ // safe band beyond 1:3..3:1. When it drew another shape, the crop cut more and may have hit the subject.
59
+ function cropWarning(im) {
60
+ const { full } = im;
61
+ if (!im.aspect || !im.width || !im.height || !full.width || !full.height) return null;
62
+ const p = aspectOption(im.aspect);
63
+ const axis = im.width < full.width ? 'width' : 'height';
64
+ const kept = axis === 'width' ? im.width / full.width : im.height / full.height;
65
+ const band = safeBand(p);
66
+ const allowed = band && band.axis === axis ? band.share : 1;
67
+ if (kept >= allowed * (1 - TOLERANCE)) return null;
68
+ const edges = axis === 'height' ? 'top and bottom' : 'left and right';
69
+ return `⚠ ChatGPT drew ${full.width}x${full.height} instead of ${p.generate.label}, so the crop removed ${percent(1 - kept)} of the ${axis} ` +
70
+ `(${percent((1 - kept) / 2)} at the ${edges} each${allowed < 1 ? `; it was told only ${percent((1 - allowed) / 2)} each may be cut off` : ''}). ` +
71
+ 'Check the saved image for cut-off subjects or text; the full image is kept too.';
72
+ }
73
+
74
+ function aspectLine(im) {
75
+ const [w, h] = im.aspect.split(':').map(Number);
76
+ if (!im.width || !im.height) return `Aspect ratio: ${im.aspect} requested; the image size is unknown, so it was not cropped.`;
77
+ const got = `${im.width}x${im.height}`;
78
+ if (Math.abs(im.width * h - im.height * w) <= TOLERANCE * im.height * w) return `Aspect ratio: ${im.aspect} requested, got ${got} (within ${TOLERANCE * 100}%, not cropped).`;
79
+ return `⚠ Aspect ratio: ${im.aspect} requested, got ${got}; it could not be cropped.`;
80
+ }
81
+
82
+ // Judged on ChatGPT's own file: a cropped copy is always an RGBA PNG (canvas), whatever came in.
83
+ // The pixels were measured on the saved (cropped) image.
84
+ function transparencyLine(im) {
85
+ const ext = (String((im.full || im).path).match(/\.([a-z0-9]+)$/i) || [])[1] || '';
86
+ const kind = KINDS[ext.toLowerCase()] || 'image';
87
+ const share = im.alpha ? im.alpha.transparentShare : null;
88
+ // Only a PNG header, a JPEG or measured pixels tell whether there is an alpha channel at all.
89
+ if (!im.png && kind !== 'JPEG' && share === null) return `Transparent background: ${kind}; whether it is transparent could not be measured.`;
90
+ const channel = im.png ? im.png.alphaChannel || im.png.trns : kind !== 'JPEG' && share > 0;
91
+ if (!channel) {
92
+ const format = im.png ? `${COLOR_TYPES[im.png.colorType] || `color type ${im.png.colorType}`} PNG` : kind;
93
+ return `${MISSING}: ChatGPT returned an image without an alpha channel (${format}), so the background is not transparent. ${RETRY}`;
94
+ }
95
+ if (share === null) return `Transparent background: ${kind} with an alpha channel; how much of it is transparent could not be measured.`;
96
+ if (share > 0.02) return `Transparent background: yes — ${kind} with an alpha channel, ${Math.round(share * 100)}% of the pixels are transparent.`;
97
+ return `${MISSING}: the ${kind} has an alpha channel but ${share > 0 ? 'almost ' : ''}every pixel is opaque. ${RETRY}`;
98
+ }
99
+
100
+ // The Saved line of one image, plus what was asked about its shape and background.
101
+ function imageLines(job, im) {
102
+ const lines = [];
103
+ if (im.full) {
104
+ lines.push(`Saved: ${im.path} (${im.width ? `${im.width}x${im.height}, ` : ''}cropped from the centre to ${im.aspect})`,
105
+ `Full image before cropping: ${im.full.path}${im.full.width ? ` (${im.full.width}x${im.full.height})` : ''}`);
106
+ // Only a generated image was drawn to a requested shape; a fetched one is simply cropped.
107
+ const warning = job.mode !== 'collect' && cropWarning(im);
108
+ if (warning) lines.push(warning);
109
+ } else {
110
+ lines.push(`Saved: ${im.path}${im.width ? ` (${im.width}x${im.height})` : ''}`);
111
+ if (im.aspect) lines.push(aspectLine(im));
112
+ }
113
+ if (job.transparent) lines.push(transparencyLine(im));
114
+ return lines;
115
+ }
116
+
117
+ function resultLines(job) {
118
+ const lines = [];
119
+ switch (job.status) {
120
+ case 'succeeded':
121
+ lines.push(`SUCCESS — ChatGPT generated ${job.images.length} image(s). Job ${job.id}.`);
122
+ break;
123
+ case 'needs_reference':
124
+ lines.push(`NEEDS REFERENCE IMAGE — ChatGPT could not access the image it should edit and asked for it to be uploaded again (job ${job.id}).${job.reattached ? ' It still asked after the bridge re-attached the image.' : ''}`,
125
+ 'Do NOT resend the same prompt. Next step: call generate_image again with reference_images=[the local file of the image to edit] — most reliable WITHOUT conversation_url (a fresh chat). ' +
126
+ 'Alternatively pass auto_reattach=true to let the bridge attach the chat\'s latest image itself.');
127
+ break;
128
+ case 'text_reply':
129
+ lines.push(`CHATGPT REPLIED WITHOUT AN IMAGE (job ${job.id}). Read its reply below and act on it — do not resend the identical prompt. ` +
130
+ 'If it asked a question, answer it with generate_image(conversation_url=<the link below>, prompt=<your answer>, send_as_is=true); if it refused, rephrase the request.');
131
+ break;
132
+ case 'failed':
133
+ lines.push(`FAILED — ${job.error || 'unknown error'} (job ${job.id})`);
134
+ break;
135
+ case 'cancelled':
136
+ lines.push(`CANCELLED — job ${job.id}.`);
137
+ break;
138
+ case 'needs_human':
139
+ lines.push(`NEEDS USER ACTION — ${job.message} (job ${job.id})`,
140
+ 'Ask the user to open the ChatGPT tab in Chrome and complete it themselves; do not try to get around it. ' +
141
+ `The job continues automatically for up to 10 minutes; check it with job_status(job_id="${job.id}", wait_seconds=${DEFAULT_WAIT}).`);
142
+ break;
143
+ case 'queued':
144
+ lines.push(`QUEUED — position ${job.queuePosition || '?'} in the queue (position 1 starts as soon as the running job, if any, is done). Job ${job.id}. ${checkAgain(job.id)}`);
145
+ break;
146
+ default:
147
+ lines.push(`STILL RUNNING — ${job.message || job.step}. Job ${job.id}. ${checkAgain(job.id)}`);
148
+ }
149
+ if (job.conversationUrl) lines.push(`ChatGPT conversation: ${job.conversationUrl}`);
150
+ // The ratio belongs to this job: a later fetch_images only crops when it is given the ratio again.
151
+ if (job.status === 'failed' && job.conversationUrl && job.aspect) {
152
+ lines.push(`If the chat finishes later, fetch_images(conversation_url=<the link above>, aspect_ratio="${job.aspect}") saves the image cropped the same way.`);
153
+ }
154
+ lines.push(...referenceLines(job));
155
+ if (job.reattached && job.status === 'succeeded') lines.push('Note: ChatGPT first asked for the image again; the bridge re-attached it and asked once more.');
156
+ for (const im of job.images || []) lines.push(...imageLines(job, im));
157
+ for (const c of job.copies || []) lines.push(`Copied to: ${c}`);
158
+ if (job.copyError) lines.push(`Could not copy into output_dir: ${job.copyError}`);
159
+ lines.push(...editCheckLines(job));
160
+ if (job.assistantText) {
161
+ lines.push(job.status === 'succeeded' ? `ChatGPT's text: ${job.assistantText.slice(0, 1500)}` : `ChatGPT's reply:\n${job.assistantText.slice(0, 3000)}`);
162
+ }
163
+ if (job.chatImages && job.chatImages.length) {
164
+ lines.push('Images already saved from this chat (newest first) — usable as reference_images:');
165
+ for (const im of job.chatImages) lines.push(`- ${im.path} (${im.job}: "${im.prompt}")`);
166
+ }
167
+ return lines;
168
+ }
169
+
170
+ module.exports = { FINAL, DEFAULT_WAIT, EDIT_INTENT, waitSeconds, editCheckLines, imageLines, referenceLines, resultLines };
package/lib/term.js ADDED
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+ // Terminal output for setup and doctor: a status symbol per line, in colour only on a terminal that
3
+ // wants it (NO_COLOR, a pipe or a file get plain text, which stays readable).
4
+
5
+ const SYMBOLS = { ok: '✓', warn: '⚠', fail: '✗', info: '·' };
6
+ const COLORS = { ok: 32, warn: 33, fail: 31, info: 90, bold: 1, dim: 90 };
7
+
8
+ function useColor(stream = process.stdout, env = process.env) {
9
+ return !!(stream && stream.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
10
+ }
11
+
12
+ function painter(color) {
13
+ return (kind, text) => (color && COLORS[kind] ? `\x1b[${COLORS[kind]}m${text}\x1b[0m` : text);
14
+ }
15
+
16
+ // "✓ Label detail", with the symbol coloured.
17
+ function line(paint, status, label, detail, width = 0) {
18
+ const head = `${paint(status, SYMBOLS[status])} ${width ? label.padEnd(width) : label}`;
19
+ return detail ? `${head} ${detail}` : head;
20
+ }
21
+
22
+ module.exports = { SYMBOLS, useColor, painter, line };
package/lib/toml.js ADDED
@@ -0,0 +1,248 @@
1
+ 'use strict';
2
+ // Just enough TOML to keep one MCP server table up to date in Codex's config.toml without a parser
3
+ // dependency: find the [a.b] table (and its [a.b.*] sub-tables), set or replace a few single keys in
4
+ // it, or drop it, and leave every other line of the file exactly as it was. Forms this can't edit
5
+ // safely (the table written as dotted keys or as an inline table) are refused instead of guessed at.
6
+
7
+ // Splits a key path such as mcp_servers . "backdraw" into its parts; null if it isn't one.
8
+ function keyPath(text) {
9
+ const parts = [];
10
+ let s = text.trim();
11
+ while (s) {
12
+ let m;
13
+ if ((m = /^"((?:[^"\\]|\\.)*)"/.exec(s))) parts.push(unescapeBasic(m[1]));
14
+ else if ((m = /^'([^']*)'/.exec(s))) parts.push(m[1]);
15
+ else if ((m = /^[A-Za-z0-9_-]+/.exec(s))) parts.push(m[0]);
16
+ else return null;
17
+ s = s.slice(m[0].length).trim();
18
+ if (!s) break;
19
+ if (s[0] !== '.') return null;
20
+ s = s.slice(1).trim();
21
+ if (!s) return null;
22
+ }
23
+ return parts.length ? parts : null;
24
+ }
25
+
26
+ // The escapes of a TOML basic string ("…").
27
+ function unescapeBasic(body) {
28
+ const simple = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '\\': '\\' };
29
+ return body.replace(/\\(u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|.)/g, (all, e) => {
30
+ if (e.length > 1) return String.fromCodePoint(parseInt(e.slice(1), 16));
31
+ return e in simple ? simple[e] : all;
32
+ });
33
+ }
34
+
35
+ const startsWith = (parts, prefix) => prefix.every((p, i) => parts[i] === p);
36
+ const samePath = (a, b) => a.length === b.length && startsWith(a, b);
37
+
38
+ // Reads one line from `state` ({ ml: open multi-line string quote or null, depth: open [ and { of a
39
+ // value }), skipping strings and comments. Returns the state at the end of the line and the text
40
+ // before any comment.
41
+ function tokenize(line, state) {
42
+ let { ml, depth } = state;
43
+ let i = 0;
44
+ let codeEnd = line.length;
45
+ while (i < line.length) {
46
+ if (ml) {
47
+ if (ml === '"""' && line[i] === '\\') { i += 2; continue; }
48
+ if (line.startsWith(ml, i)) {
49
+ i += 3;
50
+ while (line[i] === ml[0]) i++; // up to two quotes may end the content
51
+ ml = null;
52
+ continue;
53
+ }
54
+ i++;
55
+ continue;
56
+ }
57
+ const c = line[i];
58
+ if (c === '#') { codeEnd = i; break; }
59
+ if (line.startsWith('"""', i) || line.startsWith("'''", i)) { ml = line.slice(i, i + 3); i += 3; continue; }
60
+ if (c === '"' || c === "'") {
61
+ i++;
62
+ while (i < line.length && line[i] !== c) i += c === '"' && line[i] === '\\' ? 2 : 1;
63
+ i++;
64
+ continue;
65
+ }
66
+ if (c === '[' || c === '{') depth++;
67
+ else if (c === ']' || c === '}') depth = Math.max(0, depth - 1);
68
+ i++;
69
+ }
70
+ return { state: { ml, depth }, code: line.slice(0, codeEnd).trimEnd() };
71
+ }
72
+
73
+ // One entry per line:
74
+ // header its table path, when the line is a [a.b] or [[a.b]] header
75
+ // table the table the line is in
76
+ // cont the line continues a value from the line before (a multi-line string or array)
77
+ // blank / comment nothing but whitespace / only a comment
78
+ function scan(lines) {
79
+ let table = [];
80
+ let state = { ml: null, depth: 0 };
81
+ return lines.map(line => {
82
+ const cont = !!state.ml || state.depth > 0;
83
+ if (!cont) {
84
+ const m = /^\s*\[\[?([^\[\]]+)\]\]?\s*(?:#.*)?$/.exec(line);
85
+ const header = m && keyPath(m[1]);
86
+ if (header) { table = header; return { header, table, cont: false }; }
87
+ }
88
+ const t = tokenize(line, state);
89
+ state = t.state;
90
+ return { header: null, table, cont, blank: !cont && !line.trim(), comment: !cont && /^\s*#/.test(line) };
91
+ });
92
+ }
93
+
94
+ // Ranges [start, end) of the lines that belong to the table at `parts` or one of its sub-tables.
95
+ // Comments and blank lines right before the next header belong to that next table.
96
+ function tableRanges(info, parts) {
97
+ const ranges = [];
98
+ for (let i = 0; i < info.length; i++) {
99
+ if (!info[i].header || !startsWith(info[i].header, parts)) continue;
100
+ let end = i + 1;
101
+ while (end < info.length && !(info[end].header && !startsWith(info[end].header, parts))) end++;
102
+ let last = end;
103
+ if (end < info.length) while (last > i + 1 && (info[last - 1].blank || info[last - 1].comment)) last--;
104
+ ranges.push([i, last]);
105
+ i = end - 1;
106
+ }
107
+ return ranges;
108
+ }
109
+
110
+ // The key path and value text of a `key = value` line, or null.
111
+ function assignment(line) {
112
+ const m = /^\s*((?:"(?:[^"\\]|\\.)*"|'[^']*'|[^=#"'])+?)\s*=\s*(.*)$/.exec(line);
113
+ const key = m && keyPath(m[1]);
114
+ return key ? { key, value: m[2] } : null;
115
+ }
116
+
117
+ // The table (or a parent of it) written some other way, as dotted keys (a.b.key = …) or as an
118
+ // inline table (a = { b = … }), can't be edited line by line: a [a.b] header next to it would even
119
+ // make the file invalid.
120
+ function assertPlainTable(lines, info, parts) {
121
+ lines.forEach((line, i) => {
122
+ if (info[i].header || info[i].cont || startsWith(info[i].table, parts)) return;
123
+ const a = assignment(line);
124
+ if (!a) return;
125
+ const full = [...info[i].table, ...a.key];
126
+ if (startsWith(full, parts) || startsWith(parts, full)) {
127
+ throw new Error(`${parts.join('.')} is written in a form Backdraw doesn't edit (line ${i + 1}); change it by hand`);
128
+ }
129
+ });
130
+ }
131
+
132
+ const tomlString = s => `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
133
+
134
+ function tomlValue(v) {
135
+ if (Array.isArray(v)) return `[${v.map(tomlValue).join(', ')}]`;
136
+ if (typeof v === 'boolean' || typeof v === 'number') return String(v);
137
+ return tomlString(v);
138
+ }
139
+
140
+ // The line after a key's value (a value may go on over continuation lines).
141
+ function valueEnd(info, i) {
142
+ let end = i + 1;
143
+ while (end < info.length && info[end].cont) end++;
144
+ return end;
145
+ }
146
+
147
+ // Removes `key = value` from the table's own lines starting at `from`; returns whether it was there.
148
+ function dropKey(lines, info, from, key) {
149
+ for (let i = from; i < lines.length && !info[i].header; i++) {
150
+ if (info[i].cont) continue;
151
+ const a = assignment(lines[i]);
152
+ if (!a || a.key.length !== 1 || a.key[0] !== key) continue;
153
+ const end = valueEnd(info, i);
154
+ lines.splice(i, end - i);
155
+ info.splice(i, end - i);
156
+ return true;
157
+ }
158
+ return false;
159
+ }
160
+
161
+ function splitLines(text) {
162
+ const eol = /\r\n/.test(text) ? '\r\n' : '\n';
163
+ const lines = text.split(/\r?\n/);
164
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
165
+ return { lines, eol };
166
+ }
167
+
168
+ const joinLines = (lines, eol) => (lines.length ? lines.join(eol) + eol : '');
169
+
170
+ const ownTable = (info, parts) => tableRanges(info, parts).find(([start]) => samePath(info[start].header, parts));
171
+
172
+ // Sets `values` (key -> string/array/number/boolean) in the table at `parts`, creating the table at
173
+ // the end of the file if it isn't there. Other keys and sub-tables of the table stay as they are.
174
+ function setTable(text, parts, values) {
175
+ const { lines, eol } = splitLines(text);
176
+ const info = scan(lines);
177
+ assertPlainTable(lines, info, parts);
178
+ const own = ownTable(info, parts);
179
+ const assignments = Object.entries(values).map(([k, v]) => `${/^[A-Za-z0-9_-]+$/.test(k) ? k : tomlString(k)} = ${tomlValue(v)}`);
180
+ if (!own) {
181
+ if (lines.length && lines[lines.length - 1].trim() !== '') lines.push('');
182
+ lines.push(`[${parts.map(p => (/^[A-Za-z0-9_-]+$/.test(p) ? p : tomlString(p))).join('.')}]`, ...assignments);
183
+ return joinLines(lines, eol);
184
+ }
185
+ const [start] = own;
186
+ for (const key of Object.keys(values)) dropKey(lines, info, start + 1, key);
187
+ lines.splice(start + 1, 0, ...assignments);
188
+ return joinLines(lines, eol);
189
+ }
190
+
191
+ // Drops the table at `parts` with its sub-tables (and the blank lines left in front of it).
192
+ function removeTable(text, parts) {
193
+ const { lines, eol } = splitLines(text);
194
+ const info = scan(lines);
195
+ assertPlainTable(lines, info, parts);
196
+ for (const [start, end] of tableRanges(info, parts).reverse()) {
197
+ let from = start;
198
+ while (from > 0 && lines[from - 1].trim() === '') from--;
199
+ lines.splice(from, end - from);
200
+ while (from < lines.length && lines[from].trim() === '') lines.splice(from, 1);
201
+ if (from > 0 && from < lines.length) lines.splice(from, 0, '');
202
+ }
203
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
204
+ return joinLines(lines, eol);
205
+ }
206
+
207
+ // Reads the plain string / array-of-strings values of the table at `parts`, or null without it.
208
+ // Only what Backdraw itself writes is understood; other values come back as their raw text.
209
+ function readTable(text, parts) {
210
+ const { lines } = splitLines(text);
211
+ const info = scan(lines);
212
+ const own = ownTable(info, parts);
213
+ if (!own) return null;
214
+ const out = {};
215
+ for (let i = own[0] + 1; i < lines.length && !info[i].header; i++) {
216
+ if (info[i].cont) continue;
217
+ const a = assignment(lines[i]);
218
+ if (!a || a.key.length !== 1) continue;
219
+ const end = valueEnd(info, i);
220
+ let state = { ml: null, depth: 0 };
221
+ const parts2 = [];
222
+ for (const l of [a.value, ...lines.slice(i + 1, end)]) {
223
+ const t = tokenize(l, state);
224
+ state = t.state;
225
+ parts2.push(t.code.trim());
226
+ }
227
+ out[a.key[0]] = parseValue(parts2.join(' ').trim());
228
+ }
229
+ return out;
230
+ }
231
+
232
+ function parseString(s) {
233
+ if (s.startsWith('"')) return unescapeBasic(s.slice(1, -1));
234
+ if (s.startsWith("'")) return s.slice(1, -1);
235
+ return s;
236
+ }
237
+
238
+ function parseValue(raw) {
239
+ if (/^("(?:[^"\\]|\\.)*"|'[^']*')$/.test(raw)) return parseString(raw);
240
+ const arr = /^\[(.*)\]$/s.exec(raw);
241
+ if (arr) {
242
+ const items = arr[1].match(/"(?:[^"\\]|\\.)*"|'[^']*'/g) || [];
243
+ return items.map(parseString);
244
+ }
245
+ return raw;
246
+ }
247
+
248
+ module.exports = { keyPath, setTable, removeTable, readTable };