veodl 1.9.0 → 1.10.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/CHANGELOG.md +28 -0
- package/README.md +57 -37
- package/docs/AGENT_GUIDE.md +8 -5
- package/package.json +1 -1
- package/src/backend.js +64 -36
- package/src/cli.js +120 -56
- package/src/config-diagnostics.js +19 -9
- package/src/config-editor.js +181 -66
- package/src/config-template.js +63 -73
- package/src/config.js +110 -27
- package/src/downloader.js +28 -21
- package/src/interactive.js +19 -19
- package/src/jobs.js +13 -4
- package/src/option-suggestions.js +43 -0
- package/src/progress.js +68 -12
- package/src/runs.js +8 -5
- package/src/source-discovery.js +82 -30
package/src/config-editor.js
CHANGED
|
@@ -4,7 +4,8 @@ import { PassThrough } from 'node:stream';
|
|
|
4
4
|
import { StringDecoder } from 'node:string_decoder';
|
|
5
5
|
import { parseConfigText } from './config.js';
|
|
6
6
|
import { semanticIssue, editorCompletions } from './config-diagnostics.js';
|
|
7
|
-
import { configClipboard } from './config-clipboard.js';
|
|
7
|
+
import { configClipboard } from './config-clipboard.js';
|
|
8
|
+
import { addConfigGuide, configGuideState, removeConfigGuide } from './config-template.js';
|
|
8
9
|
|
|
9
10
|
const safe = text => text.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
|
|
10
11
|
|
|
@@ -21,7 +22,7 @@ export async function validateEditorText(text) {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
// A small text buffer shared by the keyboard handler and focused editing tests.
|
|
24
|
-
export class EditorBuffer {
|
|
25
|
+
export class EditorBuffer {
|
|
25
26
|
constructor(text) { this.text = text; this.cursor = 0; this.anchor = null; }
|
|
26
27
|
get selection() {
|
|
27
28
|
return this.anchor === null ? [this.cursor, this.cursor] : [Math.min(this.anchor, this.cursor), Math.max(this.anchor, this.cursor)];
|
|
@@ -70,7 +71,33 @@ export class EditorBuffer {
|
|
|
70
71
|
if (name === 'return') this.insert('\n');
|
|
71
72
|
if (name === 'tab') this.insert(' ');
|
|
72
73
|
}
|
|
73
|
-
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class EditorHistory {
|
|
77
|
+
constructor(buffer) { this.buffer = buffer; this.undoStack = []; this.redoStack = []; this.group = null; }
|
|
78
|
+
snapshot() { return { text: this.buffer.text, cursor: this.buffer.cursor, anchor: this.buffer.anchor }; }
|
|
79
|
+
breakGroup() { this.group = null; }
|
|
80
|
+
record(before, kind, groupable = false) {
|
|
81
|
+
if (before.text === this.buffer.text) return;
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
if (!groupable || this.group?.kind !== kind || now - this.group.at > 600) {
|
|
84
|
+
this.undoStack.push(before);
|
|
85
|
+
if (this.undoStack.length > 100) this.undoStack.shift();
|
|
86
|
+
}
|
|
87
|
+
this.redoStack.length = 0;
|
|
88
|
+
this.group = groupable ? { kind, at: now } : null;
|
|
89
|
+
}
|
|
90
|
+
change(direction) {
|
|
91
|
+
const from = direction === 'undo' ? this.undoStack : this.redoStack;
|
|
92
|
+
if (!from.length) return false;
|
|
93
|
+
const to = direction === 'undo' ? this.redoStack : this.undoStack;
|
|
94
|
+
to.push(this.snapshot());
|
|
95
|
+
const state = from.pop();
|
|
96
|
+
Object.assign(this.buffer, state);
|
|
97
|
+
this.breakGroup();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
74
101
|
|
|
75
102
|
export function colorLine(line, enabled = true) {
|
|
76
103
|
const clean = safe(line);
|
|
@@ -83,20 +110,27 @@ export async function editConfig(file, { input = process.stdin, output = process
|
|
|
83
110
|
if (!input.isTTY || !output.isTTY || !input.setRawMode) throw new Error('The config editor requires an interactive terminal. Set EDITOR to use an external editor.');
|
|
84
111
|
const original = await readFile(file, 'utf8');
|
|
85
112
|
const newline = original.includes('\r\n') ? '\r\n' : '\n';
|
|
86
|
-
const buffer = new EditorBuffer(original.replace(/\r\n/g, '\n'));
|
|
113
|
+
const buffer = new EditorBuffer(original.replace(/\r\n/g, '\n'));
|
|
114
|
+
const history = new EditorHistory(buffer);
|
|
87
115
|
let saved = buffer.text, disk = original, issue = await validateEditorText(buffer.text);
|
|
88
|
-
let top = 0, left = 0, followCursor = true, question = false, busy = false, closed = false, timer;
|
|
89
|
-
|
|
116
|
+
let top = 0, left = 0, followCursor = true, question = false, guidePrompt = false, busy = false, closed = false, timer;
|
|
117
|
+
const guideState = configGuideState(buffer.text);
|
|
118
|
+
let status = guideState === 'outdated' ? 'Updated config guide available. Press F3 to choose.' :
|
|
119
|
+
guideState === 'current' ? 'Generated guide in file. Press F3 to remove it if duplicated.' : '', revision = 0;
|
|
90
120
|
let viewport, dragging = false, completion = null;
|
|
91
121
|
const wasRaw = input.isRaw, wasPaused = input.isPaused();
|
|
92
122
|
const render = () => {
|
|
93
123
|
if (closed) return;
|
|
94
124
|
const width = Math.max(1, (output.columns || 80) - 1);
|
|
95
|
-
const diagnosis = issue ? `${issue.line ? `Line ${issue.line}, column ${issue.column}: ` : ''}${issue.message}` : '';
|
|
96
|
-
const detail = safe(completion ? `Options (${completion.index + 1}/${completion.choices.length}): ${JSON.stringify(completion.choices[completion.index])} | Up/Down choose, Enter apply, Esc cancel` :
|
|
97
|
-
status ? `${status}${diagnosis ? ` | ${diagnosis}` : ''}` : diagnosis || 'Config OK');
|
|
98
|
-
const detailRows = Math.min(3, Math.max(1, Math.ceil(detail.length / width)));
|
|
99
|
-
const
|
|
125
|
+
const diagnosis = issue ? `${issue.line ? `Line ${issue.line}, column ${issue.column}: ` : ''}${issue.message}` : '';
|
|
126
|
+
const detail = safe(completion ? `Options (${completion.index + 1}/${completion.choices.length}): ${JSON.stringify(completion.choices[completion.index])} | Up/Down choose, Enter apply, Esc cancel` :
|
|
127
|
+
status ? `${status}${diagnosis ? ` | ${diagnosis}` : ''}` : diagnosis || 'Config OK');
|
|
128
|
+
const detailRows = Math.min(3, Math.max(1, Math.ceil(detail.length / width)));
|
|
129
|
+
const helpLines = question ? ['Discard unsaved changes? Y = discard, N / Esc = keep editing'] :
|
|
130
|
+
guidePrompt ? ['Guide: A add/update | R remove | Esc cancel'] :
|
|
131
|
+
['F3 Guide | F2 Options | Ctrl+S Save | Ctrl+Z Undo | Ctrl+Y Redo',
|
|
132
|
+
'Ctrl+A All | Ctrl+C Copy | Ctrl+V Paste | Esc / Ctrl+Q Exit'];
|
|
133
|
+
const height = Math.max(1, (output.rows || 24) - 1 - helpLines.length - detailRows);
|
|
100
134
|
const lines = buffer.text.split('\n'), { row, col } = buffer.position;
|
|
101
135
|
const gutter = Math.min(width - 1, String(lines.length).length + 2), available = Math.max(1, width - gutter);
|
|
102
136
|
if (followCursor) {
|
|
@@ -121,22 +155,54 @@ export async function editConfig(file, { input = process.stdin, output = process
|
|
|
121
155
|
screen.push(to > from ? prefix + colorLine(content.slice(0, from), color) + '\x1b[41;97m' + safe(content.slice(from, to)) + '\x1b[0m' + colorLine(content.slice(to), color) : prefix + colorLine(content, color));
|
|
122
156
|
} else screen.push(index + 1 === issue?.line && color ? `\x1b[41;97m${prefix}${safe(content)}\x1b[0m` : prefix + colorLine(content, color));
|
|
123
157
|
offset += (line || '').length + 1;
|
|
124
|
-
}
|
|
125
|
-
const position = `Ln ${row + 1}, Col ${col + 1}`;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
158
|
+
}
|
|
159
|
+
const position = `Ln ${row + 1}, Col ${col + 1}`;
|
|
160
|
+
for (let index = 0; index < helpLines.length; index++) {
|
|
161
|
+
const last = index === helpLines.length - 1;
|
|
162
|
+
const helpWidth = Math.max(0, width - (last ? position.length + 1 : 0));
|
|
163
|
+
const visibleHelp = safe(helpLines[index]).slice(0, helpWidth);
|
|
164
|
+
const footer = last ? visibleHelp + ' '.repeat(Math.max(1, width - position.length - visibleHelp.length)) + position : visibleHelp;
|
|
165
|
+
screen.push(color ? footer.replace(/Ctrl\+[A-Z]|F[23]|Esc|\b[A,R]\b/g, match => `\x1b[1;36m${match}\x1b[0m`) : footer);
|
|
166
|
+
}
|
|
167
|
+
for (let index = 0; index < detailRows; index++) {
|
|
168
|
+
const line = detail.slice(index * width, (index + 1) * width);
|
|
169
|
+
const tone = completion ? 33 : issue ? 31 : status ? 32 : 90;
|
|
170
|
+
screen.push(color && line ? `\x1b[${tone}m${line}\x1b[0m` : line);
|
|
171
|
+
}
|
|
130
172
|
const cursorVisible = row >= top && row < top + height;
|
|
131
173
|
output.write('\x1b[?25l\x1b[H' + screen.map(line => line + '\x1b[K').join('\r\n') +
|
|
132
174
|
(cursorVisible ? `\x1b[${row - top + 2};${gutter + col - left + 1}H\x1b[?25h` : ''));
|
|
133
175
|
};
|
|
134
176
|
await new Promise((resolve, reject) => {
|
|
135
|
-
const keyboard = new PassThrough();
|
|
136
|
-
const decoder = new StringDecoder('utf8');
|
|
137
|
-
let pending = '', mouseTimer;
|
|
138
|
-
|
|
139
|
-
|
|
177
|
+
const keyboard = new PassThrough();
|
|
178
|
+
const decoder = new StringDecoder('utf8');
|
|
179
|
+
let pending = '', mouseTimer;
|
|
180
|
+
let dragTimer, dragDirection = 0, dragX = 1;
|
|
181
|
+
const stopDragScroll = () => { clearInterval(dragTimer); dragTimer = undefined; dragDirection = 0; };
|
|
182
|
+
const stopDrag = () => { dragging = false; stopDragScroll(); };
|
|
183
|
+
const extendAt = (x, y) => {
|
|
184
|
+
const screenRow = Math.max(0, Math.min(viewport.height - 1, y - 2));
|
|
185
|
+
buffer.point(top + screenRow, left + x - viewport.gutter - 1, true);
|
|
186
|
+
};
|
|
187
|
+
const scrollDrag = () => {
|
|
188
|
+
if (!dragging || !dragDirection || closed) return stopDragScroll();
|
|
189
|
+
const lastTop = top;
|
|
190
|
+
top = Math.max(0, Math.min(top + dragDirection, Math.max(0, buffer.text.split('\n').length - viewport.height)));
|
|
191
|
+
if (top === lastTop) return stopDragScroll();
|
|
192
|
+
followCursor = false;
|
|
193
|
+
extendAt(dragX, dragDirection < 0 ? 2 : viewport.height + 1);
|
|
194
|
+
render();
|
|
195
|
+
};
|
|
196
|
+
const updateDragScroll = (x, y) => {
|
|
197
|
+
dragX = x;
|
|
198
|
+
const direction = y <= 2 ? -1 : y >= viewport.height + 1 ? 1 : 0;
|
|
199
|
+
if (direction === dragDirection) return;
|
|
200
|
+
stopDragScroll();
|
|
201
|
+
dragDirection = direction;
|
|
202
|
+
if (direction) dragTimer = setInterval(scrollDrag, 90);
|
|
203
|
+
};
|
|
204
|
+
const mouse = (button, x, y, release) => {
|
|
205
|
+
if (busy || closed || question) return;
|
|
140
206
|
if (button & 64) {
|
|
141
207
|
if (!release) {
|
|
142
208
|
followCursor = false;
|
|
@@ -144,20 +210,25 @@ export async function editConfig(file, { input = process.stdin, output = process
|
|
|
144
210
|
render();
|
|
145
211
|
}
|
|
146
212
|
return;
|
|
147
|
-
}
|
|
148
|
-
completion = null;
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
213
|
+
}
|
|
214
|
+
completion = null;
|
|
215
|
+
if (release) {
|
|
216
|
+
if (dragging) extendAt(x, y);
|
|
217
|
+
stopDrag(); render(); return;
|
|
218
|
+
}
|
|
219
|
+
if ((button & 3) !== 0) return;
|
|
220
|
+
const inside = y >= 2 && y <= viewport.height + 1 && x >= 1 && x <= viewport.width;
|
|
221
|
+
if (!inside && !dragging) return;
|
|
222
|
+
if (!(button & 32)) {
|
|
223
|
+
stopDrag(); history.breakGroup();
|
|
224
|
+
followCursor = true;
|
|
225
|
+
buffer.point(top + y - 2, left + x - viewport.gutter - 1);
|
|
226
|
+
buffer.anchor = buffer.cursor; dragging = true;
|
|
227
|
+
} else if (dragging) {
|
|
228
|
+
extendAt(x, y);
|
|
229
|
+
updateDragScroll(x, y);
|
|
230
|
+
}
|
|
231
|
+
render();
|
|
161
232
|
};
|
|
162
233
|
// Filter SGR mouse reports before readline so their digits never enter the file.
|
|
163
234
|
const onData = chunk => {
|
|
@@ -179,7 +250,7 @@ export async function editConfig(file, { input = process.stdin, output = process
|
|
|
179
250
|
};
|
|
180
251
|
const finish = error => {
|
|
181
252
|
if (closed) return;
|
|
182
|
-
closed = true; clearTimeout(timer); clearTimeout(mouseTimer);
|
|
253
|
+
closed = true; clearTimeout(timer); clearTimeout(mouseTimer); stopDrag();
|
|
183
254
|
input.off('data', onData); keyboard.off('keypress', onKey); keyboard.destroy();
|
|
184
255
|
input.off('keypress', onKey); input.off('end', onEnd); input.off('error', onError); output.off('resize', render);
|
|
185
256
|
input.setRawMode(Boolean(wasRaw)); if (wasPaused) input.pause();
|
|
@@ -188,31 +259,67 @@ export async function editConfig(file, { input = process.stdin, output = process
|
|
|
188
259
|
};
|
|
189
260
|
const onEnd = () => finish();
|
|
190
261
|
const onError = error => finish(error);
|
|
191
|
-
const onKey = async (text, key = {}) => {
|
|
192
|
-
if (busy || closed) return;
|
|
262
|
+
const onKey = async (text, key = {}) => {
|
|
263
|
+
if (busy || closed) return;
|
|
264
|
+
if (dragging) stopDrag();
|
|
193
265
|
try {
|
|
194
|
-
if (question) {
|
|
266
|
+
if (question) {
|
|
195
267
|
if (text?.toLowerCase() === 'y') return finish();
|
|
196
268
|
if (text?.toLowerCase() === 'n' || key.name === 'escape') question = false;
|
|
197
|
-
render(); return;
|
|
198
|
-
}
|
|
199
|
-
if (
|
|
269
|
+
render(); return;
|
|
270
|
+
}
|
|
271
|
+
if (guidePrompt) {
|
|
272
|
+
guidePrompt = false;
|
|
273
|
+
if (text?.toLowerCase() === 'a' || text?.toLowerCase() === 'r') {
|
|
274
|
+
const before = history.snapshot();
|
|
275
|
+
const updated = text.toLowerCase() === 'a' ? addConfigGuide(buffer.text) : removeConfigGuide(buffer.text);
|
|
276
|
+
if (updated !== buffer.text) {
|
|
277
|
+
buffer.text = updated; buffer.cursor = 0; buffer.anchor = null; followCursor = true;
|
|
278
|
+
history.record(before, 'guide');
|
|
279
|
+
revision++; clearTimeout(timer);
|
|
280
|
+
busy = true; issue = await validateEditorText(buffer.text); busy = false;
|
|
281
|
+
status = text.toLowerCase() === 'a' ? 'Guide added/updated. Ctrl+S saves it.' : 'Generated guide removed. Ctrl+S saves it.';
|
|
282
|
+
} else status = text.toLowerCase() === 'r' ? 'No generated guide to remove.' : 'Guide is already current.';
|
|
283
|
+
}
|
|
284
|
+
render(); return;
|
|
285
|
+
}
|
|
286
|
+
if (key.ctrl && key.name === 'a') {
|
|
287
|
+
history.breakGroup();
|
|
288
|
+
completion = null; buffer.anchor = 0; buffer.cursor = buffer.text.length; followCursor = true;
|
|
289
|
+
status = 'All text selected.'; render(); return;
|
|
290
|
+
}
|
|
291
|
+
if (key.ctrl && ['z', 'y'].includes(key.name)) {
|
|
292
|
+
completion = null;
|
|
293
|
+
const direction = key.name === 'z' ? 'undo' : 'redo';
|
|
294
|
+
if (history.change(direction)) {
|
|
295
|
+
followCursor = true; revision++; clearTimeout(timer);
|
|
296
|
+
busy = true; issue = await validateEditorText(buffer.text); busy = false;
|
|
297
|
+
status = direction === 'undo' ? 'Undone.' : 'Redone.';
|
|
298
|
+
} else status = direction === 'undo' ? 'Nothing to undo.' : 'Nothing to redo.';
|
|
299
|
+
render(); return;
|
|
300
|
+
}
|
|
301
|
+
if (completion) {
|
|
200
302
|
if (key.name === 'escape') completion = null;
|
|
201
303
|
else if (['up', 'down', 'tab'].includes(key.name)) completion.index = (completion.index + (key.name === 'up' ? -1 : 1) + completion.choices.length) % completion.choices.length;
|
|
202
|
-
else if (key.name === 'return') {
|
|
203
|
-
|
|
204
|
-
buffer.
|
|
304
|
+
else if (key.name === 'return') {
|
|
305
|
+
const before = history.snapshot();
|
|
306
|
+
buffer.anchor = completion.start; buffer.cursor = completion.end;
|
|
307
|
+
buffer.insert(JSON.stringify(completion.choices[completion.index]));
|
|
308
|
+
history.record(before, 'completion');
|
|
205
309
|
completion = null; status = ''; revision++; clearTimeout(timer);
|
|
206
310
|
busy = true; issue = await validateEditorText(buffer.text); busy = false;
|
|
207
311
|
}
|
|
208
312
|
render(); return;
|
|
209
313
|
}
|
|
210
|
-
if (key.name === 'f2' || (key.ctrl && key.name === 'space')) {
|
|
314
|
+
if (key.name === 'f2' || (key.ctrl && key.name === 'space')) {
|
|
211
315
|
const options = editorCompletions(buffer.text, buffer.cursor);
|
|
212
316
|
completion = options ? { ...options, index: 0 } : null;
|
|
213
317
|
status = options ? '' : 'No options here. Place the cursor on a property or value in valid JSON.';
|
|
214
|
-
render(); return;
|
|
215
|
-
}
|
|
318
|
+
render(); return;
|
|
319
|
+
}
|
|
320
|
+
if (key.name === 'f3') {
|
|
321
|
+
completion = null; guidePrompt = true; render(); return;
|
|
322
|
+
}
|
|
216
323
|
if (key.ctrl && key.name === 'c') {
|
|
217
324
|
const [start, end] = buffer.selection;
|
|
218
325
|
if (start === end) status = 'Select text first, then press Ctrl+C.';
|
|
@@ -224,12 +331,14 @@ export async function editConfig(file, { input = process.stdin, output = process
|
|
|
224
331
|
}
|
|
225
332
|
render(); return;
|
|
226
333
|
}
|
|
227
|
-
if (key.ctrl && key.name === 'v') {
|
|
228
|
-
busy = true;
|
|
229
|
-
try {
|
|
230
|
-
const pasted = (await clipboard.paste()).replace(/\r\n?|\n/g, '\n').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
|
|
231
|
-
if (pasted) {
|
|
232
|
-
|
|
334
|
+
if (key.ctrl && key.name === 'v') {
|
|
335
|
+
busy = true;
|
|
336
|
+
try {
|
|
337
|
+
const pasted = (await clipboard.paste()).replace(/\r\n?|\n/g, '\n').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
|
|
338
|
+
if (pasted) {
|
|
339
|
+
const before = history.snapshot();
|
|
340
|
+
buffer.insert(pasted); followCursor = true;
|
|
341
|
+
history.record(before, 'paste');
|
|
233
342
|
status = ''; issue = await validateEditorText(buffer.text);
|
|
234
343
|
revision++; clearTimeout(timer);
|
|
235
344
|
} else status = 'Clipboard has no text.';
|
|
@@ -244,8 +353,9 @@ export async function editConfig(file, { input = process.stdin, output = process
|
|
|
244
353
|
if (buffer.text === saved) return finish();
|
|
245
354
|
question = true; render(); return;
|
|
246
355
|
}
|
|
247
|
-
if (key.ctrl && key.name === 's') {
|
|
248
|
-
|
|
356
|
+
if (key.ctrl && key.name === 's') {
|
|
357
|
+
history.breakGroup();
|
|
358
|
+
followCursor = true;
|
|
249
359
|
revision++; clearTimeout(timer); buffer.anchor = null;
|
|
250
360
|
busy = true;
|
|
251
361
|
issue = await validateEditorText(buffer.text);
|
|
@@ -260,19 +370,24 @@ export async function editConfig(file, { input = process.stdin, output = process
|
|
|
260
370
|
}
|
|
261
371
|
busy = false; render(); return;
|
|
262
372
|
}
|
|
263
|
-
if (key.ctrl || key.meta) return;
|
|
264
|
-
followCursor = true;
|
|
265
|
-
const before =
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
else if (
|
|
269
|
-
if (
|
|
270
|
-
|
|
373
|
+
if (key.ctrl || key.meta) return;
|
|
374
|
+
followCursor = true;
|
|
375
|
+
const before = history.snapshot();
|
|
376
|
+
const hadSelection = buffer.selection[0] !== buffer.selection[1];
|
|
377
|
+
if (key.name === 'pageup' || key.name === 'pagedown') buffer.move((key.name === 'pageup' ? -1 : 1) * viewport.height);
|
|
378
|
+
else if (['left', 'right', 'up', 'down', 'home', 'end', 'backspace', 'delete', 'return', 'tab'].includes(key.name)) buffer.key(key.name);
|
|
379
|
+
else if (text && !/[\x00-\x1f\x7f-\x9f]/.test(text)) buffer.insert(text);
|
|
380
|
+
if (before.text !== buffer.text) {
|
|
381
|
+
const typing = text && text.length === 1 && !/[\x00-\x1f\x7f-\x9f]/.test(text) && !hadSelection &&
|
|
382
|
+
!['return', 'tab', 'backspace', 'delete'].includes(key.name);
|
|
383
|
+
const deleting = ['backspace', 'delete'].includes(key.name) && !hadSelection;
|
|
384
|
+
history.record(before, typing ? 'typing' : deleting ? key.name : key.name || 'edit', typing || deleting);
|
|
385
|
+
status = ''; issue = null; const current = ++revision; clearTimeout(timer);
|
|
271
386
|
timer = setTimeout(async () => {
|
|
272
387
|
const result = await validateEditorText(buffer.text);
|
|
273
388
|
if (!closed && current === revision) { issue = result; render(); }
|
|
274
389
|
}, 150);
|
|
275
|
-
}
|
|
390
|
+
} else history.breakGroup();
|
|
276
391
|
render();
|
|
277
392
|
} catch (error) { finish(error); }
|
|
278
393
|
};
|
package/src/config-template.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { translateLegacyConfigComments } from './legacy-config-comments.js';
|
|
3
2
|
|
|
4
3
|
export const TEMPLATE_MARKER = '// veo: commented configuration template';
|
|
5
4
|
|
|
@@ -8,7 +7,7 @@ export const SOURCE_OPTIONS_GUIDE = String.raw`// veo: source discovery options
|
|
|
8
7
|
// "deepScan": true, // Verify every observed media candidate instead of the first 30
|
|
9
8
|
// "timeout": "2m", // Discovery deadline: 5s to 10m; plain numbers mean seconds
|
|
10
9
|
// "listSources": false, // true = list sources and exit for single-URL calls
|
|
11
|
-
// "autoListSources":
|
|
10
|
+
// "autoListSources": true, // Search after extraction fails; false disables fallback
|
|
12
11
|
// Command-line flags override these values, including --no-deep-scan and --no-list-sources.
|
|
13
12
|
`;
|
|
14
13
|
|
|
@@ -25,7 +24,7 @@ export const PROFILE_OPTIONS_GUIDE = String.raw`${PROFILE_OPTIONS_MARKER}
|
|
|
25
24
|
// "deepScan": true, // Check every observed source candidate
|
|
26
25
|
// "timeout": "2m", // Source-search deadline, 5s to 10m
|
|
27
26
|
// "listSources": false, // List sources and exit for one URL
|
|
28
|
-
// "autoListSources": true, // Search after extraction fails
|
|
27
|
+
// "autoListSources": true, // Search after extraction fails (default)
|
|
29
28
|
// "playlist": true, // Enable only for playlist downloads
|
|
30
29
|
// "playlistConcurrency": 2, // Simultaneous entries: 1-4; default 2
|
|
31
30
|
// "checkSpace": true, // Estimate free cache/output space first
|
|
@@ -96,8 +95,10 @@ ${SOURCE_OPTIONS_GUIDE}
|
|
|
96
95
|
// "embedMetadata": true, // Embed the title, date and other metadata
|
|
97
96
|
// "embedThumbnail": true, // Embed the thumbnail
|
|
98
97
|
|
|
99
|
-
// The default profile is used automatically unless you select another profile.
|
|
100
|
-
|
|
98
|
+
// The default profile is used automatically unless you select another profile.
|
|
99
|
+
// Use veo profile NAME to make any named profile the default for future commands.
|
|
100
|
+
// The active choice is stored at the top level: "activeProfile": "music",
|
|
101
|
+
${PROFILE_OPTIONS_GUIDE}
|
|
101
102
|
// Select other profiles with --profile NAME or in the interactive wizard.
|
|
102
103
|
// Example: veo "https://example.com/video.mp4" --profile music
|
|
103
104
|
"profiles": {
|
|
@@ -126,18 +127,62 @@ ${PROFILE_OPTIONS_GUIDE}
|
|
|
126
127
|
}
|
|
127
128
|
`;
|
|
128
129
|
|
|
129
|
-
// The revision follows
|
|
130
|
-
// maintaining a separate version number.
|
|
130
|
+
// The guide revision follows its content so the editor can offer an update.
|
|
131
131
|
const TEMPLATE_REVISION = createHash('sha256').update(TEMPLATE_SOURCE).digest('hex').slice(0, 12);
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
132
|
+
const REFERENCE_BEGIN = '// veo: template reference begin';
|
|
133
|
+
const REFERENCE_END = '// veo: template reference end';
|
|
134
|
+
export const CONFIG_TEMPLATE = `{
|
|
135
|
+
"profiles": {
|
|
136
|
+
"default": {},
|
|
137
|
+
"kompatibel": { "audio": false, "format": "mp4", "compatible": true, "recode": false },
|
|
138
|
+
"music": { "audio": true, "format": "mp3" },
|
|
139
|
+
"archive": { "quality": "1080p", "embedMetadata": true, "subLangs": "de,en" }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
`;
|
|
143
|
+
|
|
144
|
+
// Kept in the program. It is inserted into a config only when requested.
|
|
145
|
+
export const CONFIG_GUIDE = `${REFERENCE_BEGIN} ${TEMPLATE_REVISION}\n// Current veo options. Copy only the settings you want into the JSON below.\n${TEMPLATE_SOURCE.split('\n').slice(1).map(line => line.startsWith('//') ? line : `// ${line}`).join('\n').trimEnd()}\n${REFERENCE_END}\n`;
|
|
146
|
+
|
|
147
|
+
function generatedRange(text) {
|
|
148
|
+
const begin = text.indexOf(REFERENCE_BEGIN);
|
|
149
|
+
const end = begin < 0 ? -1 : text.indexOf(REFERENCE_END, begin);
|
|
150
|
+
if (begin < 0) {
|
|
151
|
+
const legacyHeader = /^\uFEFF?\/\/ veo: commented configuration template\r?\n\/\/ Reference: copy any examples/;
|
|
152
|
+
const settingsMarker = '// Your existing settings:';
|
|
153
|
+
const markerAt = legacyHeader.test(text) ? text.indexOf(settingsMarker) : -1;
|
|
154
|
+
if (markerAt < 0) return null;
|
|
155
|
+
const lineEnd = text.indexOf('\n', markerAt);
|
|
156
|
+
return { start: 0, stop: lineEnd < 0 ? text.length : lineEnd + 1, revision: 'legacy' };
|
|
157
|
+
}
|
|
158
|
+
if (end < 0) return null;
|
|
159
|
+
let start = begin;
|
|
160
|
+
const before = text.slice(0, begin);
|
|
161
|
+
if (/^\uFEFF?\/\/ veo: commented configuration template\r?\n$/.test(before)) start = 0;
|
|
162
|
+
const lineEnd = text.indexOf('\n', end);
|
|
163
|
+
let stop = lineEnd < 0 ? text.length : lineEnd + 1;
|
|
164
|
+
if (text.slice(stop).startsWith('// Your existing settings:\n')) stop += '// Your existing settings:\n'.length;
|
|
165
|
+
return { start, stop, revision: text.slice(begin, text.indexOf('\n', begin) < 0 ? text.length : text.indexOf('\n', begin)) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function configGuideState(text) {
|
|
169
|
+
const range = generatedRange(text);
|
|
170
|
+
if (!range) return text.includes(REFERENCE_BEGIN) || text.includes('// Reference: copy any examples') ? 'incomplete' : 'absent';
|
|
171
|
+
return range.revision === `${REFERENCE_BEGIN} ${TEMPLATE_REVISION}` ? 'current' : 'outdated';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function addConfigGuide(text) {
|
|
175
|
+
const range = generatedRange(text);
|
|
176
|
+
if (!range && configGuideState(text) === 'incomplete') return text;
|
|
177
|
+
const bom = text.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
178
|
+
if (range) return (range.start === 0 ? bom : text.slice(0, range.start)) + CONFIG_GUIDE + text.slice(range.stop);
|
|
179
|
+
return bom + CONFIG_GUIDE + text.slice(bom.length);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function removeConfigGuide(text) {
|
|
183
|
+
const range = generatedRange(text);
|
|
184
|
+
return range ? (range.start === 0 && text.startsWith('\uFEFF') ? '\uFEFF' : text.slice(0, range.start)) + text.slice(range.stop) : text;
|
|
185
|
+
}
|
|
141
186
|
|
|
142
187
|
// Remove comments only outside JSON strings. Whitespace replacement preserves
|
|
143
188
|
// token boundaries, so malformed input cannot become valid by joining tokens.
|
|
@@ -165,60 +210,5 @@ export function stripConfigComments(text) {
|
|
|
165
210
|
if (!closed) throw Object.assign(new Error('Unterminated block comment; add */ to close it.'), { position: commentStart });
|
|
166
211
|
} else result += char;
|
|
167
212
|
}
|
|
168
|
-
return result;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function withConfigTemplate(text) {
|
|
172
|
-
text = translateLegacyConfigComments(text);
|
|
173
|
-
text = addDefaultProfile(text);
|
|
174
|
-
if (!text.replace(/^\uFEFF/, '').trim()) return CONFIG_TEMPLATE;
|
|
175
|
-
text = text.replace(/^\uFEFF/, '');
|
|
176
|
-
const reference = currentReference();
|
|
177
|
-
const begin = text.indexOf(REFERENCE_BEGIN);
|
|
178
|
-
const end = begin < 0 ? -1 : text.indexOf(REFERENCE_END, begin);
|
|
179
|
-
if (begin >= 0 && end < 0) return text; // Never replace an incomplete user-edited block.
|
|
180
|
-
if (begin >= 0 && end >= 0) {
|
|
181
|
-
if (text.slice(begin, end).startsWith(`${REFERENCE_BEGIN} ${TEMPLATE_REVISION}\n`)) return text;
|
|
182
|
-
const endOfLine = text.indexOf('\n', end);
|
|
183
|
-
const after = endOfLine < 0 ? text.length : endOfLine;
|
|
184
|
-
return text.slice(0, begin) + reference + text.slice(after);
|
|
185
|
-
}
|
|
186
|
-
if (text.startsWith(TEMPLATE_MARKER) && text.slice(0, 200).includes(REVISION_LINE)) return text;
|
|
187
|
-
if (text.startsWith(TEMPLATE_MARKER)) {
|
|
188
|
-
// Replace the older generated reference prefix, if present. Everything
|
|
189
|
-
// below the user's settings marker stays exactly as it was.
|
|
190
|
-
const settingsMarker = '// Your existing settings:';
|
|
191
|
-
const settingsAt = text.indexOf(settingsMarker);
|
|
192
|
-
if (text.includes('// Reference: copy any examples') && settingsAt >= 0) {
|
|
193
|
-
return `${TEMPLATE_MARKER}\n${reference}\n${text.slice(settingsAt)}`;
|
|
194
|
-
}
|
|
195
|
-
return text.replace(TEMPLATE_MARKER, `${TEMPLATE_MARKER}\n${reference}`);
|
|
196
|
-
}
|
|
197
|
-
return `${TEMPLATE_MARKER}\n${reference}\n// Your existing settings:\n${text}`;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function addDefaultProfile(text) {
|
|
201
|
-
// Insert only the missing profile, preserving user comments and formatting.
|
|
202
|
-
let clean, config;
|
|
203
|
-
try { clean = stripConfigComments(text); config = JSON.parse(clean); } catch { return text; }
|
|
204
|
-
if (!config || Array.isArray(config) || typeof config !== 'object') return text;
|
|
205
|
-
if (config.profiles && Object.hasOwn(config.profiles, 'default')) return text;
|
|
206
|
-
if (config.profiles !== undefined && (!config.profiles || Array.isArray(config.profiles) || typeof config.profiles !== 'object')) return text;
|
|
207
|
-
text = text.replace(/^\uFEFF/, '');
|
|
208
|
-
// Track depth to find the top-level profiles object, never a profile named profiles.
|
|
209
|
-
let depth = 0;
|
|
210
|
-
const tokens = /"(?:\\.|[^"\\])*"|[{}]/g;
|
|
211
|
-
for (const token of clean.matchAll(tokens)) {
|
|
212
|
-
if (token[0] === '{') depth++;
|
|
213
|
-
else if (token[0] === '}') depth--;
|
|
214
|
-
else if (depth === 1 && token[0] === '"profiles"') {
|
|
215
|
-
const after = token.index + token[0].length;
|
|
216
|
-
const match = /^\s*:\s*\{/.exec(clean.slice(after));
|
|
217
|
-
if (!match) continue;
|
|
218
|
-
const index = after + match[0].length;
|
|
219
|
-
return text.slice(0, index) + '\n // Used automatically when no other profile is selected.\n "default": {}' + (Object.keys(config.profiles).length ? ',' : '') + '\n' + text.slice(index);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
const index = clean.indexOf('{') + 1;
|
|
223
|
-
return text.slice(0, index) + '\n // Used automatically when no other profile is selected.\n "profiles": { "default": {} }' + (Object.keys(config).length ? ',' : '') + '\n' + text.slice(index);
|
|
224
|
-
}
|
|
213
|
+
return result;
|
|
214
|
+
}
|