juice-email-cli 2.3.2 → 2.3.4

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/bin/juice.js CHANGED
@@ -103,6 +103,7 @@ program
103
103
  .option('-t, --template <file-path>', '仅拷贝模板 HTML 文件')
104
104
  .option('-s, --snippet <file-path>', '仅拷贝片段 HTML 文件')
105
105
  .option('-c, --config <file-path>', '仅拷贝配置 YAML 文件')
106
+ .option('--all [target]', '拷贝整个 EDM 资源目录到当前或指定目录')
106
107
  .action(async (initPath, options) => {
107
108
  const { runInitMode } = require('../src/init');
108
109
  await runInitMode({
@@ -110,6 +111,7 @@ program
110
111
  template: options.template || null,
111
112
  snippet: options.snippet || null,
112
113
  config: options.config || null,
114
+ all: options.all !== undefined ? (options.all || true) : null,
113
115
  });
114
116
  });
115
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juice-email-cli",
3
- "version": "2.3.2",
3
+ "version": "2.3.4",
4
4
  "description": "CLI tool to generate email-client-compatible HTML with juice (CSS inlining) + Mustache templating + minification",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -228,7 +228,7 @@ function registerSubCommands(containerPath, kind, nodePath, scriptPath, iconPath
228
228
  const viewKey = `${containerPath}\\shell\\${SUBCMDS.viewEdm}`;
229
229
  regAdd(viewKey, 'MUIVerb', 'REG_SZ', '📦 查看资源列表');
230
230
  regAdd(viewKey, 'Icon', 'REG_SZ', iconPath);
231
- regAdd(`${viewKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'view'));
231
+ regAdd(`${viewKey}\\command`, '', 'REG_SZ', wrapWithPause(nodePath, scriptPath, 'view'));
232
232
 
233
233
  const viewIntKey = `${containerPath}\\shell\\${SUBCMDS.viewEdmInt}`;
234
234
  regAdd(viewIntKey, 'MUIVerb', 'REG_SZ', '📋 浏览资源库');
@@ -246,52 +246,77 @@ function registerSubCommands(containerPath, kind, nodePath, scriptPath, iconPath
246
246
  }
247
247
  }
248
248
 
249
+ // ─── 非交互命令包装(始终暂停以便用户查看输出)─────────────────────────────────
250
+
251
+ function wrapWithPause(nodePath, scriptPath, cliArgs) {
252
+ const node = nodePath.replace(/'/g, "''");
253
+ const script = scriptPath.replace(/'/g, "''");
254
+ const ps = [
255
+ `& '${node}' '${script}' ${cliArgs}`,
256
+ `Write-Host ''`,
257
+ `Read-Host 'Press Enter to close'`,
258
+ ].join('; ');
259
+ return `powershell.exe -Command "${ps}"`;
260
+ }
261
+
249
262
  // ─── Directory / Background 菜单注册 ──────────────────────────────────────────
250
263
 
264
+ /**
265
+ * Register Directory and Background right-click menus using SubCommands
266
+ * + CommandStore (same format as file-type menus for consistent UX).
267
+ */
251
268
  function registerDirBgMenus(nodePath, scriptPath, iconPath, pwshPath) {
252
- // Register directory container subcommands
253
- const dirContainer = `${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_DIR}`;
254
- const bgContainer = `${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_BG}`;
255
-
256
- for (const containerPath of [dirContainer, bgContainer]) {
257
- // 📥 从资源库拷贝到此处
258
- const initKey = `${containerPath}\\shell\\${SUBCMDS.initEdm}`;
259
- regAdd(initKey, 'MUIVerb', 'REG_SZ', '📥 从资源库拷贝到此处');
260
- regAdd(initKey, 'Icon', 'REG_SZ', iconPath);
261
- regAdd(`${initKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'init'));
262
-
263
- // 📦 查看资源列表
264
- const viewKey = `${containerPath}\\shell\\${SUBCMDS.viewEdm}`;
265
- regAdd(viewKey, 'MUIVerb', 'REG_SZ', '📦 查看资源列表');
266
- regAdd(viewKey, 'Icon', 'REG_SZ', iconPath);
267
- regAdd(`${viewKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'view'));
269
+ const node = nodePath.replace(/'/g, "''");
270
+ const script = scriptPath.replace(/'/g, "''");
268
271
 
269
- // 📋 浏览资源库
270
- const viewIntKey = `${containerPath}\\shell\\${SUBCMDS.viewEdmInt}`;
271
- regAdd(viewIntKey, 'MUIVerb', 'REG_SZ', '📋 浏览资源库');
272
- regAdd(viewIntKey, 'Icon', 'REG_SZ', iconPath);
273
- regAdd(`${viewIntKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'view -i'));
272
+ // CommandStore subcommand names
273
+ const DIR_VIEW = 'JuiceEmailDir.View';
274
+ const DIR_COPYALL = 'JuiceEmailDir.CopyAll';
275
+ const DIR_COPYPICK= 'JuiceEmailDir.CopyPick';
276
+ const DIR_PWSH = 'JuiceEmailDir.Pwsh';
277
+
278
+ // Helper: register a CommandStore entry
279
+ function regCmd(name, label, psCmd) {
280
+ const cmdKey = `${HKCU_SHELL}\\${name}`;
281
+ regAdd(cmdKey, 'MUIVerb', 'REG_SZ', label);
282
+ regAdd(cmdKey, 'Icon', 'REG_SZ', iconPath);
283
+ // Always pause so user can see output
284
+ const fullPs = `${psCmd}; Write-Host ''; Read-Host 'Press Enter to close'`;
285
+ regAdd(`${cmdKey}\\command`, '', 'REG_SZ', `powershell.exe -Command "${fullPs}"`);
286
+ }
274
287
 
275
- // 📂 在此打开终端
276
- if (pwshPath) {
277
- const pwshKey = `${containerPath}\\shell\\${SUBCMDS.pwsh}`;
278
- regAdd(pwshKey, 'MUIVerb', 'REG_SZ', '📂 在此打开终端');
279
- regAdd(pwshKey, 'Icon', 'REG_SZ', pwshPath);
280
- regAdd(`${pwshKey}\\command`, '', 'REG_SZ',
281
- `"${pwshPath}" -NoExit -Command "Set-Location -LiteralPath '%1'"`);
282
- }
288
+ // View: non-interactive, no directory dependency
289
+ regCmd(DIR_VIEW, '📋 查看可用资源', `& '${node}' '${script}' view`);
290
+
291
+ // CopyAll: copy entire EDM to clicked directory
292
+ regCmd(DIR_COPYALL, '📦 拷贝全部资源', `& '${node}' '${script}' init --all '%V'`);
293
+
294
+ // CopyPick: interactive, cd to directory first so output lands there
295
+ regCmd(DIR_COPYPICK, '📥 选择资源拷贝', `Set-Location '%V'; & '${node}' '${script}' init`);
296
+
297
+ // Pwsh
298
+ if (pwshPath) {
299
+ const pwshKey = `${HKCU_SHELL}\\${DIR_PWSH}`;
300
+ regAdd(pwshKey, 'MUIVerb', 'REG_SZ', '📂 在此打开终端');
301
+ regAdd(pwshKey, 'Icon', 'REG_SZ', pwshPath);
302
+ regAdd(`${pwshKey}\\command`, '', 'REG_SZ',
303
+ `"${pwshPath}" -NoExit -Command "Set-Location -LiteralPath '%V'"`);
283
304
  }
284
305
 
285
- // Parent keys: Directory and Directory\Background
286
- const dirParent = `${HKCU_SHELL}\\Directory\\shell\\${PARENT_KEY_NAME}`;
287
- regAdd(dirParent, 'MUIVerb', 'REG_SZ', '📧 juice 邮件工具');
288
- regAdd(dirParent, 'Icon', 'REG_SZ', iconPath);
289
- regAdd(dirParent, 'ExtendedSubCommandsKey', 'REG_SZ', SUB_CMDS_CONTAINER_DIR);
306
+ // Build SubCommands string
307
+ const subCmds = [DIR_VIEW, DIR_COPYALL, DIR_COPYPICK];
308
+ if (pwshPath) subCmds.push(DIR_PWSH);
290
309
 
291
- const bgParent = `${HKCU_SHELL}\\Directory\\Background\\shell\\${PARENT_KEY_NAME}`;
292
- regAdd(bgParent, 'MUIVerb', 'REG_SZ', '📧 juice 邮件工具');
293
- regAdd(bgParent, 'Icon', 'REG_SZ', iconPath);
294
- regAdd(bgParent, 'ExtendedSubCommandsKey', 'REG_SZ', SUB_CMDS_CONTAINER_BG);
310
+ // Parent keys: Directory + Directory\Background
311
+ const roots = [
312
+ `${HKCU_SHELL}\\Directory\\shell`,
313
+ `${HKCU_SHELL}\\Directory\\Background\\shell`,
314
+ ];
315
+ for (const root of roots) {
316
+ regAdd(`${root}\\${PARENT_KEY_NAME}`, 'MUIVerb', 'REG_SZ', '📧 用 juice 生成邮件 HTML');
317
+ regAdd(`${root}\\${PARENT_KEY_NAME}`, 'Icon', 'REG_SZ', iconPath);
318
+ regAdd(`${root}\\${PARENT_KEY_NAME}`, 'SubCommands', 'REG_SZ', subCmds.join(';'));
319
+ }
295
320
  }
296
321
 
297
322
  // ─── 注册 ─────────────────────────────────────────────────────────────────────
@@ -348,8 +373,20 @@ async function registerContextMenu() {
348
373
  regAdd(parentKey, 'ExtendedSubCommandsKey', 'REG_SZ', SUB_CMDS_CONTAINER_YAML);
349
374
  }
350
375
 
351
- // Directory / Background → 独立容器
376
+ // Directory / Background → 子菜单(SubCommands + CommandStore)
352
377
  console.log(chalk.gray(' 注册文件夹/空白处菜单...'));
378
+ // Clean up old individual entries from v2.3.x before registering new format
379
+ for (const root of [`${HKCU_SHELL}\\Directory\\shell`, `${HKCU_SHELL}\\Directory\\Background\\shell`]) {
380
+ regDelete(`${root}\\${PARENT_KEY_NAME}`);
381
+ regDelete(`${root}\\JuiceEmail.Init`);
382
+ regDelete(`${root}\\JuiceEmail.View`);
383
+ regDelete(`${root}\\JuiceEmail.Browse`);
384
+ regDelete(`${root}\\JuiceEmail.Pwsh`);
385
+ }
386
+ // Clean up old CommandStore entries for Dir menu (from any prior version)
387
+ for (const name of ['JuiceEmailDir.View', 'JuiceEmailDir.CopyAll', 'JuiceEmailDir.CopyPick', 'JuiceEmailDir.Pwsh']) {
388
+ regDelete(`${HKCU_SHELL}\\${name}`);
389
+ }
353
390
  registerDirBgMenus(nodePath, scriptPath, iconPath, pwshPath);
354
391
 
355
392
  if (!ok) {
@@ -373,10 +410,10 @@ async function registerContextMenu() {
373
410
  ` ├── 📋 浏览资源库 → juice view -i\n` +
374
411
  (pwshPath ? ` └── 📂 打开 PowerShell\n` : '') +
375
412
  `\n ${chalk.bold('文件夹 / 空白处')} 右键:\n` +
376
- ` ${chalk.bold('📧 juice 邮件工具')}\n` +
377
- ` ├── 📥 从资源库拷贝到此处 → juice init\n` +
378
- ` ├── 📦 查看资源列表 → juice view\n` +
379
- ` ├── 📋 浏览资源库 → juice view -i\n` +
413
+ ` ${chalk.bold('📧 juice 生成邮件 HTML')}\n` +
414
+ ` ├── 📋 查看可用资源 → juice view\n` +
415
+ ` ├── 📦 拷贝全部资源 → juice init --all\n` +
416
+ ` ├── 📥 选择资源拷贝 → juice init\n` +
380
417
  (pwshPath ? ` └── 📂 在此打开终端\n` : '') +
381
418
  '\n' +
382
419
  chalk.gray(' 注意:如菜单未出现,请重启文件资源管理器(explorer.exe)。\n')
@@ -395,22 +432,39 @@ async function unregisterContextMenu() {
395
432
 
396
433
  let removed = 0;
397
434
 
398
- // 清理 HKCU 父菜单(4 个文件类型 + Directory + Background)
435
+ // 清理 HKCU 父菜单(文件类型)
399
436
  const allRoots = [
400
437
  ...HTML_ROOTS,
401
438
  ...YAML_ROOTS,
402
- `${HKCU_SHELL}\\Directory\\shell`,
403
- `${HKCU_SHELL}\\Directory\\Background\\shell`,
404
439
  ];
405
440
  for (const root of allRoots) {
406
441
  if (regDelete(`${root}\\${PARENT_KEY_NAME}`)) removed++;
407
442
  }
408
443
 
409
- // 清理所有子命令容器
444
+ // 清理 Directory / Background 父菜单 + 旧版独立条目
445
+ const dirBgRoots = [
446
+ `${HKCU_SHELL}\\Directory\\shell`,
447
+ `${HKCU_SHELL}\\Directory\\Background\\shell`,
448
+ ];
449
+ // New parent key + old individual entries from v2.3.x
450
+ const dirBgKeys = [PARENT_KEY_NAME, 'JuiceEmail.Init', 'JuiceEmail.View', 'JuiceEmail.Browse', 'JuiceEmail.Pwsh'];
451
+ for (const root of dirBgRoots) {
452
+ for (const key of dirBgKeys) {
453
+ if (regDelete(`${root}\\${key}`)) removed++;
454
+ }
455
+ }
456
+ // CommandStore entries for Dir menus
457
+ for (const name of ['JuiceEmailDir.View', 'JuiceEmailDir.CopyAll', 'JuiceEmailDir.CopyPick', 'JuiceEmailDir.Pwsh']) {
458
+ if (regDelete(`${HKCU_SHELL}\\${name}`)) removed++;
459
+ }
460
+
461
+ // 清理所有子命令容器(含旧版 Dir/Bg 容器)
410
462
  if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_HTML}`)) removed++;
411
463
  if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_YAML}`)) removed++;
412
464
  if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_DIR}`)) removed++;
413
465
  if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_BG}`)) removed++;
466
+ if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_DIR}`)) removed++;
467
+ if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_BG}`)) removed++;
414
468
 
415
469
  // 清理旧版共享容器(v2.1.14 之前只有一个容器)
416
470
  regDelete(`${HKCU_SHELL}\\JuiceEmail.SubCommands`);
package/src/init.js CHANGED
@@ -71,8 +71,7 @@ function deriveDefaultName(filePath) {
71
71
  async function directCopy(srcPath, cwd) {
72
72
  const resolved = path.resolve(srcPath);
73
73
  if (!fs.existsSync(resolved)) {
74
- console.error(chalk.red(`\n ✘ 文件不存在:${resolved}\n`));
75
- process.exit(1);
74
+ throw new Error(`文件不存在:${resolved}`);
76
75
  }
77
76
 
78
77
  const destName = await promptOutputName(
@@ -231,9 +230,110 @@ async function interactiveInit(edmDir, cwd) {
231
230
 
232
231
  // ─── Main Entry ──────────────────────────────────────────────────────────────
233
232
 
234
- async function runInitMode({ initPath, template, snippet, config }) {
233
+ function copyDir(src, dest) {
234
+ fs.mkdirSync(dest, { recursive: true });
235
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
236
+ const srcPath = path.join(src, entry.name);
237
+ const destPath = path.join(dest, entry.name);
238
+ if (entry.isDirectory()) {
239
+ copyDir(srcPath, destPath);
240
+ } else {
241
+ fs.copyFileSync(srcPath, destPath);
242
+ }
243
+ }
244
+ }
245
+
246
+ function dirSize(dir) {
247
+ let size = 0;
248
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
249
+ const p = path.join(dir, entry.name);
250
+ if (entry.isDirectory()) {
251
+ size += dirSize(p);
252
+ } else {
253
+ size += fs.statSync(p).size;
254
+ }
255
+ }
256
+ return size;
257
+ }
258
+
259
+ function countFiles(dir) {
260
+ let count = 0;
261
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
262
+ if (entry.isDirectory()) {
263
+ count += countFiles(path.join(dir, entry.name));
264
+ } else {
265
+ count++;
266
+ }
267
+ }
268
+ return count;
269
+ }
270
+
271
+ function findNextEdmVersion(targetDir) {
272
+ let v = 1;
273
+ while (true) {
274
+ const candidate = path.join(targetDir, 'edm-v' + v);
275
+ if (!fs.existsSync(candidate)) return candidate;
276
+ v++;
277
+ }
278
+ }
279
+
280
+ async function runInitMode({ initPath, template, snippet, config, all }) {
235
281
  const cwd = process.cwd();
236
282
 
283
+ // --all: copy entire EDM directory
284
+ if (all) {
285
+ let edmDir;
286
+ try {
287
+ edmDir = resolveEdmDir();
288
+ } catch (err) {
289
+ console.error(chalk.red(`\n ✘ ${err.message}\n`));
290
+ process.exit(1);
291
+ }
292
+ const target = all === true ? cwd : path.resolve(all);
293
+ const destDir = path.join(target, 'edm');
294
+
295
+ // Prevent deleting the source when target overlaps
296
+ if (path.resolve(edmDir) === path.resolve(destDir)) {
297
+ console.log(chalk.yellow('目标目录与 EDM 资源库相同,无需拷贝。\n'));
298
+ return;
299
+ }
300
+ console.log(chalk.cyan(`\n 拷贝 EDM 资源库...`));
301
+ console.log(chalk.gray(` 源:${edmDir}`));
302
+ console.log(chalk.gray(` 目标:${destDir}`));
303
+
304
+ if (fs.existsSync(destDir)) {
305
+ const { select } = await import('@inquirer/prompts');
306
+ const vName = findNextEdmVersion(target);
307
+ const action = await select({
308
+ message: `目录 ${destDir} 已存在:`,
309
+ choices: [
310
+ { name: '覆盖', value: 'overwrite' },
311
+ { name: `自动版本号(${path.basename(vName)})`, value: 'version' },
312
+ { name: '取消', value: 'cancel' },
313
+ ],
314
+ });
315
+ if (action === 'cancel') {
316
+ console.log(chalk.gray('已取消。\n'));
317
+ return;
318
+ }
319
+ if (action === 'version') {
320
+ copyDir(edmDir, vName);
321
+ const edmSize = dirSize(vName);
322
+ const fileCount = countFiles(vName);
323
+ console.log(chalk.green(`\n✔ 已拷贝 EDM 资源库 → ${path.basename(vName)}(${fileCount} 个文件,${fmtBytes(edmSize)})\n`));
324
+ return;
325
+ }
326
+ // overwrite: remove and replace
327
+ fs.rmSync(destDir, { recursive: true, force: true });
328
+ }
329
+
330
+ copyDir(edmDir, destDir);
331
+ const edmSize = dirSize(destDir);
332
+ const fileCount = countFiles(destDir);
333
+ console.log(chalk.green(`\n✔ 已拷贝 EDM 资源库(${fileCount} 个文件,${fmtBytes(edmSize)})\n`));
334
+ return;
335
+ }
336
+
237
337
  // Direct copy modes
238
338
  if (template) {
239
339
  await directCopy(template, cwd);
package/src/view.js CHANGED
@@ -12,6 +12,27 @@ function fmtBytes(b) {
12
12
  return b < 1024 ? `${b} B` : `${(b / 1024).toFixed(1)} KB`;
13
13
  }
14
14
 
15
+ /**
16
+ * Copy src to dest, auto-versioning (-v1, -v2, ...) if dest exists.
17
+ * Returns the actual destination path used.
18
+ */
19
+ function copyFileSafe(srcPath, destPath) {
20
+ if (!fs.existsSync(destPath)) {
21
+ fs.copyFileSync(srcPath, destPath);
22
+ return destPath;
23
+ }
24
+ const parsed = path.parse(destPath);
25
+ let v = 1;
26
+ while (true) {
27
+ const alt = path.join(parsed.dir, parsed.name + '-v' + v + parsed.ext);
28
+ if (!fs.existsSync(alt)) {
29
+ fs.copyFileSync(srcPath, alt);
30
+ return alt;
31
+ }
32
+ v++;
33
+ }
34
+ }
35
+
15
36
  // ─── Path Parser ──────────────────────────────────────────────────────────────
16
37
 
17
38
  /**
@@ -327,13 +348,17 @@ async function showCheckbox(title, choices) {
327
348
  * Trigger copy via the init module.
328
349
  */
329
350
  async function copyResource(type, resourcePath, cwd) {
330
- const { runInitMode } = require('./init');
331
- if (type === 'template') {
332
- await runInitMode({ template: resourcePath });
333
- } else if (type === 'snippet') {
334
- await runInitMode({ snippet: resourcePath });
335
- } else if (type === 'config') {
336
- await runInitMode({ config: resourcePath });
351
+ try {
352
+ const { runInitMode } = require('./init');
353
+ if (type === 'template') {
354
+ await runInitMode({ template: resourcePath });
355
+ } else if (type === 'snippet') {
356
+ await runInitMode({ snippet: resourcePath });
357
+ } else if (type === 'config') {
358
+ await runInitMode({ config: resourcePath });
359
+ }
360
+ } catch (err) {
361
+ console.error(chalk.red(` ✘ 拷贝失败:${err.message}`));
337
362
  }
338
363
  }
339
364
 
@@ -420,20 +445,6 @@ async function interactiveBrowse(edmDir, startParsed) {
420
445
  description: allSeries.length + ' 个系列',
421
446
  });
422
447
  }
423
-
424
- // Copy options
425
- if (versions.length > 0) {
426
- choices.push({
427
- name: '📥 拷贝模板到当前目录',
428
- value: { action: 'copy-template' },
429
- });
430
- }
431
- if (allSeries.length > 0) {
432
- choices.push({
433
- name: '📥 拷贝配置到当前目录',
434
- value: { action: 'copy-config-brand' },
435
- });
436
- }
437
448
  break;
438
449
  }
439
450
 
@@ -511,27 +522,15 @@ async function interactiveBrowse(edmDir, startParsed) {
511
522
  if (infoLines.length > 0) {
512
523
  console.log(chalk.dim('\n ' + infoLines.join('\n ') + '\n'));
513
524
  }
514
- // Copy actions
515
- if (fs.existsSync(snipPath)) {
516
- choices.push({
517
- name: '📥 拷贝片段到当前目录',
518
- value: { action: 'copy-snippet' },
519
- });
520
- }
521
- if (configs.length > 0) {
522
- choices.push({
523
- name: '📥 拷贝配置到当前目录',
524
- value: { action: 'copy-config' },
525
- });
526
- }
527
- // Also allow copying the template
525
+ // Multi-select copy
526
+ const hasResources = fs.existsSync(snipPath) || configs.length > 0;
528
527
  let versions;
529
528
  const brandDir = path.join(edmDir, current.brand);
530
529
  try { versions = findTemplateVersions(brandDir); } catch (_) { versions = []; }
531
- if (versions.length > 0) {
530
+ if (hasResources || versions.length > 0) {
532
531
  choices.push({
533
- name: '📥 拷贝模板到当前目录',
534
- value: { action: 'copy-template' },
532
+ name: '📥 拷贝到当前目录',
533
+ value: { action: 'copy-multi' },
535
534
  });
536
535
  }
537
536
  break;
@@ -558,125 +557,91 @@ async function interactiveBrowse(edmDir, startParsed) {
558
557
 
559
558
  // Copy actions
560
559
  if (result.action === 'copy-template') {
561
- let tplPath;
562
- if (current.level === 'brand') {
563
- const brandDir = path.join(edmDir, current.brand);
564
- const versions = findTemplateVersions(brandDir);
565
- if (versions.length === 1) {
566
- tplPath = versions[0].templatePath;
567
- } else {
568
- // Let user pick which template version
569
- const tplChoices = versions.map(v => ({
570
- name: formatName(v.meta, v.name) + formatDesc(v.meta),
571
- value: v.templatePath,
572
- }));
573
- tplPath = await showMenu('选择要拷贝的模板版本', tplChoices);
574
- }
575
- } else if (current.templatePath) {
576
- tplPath = current.templatePath;
577
- } else if (current.versionData) {
578
- tplPath = current.versionData.templatePath;
579
- } else {
580
- const brandDir = path.join(edmDir, current.brand);
581
- const versions = findTemplateVersions(brandDir);
582
- if (versions.length === 0) continue;
583
- if (versions.length === 1) {
584
- tplPath = versions[0].templatePath;
585
- } else {
586
- const tplChoices = versions.map(v => ({
587
- name: formatName(v.meta, v.name) + formatDesc(v.meta),
588
- value: v.templatePath,
589
- }));
590
- tplPath = await showMenu('选择要拷贝的模板版本', tplChoices);
591
- }
592
- }
593
- if (tplPath) {
560
+ const v = current.versionData;
561
+ if (v && v.templatePath) {
594
562
  console.log();
595
- await copyResource('template', tplPath, process.cwd());
563
+ await copyResource('template', v.templatePath, process.cwd());
596
564
  console.log();
597
565
  }
598
566
  continue;
599
567
  }
600
568
 
601
- if (result.action === 'copy-snippet') {
569
+ if (result.action === 'copy-multi') {
570
+ const brandDir = path.join(edmDir, current.brand);
571
+ const copyItems = [];
572
+
573
+ // Template
574
+ let versions;
575
+ try { versions = findTemplateVersions(brandDir); } catch (_) { versions = []; }
576
+ if (versions.length > 0) {
577
+ const tplPath = versions[0].templatePath;
578
+ copyItems.push({
579
+ name: `📋 模板 HTML(${versions[0].meta.name || versions[0].name})`,
580
+ value: 'template',
581
+ description: path.basename(tplPath),
582
+ checked: true,
583
+ });
584
+ }
585
+
586
+ // Snippet
602
587
  const snipPath = path.join(current.variantData.path, 'snippet.html');
603
588
  if (fs.existsSync(snipPath)) {
604
- console.log();
605
- await copyResource('snippet', snipPath, process.cwd());
606
- console.log();
589
+ copyItems.push({
590
+ name: '🧩 片段 HTML',
591
+ value: 'snippet',
592
+ description: path.basename(snipPath),
593
+ checked: true,
594
+ });
607
595
  }
608
- continue;
609
- }
610
596
 
611
- if (result.action === 'copy-config') {
597
+ // Config
612
598
  const configs = findConfigs(current.variantData.path);
613
- if (configs.length === 1) {
614
- console.log();
615
- await copyResource('config', configs[0].path, process.cwd());
616
- console.log();
617
- } else if (configs.length > 1) {
618
- const cfgChoices = configs.map(c => ({
619
- name: c.isOptimal ? c.name + chalk.green(' (最优配对)') : c.name,
620
- value: c.path,
621
- }));
622
- const cfgPath = await showMenu('选择要拷贝的配置文件', cfgChoices);
623
- if (cfgPath) {
624
- console.log();
625
- await copyResource('config', cfgPath, process.cwd());
626
- console.log();
627
- }
628
- }
629
- continue;
630
- }
631
-
632
- if (result.action === 'copy-config-brand') {
633
- // From brand level: pick series → variant → config
634
- const brandDir = path.join(edmDir, current.brand);
635
- const allSeries = findSeriesDirs(brandDir);
636
- if (allSeries.length === 0) continue;
637
-
638
- let series;
639
- if (allSeries.length === 1) {
640
- series = allSeries[0];
641
- } else {
642
- const sChoices = allSeries.map(s => ({
643
- name: formatName(s.meta, s.name) + formatDesc(s.meta),
644
- value: s,
645
- }));
646
- series = await showMenu('选择系列', sChoices);
599
+ if (configs.length > 0) {
600
+ copyItems.push({
601
+ name: '⚙️ 配置文件',
602
+ value: 'config',
603
+ description: configs.map(c => c.name).join(', '),
604
+ checked: true,
605
+ });
647
606
  }
648
- if (!series) continue;
649
607
 
650
- const variants = findSnippetVariants(series.path);
651
- if (variants.length === 0) continue;
652
- let variant;
653
- if (variants.length === 1) {
654
- variant = variants[0];
655
- } else {
656
- const vChoices = variants.map(v => ({
657
- name: formatName(v.meta, v.name) + formatDesc(v.meta),
658
- value: v,
659
- }));
660
- variant = await showMenu('选择变体', vChoices);
661
- }
662
- if (!variant) continue;
608
+ if (copyItems.length > 0) {
609
+ const { checkbox } = await import('@inquirer/prompts');
610
+ const selected = await checkbox({
611
+ message: '选择要拷贝的内容:',
612
+ choices: copyItems,
613
+ });
663
614
 
664
- const configs = findConfigs(variant.path);
665
- if (configs.length === 0) continue;
666
- let cfgPath;
667
- if (configs.length === 1) {
668
- cfgPath = configs[0].path;
669
- } else {
670
- const cfgChoices = configs.map(c => ({
671
- name: c.isOptimal ? c.name + chalk.green(' (最优配对)') : c.name,
672
- value: c.path,
673
- }));
674
- cfgPath = await showMenu('选择配置文件', cfgChoices);
675
- }
676
- if (cfgPath) {
677
- console.log();
678
- await copyResource('config', cfgPath, process.cwd());
679
- console.log();
615
+ if (selected.length > 0) {
616
+ // Include template version in default name for clarity
617
+ const versionName = versions.length > 0 ? versions[0].name : 'template';
618
+ const defaultBaseName = `${current.brand}-${versionName}-${current.series}-${current.variant}`;
619
+ const { promptOutputName: pn } = require('./snippet');
620
+ const outputBaseName = await pn(defaultBaseName, process.cwd());
621
+ const cwd = process.cwd();
622
+ const cwdRel = (p) => './' + path.relative(cwd, p);
623
+
624
+ console.log(chalk.green('\n✔ 已拷贝:'));
625
+ if (selected.includes('template') && versions.length > 0) {
626
+ const src = versions[0].templatePath;
627
+ const dest = path.join(cwd, outputBaseName + path.extname(src));
628
+ const actual = copyFileSafe(src, dest);
629
+ console.log(` ${chalk.cyan('·')} ${cwdRel(actual)} ${chalk.gray('(模板, ' + fmtBytes(fs.statSync(actual).size) + ')')}`);
630
+ }
631
+ if (selected.includes('snippet') && fs.existsSync(snipPath)) {
632
+ const dest = path.join(cwd, outputBaseName + '-snippet' + path.extname(snipPath));
633
+ const actual = copyFileSafe(snipPath, dest);
634
+ console.log(` ${chalk.cyan('·')} ${cwdRel(actual)} ${chalk.gray('(片段, ' + fmtBytes(fs.statSync(actual).size) + ')')}`);
635
+ }
636
+ if (selected.includes('config') && configs.length > 0) {
637
+ const optimal = configs.find(c => c.isOptimal);
638
+ const cfgPath = optimal ? optimal.path : configs[0].path;
639
+ const dest = path.join(cwd, 'juice.yaml');
640
+ const actual = copyFileSafe(cfgPath, dest);
641
+ console.log(` ${chalk.cyan('·')} ${cwdRel(actual)} ${chalk.gray('(配置, ' + fmtBytes(fs.statSync(actual).size) + ')')}`);
642
+ }
643
+ console.log();
644
+ }
680
645
  }
681
646
  continue;
682
647
  }