stella-coder 5.2.1 → 5.3.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/package.json +1 -1
- package/stella-cli/adb.mjs +200 -0
- package/stella-cli/charts.mjs +411 -0
- package/stella-cli/game-engine.mjs +708 -0
- package/stella-cli/gdrive-backup.mjs +338 -0
- package/stella-cli/gmail.mjs +415 -0
- package/stella-cli/home-assistant.mjs +168 -0
- package/stella-cli/index.mjs +923 -0
- package/stella-cli/yandex-maps.mjs +426 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stella-coder",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Stella Coder 5.0 — AI coding agent with Telegram bot, huge context, TDD, Git ecosystem, presentations, computer control, smart home, Office automation, and antivirus",
|
|
6
6
|
"main": "stella-cli/index.mjs",
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { execSync } from "node:child_process"
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
|
|
6
|
+
const ADB_DIR = path.join(os.homedir(), ".stella", "adb")
|
|
7
|
+
|
|
8
|
+
function ensureDir() { if (!fs.existsSync(ADB_DIR)) fs.mkdirSync(ADB_DIR, { recursive: true }) }
|
|
9
|
+
|
|
10
|
+
function adb(args) {
|
|
11
|
+
try {
|
|
12
|
+
return execSync(`adb ${args}`, { encoding: "utf8", timeout: 10000 }).trim()
|
|
13
|
+
} catch (e) {
|
|
14
|
+
return { error: e.stderr?.trim() || e.message }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ADB {
|
|
19
|
+
constructor() { ensureDir() }
|
|
20
|
+
|
|
21
|
+
isAvailable() {
|
|
22
|
+
try {
|
|
23
|
+
execSync("adb --version", { encoding: "utf8", stdio: "ignore" })
|
|
24
|
+
return true
|
|
25
|
+
} catch { return false }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getDevices() {
|
|
29
|
+
const out = adb("devices")
|
|
30
|
+
if (out.error) return { success: false, error: out.error }
|
|
31
|
+
const lines = out.split("\n").filter(l => l.trim() && !l.includes("List of devices"))
|
|
32
|
+
const devices = lines.map(l => { const [id, status] = l.trim().split(/\s+/); return { id, status } })
|
|
33
|
+
return { success: true, devices }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getState(serial) {
|
|
37
|
+
const s = serial ? `-s ${serial}` : ""
|
|
38
|
+
const out = adb(`${s} get-state`)
|
|
39
|
+
return out.error ? { success: false, error: out.error } : { success: true, state: out }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
shell(serial, cmd) {
|
|
43
|
+
const s = serial ? `-s ${serial}` : ""
|
|
44
|
+
const out = adb(`${s} shell ${cmd}`)
|
|
45
|
+
return out.error ? { success: false, error: out.error } : { success: true, output: out }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
screenshot(serial) {
|
|
49
|
+
ensureDir()
|
|
50
|
+
const s = serial ? `-s ${serial}` : ""
|
|
51
|
+
const filename = `screenshot_${Date.now()}.png`
|
|
52
|
+
const localPath = path.join(ADB_DIR, filename)
|
|
53
|
+
adb(`${s} exec-out screencap -p > "${localPath}"`)
|
|
54
|
+
if (fs.existsSync(localPath) && fs.statSync(localPath).size > 0) {
|
|
55
|
+
return { success: true, path: localPath }
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
adb(`${s} shell screencap -p /sdcard/screen.png`)
|
|
59
|
+
adb(`${s} pull /sdcard/screen.png "${localPath}"`)
|
|
60
|
+
adb(`${s} shell rm /sdcard/screen.png`)
|
|
61
|
+
if (fs.existsSync(localPath) && fs.statSync(localPath).size > 0) return { success: true, path: localPath }
|
|
62
|
+
} catch {}
|
|
63
|
+
return { success: false, error: "Screenshot failed" }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
tap(serial, x, y) {
|
|
67
|
+
const s = serial ? `-s ${serial}` : ""
|
|
68
|
+
const out = adb(`${s} shell input tap ${x} ${y}`)
|
|
69
|
+
return out.error ? { success: false, error: out.error } : { success: true }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
swipe(serial, x1, y1, x2, y2, duration = 300) {
|
|
73
|
+
const s = serial ? `-s ${serial}` : ""
|
|
74
|
+
const out = adb(`${s} shell input swipe ${x1} ${y1} ${x2} ${y2} ${duration}`)
|
|
75
|
+
return out.error ? { success: false, error: out.error } : { success: true }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
text(serial, text) {
|
|
79
|
+
const s = serial ? `-s ${serial}` : ""
|
|
80
|
+
const escaped = text.replace(/ /g, "%s").replace(/"/g, '\\"')
|
|
81
|
+
const out = adb(`${s} shell input text "${escaped}"`)
|
|
82
|
+
return out.error ? { success: false, error: out.error } : { success: true }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
key(serial, keycode) {
|
|
86
|
+
const s = serial ? `-s ${serial}` : ""
|
|
87
|
+
const out = adb(`${s} shell input keyevent ${keycode}`)
|
|
88
|
+
return out.error ? { success: false, error: out.error } : { success: true }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
pressHome(serial) { return this.key(serial, 3) }
|
|
92
|
+
pressBack(serial) { return this.key(serial, 4) }
|
|
93
|
+
pressMenu(serial) { return this.key(serial, 82) }
|
|
94
|
+
pressPower(serial) { return this.key(serial, 26) }
|
|
95
|
+
pressVolumeUp(serial) { return this.key(serial, 24) }
|
|
96
|
+
pressVolumeDown(serial) { return this.key(serial, 25) }
|
|
97
|
+
|
|
98
|
+
installApp(serial, apkPath) {
|
|
99
|
+
if (!fs.existsSync(apkPath)) return { success: false, error: "APK not found" }
|
|
100
|
+
const s = serial ? `-s ${serial}` : ""
|
|
101
|
+
const out = adb(`${s} install "${apkPath}"`)
|
|
102
|
+
if (out.error) return { success: false, error: out.error }
|
|
103
|
+
return { success: true, output: out }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
uninstallApp(serial, packageName) {
|
|
107
|
+
const s = serial ? `-s ${serial}` : ""
|
|
108
|
+
const out = adb(`${s} uninstall ${packageName}`)
|
|
109
|
+
if (out.error) return { success: false, error: out.error }
|
|
110
|
+
return { success: true, output: out }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
listPackages(serial, filter = "") {
|
|
114
|
+
const s = serial ? `-s ${serial}` : ""
|
|
115
|
+
const out = adb(`${s} shell pm list packages ${filter}`)
|
|
116
|
+
if (out.error) return { success: false, error: out.error }
|
|
117
|
+
const packages = out.split("\n").filter(l => l.trim()).map(l => l.replace("package:", "").trim())
|
|
118
|
+
return { success: true, packages }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
getBattery(serial) {
|
|
122
|
+
const s = serial ? `-s ${serial}` : ""
|
|
123
|
+
const out = adb(`${s} shell dumpsys battery`)
|
|
124
|
+
if (out.error) return { success: false, error: out.error }
|
|
125
|
+
const level = out.match(/level:\s*(\d+)/)?.[1]
|
|
126
|
+
const temp = out.match(/temperature:\s*(\d+)/)?.[1]
|
|
127
|
+
const status = out.match(/status:\s*(\d+)/)?.[1]
|
|
128
|
+
const statusMap = { 1: "unknown", 2: "charging", 3: "discharging", 4: "not charging", 5: "full" }
|
|
129
|
+
return { success: true, level: parseInt(level), temperature: temp ? parseInt(temp) / 10 : null, status: statusMap[status] || "unknown" }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getInfo(serial) {
|
|
133
|
+
const s = serial ? `-s ${serial}` : ""
|
|
134
|
+
const model = adb(`${s} shell getprop ro.product.model`)
|
|
135
|
+
const brand = adb(`${s} shell getprop ro.product.brand`)
|
|
136
|
+
const android = adb(`${s} shell getprop ro.build.version.release`)
|
|
137
|
+
const sdk = adb(`${s} shell getprop ro.build.version.sdk`)
|
|
138
|
+
const resolution = adb(`${s} shell wm size`)
|
|
139
|
+
const density = adb(`${s} shell wm density`)
|
|
140
|
+
return {
|
|
141
|
+
success: true,
|
|
142
|
+
model: model.error ? "unknown" : model,
|
|
143
|
+
brand: brand.error ? "unknown" : brand,
|
|
144
|
+
android: android.error ? "unknown" : android,
|
|
145
|
+
sdk: sdk.error ? "unknown" : sdk,
|
|
146
|
+
resolution: resolution.error ? "unknown" : resolution.replace("Physical size: ", ""),
|
|
147
|
+
density: density.error ? "unknown" : density.replace("Physical density: ", ""),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
startApp(serial, packageName) {
|
|
152
|
+
return this.shell(serial, `monkey -p ${packageName} -c android.intent.category.LAUNCHER 1`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
killApp(serial, packageName) {
|
|
156
|
+
return this.shell(serial, `am force-stop ${packageName}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
pullFile(serial, remotePath, localPath) {
|
|
160
|
+
const s = serial ? `-s ${serial}` : ""
|
|
161
|
+
ensureDir()
|
|
162
|
+
const dest = localPath || path.join(ADB_DIR, path.basename(remotePath))
|
|
163
|
+
const out = adb(`${s} pull "${remotePath}" "${dest}"`)
|
|
164
|
+
if (out.error) return { success: false, error: out.error }
|
|
165
|
+
return { success: true, path: dest }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
pushFile(serial, localPath, remotePath) {
|
|
169
|
+
const s = serial ? `-s ${serial}` : ""
|
|
170
|
+
const out = adb(`${s} push "${localPath}" "${remotePath}"`)
|
|
171
|
+
if (out.error) return { success: false, error: out.error }
|
|
172
|
+
return { success: true }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
reboot(serial) {
|
|
176
|
+
const s = serial ? `-s ${serial}` : ""
|
|
177
|
+
const out = adb(`${s} reboot`)
|
|
178
|
+
if (out.error) return { success: false, error: out.error }
|
|
179
|
+
return { success: true }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
logcat(serial, filter = "", lines = 50) {
|
|
183
|
+
const s = serial ? `-s ${serial}` : ""
|
|
184
|
+
const out = adb(`${s} logcat -d -t ${lines} ${filter}`)
|
|
185
|
+
if (out.error) return { success: false, error: out.error }
|
|
186
|
+
return { success: true, log: out }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
wifiConnect(ip, port = 5555) {
|
|
190
|
+
const out = adb(`connect ${ip}:${port}`)
|
|
191
|
+
if (out.error) return { success: false, error: out.error }
|
|
192
|
+
return { success: true, output: out }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
wifiDisconnect(ip) {
|
|
196
|
+
const out = adb(`disconnect ${ip}`)
|
|
197
|
+
if (out.error) return { success: false, error: out.error }
|
|
198
|
+
return { success: true, output: out }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import { execSync } from "node:child_process"
|
|
5
|
+
|
|
6
|
+
const CHARTS_DIR = path.join(os.homedir(), ".stella", "charts")
|
|
7
|
+
|
|
8
|
+
function ensureDir() {
|
|
9
|
+
if (!fs.existsSync(CHARTS_DIR)) fs.mkdirSync(CHARTS_DIR, { recursive: true })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function openFile(filePath) {
|
|
13
|
+
try {
|
|
14
|
+
if (process.platform === "win32") {
|
|
15
|
+
execSync(`start "" "${filePath}"`, { shell: "cmd.exe", stdio: "ignore" })
|
|
16
|
+
} else if (process.platform === "darwin") {
|
|
17
|
+
execSync(`open "${filePath}"`, { stdio: "ignore" })
|
|
18
|
+
} else {
|
|
19
|
+
execSync(`xdg-open "${filePath}"`, { stdio: "ignore" })
|
|
20
|
+
}
|
|
21
|
+
} catch {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function generateChartHTML(config) {
|
|
25
|
+
const {
|
|
26
|
+
type = "bar",
|
|
27
|
+
title = "Chart",
|
|
28
|
+
labels = [],
|
|
29
|
+
datasets = [],
|
|
30
|
+
width = 800,
|
|
31
|
+
height = 500,
|
|
32
|
+
xAxisLabel = "",
|
|
33
|
+
yAxisLabel = "",
|
|
34
|
+
backgroundColor = [],
|
|
35
|
+
borderColor = [],
|
|
36
|
+
showLegend = true,
|
|
37
|
+
showGrid = true,
|
|
38
|
+
animationDuration = 1000,
|
|
39
|
+
fontSize = 14,
|
|
40
|
+
darkMode = false,
|
|
41
|
+
} = config
|
|
42
|
+
|
|
43
|
+
const bg = darkMode ? "#1e1e2e" : "#ffffff"
|
|
44
|
+
const fg = darkMode ? "#cdd6f4" : "#333333"
|
|
45
|
+
const gridColor = darkMode ? "#45475a" : "#e0e0e0"
|
|
46
|
+
|
|
47
|
+
const chartDatasets = datasets.map((ds, i) => {
|
|
48
|
+
const dsConfig = {
|
|
49
|
+
label: ds.label || `Series ${i + 1}`,
|
|
50
|
+
data: ds.data || [],
|
|
51
|
+
}
|
|
52
|
+
if (type === "line" || type === "radar") {
|
|
53
|
+
dsConfig.borderColor = borderColor[i] || ds.borderColor || getColor(i)
|
|
54
|
+
dsConfig.backgroundColor = backgroundColor[i] || ds.backgroundColor || getColorAlpha(i, 0.2)
|
|
55
|
+
dsConfig.borderWidth = ds.borderWidth || 2
|
|
56
|
+
dsConfig.pointRadius = ds.pointRadius ?? 4
|
|
57
|
+
dsConfig.tension = ds.tension ?? 0.3
|
|
58
|
+
dsConfig.fill = ds.fill ?? false
|
|
59
|
+
} else if (type === "pie" || type === "doughnut") {
|
|
60
|
+
dsConfig.backgroundColor = (ds.data || []).map((_, j) => backgroundColor[j] || getColor(j))
|
|
61
|
+
dsConfig.borderColor = bg
|
|
62
|
+
dsConfig.borderWidth = 2
|
|
63
|
+
} else {
|
|
64
|
+
dsConfig.backgroundColor = (ds.data || []).map((_, j) => backgroundColor[j] || getColorAlpha(j, 0.7))
|
|
65
|
+
dsConfig.borderColor = (ds.data || []).map((_, j) => borderColor[j] || getColor(j))
|
|
66
|
+
dsConfig.borderWidth = ds.borderWidth ?? 1
|
|
67
|
+
dsConfig.borderRadius = ds.borderRadius ?? 4
|
|
68
|
+
}
|
|
69
|
+
return dsConfig
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const chartConfig = {
|
|
73
|
+
type: type === "horizontalBar" ? "bar" : type,
|
|
74
|
+
data: {
|
|
75
|
+
labels,
|
|
76
|
+
datasets: chartDatasets,
|
|
77
|
+
},
|
|
78
|
+
options: {
|
|
79
|
+
responsive: true,
|
|
80
|
+
maintainAspectRatio: false,
|
|
81
|
+
animation: { duration: animationDuration },
|
|
82
|
+
plugins: {
|
|
83
|
+
title: {
|
|
84
|
+
display: !!title,
|
|
85
|
+
text: title,
|
|
86
|
+
color: fg,
|
|
87
|
+
font: { size: fontSize + 4, weight: "bold" },
|
|
88
|
+
},
|
|
89
|
+
legend: {
|
|
90
|
+
display: showLegend,
|
|
91
|
+
labels: { color: fg, font: { size: fontSize } },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
scales: {},
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!["pie", "doughnut", "radar", "polarArea"].includes(type)) {
|
|
99
|
+
chartConfig.options.indexAxis = type === "horizontalBar" ? "y" : "x"
|
|
100
|
+
chartConfig.options.scales = {
|
|
101
|
+
x: {
|
|
102
|
+
display: true,
|
|
103
|
+
title: { display: !!xAxisLabel, text: xAxisLabel, color: fg, font: { size: fontSize } },
|
|
104
|
+
ticks: { color: fg, font: { size: fontSize - 2 } },
|
|
105
|
+
grid: { display: showGrid, color: gridColor },
|
|
106
|
+
},
|
|
107
|
+
y: {
|
|
108
|
+
display: true,
|
|
109
|
+
title: { display: !!yAxisLabel, text: yAxisLabel, color: fg, font: { size: fontSize } },
|
|
110
|
+
ticks: { color: fg, font: { size: fontSize - 2 } },
|
|
111
|
+
grid: { display: showGrid, color: gridColor },
|
|
112
|
+
beginAtZero: true,
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (type === "radar") {
|
|
118
|
+
chartConfig.options.scales = {
|
|
119
|
+
r: {
|
|
120
|
+
angleLines: { color: gridColor },
|
|
121
|
+
grid: { color: gridColor },
|
|
122
|
+
pointLabels: { color: fg, font: { size: fontSize } },
|
|
123
|
+
ticks: { color: fg, backdropColor: "transparent" },
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return `<!DOCTYPE html>
|
|
129
|
+
<html lang="en">
|
|
130
|
+
<head>
|
|
131
|
+
<meta charset="UTF-8">
|
|
132
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
133
|
+
<title>${title}</title>
|
|
134
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
|
135
|
+
<style>
|
|
136
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
137
|
+
body { background: ${bg}; color: ${fg}; font-family: 'Segoe UI', system-ui, sans-serif; display: flex; flex-direction: column; align-items: center; padding: 24px; min-height: 100vh; }
|
|
138
|
+
h1 { margin-bottom: 8px; font-size: 20px; }
|
|
139
|
+
.meta { color: ${gridColor}; font-size: 12px; margin-bottom: 16px; }
|
|
140
|
+
.chart-container { width: ${width}px; height: ${height}px; background: ${bg}; border-radius: 12px; padding: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); }
|
|
141
|
+
canvas { max-width: 100%; }
|
|
142
|
+
.export-btns { margin-top: 16px; display: flex; gap: 8px; }
|
|
143
|
+
.export-btns button { padding: 8px 16px; border: 1px solid ${gridColor}; border-radius: 6px; background: ${bg}; color: ${fg}; cursor: pointer; font-size: 13px; }
|
|
144
|
+
.export-btns button:hover { background: ${gridColor}; }
|
|
145
|
+
</style>
|
|
146
|
+
</head>
|
|
147
|
+
<body>
|
|
148
|
+
<h1>${title}</h1>
|
|
149
|
+
<div class="meta">Stella Coder Charts • ${new Date().toLocaleString()}</div>
|
|
150
|
+
<div class="chart-container">
|
|
151
|
+
<canvas id="chart"></canvas>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="export-btns">
|
|
154
|
+
<button onclick="downloadPNG()">PNG</button>
|
|
155
|
+
<button onclick="downloadSVG()">SVG</button>
|
|
156
|
+
<button onclick="downloadCSV()">CSV</button>
|
|
157
|
+
</div>
|
|
158
|
+
<script>
|
|
159
|
+
const config = ${JSON.stringify(chartConfig, null, 2)};
|
|
160
|
+
const ctx = document.getElementById('chart').getContext('2d');
|
|
161
|
+
const chart = new Chart(ctx, config);
|
|
162
|
+
|
|
163
|
+
function downloadPNG() {
|
|
164
|
+
const link = document.createElement('a');
|
|
165
|
+
link.download = '${title.replace(/[^a-zA-Z0-9]/g, "_")}.png';
|
|
166
|
+
link.href = chart.toBase64Image('image/png', 1);
|
|
167
|
+
link.click();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function downloadSVG() {
|
|
171
|
+
const canvas = document.getElementById('chart');
|
|
172
|
+
const svgNS = 'http://www.w3.org/2000/svg';
|
|
173
|
+
const svg = document.createElementNS(svgNS, 'svg');
|
|
174
|
+
svg.setAttribute('width', canvas.width);
|
|
175
|
+
svg.setAttribute('height', canvas.height);
|
|
176
|
+
const image = document.createElementNS(svgNS, 'image');
|
|
177
|
+
image.setAttribute('href', canvas.toDataURL('image/png'));
|
|
178
|
+
image.setAttribute('width', canvas.width);
|
|
179
|
+
image.setAttribute('height', canvas.height);
|
|
180
|
+
svg.appendChild(image);
|
|
181
|
+
const blob = new Blob([svg.outerHTML], { type: 'image/svg+xml' });
|
|
182
|
+
const link = document.createElement('a');
|
|
183
|
+
link.download = '${title.replace(/[^a-zA-Z0-9]/g, "_")}.svg';
|
|
184
|
+
link.href = URL.createObjectURL(blob);
|
|
185
|
+
link.click();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function downloadCSV() {
|
|
189
|
+
const labels = ${JSON.stringify(labels)};
|
|
190
|
+
const datasets = ${JSON.stringify(datasets.map(ds => ({ label: ds.label, data: ds.data })))};
|
|
191
|
+
let csv = 'Label,' + datasets.map(d => d.label).join(',') + '\\n';
|
|
192
|
+
labels.forEach((l, i) => {
|
|
193
|
+
csv += '"' + l + '",' + datasets.map(d => d.data[i] ?? '').join(',') + '\\n';
|
|
194
|
+
});
|
|
195
|
+
const blob = new Blob([csv], { type: 'text/csv' });
|
|
196
|
+
const link = document.createElement('a');
|
|
197
|
+
link.download = '${title.replace(/[^a-zA-Z0-9]/g, "_")}.csv';
|
|
198
|
+
link.href = URL.createObjectURL(blob);
|
|
199
|
+
link.click();
|
|
200
|
+
}
|
|
201
|
+
</script>
|
|
202
|
+
</body>
|
|
203
|
+
</html>`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getColor(index) {
|
|
207
|
+
const colors = [
|
|
208
|
+
"#7c3aed", "#2563eb", "#059669", "#d97706", "#dc2626",
|
|
209
|
+
"#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#ef4444",
|
|
210
|
+
"#6d28d9", "#1d4ed8", "#047857", "#b45309", "#b91c1c",
|
|
211
|
+
"#a78bfa", "#60a5fa", "#34d399", "#fbbf24", "#f87171",
|
|
212
|
+
]
|
|
213
|
+
return colors[index % colors.length]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getColorAlpha(index, alpha) {
|
|
217
|
+
const hex = getColor(index)
|
|
218
|
+
const r = parseInt(hex.slice(1, 3), 16)
|
|
219
|
+
const g = parseInt(hex.slice(3, 5), 16)
|
|
220
|
+
const b = parseInt(hex.slice(5, 7), 16)
|
|
221
|
+
return `rgba(${r},${g},${b},${alpha})`
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export class ChartGenerator {
|
|
225
|
+
constructor() {
|
|
226
|
+
ensureDir()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async createChart(config) {
|
|
230
|
+
const html = generateChartHTML(config)
|
|
231
|
+
const filename = `chart_${Date.now()}.html`
|
|
232
|
+
const filePath = path.join(CHARTS_DIR, filename)
|
|
233
|
+
fs.writeFileSync(filePath, html)
|
|
234
|
+
openFile(filePath)
|
|
235
|
+
return { success: true, path: filePath, filename }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async bar(title, labels, data, options = {}) {
|
|
239
|
+
return this.createChart({
|
|
240
|
+
type: "bar",
|
|
241
|
+
title,
|
|
242
|
+
labels,
|
|
243
|
+
datasets: [{ label: options.label || title, data }],
|
|
244
|
+
...options,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async line(title, labels, datasets, options = {}) {
|
|
249
|
+
return this.createChart({
|
|
250
|
+
type: "line",
|
|
251
|
+
title,
|
|
252
|
+
labels,
|
|
253
|
+
datasets: datasets.map((d, i) => ({
|
|
254
|
+
label: d.label || `Series ${i + 1}`,
|
|
255
|
+
data: d.data || d,
|
|
256
|
+
borderColor: d.borderColor,
|
|
257
|
+
backgroundColor: d.backgroundColor,
|
|
258
|
+
fill: d.fill,
|
|
259
|
+
tension: d.tension,
|
|
260
|
+
...d,
|
|
261
|
+
})),
|
|
262
|
+
...options,
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async pie(title, labels, data, options = {}) {
|
|
267
|
+
return this.createChart({
|
|
268
|
+
type: "pie",
|
|
269
|
+
title,
|
|
270
|
+
labels,
|
|
271
|
+
datasets: [{ label: title, data }],
|
|
272
|
+
showLegend: true,
|
|
273
|
+
showGrid: false,
|
|
274
|
+
...options,
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async doughnut(title, labels, data, options = {}) {
|
|
279
|
+
return this.createChart({
|
|
280
|
+
type: "doughnut",
|
|
281
|
+
title,
|
|
282
|
+
labels,
|
|
283
|
+
datasets: [{ label: title, data }],
|
|
284
|
+
showLegend: true,
|
|
285
|
+
showGrid: false,
|
|
286
|
+
...options,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async radar(title, labels, datasets, options = {}) {
|
|
291
|
+
return this.createChart({
|
|
292
|
+
type: "radar",
|
|
293
|
+
title,
|
|
294
|
+
labels,
|
|
295
|
+
datasets: datasets.map((d, i) => ({
|
|
296
|
+
label: d.label || `Series ${i + 1}`,
|
|
297
|
+
data: d.data || d,
|
|
298
|
+
...d,
|
|
299
|
+
})),
|
|
300
|
+
showGrid: true,
|
|
301
|
+
...options,
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async scatter(title, dataPoints, options = {}) {
|
|
306
|
+
const datasets = options.datasets || [{ label: title, data: dataPoints }]
|
|
307
|
+
return this.createChart({
|
|
308
|
+
type: "scatter",
|
|
309
|
+
title,
|
|
310
|
+
datasets: datasets.map((d, i) => ({
|
|
311
|
+
label: d.label || `Series ${i + 1}`,
|
|
312
|
+
data: d.data || d,
|
|
313
|
+
backgroundColor: getColorAlpha(i, 0.6),
|
|
314
|
+
borderColor: getColor(i),
|
|
315
|
+
pointRadius: 5,
|
|
316
|
+
...d,
|
|
317
|
+
})),
|
|
318
|
+
...options,
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async horizontalBar(title, labels, data, options = {}) {
|
|
323
|
+
return this.createChart({
|
|
324
|
+
type: "horizontalBar",
|
|
325
|
+
title,
|
|
326
|
+
labels,
|
|
327
|
+
datasets: [{ label: options.label || title, data }],
|
|
328
|
+
...options,
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async polarArea(title, labels, data, options = {}) {
|
|
333
|
+
return this.createChart({
|
|
334
|
+
type: "polarArea",
|
|
335
|
+
title,
|
|
336
|
+
labels,
|
|
337
|
+
datasets: [{ label: title, data }],
|
|
338
|
+
showGrid: false,
|
|
339
|
+
...options,
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async bubble(title, dataPoints, options = {}) {
|
|
344
|
+
return this.createChart({
|
|
345
|
+
type: "bubble",
|
|
346
|
+
title,
|
|
347
|
+
datasets: [{
|
|
348
|
+
label: options.label || title,
|
|
349
|
+
data: dataPoints,
|
|
350
|
+
backgroundColor: getColorAlpha(0, 0.6),
|
|
351
|
+
borderColor: getColor(0),
|
|
352
|
+
}],
|
|
353
|
+
...options,
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async fromCSV(csvPath, options = {}) {
|
|
358
|
+
if (!fs.existsSync(csvPath)) return { success: false, error: "CSV file not found" }
|
|
359
|
+
const content = fs.readFileSync(csvPath, "utf8")
|
|
360
|
+
const lines = content.trim().split("\n")
|
|
361
|
+
if (lines.length < 2) return { success: false, error: "CSV too short" }
|
|
362
|
+
|
|
363
|
+
const headers = lines[0].split(",").map(h => h.trim().replace(/"/g, ""))
|
|
364
|
+
const labels = []
|
|
365
|
+
const datasets = headers.slice(1).map(h => ({ label: h, data: [] }))
|
|
366
|
+
|
|
367
|
+
for (let i = 1; i < lines.length; i++) {
|
|
368
|
+
const cols = lines[i].split(",").map(c => c.trim().replace(/"/g, ""))
|
|
369
|
+
labels.push(cols[0] || `Row ${i}`)
|
|
370
|
+
for (let j = 1; j < cols.length; j++) {
|
|
371
|
+
datasets[j - 1].data.push(parseFloat(cols[j]) || 0)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const chartType = options.type || (labels.length > 6 ? "line" : "bar")
|
|
376
|
+
return this.createChart({
|
|
377
|
+
type: chartType,
|
|
378
|
+
title: options.title || path.basename(csvPath, ".csv"),
|
|
379
|
+
labels,
|
|
380
|
+
datasets,
|
|
381
|
+
...options,
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async fromJSON(jsonPath, options = {}) {
|
|
386
|
+
if (!fs.existsSync(jsonPath)) return { success: false, error: "JSON file not found" }
|
|
387
|
+
const data = JSON.parse(fs.readFileSync(jsonPath, "utf8"))
|
|
388
|
+
return this.createChart({ ...data, ...options })
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
listCharts() {
|
|
392
|
+
if (!fs.existsSync(CHARTS_DIR)) return []
|
|
393
|
+
return fs.readdirSync(CHARTS_DIR)
|
|
394
|
+
.filter(f => f.endsWith(".html"))
|
|
395
|
+
.map(f => ({
|
|
396
|
+
name: f,
|
|
397
|
+
path: path.join(CHARTS_DIR, f),
|
|
398
|
+
created: fs.statSync(path.join(CHARTS_DIR, f)).birthtime,
|
|
399
|
+
}))
|
|
400
|
+
.sort((a, b) => b.created - a.created)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
deleteChart(filename) {
|
|
404
|
+
const filePath = path.join(CHARTS_DIR, filename)
|
|
405
|
+
if (fs.existsSync(filePath)) {
|
|
406
|
+
fs.unlinkSync(filePath)
|
|
407
|
+
return { success: true }
|
|
408
|
+
}
|
|
409
|
+
return { success: false, error: "Not found" }
|
|
410
|
+
}
|
|
411
|
+
}
|