dsh-muv-table 0.2.9 → 0.2.10

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.
@@ -1,6 +1,6 @@
1
1
  // MUV Initvar parser: converts YAML-like indented initvar text to nested objects.
2
- // Format: 2-space indentation, `键: 值` pairs. Supports nested objects and inline
3
- // lists (comma-separated values wrapped in [ ]).
2
+ // Format: 2-space indentation, `键: 值` pairs. Supports nested objects, `- item`
3
+ // lists, and inline lists (comma-separated values wrapped in [ ]).
4
4
 
5
5
  /**
6
6
  * Parse an initvar text block into a nested object.
@@ -8,9 +8,12 @@
8
8
  * @returns {object} Parsed nested object
9
9
  */
10
10
  export function parseInitvar(text) {
11
- const lines = text.split(/\r?\n/)
11
+ const lines = String(text ?? '').split(/\r?\n/)
12
12
  const root = {}
13
- const stack = [{ obj: root, indent: -1 }]
13
+ // Each frame remembers the key that opened it and the object that holds it, so
14
+ // a frame created as an assumed-empty object can still be turned into an array
15
+ // once its contents turn out to be `- item` lines.
16
+ const stack = [{ obj: root, indent: -1, key: null, parent: null, list: false }]
14
17
 
15
18
  for (const rawLine of lines) {
16
19
  const line = rawLine.trimEnd()
@@ -19,16 +22,55 @@ export function parseInitvar(text) {
19
22
  const indent = line.length - line.trimStart().length
20
23
  const trimmed = line.trim()
21
24
 
25
+ // A `- item` line is a list entry, not a `键: 值` pair. It used to be dropped
26
+ // outright (`colonIdx === -1` → continue), which silently emptied every list
27
+ // a card declares the way 苍玄界 does:
28
+ //
29
+ // 最近互动记录:
30
+ // - 因为想吃灵鹤被{{user}}抓包,目前心虚加不知所措。
31
+ //
32
+ // The key before it looks like an empty value, so the entry belongs to the
33
+ // innermost *pending* key: that key was optimistically created as `{}` and is
34
+ // now revealed to be an array. Checked before the stack pop because a list is
35
+ // allowed to sit at the same indent as its own key.
36
+ const itemMatch = /^-(?:\s+(.*))?$/.exec(trimmed)
37
+ if (itemMatch) {
38
+ const frame = stack[stack.length - 1]
39
+ if (frame.key !== null && !frame.list && Object.keys(frame.obj).length === 0) {
40
+ const arr = []
41
+ frame.parent[frame.key] = arr
42
+ frame.obj = arr
43
+ frame.list = true
44
+ }
45
+ // No pending key above us means a stray item with nowhere to go; dropping
46
+ // it is the same behaviour this line always had.
47
+ if (frame.list) frame.obj.push(parseValue((itemMatch[1] ?? '').trim()))
48
+ continue
49
+ }
50
+
22
51
  // Pop stack until we find the right parent
23
52
  while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
24
53
  stack.pop()
25
54
  }
26
55
 
27
- const parent = stack[stack.length - 1].obj
56
+ const parentFrame = stack[stack.length - 1]
57
+ const parent = parentFrame.obj
28
58
  const colonIdx = trimmed.indexOf(':')
29
59
 
30
60
  if (colonIdx === -1) {
31
- // Value-only line (continuation or list item)
61
+ // Value-only line (continuation) — not representable in this format, and
62
+ // dropped exactly as before. Inside a list there are no keys at all, so the
63
+ // raw line is kept as an item rather than vanishing.
64
+ if (parentFrame.list) parent.push(trimmed)
65
+ continue
66
+ }
67
+
68
+ // A `键: 值` line under a list is not a key either (`- a` followed by an
69
+ // indented `x: 1` is a list of objects, which this format cannot express).
70
+ // Keeping the text as an item preserves the content; writing it as a property
71
+ // of an array would be silently lost by JSON.stringify.
72
+ if (parentFrame.list) {
73
+ parent.push(trimmed)
32
74
  continue
33
75
  }
34
76
 
@@ -36,10 +78,10 @@ export function parseInitvar(text) {
36
78
  const value = trimmed.slice(colonIdx + 1).trim()
37
79
 
38
80
  if (value === '') {
39
- // Nested object start
81
+ // Nested object start — may still turn out to be a list (see above).
40
82
  const newObj = {}
41
83
  parent[key] = newObj
42
- stack.push({ obj: newObj, indent })
84
+ stack.push({ obj: newObj, indent, key, parent, list: false })
43
85
  } else {
44
86
  // Leaf value
45
87
  parent[key] = parseValue(value)
@@ -52,12 +94,14 @@ export function parseInitvar(text) {
52
94
  /**
53
95
  * Parse a leaf value string to appropriate type.
54
96
  * @param {string} val
55
- * @returns {string|number|boolean}
97
+ * @returns {string|number|boolean|Array}
56
98
  */
57
99
  function parseValue(val) {
58
100
  // Empty string
59
101
  if (val === '""' || val === "''") return ''
60
102
  if (val === '{}') return '{}'
103
+ // Empty list — serialized that way so it does not come back as `{}`.
104
+ if (val === '[]') return []
61
105
 
62
106
  // Number
63
107
  if (/^-?\d+(\.\d+)?$/.test(val)) {
@@ -82,7 +126,16 @@ export function serializeInitvar(obj, depth = 0) {
82
126
  let result = ''
83
127
 
84
128
  for (const [key, value] of Object.entries(obj)) {
85
- if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
129
+ if (Array.isArray(value)) {
130
+ // Lists are written the way the cards write them (`键:` then ` - item`), so
131
+ // a parsed list survives a round trip instead of collapsing into "a,b".
132
+ if (value.length === 0) {
133
+ result += `${indent}${key}: []\n`
134
+ } else {
135
+ result += `${indent}${key}:\n`
136
+ for (const item of value) result += `${indent} - ${formatValue(item)}\n`
137
+ }
138
+ } else if (value !== null && typeof value === 'object') {
86
139
  result += `${indent}${key}:\n`
87
140
  result += serializeInitvar(value, depth + 1)
88
141
  } else {
@@ -97,5 +150,10 @@ function formatValue(val) {
97
150
  if (val === null || val === undefined) return ''
98
151
  if (typeof val === 'boolean') return val ? 'true' : 'false'
99
152
  if (typeof val === 'number') return String(val)
153
+ // An empty string must stay quoted. Written bare (`键: `) the line looks like
154
+ // the start of a nested object, so 苍玄界's `在场角色: ""` came back as `{}`
155
+ // the next time the block was read — a silent type flip. The cards quote it
156
+ // themselves; this just keeps that convention on the way out.
157
+ if (val === '') return '""'
100
158
  return String(val)
101
159
  }
package/lib/muv-parser.js CHANGED
@@ -17,6 +17,78 @@ import { parseInitvar } from './initvar-parser.js'
17
17
  // as well park it in description/scenario, so all three are scanned.
18
18
  const VARIABLE_TEXT_FIELDS = ['first_mes', 'description', 'scenario']
19
19
 
20
+ /** Arrays only — a malformed card may carry an object where a list belongs. */
21
+ function asArray(value) {
22
+ return Array.isArray(value) ? value : []
23
+ }
24
+
25
+ /**
26
+ * Every text blob of a card that might hold `<initvar>` / `<VariableInsert>`.
27
+ *
28
+ * Besides the obvious greeting fields, two more sources carry these blocks in
29
+ * the field and used to be skipped entirely:
30
+ *
31
+ * - world book entries (`character_book.entries[].content`) — `[initvar]变量初始化勿开`
32
+ * is a world-book entry, not a greeting, in the cards that use this shape;
33
+ * - `tavern_helper` scripts, whose source often contains the same block.
34
+ *
35
+ * The list is ordered by preference and grouped, so a caller can ask for "the
36
+ * greeting-level sources first" without hardcoding field names again.
37
+ * @param {object} data - the card's `data` object
38
+ * @returns {Array<{group: string, where: string, text: string}>}
39
+ */
40
+ function variableTextCandidates(data) {
41
+ const out = []
42
+ if (!data || typeof data !== 'object') return out
43
+
44
+ const push = (group, where, text) => {
45
+ if (typeof text === 'string') out.push({ group, where, text })
46
+ }
47
+
48
+ for (const field of VARIABLE_TEXT_FIELDS) push('field', field, data[field])
49
+ for (const greeting of asArray(data.alternate_greetings)) {
50
+ push('greeting', 'alternate_greetings', greeting)
51
+ }
52
+ for (const entry of asArray(data.character_book?.entries)) {
53
+ push('worldbook', 'character_book:' + String(entry?.comment || ''), entry?.content)
54
+ }
55
+ for (const script of asArray(data.extensions?.tavern_helper?.scripts)) {
56
+ push('script', 'tavern_helper:' + String(script?.name || ''), script?.content)
57
+ }
58
+ return out
59
+ }
60
+
61
+ /** All texts belonging to the given candidate groups, in candidate order. */
62
+ function textsInGroups(candidates, ...groups) {
63
+ return candidates.filter(c => groups.includes(c.group)).map(c => c.text)
64
+ }
65
+
66
+ /** The first `<initvar>` block in a list of texts, or null. */
67
+ function firstInitvarBlock(texts) {
68
+ for (const text of texts) {
69
+ const match = text.match(/<initvar>([\s\S]*?)<\/initvar>/i)
70
+ if (match) return { raw: match[1].trim(), parsed: parseInitvar(match[1]) }
71
+ }
72
+ return null
73
+ }
74
+
75
+ /**
76
+ * A card's variable data, whichever spec shape it uses.
77
+ *
78
+ * Spec-v2 cards wrap every field in `data`; spec-v1 (flat) cards put the same
79
+ * fields at the top level. Reading only `data` made a flat card — one
80
+ * `findJsonMatch` happily found by name — parse as `{name:'Unnamed'}` with 0
81
+ * fields, i.e. the library file was located and then thrown away.
82
+ * @param {object} cardJson
83
+ * @returns {object}
84
+ */
85
+ function cardData(cardJson) {
86
+ if (!cardJson || typeof cardJson !== 'object') return {}
87
+ const inner = cardJson.data
88
+ if (inner && typeof inner === 'object' && !Array.isArray(inner)) return inner
89
+ return cardJson
90
+ }
91
+
20
92
  /**
21
93
  * Drop a collection's `$template` placeholder.
22
94
  *
@@ -46,15 +118,7 @@ export function stripPlaceholderEntries(data) {
46
118
  export function extractVariableInsert(data) {
47
119
  if (!data || typeof data !== 'object') return null
48
120
 
49
- const candidates = []
50
- for (const field of VARIABLE_TEXT_FIELDS) {
51
- if (typeof data[field] === 'string') candidates.push(data[field])
52
- }
53
- for (const greeting of data.alternate_greetings || []) {
54
- if (typeof greeting === 'string') candidates.push(greeting)
55
- }
56
-
57
- for (const text of candidates) {
121
+ for (const { text } of variableTextCandidates(data)) {
58
122
  const match = text.match(/<VariableInsert>([\s\S]*?)<\/VariableInsert>/i)
59
123
  if (!match) continue
60
124
  const raw = match[1].trim()
@@ -65,6 +129,9 @@ export function extractVariableInsert(data) {
65
129
  }
66
130
  } catch (_) {
67
131
  // Not JSON — keep looking; another block might be the real one.
132
+ // World-book entries and helper scripts routinely *quote* the tags in
133
+ // prose (「必须使用 `<VariableInsert>`」) or inside a fenced code block, and
134
+ // those examples are never valid JSON, so this is the common case there.
68
135
  }
69
136
  }
70
137
  return null
@@ -76,8 +143,9 @@ export function extractVariableInsert(data) {
76
143
  * @returns {{ name: string, schemas: object[], initvarData: object, zodSource: string }}
77
144
  */
78
145
  export function parseMuvCard(cardJson) {
79
- const data = cardJson?.data || {}
80
- const name = data.name || cardJson?.name || 'Unnamed'
146
+ const data = cardData(cardJson)
147
+ const name = data.name || cardJson?.data?.name || cardJson?.name || 'Unnamed'
148
+ const candidates = variableTextCandidates(data)
81
149
  const tavernHelper = data.extensions?.tavern_helper
82
150
 
83
151
  // Extract Zod schema source (for reference)
@@ -87,18 +155,17 @@ export function parseMuvCard(cardJson) {
87
155
  // (`z.object({...})` / `registerMvuSchema`)其实是 MUV 最主流的 schema 载体,
88
156
  // 读不到就等于整张卡的变量表建不出来。
89
157
  // 现在按「名字含 zod」或「内容像 Zod schema」两条线索找,兼容各种命名。
90
- const scripts = tavernHelper?.scripts || []
158
+ // `scripts` 不一定是数组(畸形卡),先归一化再找,否则这里会先抛 TypeError。
159
+ const scripts = asArray(tavernHelper?.scripts)
91
160
  const zodScript = scripts.find(s => /zod/i.test(String(s?.name || '')))
92
161
  || scripts.find(s => /z\.object\s*\(|registerMvuSchema|Mvu\.|z\.string\s*\(|prefault\s*\(/.test(String(s?.content || '')))
93
162
  const zodSource = zodScript?.content || ''
94
163
 
95
164
  // Extract initvar blocks from alternate_greetings
96
- const greetings = data.alternate_greetings || []
97
165
  const initvarBlocks = []
98
166
 
99
- for (const greeting of greetings) {
100
- if (typeof greeting !== 'string') continue
101
- const match = greeting.match(/<initvar>([\s\S]*?)<\/initvar>/i)
167
+ for (const text of textsInGroups(candidates, 'greeting')) {
168
+ const match = text.match(/<initvar>([\s\S]*?)<\/initvar>/i)
102
169
  if (match) {
103
170
  initvarBlocks.push({
104
171
  raw: match[1].trim(),
@@ -109,19 +176,12 @@ export function parseMuvCard(cardJson) {
109
176
 
110
177
  // An <initvar> may live in the opening greeting instead. Only consulted when
111
178
  // alternate_greetings had none, so a card that already worked keeps reading
112
- // from exactly the same place it used to.
179
+ // from exactly the same place it used to. (`scenario` is included here for the
180
+ // same reason `<VariableInsert>` already scans it — the two paths were
181
+ // asymmetric.)
113
182
  if (initvarBlocks.length === 0) {
114
- for (const text of [data.first_mes, data.description]) {
115
- if (typeof text !== 'string') continue
116
- const match = text.match(/<initvar>([\s\S]*?)<\/initvar>/i)
117
- if (match) {
118
- initvarBlocks.push({
119
- raw: match[1].trim(),
120
- parsed: parseInitvar(match[1])
121
- })
122
- break
123
- }
124
- }
183
+ const hit = firstInitvarBlock(textsInGroups(candidates, 'field'))
184
+ if (hit) initvarBlocks.push(hit)
125
185
  }
126
186
 
127
187
  // Use the first initvar block as default data
@@ -137,6 +197,19 @@ export function parseMuvCard(cardJson) {
137
197
  }
138
198
  }
139
199
 
200
+ // Still nothing → the block may only exist in the world book or in a
201
+ // tavern_helper script. This is deliberately the last resort, after
202
+ // <VariableInsert>: those two sources are full of *quoted examples* of the
203
+ // syntax, and an example is far more likely to be a bare `<initvar>` skeleton
204
+ // (which parses into junk but never throws) than a usable JSON tree.
205
+ if (initvarRaw.length === 0) {
206
+ const hit = firstInitvarBlock(textsInGroups(candidates, 'worldbook', 'script'))
207
+ if (hit) {
208
+ initvarData = hit.parsed
209
+ initvarRaw = [hit.raw]
210
+ }
211
+ }
212
+
140
213
  // Infer schema from initvar data
141
214
  const schemas = inferSchemaFromData(initvarData)
142
215
 
@@ -180,6 +253,19 @@ function inferSchemaFromData(data, name = '') {
180
253
  description: '',
181
254
  children: []
182
255
  })
256
+ } else if (Array.isArray(value)) {
257
+ // A `- item` list. The panel edits one row per field, so the items are
258
+ // joined with newlines for display; `initvarData` keeps the real array and
259
+ // serializeInitvar writes it back as `- item` lines. Without this branch a
260
+ // list fell into the `String(value)` case and showed up as "a,b".
261
+ fields.push({
262
+ name: key,
263
+ type: 'string',
264
+ defaultValue: value.map(v => (v === null || v === undefined) ? '' : String(v)).join('\n'),
265
+ description: '',
266
+ children: [],
267
+ isList: true
268
+ })
183
269
  } else {
184
270
  fields.push({
185
271
  name: key,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-muv-table",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "MUV 变量表格编辑器 —— 树形变量面板、宏测试浮窗(random/pick/roll)、骰子快捷栏、pick 缓存管理;支持直接读取 SillyTavern PNG 角色卡(含正则脚本、世界书、变量表)。dsh-muv-engine 的伴生插件",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -10,7 +10,7 @@
10
10
  "./lib/*": "./lib/*",
11
11
  "./package.json": "./package.json"
12
12
  },
13
- "files": ["lib", "cordis.patch.yml", "CHANGELOG.md", "README.md", "test-png-card.mjs"],
13
+ "files": ["lib", "cordis.patch.yml", "CHANGELOG.md", "README.md", "test-png-card.mjs", "test-muv-parser.mjs", "test-preset-resolve.mjs"],
14
14
  "keywords": ["dsh", "dsh-plugin", "deepseek-harness", "muv", "magvarupdate", "tavern", "variable-table"],
15
15
  "license": "PolyForm-Noncommercial-Copyleft-1.0.0",
16
16
  "dsh": {