@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.11.1
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 +52 -0
- package/LICENSE +21 -0
- package/README.en.md +63 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/channels/dingtalk.js +574 -0
- package/channels/http.js +63 -0
- package/channels/index.js +138 -0
- package/channels/lark.js +1180 -0
- package/channels/message.js +267 -0
- package/channels/telegram.js +663 -0
- package/channels/wecom.js +585 -0
- package/cordis.patch.yml +68 -0
- package/docs/CHANNELS.md +184 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +8 -0
- package/index.js +658 -0
- package/lark-bot.js +9 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +5757 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +95 -4
- package/policy.js +362 -0
- package/service/access.js +239 -0
- package/service/admin.js +555 -0
- package/service/alerts.js +233 -0
- package/service/bots.js +665 -0
- package/service/channels.js +363 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +415 -0
- package/service/evaluation.js +121 -0
- package/service/push.js +163 -0
- package/service/query.js +411 -0
- package/service/schema.js +507 -0
- package/service/shared.js +424 -0
- package/service/suggestions.js +772 -0
- package/service/uploads.js +258 -0
- package/sql-guard.js +222 -0
- package/sqlite-driver.js +677 -0
- package/store.js +225 -0
- package/xlsx.js +1001 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-neutral message helpers shared by every channel adapter.
|
|
3
|
+
*
|
|
4
|
+
* OutboundMessage = { title, text?, results?, footer?, includeSql?, chartMode?, mentionAll?, template? }
|
|
5
|
+
* ResultBlock = { title, columns:[{name,kind}], rows, rowCount, truncated, chart, sql?, filters?, error?, actions? }
|
|
6
|
+
* (see docs/CHANNELS.md)
|
|
7
|
+
*/
|
|
8
|
+
import * as echarts from 'echarts'
|
|
9
|
+
import { Resvg } from '@resvg/resvg-js'
|
|
10
|
+
|
|
11
|
+
// ------------------------------------------------------------ formatting
|
|
12
|
+
|
|
13
|
+
export function num(v) {
|
|
14
|
+
return v === null || v === undefined || v === '' ? null : Number(v)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Compact number: 1.23亿 / 4.56万 / integers as-is / two decimals. */
|
|
18
|
+
export function fmtNum(v) {
|
|
19
|
+
const n = Number(v)
|
|
20
|
+
if (!Number.isFinite(n)) return String(v ?? '')
|
|
21
|
+
if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(2)}亿`
|
|
22
|
+
if (Math.abs(n) >= 1e4) return `${(n / 1e4).toFixed(2)}万`
|
|
23
|
+
return Number.isInteger(n) ? String(n) : n.toFixed(2)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A cell as display text, truncated to `max` characters. */
|
|
27
|
+
export function cellText(v, max = 80) {
|
|
28
|
+
if (v === null || v === undefined) return ''
|
|
29
|
+
const s = String(v)
|
|
30
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A KPI-style result (one row) as `**col**:value | …` Markdown. */
|
|
34
|
+
export function kpiText(result, { separator = ' | ', bold = true } = {}) {
|
|
35
|
+
const row = result.rows?.[0] ?? []
|
|
36
|
+
return result.columns.map((c, i) => `${bold ? `**${c.name}**` : c.name}:${c.kind === 'number' ? fmtNum(row[i]) : cellText(row[i])}`).join(separator)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when the result should be shown as KPI text rather than a table/chart. */
|
|
40
|
+
export function isKpi(result) {
|
|
41
|
+
return result.chart?.type === 'kpi' || (result.rows.length === 1 && result.columns.length <= 6)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** "12 行(已截断) · 已筛选:…" */
|
|
45
|
+
export function resultMeta(result) {
|
|
46
|
+
return `${result.rowCount ?? result.rows?.length ?? 0} 行${result.truncated ? '(已截断)' : ''}${result.filters?.length ? ` · 已筛选:${result.filters.map(f => f.description).join(';')}` : ''}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ------------------------------------------------------- chart rendering
|
|
50
|
+
|
|
51
|
+
const PALETTE = ['#3370ff', '#34c724', '#ff8800', '#f54a45', '#7f3bf5', '#14c0ff', '#f14bc1', '#8f959e', '#a1d33b', '#ff7a45']
|
|
52
|
+
|
|
53
|
+
/** Move series onto a second y axis when magnitudes differ by more than 10x. Mutates series. */
|
|
54
|
+
export function dualAxis(series) {
|
|
55
|
+
if (series.length < 2 || series.length > 4) return false
|
|
56
|
+
const peak = series.map(s => Math.max(0, ...s.data.filter(v => Number.isFinite(v)).map(Math.abs)))
|
|
57
|
+
const top = Math.max(...peak)
|
|
58
|
+
if (!(top > 0)) return false
|
|
59
|
+
const small = peak.map(p => p > 0 && top / p > 10)
|
|
60
|
+
if (!small.some(Boolean) || small.every(Boolean)) return false
|
|
61
|
+
series.forEach((s, i) => { if (small[i]) s.yAxisIndex = 1 })
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** ECharts option for a result, light theme for IM. */
|
|
66
|
+
export function chartOption(result) {
|
|
67
|
+
const chart = result.chart ?? { type: 'table' }
|
|
68
|
+
const idx = (name) => result.columns.findIndex(c => c.name === name)
|
|
69
|
+
const xi = idx(chart.x)
|
|
70
|
+
const ys = (chart.y ?? []).map(idx).filter(i => i >= 0)
|
|
71
|
+
const si = chart.series ? idx(chart.series) : -1
|
|
72
|
+
const base = { color: PALETTE, backgroundColor: '#ffffff', animation: false, textStyle: { fontFamily: 'PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif' } }
|
|
73
|
+
if (chart.type === 'pie' || chart.type === 'funnel') {
|
|
74
|
+
const data = result.rows.slice(0, 20).map(r => ({ name: String(r[xi] ?? 'NULL'), value: num(r[ys[0]]) }))
|
|
75
|
+
return { ...base, legend: { bottom: 0, type: 'scroll' }, series: [chart.type === 'pie' ? { type: 'pie', radius: ['35%', '62%'], center: ['50%', '46%'], data, label: { formatter: '{b}\n{d}%' } } : { type: 'funnel', top: 20, bottom: 40, data }] }
|
|
76
|
+
}
|
|
77
|
+
if (chart.type === 'scatter') {
|
|
78
|
+
return { ...base, grid: { left: 60, right: 30, top: 30, bottom: 40 }, xAxis: { type: 'value', name: chart.x }, yAxis: { type: 'value', name: result.columns[ys[0]]?.name }, series: [{ type: 'scatter', symbolSize: 7, data: result.rows.map(r => [num(r[xi]), num(r[ys[0]])]) }] }
|
|
79
|
+
}
|
|
80
|
+
const horizontal = chart.type === 'barh'
|
|
81
|
+
const seriesType = chart.type === 'line' || chart.type === 'area' ? 'line' : 'bar'
|
|
82
|
+
let categories
|
|
83
|
+
let series
|
|
84
|
+
if (si >= 0 && ys.length) {
|
|
85
|
+
categories = [...new Set(result.rows.map(r => String(r[xi] ?? 'NULL')))]
|
|
86
|
+
const groups = new Map()
|
|
87
|
+
for (const r of result.rows) {
|
|
88
|
+
const k = String(r[si] ?? 'NULL')
|
|
89
|
+
if (!groups.has(k)) groups.set(k, new Map())
|
|
90
|
+
groups.get(k).set(String(r[xi] ?? 'NULL'), num(r[ys[0]]))
|
|
91
|
+
}
|
|
92
|
+
series = [...groups.entries()].slice(0, 12).map(([name, m]) => ({ name, type: seriesType, data: categories.map(c => m.get(c) ?? null) }))
|
|
93
|
+
} else {
|
|
94
|
+
categories = result.rows.map(r => String(r[xi] ?? 'NULL'))
|
|
95
|
+
series = ys.map(i => ({ name: result.columns[i].name, type: seriesType, data: result.rows.map(r => num(r[i])) }))
|
|
96
|
+
}
|
|
97
|
+
for (const s of series) {
|
|
98
|
+
if (seriesType === 'line') { s.smooth = true; s.showSymbol = categories.length <= 31 }
|
|
99
|
+
if (chart.type === 'area') s.areaStyle = { opacity: 0.15 }
|
|
100
|
+
if (chart.type === 'stacked') s.stack = 'total'
|
|
101
|
+
if (seriesType === 'bar') s.barMaxWidth = 32
|
|
102
|
+
}
|
|
103
|
+
const cat = { type: 'category', data: categories, axisLabel: { hideOverlap: true, color: '#646a73' } }
|
|
104
|
+
const val = { type: 'value', axisLabel: { color: '#646a73', formatter: (v) => fmtNum(v) }, splitLine: { lineStyle: { color: '#eff0f1' } } }
|
|
105
|
+
const dual = !horizontal && chart.type !== 'stacked' && dualAxis(series)
|
|
106
|
+
return {
|
|
107
|
+
...base,
|
|
108
|
+
legend: { show: series.length > 1, top: 0, type: 'scroll' },
|
|
109
|
+
grid: { left: 16, right: 24, top: series.length > 1 ? 36 : 16, bottom: 8, containLabel: true },
|
|
110
|
+
xAxis: horizontal ? val : cat,
|
|
111
|
+
yAxis: horizontal ? { ...cat, inverse: true } : dual ? [val, { ...val, splitLine: { show: false } }] : val,
|
|
112
|
+
series,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Render a result's chart to a PNG Buffer (server-side ECharts SVG → resvg). */
|
|
117
|
+
export function renderChartPng(result, { width = 960, height = 480 } = {}) {
|
|
118
|
+
const chart = echarts.init(null, null, { renderer: 'svg', ssr: true, width, height })
|
|
119
|
+
try {
|
|
120
|
+
chart.setOption(chartOption(result))
|
|
121
|
+
const svg = chart.renderToSVGString()
|
|
122
|
+
return new Resvg(svg, { fitTo: { mode: 'width', value: width * 2 }, font: { loadSystemFonts: true, defaultFontFamily: 'PingFang SC' } }).render().asPng()
|
|
123
|
+
} finally {
|
|
124
|
+
chart.dispose()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether a result can be drawn as a chart (not table/kpi, has rows and an x axis). */
|
|
129
|
+
export function chartable(result) {
|
|
130
|
+
const t = result.chart?.type
|
|
131
|
+
return Boolean(t && t !== 'table' && t !== 'kpi' && result.rows.length > 0 && (result.chart.x || t === 'scatter'))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ------------------------------------------------------------- Markdown
|
|
135
|
+
|
|
136
|
+
/** A Markdown table block (header, separator, ≥ 1 row). Group 2 is the table. */
|
|
137
|
+
export const MD_TABLE = /(^|\n)(\|[^\n]*\|[ \t]*\n\|[ \t:|-]+\|[ \t]*\n(?:\|[^\n]*\|[ \t]*(?:\n|$))+)/g
|
|
138
|
+
|
|
139
|
+
/** Parse one Markdown table block into { columns:[{name,kind}], rows }. Numeric columns become numbers. */
|
|
140
|
+
export function parseMdTable(block) {
|
|
141
|
+
const lines = block.trim().split('\n').map(l => l.trim())
|
|
142
|
+
const split = (l) => l.replace(/^\||\|$/g, '').split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'))
|
|
143
|
+
const header = split(lines[0])
|
|
144
|
+
const rows = lines.slice(2).map(split)
|
|
145
|
+
const numeric = header.map((_, i) => rows.length > 0 && rows.every(r => r[i] === undefined || r[i] === '' || /^-?[\d,]+(\.\d+)?%?$/.test(r[i])))
|
|
146
|
+
return {
|
|
147
|
+
columns: header.map((name, i) => ({ name: name.replace(/\*\*/g, '') || ' ', kind: numeric[i] && rows.every(r => !/%$/.test(r[i] ?? '')) ? 'number' : 'text' })),
|
|
148
|
+
rows: rows.map(r => header.map((_, i) => {
|
|
149
|
+
const v = r[i] ?? ''
|
|
150
|
+
return numeric[i] && !/%$/.test(v) && v !== '' ? Number(v.replace(/,/g, '')) : v
|
|
151
|
+
})),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Normalize line endings and trim. */
|
|
156
|
+
export function normalizeMarkdown(md) {
|
|
157
|
+
return String(md ?? '').replace(/\r\n?/g, '\n').trim()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Strip the Lark-flavoured extensions for platforms with basic Markdown:
|
|
162
|
+
* `<font color=…>x</font>` → `x`, `<at id=all></at>` → `@所有人` (or removed with
|
|
163
|
+
* { mentionAll: '' }), `<at id=xxx>name</at>` → `@name`, other HTML tags removed.
|
|
164
|
+
*/
|
|
165
|
+
export function toPlainMarkdown(md, { mentionAll = '@所有人' } = {}) {
|
|
166
|
+
return normalizeMarkdown(md)
|
|
167
|
+
.replace(/<font\b[^>]*>([\s\S]*?)<\/font>/gi, '$1')
|
|
168
|
+
.replace(/<at\s+id\s*=\s*["']?all["']?\s*>[\s\S]*?<\/at>/gi, mentionAll)
|
|
169
|
+
.replace(/<at\b[^>]*>([\s\S]*?)<\/at>/gi, (_, name) => (name ? `@${name}` : ''))
|
|
170
|
+
.replace(/<\/?(?:font|at|text_tag|span)\b[^>]*>/gi, '')
|
|
171
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
172
|
+
.trim()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** True when the Markdown contains `<at id=all>`. */
|
|
176
|
+
export function mentionsAll(md) {
|
|
177
|
+
return /<at\s+id\s*=\s*["']?all["']?\s*>/i.test(String(md ?? ''))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Remove Markdown syntax entirely (for platforms without Markdown). */
|
|
181
|
+
export function toPlainText(md) {
|
|
182
|
+
return toPlainMarkdown(md)
|
|
183
|
+
.replace(/```[a-z]*\n?/gi, '')
|
|
184
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
185
|
+
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2')
|
|
186
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
187
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
188
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
|
|
189
|
+
.replace(/^>\s?/gm, '')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function mdCell(v, max = 40) {
|
|
193
|
+
return cellText(v, max).replace(/\|/g, '\\|').replace(/\n/g, ' ')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A result as a Markdown table (≤ maxRows rows, ≤ maxCols columns), with a "… N more" note when cut. */
|
|
197
|
+
export function resultAsMarkdownTable(result, maxRows = 10, { maxCols = 8 } = {}) {
|
|
198
|
+
const cols = (result.columns ?? []).slice(0, maxCols)
|
|
199
|
+
if (!cols.length) return ''
|
|
200
|
+
const rows = (result.rows ?? []).slice(0, maxRows)
|
|
201
|
+
const lines = [
|
|
202
|
+
`| ${cols.map(c => mdCell(c.name)).join(' | ')} |`,
|
|
203
|
+
`| ${cols.map(c => (c.kind === 'number' ? '---:' : '---')).join(' | ')} |`,
|
|
204
|
+
...rows.map(r => `| ${cols.map((c, i) => mdCell(c.kind === 'number' && Number.isFinite(Number(r[i])) && r[i] !== null && r[i] !== '' ? Number(r[i]).toLocaleString('en-US', { maximumFractionDigits: 4 }) : r[i])).join(' | ')} |`),
|
|
205
|
+
]
|
|
206
|
+
const total = result.rowCount ?? result.rows?.length ?? 0
|
|
207
|
+
if (total > rows.length) lines.push('', `…共 ${total} 行,仅显示前 ${rows.length} 行`)
|
|
208
|
+
return lines.join('\n')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A result as aligned plain text lines (for platforms without tables). */
|
|
212
|
+
export function resultAsText(result, maxRows = 10, { maxCols = 6 } = {}) {
|
|
213
|
+
const cols = (result.columns ?? []).slice(0, maxCols)
|
|
214
|
+
const rows = (result.rows ?? []).slice(0, maxRows)
|
|
215
|
+
const lines = [cols.map(c => c.name).join(' | '), ...rows.map(r => cols.map((c, i) => (c.kind === 'number' ? fmtNum(r[i]) : cellText(r[i], 30))).join(' | '))]
|
|
216
|
+
const total = result.rowCount ?? result.rows?.length ?? 0
|
|
217
|
+
if (total > rows.length) lines.push(`…共 ${total} 行`)
|
|
218
|
+
return lines.join('\n')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A whole OutboundMessage as one Markdown document for platforms that render
|
|
223
|
+
* Markdown but no cards. Charts become tables (adapters send PNGs separately).
|
|
224
|
+
* @param {{ markdown?: 'full'|'basic'|'none', maxRows?: number, sqlMax?: number }} opts
|
|
225
|
+
*/
|
|
226
|
+
export function messageAsMarkdown(message, { markdown = 'basic', maxRows = 10, sqlMax = 1500 } = {}) {
|
|
227
|
+
const clean = markdown === 'full' ? normalizeMarkdown : markdown === 'none' ? toPlainText : toPlainMarkdown
|
|
228
|
+
const parts = []
|
|
229
|
+
if (message.title) parts.push(markdown === 'none' ? String(message.title) : `**${message.title}**`)
|
|
230
|
+
if (message.text) parts.push(clean(message.text))
|
|
231
|
+
for (const r of message.results ?? []) {
|
|
232
|
+
const head = markdown === 'none' ? `${r.title || '查询结果'}` : `**${r.title || '查询结果'}**`
|
|
233
|
+
if (r.error) { parts.push(`${head}\n${r.error}`); continue }
|
|
234
|
+
const lines = [`${head} ${resultMeta(r)}`]
|
|
235
|
+
if (!r.rows?.length) lines.push('无数据')
|
|
236
|
+
else if (isKpi(r)) lines.push(kpiText(r, { bold: markdown !== 'none' }))
|
|
237
|
+
else lines.push(markdown === 'none' ? resultAsText(r, maxRows) : resultAsMarkdownTable(r, maxRows))
|
|
238
|
+
if (r.sql && message.includeSql) lines.push(markdown === 'none' ? r.sql.slice(0, sqlMax) : `\`\`\`sql\n${r.sql.slice(0, sqlMax)}\n\`\`\``)
|
|
239
|
+
parts.push(lines.join('\n'))
|
|
240
|
+
}
|
|
241
|
+
if (message.footer) parts.push(clean(message.footer))
|
|
242
|
+
return parts.join('\n\n')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Split text into chunks of at most `max` bytes (UTF-8), on paragraph/line boundaries when possible. */
|
|
246
|
+
export function splitText(text, max) {
|
|
247
|
+
const s = String(text ?? '')
|
|
248
|
+
if (!max || Buffer.byteLength(s) <= max) return [s]
|
|
249
|
+
const out = []
|
|
250
|
+
let chunk = ''
|
|
251
|
+
const push = () => { if (chunk) out.push(chunk); chunk = '' }
|
|
252
|
+
for (const line of s.split('\n')) {
|
|
253
|
+
const next = chunk ? `${chunk}\n${line}` : line
|
|
254
|
+
if (Buffer.byteLength(next) <= max) { chunk = next; continue }
|
|
255
|
+
push()
|
|
256
|
+
if (Buffer.byteLength(line) <= max) { chunk = line; continue }
|
|
257
|
+
// A single over-long line: cut by characters.
|
|
258
|
+
let piece = ''
|
|
259
|
+
for (const ch of line) {
|
|
260
|
+
if (Buffer.byteLength(piece + ch) > max) { out.push(piece); piece = '' }
|
|
261
|
+
piece += ch
|
|
262
|
+
}
|
|
263
|
+
chunk = piece
|
|
264
|
+
}
|
|
265
|
+
push()
|
|
266
|
+
return out
|
|
267
|
+
}
|