nx-sk 0.28.0 → 0.30.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 CHANGED
@@ -2,6 +2,61 @@
2
2
 
3
3
  本文件格式参考 [Keep a Changelog](https://keepachangelog.com/),中文正文。
4
4
 
5
+ ## 0.30.0 (2026-11-10)
6
+
7
+ **`peek` 头部换成 Notion「打开方式」菜单(像素对照、键盘可达),补 `#/<栏目>?entry=<id>` 深链与默认视图;补齐 `#5` 遗留的列表计数与全屏空白。**
8
+
9
+ ### Added
10
+
11
+ - **「打开方式」菜单**:侧边预览 / 居中预览 / 整页 / 新标签页 / 编辑默认视图(二级菜单)。
12
+ 行高 32px、菜单宽 216px、图标 16px、勾选在右缘、分隔线、悬停/选中态均按 Notion 截图量取;
13
+ 支持 ↑↓/Home/End、→ 进二级、← 退二级、Esc 只关菜单、点外部关闭,关闭后焦点回触发按钮。
14
+ - **默认视图持久化**:「编辑默认视图」写入 `localStorage['nx-sk-peek-default:<source|shortcut>']`,
15
+ 重载后按该形态打开;隐私模式写盘失败不影响当前切换。
16
+ - **条目深链**:`#/<栏目>?entry=<id>` 直接选中该条;选中条目时地址栏同步 `?entry=`(仅当地址已在
17
+ 该栏目,兜底栏目仍不补 hash)。菜单的「新标签页」用这个地址打开。
18
+ - **条目列表 shortcut 计数**:列表行显示 `N 组 · M 条`(title 带字段名),补上 0.29.0 只做在折叠条/面板里的遗漏。
19
+
20
+ ### Changed
21
+
22
+ - 三态从并排按钮改为单个「打开方式」触发按钮 + 菜单;side/center/fullpage 的面板尺寸、遮罩、
23
+ 焦点陷阱与 reduced-motion 行为不变。
24
+ - fullpage/center 的键盘图区接管剩余高度并垂直居中,不再在底部单侧留大片空白。
25
+ - side 表格最小宽度 420 → 400px,留 20px 容差,字体/缩放变化不再立刻出横向滚动条。
26
+
27
+ ### Fixed
28
+
29
+ - `shortcut` 面板的「新标签页」拿不到 `sectionId/entryId` 而缺失。
30
+ - 悬停展开二级菜单后再点父项会把它关掉(hover 开 + click 切换冲突)。
31
+ - 静态菜单多了 Notion 截图里没有的二级箭头(改为悬停/聚焦时显示)。
32
+
33
+ ### 说明
34
+
35
+ - side 下隐藏键盘导航列是有意取舍:46vw 减去分组目录后只剩 ~421px,保留 150px 导航列会让长键名逐字换行。
36
+ 原因与验证边界(仅 Chromium)已写入 `docs/behavior-parity.md` §0.30.0。
37
+
38
+ ## 0.29.0 (2026-11-10)
39
+
40
+ **新增 `shortcut` 字段与 `peek` 三态外壳;`source` 迁到同一外壳。三种形态、焦点与 reduced-motion 均经真实浏览器走查(57 项断言全过,控制台/网络 0 错误)。**
41
+
42
+ ### Added
43
+
44
+ - **快捷键字段(`shortcut`)**:值按「组 → 条目 → 按键」三层存;折叠条只报组数/条数,
45
+ 点「详细」打开键位面板(默认全屏,可切侧边/居中)——左侧分组目录、右侧说明表、底部键盘图,
46
+ 录入时直接按组合键、悬停条目点亮对应键。`combo` 存 `KeyboardEvent.code`,写入接受
47
+ `"Ctrl+R"` / `["Ctrl","R"]` / `[{code,label}]` 三种写法。
48
+ - **`peek` 三态外壳**:同一份内容可在 side / center / fullpage 之间切换。center/fullpage 补遮罩、
49
+ `aria-modal`、焦点陷阱、Esc 关闭与 reduced-motion;side 保持原右侧栏的宽度、断点与动画不变。
50
+
51
+ ### Changed
52
+
53
+ - 原始资料(`source`)迁移到 `peek` 外壳:默认仍是原来的右侧栏,视觉与交互不变,新增三态切换按钮。
54
+
55
+ ### 说明
56
+
57
+ - 走查修掉三处问题:三态选择器与 JSX 类名不一致导致面板尺寸/定位全部失效;点遮罩关闭后焦点掉到
58
+ `body`(现先 `preventDefault` 再关);窄栏下快捷键表格逐字换行、全屏下表格与键盘图之间留大片空洞。
59
+
5
60
  ## 0.28.0 (2026-11-10)
6
61
 
7
62
  **全量 TypeScript 化:后端与前端源码统一为 `.ts/.tsx`;字段与模块改为注册表驱动。对外行为不变。**
@@ -3,7 +3,7 @@
3
3
  大多数字段的值是标量(一行字、一个数字、一个日期)。这一篇只讲**值不是标量**的字段,
4
4
  以及它们各自的规则、写入时的体检、空值与默认值怎么处理:
5
5
 
6
- `table`(表格)· `entrylist`(结构清单)· `md`(图文)· `file`(附件)· `source`(原始资料袋)· `secret`(密文)
6
+ `table`(表格)· `entrylist`(结构清单)· `md`(图文)· `file`(附件)· `source`(原始资料袋)· `shortcut`(快捷键)· `secret`(密文)
7
7
 
8
8
  > 为什么要单独一篇:这些字段的**值形状**决定了怎么读、怎么写、怎么在面板上编。
9
9
  > 把它们当普通字符串塞进去,最常见的后果不是报错,而是「存进去了但面板看不见」——
@@ -18,6 +18,7 @@
18
18
  | `md` | 一段 markdown 字符串 | — | 图文编辑器(Ctrl+V 贴图) | `entry get`(图片还原成路径) |
19
19
  | `file` | `[附件id, …]` | 字段字典的 `kind` / `maxItems` | 附件选择器(上传 / 从库里选) | `entry get`(附件名 + 物理路径) |
20
20
  | `source` | `[{id, kind, name, text\|fileId\|url, addedAt, …}]` | 内置(每个栏目自带一个) | 底部折叠条 → 右侧侧边栏 | `source get` |
21
+ | `shortcut` | `[{id, name, shortcuts:[{id, combo:[{code,label}], description, condition?}]}]` | 数据自己(组 → 条目 → 按键) | 折叠条只报组数/条数;`详细` 打开键位面板(默认全屏,可切侧边/居中) | `entry get` |
21
22
  | `secret` | 一个字符串(落盘是密文) | — | 输入框 | `key get` / `entry get` |
22
23
 
23
24
  共同点:**写入即校验**(不合法就整条拒绝,不会存半条)、**空值有明确语义**、
@@ -190,6 +191,29 @@
190
191
  普通文字本来就是合法 markdown,老数据不用迁移。
191
192
  - 不计完整度(`fill: optional`);袋里是明文,**敏感内容不要进袋**。
192
193
 
194
+ ## `shortcut` 快捷键
195
+
196
+ - 值 = **组 → 条目 → 按键**三层:`[{ name, shortcuts: [{ combo, description, condition? }] }]`。
197
+ 组是目录,条目是「这条快捷键做什么」,`condition` 写生效范围(不写 = 全局)。
198
+ - `combo` 存 `KeyboardEvent.code`(物理键位),不是 `key`——换键盘布局/输入法不会让快捷键错位。
199
+ - 写入时三种写法等价(都会归一成同一种形状):
200
+
201
+ ```bash
202
+ # 1) 最省事的字符串
203
+ nx-sk entry add --section demo --title 快捷键示例 \
204
+ --set 'keys=[{"name":"全局","shortcuts":[{"combo":"Ctrl+Shift+R","description":"刷新"}]}]'
205
+
206
+ # 2) 键名数组
207
+ --set 'keys=[{"name":"列表","shortcuts":[{"combo":["J"],"description":"下一条"}]}]'
208
+
209
+ # 3) 面板存下来的按键对象(精确控制左右修饰键)
210
+ --set 'keys=[{"name":"全局","shortcuts":[{"combo":[{"code":"ControlLeft","label":"Ctrl"},{"code":"KeyK","label":"K"}],"description":"命令面板"}]}]'
211
+ ```
212
+
213
+ - 每条必须有 `description`;键位认不出(例如 `Ctrl+?`)会整条拒绝并指出是第几组第几条。
214
+ - 面板里点 `详细` 打开键位图:悬停条目点亮对应键;`新增` 后直接按组合键即可录入。
215
+ - 不能当表格列(`cell: false`);不引用附件,没有子密文。
216
+
193
217
  ## `secret` 密文
194
218
 
195
219
  - 值是一个字符串,落盘 AES-256-GCM 密文;读出来默认原文,`--mask` 打码。
@@ -0,0 +1,41 @@
1
+ import { badInput } from "../../errors.js";
2
+ import { normalizeShortcutGroups, comboLabel } from "../../../shared/shortcut.js";
3
+ import { identityRemap, identityReveal, isSensitiveDef, noRefs, noSubFields } from "./common.js";
4
+ /**
5
+ * 快捷键(分组):值 = [{ name, shortcuts: [{ combo, description, condition? }] }]。
6
+ *
7
+ * 为什么不做成普通 `tags`:快捷键是「组 → 条目 → 按键」三层,条目要带说明和生效条件,
8
+ * 面板要按组渲染、点击条目高亮键盘图;字符串数组撑不起这些。
9
+ * combo 支持 `"Ctrl+R"`、`["Ctrl","R"]` 和 `[{code,label}]` 三种写法(见 shared/shortcut.ts)。
10
+ */
11
+ export const shortcutBehavior = {
12
+ cell: false,
13
+ normalizeDef: () => { },
14
+ coerce(def, raw) {
15
+ try {
16
+ return normalizeShortcutGroups(raw);
17
+ }
18
+ catch (e) {
19
+ throw badInput(`字段「${def.label}」的快捷键有问题:${e.message}`);
20
+ }
21
+ },
22
+ reveal: identityReveal,
23
+ refs: noRefs,
24
+ remap: identityRemap,
25
+ format: (_def, value) => {
26
+ if (!Array.isArray(value) || !value.length)
27
+ return null;
28
+ const lines = [];
29
+ for (const group of value) {
30
+ const shortcuts = Array.isArray(group?.shortcuts) ? group.shortcuts : [];
31
+ lines.push(`[${group?.name || '未命名'}] ${shortcuts.length} 条`);
32
+ for (const s of shortcuts) {
33
+ const condition = s?.condition ? `(${s.condition})` : '';
34
+ lines.push(` ${comboLabel(s?.combo)} ${s?.description || ''}${condition}`);
35
+ }
36
+ }
37
+ return lines;
38
+ },
39
+ sensitive: isSensitiveDef,
40
+ subFields: noSubFields,
41
+ };
@@ -20,6 +20,7 @@ import { tagsBehavior } from "./behaviors/tags.js";
20
20
  import { entrylistBehavior } from "./behaviors/entrylist.js";
21
21
  import { tableBehavior } from "./behaviors/table.js";
22
22
  import { sourceBehavior } from "./behaviors/source.js";
23
+ import { shortcutBehavior } from "./behaviors/shortcut.js";
23
24
  import { secretBehavior } from "./behaviors/secret.js";
24
25
  import { fileBehavior } from "./behaviors/file.js";
25
26
  import { mdBehavior } from "./behaviors/md.js";
@@ -35,6 +36,7 @@ export const FIELD_BEHAVIOR = {
35
36
  entrylist: entrylistBehavior,
36
37
  table: tableBehavior,
37
38
  source: sourceBehavior,
39
+ shortcut: shortcutBehavior,
38
40
  secret: secretBehavior,
39
41
  file: fileBehavior,
40
42
  md: mdBehavior,
@@ -13,6 +13,7 @@ export const FIELD_SEAL = {
13
13
  entrylist: sealEntryList,
14
14
  table: sealTable,
15
15
  source: null,
16
+ shortcut: null,
16
17
  secret: sealScalar,
17
18
  file: null,
18
19
  md: null,
@@ -366,6 +366,7 @@ const DEMO_FIELDS = [
366
366
  }),
367
367
  // —— 富内容 ——
368
368
  f('note', '图文笔记', 'md', 'rich', { hint: 'markdown 正文:标题 / 列表 / 代码块都能渲染;直接 Ctrl+V 贴截图(自动进附件库);待办勾选就是改正文本身' }),
369
+ f('keys', '快捷键', 'shortcut', 'rich', { hint: '快捷键:按组整理(组 → 条目 → 按键)。折叠条只报组数/条数,点「详细」打开键位面板;录入时直接按组合键,键位图会点亮' }),
369
370
  f('docs', '附件', 'file', 'rich', { kind: 'doc', hint: '附件字段:值是附件 id 的列表(不是路径)。在这里直接上传,或从附件库选' }),
370
371
  f('apikey', '密钥', 'secret', 'rich', { hint: '密文字段:值以 AES-256-GCM 落盘,读出来默认原文,--mask 才打码' }),
371
372
  ];
@@ -606,6 +607,38 @@ export function seedEntries(at) {
606
607
  '- [ ] 截图直接 Ctrl+V 粘进来(自动进附件库,正文里插入引用)',
607
608
  '- [ ] 勾选待办就是改正文本身,没有第二份勾选状态',
608
609
  ].join('\n'),
610
+ keys: [
611
+ {
612
+ id: 'g1',
613
+ name: '全局',
614
+ shortcuts: [
615
+ {
616
+ id: 's1-1',
617
+ combo: [{ code: 'ControlLeft', label: 'Ctrl', isModifier: true }, { code: 'KeyK', label: 'K' }],
618
+ description: '打开命令面板',
619
+ },
620
+ {
621
+ id: 's1-2',
622
+ combo: [
623
+ { code: 'ControlLeft', label: 'Ctrl', isModifier: true },
624
+ { code: 'ShiftLeft', label: 'Shift', isModifier: true },
625
+ { code: 'KeyF', label: 'F' },
626
+ ],
627
+ description: '全库搜索',
628
+ condition: '工作台',
629
+ },
630
+ ],
631
+ },
632
+ {
633
+ id: 'g2',
634
+ name: '列表',
635
+ shortcuts: [
636
+ { id: 's2-1', combo: [{ code: 'KeyJ', label: 'J' }], description: '下一条' },
637
+ { id: 's2-2', combo: [{ code: 'KeyK', label: 'K' }], description: '上一条' },
638
+ { id: 's2-3', combo: [{ code: 'Enter', label: 'Enter' }], description: '打开当前条目' },
639
+ ],
640
+ },
641
+ ],
609
642
  };
610
643
  return [{
611
644
  id: newId('e'),
@@ -42,11 +42,11 @@ export function blockHeightPx(rows) {
42
42
  return h * ROW_HEIGHT + (h - 1) * MARGIN[1];
43
43
  }
44
44
  /**
45
- * 这些类型的字段**天然要一整行**:多行文本、附件、图文、清单、表格、资料袋——
45
+ * 这些类型的字段**天然要一整行**:多行文本、附件、图文、清单、表格、资料袋、快捷键——
46
46
  * 压成半列只会让内容挤成一条缝。其余(单行输入 / 下拉 / 日期 / 勾选)默认半列,
47
47
  * 两个并排刚好,也才有「把单列变成多列」的余地。
48
48
  */
49
- const WIDE_TYPES = new Set(['textarea', 'file', 'md', 'entrylist', 'table', 'source']);
49
+ const WIDE_TYPES = new Set(['textarea', 'file', 'md', 'entrylist', 'table', 'source', 'shortcut']);
50
50
  /**
51
51
  * 各类型的**默认最小高度(px)**——不是「固定高度」:内容比它高就按内容长(见
52
52
  * web 端 blockBoard 的测量逻辑),删掉内容再缩回这个高度。所以这里估的是
@@ -67,6 +67,7 @@ const DEFAULT_PX = {
67
67
  entrylist: 150,
68
68
  table: 200,
69
69
  source: 90,
70
+ shortcut: 90,
70
71
  };
71
72
  const DEFAULT_H = Object.fromEntries(Object.entries(DEFAULT_PX).map(([type, px]) => [type, rowsForPx(px)]));
72
73
  const clampInt = (v, lo, hi, def) => {
@@ -10,7 +10,7 @@
10
10
  /** 字段类型清单。**顺序是可见行为的一部分**(bootstrap 原样下发,面板下拉框照此排序)。 */
11
11
  export const FIELD_TYPES = [
12
12
  'text', 'textarea', 'number', 'date', 'month', 'bool', 'select', 'tags',
13
- 'entrylist', 'table', 'source', 'secret', 'file', 'md',
13
+ 'entrylist', 'table', 'source', 'secret', 'file', 'md', 'shortcut',
14
14
  ];
15
15
  /**
16
16
  * 类型的人读标签 —— 只用于**面板下拉框**。
@@ -30,6 +30,7 @@ export const FIELD_TYPE_LABELS = {
30
30
  entrylist: '结构清单(变长行)',
31
31
  table: '表格(勾选/下拉/计算)',
32
32
  source: '原始资料(乱入后整理)',
33
+ shortcut: '快捷键(分组)',
33
34
  secret: '密文',
34
35
  file: '附件',
35
36
  md: '图文(可贴图)',
@@ -0,0 +1,196 @@
1
+ const MODIFIERS = {
2
+ ctrl: { code: 'ControlLeft', label: 'Ctrl' },
3
+ control: { code: 'ControlLeft', label: 'Ctrl' },
4
+ shift: { code: 'ShiftLeft', label: 'Shift' },
5
+ alt: { code: 'AltLeft', label: 'Alt' },
6
+ option: { code: 'AltLeft', label: 'Alt' },
7
+ opt: { code: 'AltLeft', label: 'Alt' },
8
+ meta: { code: 'MetaLeft', label: 'Meta' },
9
+ cmd: { code: 'MetaLeft', label: 'Meta' },
10
+ command: { code: 'MetaLeft', label: 'Meta' },
11
+ win: { code: 'MetaLeft', label: 'Meta' },
12
+ super: { code: 'MetaLeft', label: 'Meta' },
13
+ };
14
+ /** 常用键的中文/口语别名 → KeyboardEvent.code。 */
15
+ const KEY_ALIASES = {
16
+ esc: 'Escape', escape: 'Escape',
17
+ enter: 'Enter', return: 'Enter',
18
+ space: 'Space', spacebar: 'Space',
19
+ tab: 'Tab',
20
+ backspace: 'Backspace', back: 'Backspace',
21
+ delete: 'Delete', del: 'Delete',
22
+ insert: 'Insert', ins: 'Insert',
23
+ home: 'Home', end: 'End',
24
+ pageup: 'PageUp', pgup: 'PageUp',
25
+ pagedown: 'PageDown', pgdn: 'PageDown', pgdown: 'PageDown',
26
+ up: 'ArrowUp', arrowup: 'ArrowUp',
27
+ down: 'ArrowDown', arrowdown: 'ArrowDown',
28
+ left: 'ArrowLeft', arrowleft: 'ArrowLeft',
29
+ right: 'ArrowRight', arrowright: 'ArrowRight',
30
+ comma: 'Comma', period: 'Period', dot: 'Period',
31
+ slash: 'Slash', backslash: 'Backslash',
32
+ semicolon: 'Semicolon', quote: 'Quote',
33
+ bracketleft: 'BracketLeft', bracketright: 'BracketRight',
34
+ minus: 'Minus', equal: 'Equal', backquote: 'Backquote',
35
+ capslock: 'CapsLock', contextmenu: 'ContextMenu',
36
+ };
37
+ const MODIFIER_ORDER = ['ControlLeft', 'ControlRight', 'ShiftLeft', 'ShiftRight', 'AltLeft', 'AltRight', 'MetaLeft', 'MetaRight'];
38
+ export function isModifierCode(code) {
39
+ return MODIFIER_ORDER.includes(code);
40
+ }
41
+ /** 把一个 code 变成展示名(KeyR → R,Digit1 → 1,NumpadAdd → Num +)。 */
42
+ export function labelOfCode(code) {
43
+ if (/^Key[A-Z]$/.test(code))
44
+ return code.slice(3);
45
+ if (/^Digit\d$/.test(code))
46
+ return code.slice(5);
47
+ if (/^Numpad/.test(code)) {
48
+ const tail = code.slice(6);
49
+ return tail ? `Num ${tail}` : 'Num';
50
+ }
51
+ if (/^F\d{1,2}$/.test(code))
52
+ return code;
53
+ for (const m of Object.values(MODIFIERS))
54
+ if (m.code === code)
55
+ return m.label;
56
+ const alias = Object.entries(KEY_ALIASES).find(([, v]) => v === code);
57
+ if (alias)
58
+ return alias[0].replace(/^./, (c) => c.toUpperCase());
59
+ return code;
60
+ }
61
+ function strokeFromToken(token) {
62
+ const raw = token.trim();
63
+ if (!raw)
64
+ throw new Error('组合键里有空键位');
65
+ const lower = raw.toLowerCase();
66
+ const mod = MODIFIERS[lower];
67
+ if (mod)
68
+ return { ...mod, isModifier: true };
69
+ const aliased = KEY_ALIASES[lower];
70
+ if (aliased)
71
+ return { code: aliased, label: labelOfCode(aliased) };
72
+ if (/^[a-z]$/.test(lower))
73
+ return { code: `Key${lower.toUpperCase()}`, label: lower.toUpperCase() };
74
+ if (/^\d$/.test(lower))
75
+ return { code: `Digit${lower}`, label: lower };
76
+ if (/^f\d{1,2}$/.test(lower))
77
+ return { code: lower.toUpperCase(), label: lower.toUpperCase() };
78
+ // 已经写成 code 的(KeyR / ArrowUp / Numpad1 …)原样收下
79
+ if (/^(Key|Digit|Numpad|Arrow|F\d)[A-Za-z0-9]+$/.test(raw)) {
80
+ return { code: raw, label: labelOfCode(raw) };
81
+ }
82
+ throw new Error(`认不出键位「${raw}」`);
83
+ }
84
+ function sortCombo(strokes) {
85
+ const rank = (s) => {
86
+ const i = MODIFIER_ORDER.indexOf(s.code);
87
+ return i >= 0 ? i : MODIFIER_ORDER.length;
88
+ };
89
+ return [...strokes].sort((a, b) => rank(a) - rank(b));
90
+ }
91
+ /**
92
+ * 接受三种写法,统一成 stroke 数组:
93
+ * - `"Ctrl+Shift+R"`(CLI --set 最省事)
94
+ * - `["Ctrl", "R"]`
95
+ * - `[{ code: "KeyR", label: "R" }, ...]`(面板存下来的形态)
96
+ */
97
+ export function parseCombo(raw) {
98
+ if (raw === null || raw === undefined)
99
+ throw new Error('缺少 combo(组合键)');
100
+ let tokens;
101
+ if (typeof raw === 'string') {
102
+ tokens = raw.split('+');
103
+ }
104
+ else if (Array.isArray(raw)) {
105
+ tokens = raw;
106
+ }
107
+ else {
108
+ tokens = [raw];
109
+ }
110
+ const strokes = tokens.map((token) => {
111
+ if (typeof token === 'string')
112
+ return strokeFromToken(token);
113
+ if (!token || typeof token !== 'object')
114
+ throw new Error('combo 里有一项不是字符串或按键对象');
115
+ const code = String(token.code || '').trim();
116
+ if (!code)
117
+ throw new Error('combo 里有一项缺少 code');
118
+ const label = String(token.label || '').trim() || labelOfCode(code);
119
+ const isModifier = token.isModifier ?? isModifierCode(code);
120
+ return { code, label, ...(isModifier ? { isModifier: true } : {}) };
121
+ });
122
+ if (!strokes.length)
123
+ throw new Error('combo 不能为空');
124
+ const codes = strokes.map((s) => s.code);
125
+ if (new Set(codes).size !== codes.length)
126
+ throw new Error('combo 里有重复键位');
127
+ return sortCombo(strokes);
128
+ }
129
+ /** `Ctrl+Shift+R` 这种给人看的写法。 */
130
+ export function comboLabel(combo) {
131
+ if (!Array.isArray(combo) || !combo.length)
132
+ return '';
133
+ return combo.map((s) => String(s?.label || s?.code || '')).filter(Boolean).join('+');
134
+ }
135
+ function normalizeItem(raw, groupIndex, itemIndex) {
136
+ if (!raw || typeof raw !== 'object') {
137
+ throw new Error(`第 ${groupIndex + 1} 组第 ${itemIndex + 1} 条不是对象`);
138
+ }
139
+ const combo = parseCombo(raw.combo ?? raw.keys);
140
+ const description = String(raw.description ?? raw.desc ?? '').trim();
141
+ if (!description) {
142
+ throw new Error(`第 ${groupIndex + 1} 组第 ${itemIndex + 1} 条缺少 description(写清这条快捷键做什么)`);
143
+ }
144
+ const condition = String(raw.condition ?? '').trim();
145
+ const id = typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : `s${groupIndex + 1}-${itemIndex + 1}`;
146
+ const createdAt = Number.isFinite(raw.createdAt) ? Number(raw.createdAt) : undefined;
147
+ return {
148
+ id,
149
+ combo,
150
+ description,
151
+ ...(condition ? { condition } : {}),
152
+ ...(createdAt ? { createdAt } : {}),
153
+ };
154
+ }
155
+ /**
156
+ * 值的归一:接受数组、`{ groups: [...] }`、JSON 字符串。
157
+ * 错误信息带「第几组第几条」,CLI 直接能看懂该改哪里。
158
+ */
159
+ export function normalizeShortcutGroups(raw) {
160
+ if (raw === null || raw === undefined)
161
+ return [];
162
+ let data = raw;
163
+ if (typeof data === 'string') {
164
+ const text = data.trim();
165
+ if (!text)
166
+ return [];
167
+ try {
168
+ data = JSON.parse(text);
169
+ }
170
+ catch {
171
+ throw new Error('需要一个 JSON 数组,每项形如 { "name": "全局", "shortcuts": [{ "combo": "Ctrl+R", "description": "刷新" }] }');
172
+ }
173
+ }
174
+ let groupsRaw = data;
175
+ if (!Array.isArray(data) && data && typeof data === 'object' && Array.isArray(data.groups)) {
176
+ groupsRaw = data.groups;
177
+ }
178
+ if (!Array.isArray(groupsRaw)) {
179
+ throw new Error('需要一个数组:[{ "name": "全局", "shortcuts": [...] }]');
180
+ }
181
+ const groups = [];
182
+ groupsRaw.forEach((g, gi) => {
183
+ if (!g || typeof g !== 'object')
184
+ throw new Error(`第 ${gi + 1} 组不是对象`);
185
+ const name = String(g.name ?? g.title ?? '').trim();
186
+ if (!name)
187
+ throw new Error(`第 ${gi + 1} 组缺少 name`);
188
+ const itemsRaw = Array.isArray(g.shortcuts) ? g.shortcuts : (Array.isArray(g.items) ? g.items : null);
189
+ if (!itemsRaw)
190
+ throw new Error(`组「${name}」缺少 shortcuts 数组`);
191
+ const shortcuts = itemsRaw.map((it, si) => normalizeItem(it, gi, si));
192
+ const id = typeof g.id === 'string' && g.id.trim() ? g.id.trim() : `g${gi + 1}`;
193
+ groups.push({ id, name, shortcuts });
194
+ });
195
+ return groups;
196
+ }
@@ -1 +1 @@
1
- import{a as l}from"./index-BpUTnBjj.js";let n=null,o=0;const u=1500;function c(t=!1){return!t&&n&&Date.now()-o<u||(o=Date.now(),n=l("/api/files").then(e=>e).catch(e=>{throw n=null,e})),n}function d(){return n=null,c(!0)}function h(t,e){const a=Array.isArray(t)?t:[],r=(Array.isArray(e)?e:[]).filter(Boolean);if(!r.length)return a;const s=new Set(r.map(i=>i.id));return[...r,...a.filter(i=>!s.has(i.id))]}function m(t){return new Promise((e,a)=>{const r=new FileReader;r.onload=()=>e(r.result),r.onerror=()=>a(new Error("读取文件失败")),r.readAsDataURL(t)})}export{m as f,c as l,h as m,d as r};
1
+ import{a as l}from"./index-CQF6tWPx.js";let n=null,o=0;const u=1500;function c(t=!1){return!t&&n&&Date.now()-o<u||(o=Date.now(),n=l("/api/files").then(e=>e).catch(e=>{throw n=null,e})),n}function d(){return n=null,c(!0)}function h(t,e){const a=Array.isArray(t)?t:[],r=(Array.isArray(e)?e:[]).filter(Boolean);if(!r.length)return a;const s=new Set(r.map(i=>i.id));return[...r,...a.filter(i=>!s.has(i.id))]}function m(t){return new Promise((e,a)=>{const r=new FileReader;r.onload=()=>e(r.result),r.onerror=()=>a(new Error("读取文件失败")),r.readAsDataURL(t)})}export{m as f,c as l,h as m,d as r};