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.
- package/CHANGELOG.md +230 -34
- package/lib/block-generator.js +8 -0
- package/lib/client.js +4 -1
- package/lib/index.js +135 -19
- package/lib/initvar-parser.js +68 -10
- package/lib/muv-parser.js +114 -28
- package/package.json +2 -2
- package/test-muv-parser.mjs +372 -0
- package/test-preset-resolve.mjs +210 -0
package/lib/initvar-parser.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
100
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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.
|
|
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": {
|