@heixui/dsh-session-suite 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +381 -2
- package/README.zh.md +178 -0
- package/THIRD-PARTY.md +64 -0
- package/client.js +2974 -0
- package/cordis.patch.yml +77 -0
- package/icon.svg +19 -0
- package/index.js +211 -0
- package/lib/api.js +387 -0
- package/lib/checkpoints.js +774 -0
- package/lib/data/jieba-dict.txt +109750 -0
- package/lib/factkeys.js +212 -0
- package/lib/markdown.js +252 -0
- package/lib/memory-engine.js +1323 -0
- package/lib/memory-host.js +640 -0
- package/lib/memory.js +224 -0
- package/lib/retrieval.js +217 -0
- package/lib/service.js +1273 -0
- package/lib/share.js +641 -0
- package/lib/step-tag-filter.js +199 -0
- package/lib/tags.js +140 -0
- package/lib/util.js +261 -0
- package/lib/wordlist.js +183 -0
- package/locale/en.json +87 -0
- package/locale/zh.json +87 -0
- package/package.json +75 -4
package/lib/factkeys.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fact keys and supersession.
|
|
3
|
+
*
|
|
4
|
+
* Lexical similarity cannot see a value change: "拼团还没开" and "拼团开了" are
|
|
5
|
+
* two states of one fact that share almost no vocabulary, while "身高一米七"
|
|
6
|
+
* and "长胖到 55 公斤" share a lot but are two different facts. Keying each
|
|
7
|
+
* memory with `对象.方面` (for example `她.身高`) gives the engine something
|
|
8
|
+
* beyond the wording: entries that share a key describe the same slot, so the
|
|
9
|
+
* newest one wins and the older ones are marked superseded — kept in storage so
|
|
10
|
+
* the decision can be undone, but no longer recalled.
|
|
11
|
+
*
|
|
12
|
+
* This module is pure: no IO, no clock, no imports. The caller supplies the
|
|
13
|
+
* timestamps, and every function leaves its input untouched.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Segments generic enough that a key built from them says nothing. A memory
|
|
18
|
+
* keyed `信息.内容` would collide with every other vague note, which is worse
|
|
19
|
+
* than no key at all, because the collision silently buries memories.
|
|
20
|
+
*/
|
|
21
|
+
export const CATCH_ALL_SEGMENTS = Object.freeze([
|
|
22
|
+
'信息',
|
|
23
|
+
'内容',
|
|
24
|
+
'事情',
|
|
25
|
+
'事项',
|
|
26
|
+
'记忆',
|
|
27
|
+
'其他',
|
|
28
|
+
'杂项',
|
|
29
|
+
'note',
|
|
30
|
+
'info',
|
|
31
|
+
'other',
|
|
32
|
+
'misc',
|
|
33
|
+
'general',
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
/** A catch-all key groups everything, so a key may have at most this many segments. */
|
|
37
|
+
const MAX_SEGMENTS = 3
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Separator characters that all mean "segment boundary". Half-width `.` is the
|
|
41
|
+
* canonical form; the rest are the ones a model actually emits when asked for a
|
|
42
|
+
* dotted key in Chinese — the ideographic full stop and the middle dots.
|
|
43
|
+
*/
|
|
44
|
+
const SEPARATORS = /[.。·・//.﹒]/g
|
|
45
|
+
|
|
46
|
+
/** Every kind of whitespace, including the ideographic space. */
|
|
47
|
+
const WHITESPACE = /\s+/g
|
|
48
|
+
|
|
49
|
+
/** Only these shapes are parsed as a timestamp; anything else is "unknown". */
|
|
50
|
+
const ISO_LIKE = /^\d{4}-\d{2}-\d{2}/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Normalise a raw key to canonical form.
|
|
54
|
+
*
|
|
55
|
+
* Whitespace is dropped, separators are unified, empty segments collapse, ASCII
|
|
56
|
+
* is lowercased, and the result is trimmed of leading/trailing dots.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} raw candidate key
|
|
59
|
+
* @returns {string | null} canonical key, or null when nothing usable remains
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeFactKey(raw) {
|
|
62
|
+
if (typeof raw !== 'string') return null
|
|
63
|
+
// NFKC folds full-width ASCII (`ABC`, `1`, `.`) onto its half-width form,
|
|
64
|
+
// which is what makes `她.身高` and `她.身高` the same key.
|
|
65
|
+
const folded = raw.normalize('NFKC')
|
|
66
|
+
const segments = folded
|
|
67
|
+
.replace(SEPARATORS, '.')
|
|
68
|
+
.replace(WHITESPACE, '')
|
|
69
|
+
.split('.')
|
|
70
|
+
.map(segment => segment.replace(/[A-Z]/g, ch => ch.toLowerCase()))
|
|
71
|
+
.filter(segment => segment.length > 0)
|
|
72
|
+
if (segments.length === 0) return null
|
|
73
|
+
return segments.join('.')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Is this key too broad to group memories safely?
|
|
78
|
+
*
|
|
79
|
+
* A key is catch-all when it has no usable segment (null, empty, punctuation
|
|
80
|
+
* only), a single segment, more than three segments, or any segment drawn from
|
|
81
|
+
* {@link CATCH_ALL_SEGMENTS}. The raw value is accepted as well as a canonical
|
|
82
|
+
* one, so callers cannot forget to normalise first.
|
|
83
|
+
*
|
|
84
|
+
* @param {unknown} key key to judge
|
|
85
|
+
* @returns {boolean} true when the key must not be used for supersession
|
|
86
|
+
*/
|
|
87
|
+
export function isCatchAllKey(key) {
|
|
88
|
+
const canonical = normalizeFactKey(key)
|
|
89
|
+
if (!canonical) return true
|
|
90
|
+
const segments = canonical.split('.')
|
|
91
|
+
if (segments.length < 2 || segments.length > MAX_SEGMENTS) return true
|
|
92
|
+
return segments.some(segment => CATCH_ALL_SEGMENTS.includes(segment))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read an entry's timestamp as a comparable number.
|
|
97
|
+
*
|
|
98
|
+
* @param {unknown} value `createdAt` in any accepted shape
|
|
99
|
+
* @returns {number | null} epoch milliseconds, or null when it cannot be judged
|
|
100
|
+
*/
|
|
101
|
+
function comparableTime(value) {
|
|
102
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
|
103
|
+
if (value instanceof Date) {
|
|
104
|
+
const time = value.getTime()
|
|
105
|
+
return Number.isFinite(time) ? time : null
|
|
106
|
+
}
|
|
107
|
+
if (typeof value === 'string' && ISO_LIKE.test(value)) {
|
|
108
|
+
const time = Date.parse(value)
|
|
109
|
+
return Number.isFinite(time) ? time : null
|
|
110
|
+
}
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Work out which entries a fact key retires.
|
|
116
|
+
*
|
|
117
|
+
* Rules:
|
|
118
|
+
* - Entries are grouped by canonical key. Entries without a key, with an
|
|
119
|
+
* unparsable key, or with a catch-all key never participate.
|
|
120
|
+
* - Within a group the greatest `createdAt` survives; every entry strictly
|
|
121
|
+
* older than it is superseded.
|
|
122
|
+
* - **Exact timestamp ties supersede nothing.** When two entries claim the same
|
|
123
|
+
* slot at the same instant there is no basis for ordering them, and guessing
|
|
124
|
+
* would permanently bury a real memory, so both stay live.
|
|
125
|
+
* - Entries whose timestamp cannot be read are likewise kept.
|
|
126
|
+
*
|
|
127
|
+
* Grouping by canonical key also means `她.身高` and `她。身高` compete, and the
|
|
128
|
+
* result does not depend on input order (only `byId` uses the first tied winner
|
|
129
|
+
* when several entries share the greatest timestamp).
|
|
130
|
+
*
|
|
131
|
+
* @param {Array<{id?: string, factKey?: unknown, createdAt?: unknown}>} entries memories
|
|
132
|
+
* @returns {{superseded: Array<{id: unknown, byId: unknown, factKey: string}>, kept: unknown[]}}
|
|
133
|
+
*/
|
|
134
|
+
export function converge(entries) {
|
|
135
|
+
const list = Array.isArray(entries) ? entries : []
|
|
136
|
+
/** @type {Map<string, number[]>} */
|
|
137
|
+
const groups = new Map()
|
|
138
|
+
|
|
139
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
140
|
+
const entry = list[index]
|
|
141
|
+
if (!entry || typeof entry !== 'object') continue
|
|
142
|
+
const key = normalizeFactKey(entry.factKey)
|
|
143
|
+
if (!key || isCatchAllKey(key)) continue
|
|
144
|
+
const bucket = groups.get(key)
|
|
145
|
+
if (bucket) bucket.push(index)
|
|
146
|
+
else groups.set(key, [index])
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const superseded = []
|
|
150
|
+
const retired = new Set()
|
|
151
|
+
const times = list.map(entry => (entry && typeof entry === 'object' ? comparableTime(entry.createdAt) : null))
|
|
152
|
+
|
|
153
|
+
for (const [key, indexes] of groups) {
|
|
154
|
+
if (indexes.length < 2) continue
|
|
155
|
+
const timed = indexes.filter(index => times[index] !== null)
|
|
156
|
+
if (timed.length === 0) continue
|
|
157
|
+
let newest = timed[0]
|
|
158
|
+
for (const index of timed) {
|
|
159
|
+
// Strict `>` keeps the first entry of an exact tie as `byId`; the tie
|
|
160
|
+
// itself supersedes nothing, which is enforced by the strict `<` below.
|
|
161
|
+
if (times[index] > times[newest]) newest = index
|
|
162
|
+
}
|
|
163
|
+
for (const index of timed) {
|
|
164
|
+
if (times[index] < times[newest]) {
|
|
165
|
+
retired.add(index)
|
|
166
|
+
superseded.push({ id: list[index].id, byId: list[newest].id, factKey: key })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const kept = []
|
|
172
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
173
|
+
if (!retired.has(index)) kept.push(list[index]?.id)
|
|
174
|
+
}
|
|
175
|
+
return { superseded, kept }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Annotate a copy of the entry list with the supersession verdict.
|
|
180
|
+
*
|
|
181
|
+
* Superseded entries gain `supersededBy` (the surviving id) and `supersededAt`
|
|
182
|
+
* (the surviving entry's `createdAt`, so the stamp is deterministic); every
|
|
183
|
+
* other field and every other entry is copied through unchanged. The input
|
|
184
|
+
* objects are never written to — callers can undo a supersession by dropping
|
|
185
|
+
* those two fields again.
|
|
186
|
+
*
|
|
187
|
+
* @param {Array<object>} entries memories
|
|
188
|
+
* @param {{now?: number}} [options] override the recorded `supersededAt`
|
|
189
|
+
* @returns {Array<object>} new array, same length and order as the input
|
|
190
|
+
*/
|
|
191
|
+
export function applySupersession(entries, options = {}) {
|
|
192
|
+
const list = Array.isArray(entries) ? entries : []
|
|
193
|
+
const { superseded } = converge(list)
|
|
194
|
+
const override = options && typeof options === 'object' ? comparableTime(options.now) : null
|
|
195
|
+
|
|
196
|
+
const byId = new Map()
|
|
197
|
+
const entryById = new Map()
|
|
198
|
+
for (const entry of list) {
|
|
199
|
+
if (entry && typeof entry === 'object') entryById.set(entry.id, entry)
|
|
200
|
+
}
|
|
201
|
+
for (const item of superseded) {
|
|
202
|
+
if (!byId.has(item.id)) byId.set(item.id, item.byId)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return list.map(entry => {
|
|
206
|
+
if (!entry || typeof entry !== 'object') return entry
|
|
207
|
+
if (!byId.has(entry.id)) return { ...entry }
|
|
208
|
+
const survivor = entryById.get(byId.get(entry.id))
|
|
209
|
+
const at = override ?? comparableTime(survivor?.createdAt)
|
|
210
|
+
return { ...entry, supersededBy: byId.get(entry.id), supersededAt: at }
|
|
211
|
+
})
|
|
212
|
+
}
|
package/lib/markdown.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small Markdown subset parser shared by the HTML and image exports.
|
|
3
|
+
*
|
|
4
|
+
* The export pipeline must not depend on a markup library (a Host plugin cannot
|
|
5
|
+
* import extra packages), and both renderers need the *same* structure: headings,
|
|
6
|
+
* paragraphs, lists, quotes, fenced code, and inline emphasis. So the Host parses
|
|
7
|
+
* once into blocks with inline runs, the HTML renderer emits markup, and the
|
|
8
|
+
* browser draws the very same runs on a canvas.
|
|
9
|
+
*
|
|
10
|
+
* Supported: `#`–`######`, paragraphs, `-`/`*`/`+` and `1.` lists (indent = nesting),
|
|
11
|
+
* `>` quotes, ``` fenced code, `---` rules, `**bold**`, `*italic*`, `` `code` ``,
|
|
12
|
+
* `[text](url)`. Anything else stays literal text, which is the right failure mode
|
|
13
|
+
* for a transcript.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Split one line of inline Markdown into styled runs.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} text inline source
|
|
20
|
+
* @returns {{ text: string, bold?: true, italic?: true, code?: true, href?: string }[]} runs
|
|
21
|
+
*/
|
|
22
|
+
export function inlineRuns(text) {
|
|
23
|
+
const source = String(text ?? '')
|
|
24
|
+
const pattern = /(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)\s]+\))/g
|
|
25
|
+
const runs = []
|
|
26
|
+
let last = 0
|
|
27
|
+
for (const match of source.matchAll(pattern)) {
|
|
28
|
+
if (match.index > last) runs.push({ text: source.slice(last, match.index) })
|
|
29
|
+
const token = match[0]
|
|
30
|
+
if (token.startsWith('**') || token.startsWith('__')) runs.push({ text: token.slice(2, -2), bold: true })
|
|
31
|
+
else if (token.startsWith('`')) runs.push({ text: token.slice(1, -1), code: true })
|
|
32
|
+
else if (token.startsWith('[')) {
|
|
33
|
+
const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(token)
|
|
34
|
+
if (link) runs.push({ text: link[1], href: link[2] })
|
|
35
|
+
else runs.push({ text: token })
|
|
36
|
+
} else runs.push({ text: token.slice(1, -1), italic: true })
|
|
37
|
+
last = match.index + token.length
|
|
38
|
+
}
|
|
39
|
+
if (last < source.length) runs.push({ text: source.slice(last) })
|
|
40
|
+
return runs.length > 0 ? runs : [{ text: '' }]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse a Markdown document into blocks.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} source markdown
|
|
47
|
+
* @returns {any[]} blocks
|
|
48
|
+
*/
|
|
49
|
+
export function parseMarkdown(source) {
|
|
50
|
+
const lines = String(source ?? '').replace(/\r\n?/g, '\n').split('\n')
|
|
51
|
+
const blocks = []
|
|
52
|
+
let paragraph = []
|
|
53
|
+
let index = 0
|
|
54
|
+
|
|
55
|
+
const flush = () => {
|
|
56
|
+
if (paragraph.length === 0) return
|
|
57
|
+
blocks.push({ type: 'paragraph', runs: inlineRuns(paragraph.join('\n').trim()) })
|
|
58
|
+
paragraph = []
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
while (index < lines.length) {
|
|
62
|
+
const line = lines[index]
|
|
63
|
+
|
|
64
|
+
if (/^\s*```/.test(line)) {
|
|
65
|
+
flush()
|
|
66
|
+
const lang = line.trim().slice(3).trim()
|
|
67
|
+
const body = []
|
|
68
|
+
index += 1
|
|
69
|
+
while (index < lines.length && !/^\s*```/.test(lines[index])) {
|
|
70
|
+
body.push(lines[index])
|
|
71
|
+
index += 1
|
|
72
|
+
}
|
|
73
|
+
index += 1
|
|
74
|
+
blocks.push({ type: 'code', lang, text: body.join('\n') })
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (/^\s*$/.test(line)) {
|
|
79
|
+
flush()
|
|
80
|
+
index += 1
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line)
|
|
85
|
+
if (heading) {
|
|
86
|
+
flush()
|
|
87
|
+
blocks.push({ type: 'heading', level: heading[1].length, runs: inlineRuns(heading[2].trim()) })
|
|
88
|
+
index += 1
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
93
|
+
flush()
|
|
94
|
+
blocks.push({ type: 'hr' })
|
|
95
|
+
index += 1
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Pipe table: a header row immediately followed by a separator row.
|
|
100
|
+
const separator = /^\s*\|?[\s:|-]*-[\s:|-]*\|[\s:|-]*$/
|
|
101
|
+
if (/^\s*\|.*\|\s*$/.test(line) && index + 1 < lines.length && separator.test(lines[index + 1]) && lines[index + 1].includes('-')) {
|
|
102
|
+
flush()
|
|
103
|
+
const cells = row => row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim())
|
|
104
|
+
const headers = cells(line).map(cell => inlineRuns(cell))
|
|
105
|
+
index += 2
|
|
106
|
+
const rows = []
|
|
107
|
+
while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index])) {
|
|
108
|
+
rows.push(cells(lines[index]).map(cell => inlineRuns(cell)))
|
|
109
|
+
index += 1
|
|
110
|
+
}
|
|
111
|
+
blocks.push({ type: 'table', headers, rows })
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (/^\s*>/.test(line)) {
|
|
116
|
+
flush()
|
|
117
|
+
const quote = []
|
|
118
|
+
while (index < lines.length && /^\s*>/.test(lines[index])) {
|
|
119
|
+
quote.push(lines[index].replace(/^\s*>\s?/, ''))
|
|
120
|
+
index += 1
|
|
121
|
+
}
|
|
122
|
+
blocks.push({ type: 'quote', lines: quote.map(entry => inlineRuns(entry.trim())).filter(runs => runs.some(run => run.text.trim())) })
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const bullet = /^(\s*)([-*+])\s+(.*)$/.exec(line)
|
|
127
|
+
const ordered = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(line)
|
|
128
|
+
if (bullet || ordered) {
|
|
129
|
+
flush()
|
|
130
|
+
const isOrdered = Boolean(ordered)
|
|
131
|
+
const items = []
|
|
132
|
+
while (index < lines.length) {
|
|
133
|
+
const nextBullet = /^(\s*)([-*+])\s+(.*)$/.exec(lines[index])
|
|
134
|
+
const nextOrdered = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(lines[index])
|
|
135
|
+
const match = isOrdered ? nextOrdered : nextBullet
|
|
136
|
+
if (match) {
|
|
137
|
+
items.push({ indent: Math.floor(match[1].replace(/\t/g, ' ').length / 2), runs: inlineRuns(match[3].trim()) })
|
|
138
|
+
index += 1
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (items.length > 0 && /^\s{2,}\S/.test(lines[index])) {
|
|
142
|
+
const continuation = lines[index].trim()
|
|
143
|
+
const lastItem = items[items.length - 1]
|
|
144
|
+
lastItem.runs = inlineRuns(`${runsToText(lastItem.runs)} ${continuation}`)
|
|
145
|
+
index += 1
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
break
|
|
149
|
+
}
|
|
150
|
+
blocks.push({ type: 'list', ordered: isOrdered, start: isOrdered ? Number(ordered[2]) : 1, items })
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
paragraph.push(line)
|
|
155
|
+
index += 1
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
flush()
|
|
159
|
+
return blocks
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @param {readonly any[]} runs runs @returns {string} plain text */
|
|
163
|
+
export function runsToText(runs) {
|
|
164
|
+
return (runs ?? []).map(run => run.text ?? '').join('')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** @param {string} value raw @returns {string} HTML-safe */
|
|
168
|
+
export function escapeHtml(value) {
|
|
169
|
+
return String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char])
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {readonly any[]} runs runs
|
|
174
|
+
* @returns {string} inline HTML
|
|
175
|
+
*/
|
|
176
|
+
export function runsToHtml(runs) {
|
|
177
|
+
return (runs ?? [])
|
|
178
|
+
.map(run => {
|
|
179
|
+
let html = escapeHtml(run.text ?? '')
|
|
180
|
+
if (run.code) html = `<code>${html}</code>`
|
|
181
|
+
if (run.bold) html = `<strong>${html}</strong>`
|
|
182
|
+
if (run.italic) html = `<em>${html}</em>`
|
|
183
|
+
if (run.href) html = `<a href="${escapeHtml(run.href)}" rel="noreferrer noopener" target="_blank">${html}</a>`
|
|
184
|
+
return html
|
|
185
|
+
})
|
|
186
|
+
.join('')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {readonly any[]} blocks parsed blocks
|
|
191
|
+
* @returns {string} block HTML
|
|
192
|
+
*/
|
|
193
|
+
export function blocksToHtml(blocks) {
|
|
194
|
+
const out = []
|
|
195
|
+
for (const block of blocks ?? []) {
|
|
196
|
+
if (block.type === 'heading') {
|
|
197
|
+
const level = Math.min(6, Math.max(1, block.level ?? 2) + 1)
|
|
198
|
+
out.push(`<h${level}>${runsToHtml(block.runs)}</h${level}>`)
|
|
199
|
+
} else if (block.type === 'paragraph') {
|
|
200
|
+
out.push(`<p>${runsToHtml(block.runs).replace(/\n/g, '<br>')}</p>`)
|
|
201
|
+
} else if (block.type === 'list') {
|
|
202
|
+
const tag = block.ordered ? 'ol' : 'ul'
|
|
203
|
+
const startAttr = block.ordered && block.start !== 1 ? ` start="${block.start}"` : ''
|
|
204
|
+
const items = block.items
|
|
205
|
+
.map(item => {
|
|
206
|
+
const depth = Math.min(3, item.indent ?? 0)
|
|
207
|
+
return `<li class="depth-${depth}">${runsToHtml(item.runs)}</li>`
|
|
208
|
+
})
|
|
209
|
+
.join('')
|
|
210
|
+
out.push(`<${tag}${startAttr}>${items}</${tag}>`)
|
|
211
|
+
} else if (block.type === 'quote') {
|
|
212
|
+
out.push(`<blockquote>${(block.lines ?? []).map(runs => `<p>${runsToHtml(runs)}</p>`).join('')}</blockquote>`)
|
|
213
|
+
} else if (block.type === 'table') {
|
|
214
|
+
const head = (block.headers ?? []).map(runs => `<th>${runsToHtml(runs)}</th>`).join('')
|
|
215
|
+
const body = (block.rows ?? [])
|
|
216
|
+
.map(row => `<tr>${row.map(runs => `<td>${runsToHtml(runs)}</td>`).join('')}</tr>`)
|
|
217
|
+
.join('')
|
|
218
|
+
out.push(`<div class="table-wrap"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`)
|
|
219
|
+
} else if (block.type === 'code') {
|
|
220
|
+
out.push(`<pre><code>${escapeHtml(block.text)}</code></pre>`)
|
|
221
|
+
} else if (block.type === 'hr') {
|
|
222
|
+
out.push('<hr>')
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return out.join('\n')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Plain one-line preview of a Markdown string, for menu and picker rows.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} text markdown
|
|
232
|
+
* @param {number} [limit] character budget
|
|
233
|
+
* @returns {string} preview
|
|
234
|
+
*/
|
|
235
|
+
export function previewText(text, limit = 120) {
|
|
236
|
+
const plain = String(text ?? '')
|
|
237
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
238
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
239
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
240
|
+
// attachment placeholders are honest in an export but useless in a menu row.
|
|
241
|
+
// The counted form ("[3 张图片]") must go first: the generic rule below
|
|
242
|
+
// would otherwise eat only the trailing "图片]" and leave "[3 张".
|
|
243
|
+
.replace(/[\s*\d+\s*张(?:图片|文件)\s*]/g, ' ')
|
|
244
|
+
.replace(/[文件:[^]]*]/g, ' ')
|
|
245
|
+
.replace(/\[image: [^\]]*\]/g, ' ')
|
|
246
|
+
.replace(/[?(?:图片|image|file)[^]\]]*]?/g, ' ')
|
|
247
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
|
|
248
|
+
.replace(/[*_`>]/g, '')
|
|
249
|
+
.replace(/\s+/g, ' ')
|
|
250
|
+
.trim()
|
|
251
|
+
return plain.length > limit ? `${plain.slice(0, limit)}…` : plain
|
|
252
|
+
}
|