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.
@@ -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
- let status = '', revision = 0;
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 height = Math.max(1, (output.rows || 24) - 2 - detailRows);
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
- const help = safe(question ? 'Discard unsaved changes? Y = discard, N / Esc = keep editing' : 'Ctrl+S Save | Ctrl+C Copy | Ctrl+V Paste | Esc Exit | F2 Options | Wheel Scroll');
127
- const helpWidth = Math.max(0, width - position.length - 1);
128
- screen.push((help.slice(0, helpWidth) + ' '.repeat(Math.max(1, width - position.length - Math.min(help.length, helpWidth))) + position).slice(0, width));
129
- for (let index = 0; index < detailRows; index++) screen.push(detail.slice(index * width, (index + 1) * width));
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
- const mouse = (button, x, y, release) => {
139
- if (busy || closed || question) return;
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 ((button & 3) !== 0) return;
150
- const inside = y >= 2 && y <= viewport.height + 1 && x >= 1 && x <= viewport.width;
151
- if (!inside && !dragging) return;
152
- if (!(button & 32) && !release) {
153
- followCursor = true;
154
- buffer.point(top + y - 2, left + x - viewport.gutter - 1);
155
- buffer.anchor = buffer.cursor; dragging = true;
156
- } else if (dragging) {
157
- buffer.point(top + Math.max(0, Math.min(viewport.height - 1, y - 2)), left + x - viewport.gutter - 1, true);
158
- }
159
- if (release) dragging = false;
160
- render();
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 (completion) {
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
- buffer.anchor = completion.start; buffer.cursor = completion.end;
204
- buffer.insert(JSON.stringify(completion.choices[completion.index]));
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
- buffer.insert(pasted); followCursor = true;
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
- followCursor = true;
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 = buffer.text;
266
- if (key.name === 'pageup' || key.name === 'pagedown') buffer.move((key.name === 'pageup' ? -1 : 1) * viewport.height);
267
- else if (['left', 'right', 'up', 'down', 'home', 'end', 'backspace', 'delete', 'return', 'tab'].includes(key.name)) buffer.key(key.name);
268
- else if (text && !/[\x00-\x1f\x7f-\x9f]/.test(text)) buffer.insert(text);
269
- if (before !== buffer.text) {
270
- status = ''; issue = null; const current = ++revision; clearTimeout(timer);
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
  };
@@ -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": false, // true = search after extraction fails, without a yes/no prompt
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, without asking
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
- ${PROFILE_OPTIONS_GUIDE}
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 the template content, so future examples refresh without
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 REVISION_LINE = `// veo: template revision ${TEMPLATE_REVISION}`;
133
- const REFERENCE_BEGIN = '// veo: template reference begin';
134
- const REFERENCE_END = '// veo: template reference end';
135
- export const CONFIG_TEMPLATE = TEMPLATE_SOURCE.replace(TEMPLATE_MARKER, `${TEMPLATE_MARKER}\n${REVISION_LINE}`);
136
-
137
- function currentReference() {
138
- const examples = CONFIG_TEMPLATE.split('\n').slice(2).map(line => `// ${line}`).join('\n');
139
- return `${REFERENCE_BEGIN} ${TEMPLATE_REVISION}\n// This generated reference is refreshed when the template changes. Keep personal notes below it.\n// Current veo options: copy the settings you need below.\n${examples}\n${REFERENCE_END}`;
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
+ }