alvin-bot 4.4.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/.env.example +43 -0
- package/BACKLOG.md +223 -0
- package/CHANGELOG.md +63 -0
- package/CLAUDE.example.md +152 -0
- package/CODE_OF_CONDUCT.md +52 -0
- package/CONTRIBUTING.md +72 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/SECURITY.md +38 -0
- package/SOUL.example.md +60 -0
- package/TOOLS.example.md +42 -0
- package/alvin-bot.config.example.json +24 -0
- package/bin/cli.js +1088 -0
- package/dist/.metadata_never_index +0 -0
- package/dist/claude.js +102 -0
- package/dist/config.js +65 -0
- package/dist/engine.js +90 -0
- package/dist/find-claude-binary.js +98 -0
- package/dist/handlers/commands.js +1489 -0
- package/dist/handlers/document.js +187 -0
- package/dist/handlers/message.js +200 -0
- package/dist/handlers/photo.js +154 -0
- package/dist/handlers/platform-message.js +275 -0
- package/dist/handlers/video.js +237 -0
- package/dist/handlers/voice.js +148 -0
- package/dist/i18n.js +299 -0
- package/dist/index.js +442 -0
- package/dist/init-data-dir.js +81 -0
- package/dist/middleware/auth.js +215 -0
- package/dist/migrate.js +139 -0
- package/dist/paths.js +87 -0
- package/dist/platforms/discord.js +161 -0
- package/dist/platforms/index.js +130 -0
- package/dist/platforms/signal.js +205 -0
- package/dist/platforms/slack.js +318 -0
- package/dist/platforms/telegram.js +111 -0
- package/dist/platforms/types.js +8 -0
- package/dist/platforms/whatsapp.js +648 -0
- package/dist/providers/claude-sdk-provider.js +173 -0
- package/dist/providers/codex-cli-provider.js +121 -0
- package/dist/providers/index.js +7 -0
- package/dist/providers/openai-compatible.js +388 -0
- package/dist/providers/registry.js +209 -0
- package/dist/providers/tool-executor.js +450 -0
- package/dist/providers/types.js +205 -0
- package/dist/services/access.js +144 -0
- package/dist/services/asset-index.js +230 -0
- package/dist/services/browser-manager.js +161 -0
- package/dist/services/browser.js +121 -0
- package/dist/services/compaction.js +129 -0
- package/dist/services/cron.js +462 -0
- package/dist/services/custom-tools.js +317 -0
- package/dist/services/delivery-queue.js +154 -0
- package/dist/services/elevenlabs.js +58 -0
- package/dist/services/embeddings.js +386 -0
- package/dist/services/exec-guard.js +46 -0
- package/dist/services/fallback-order.js +151 -0
- package/dist/services/heartbeat.js +192 -0
- package/dist/services/hooks.js +44 -0
- package/dist/services/imagegen.js +72 -0
- package/dist/services/language-detect.js +144 -0
- package/dist/services/markdown.js +63 -0
- package/dist/services/mcp.js +252 -0
- package/dist/services/memory.js +133 -0
- package/dist/services/personality.js +227 -0
- package/dist/services/plugins.js +171 -0
- package/dist/services/reminders.js +97 -0
- package/dist/services/restart.js +48 -0
- package/dist/services/security-audit.js +66 -0
- package/dist/services/self-search.js +129 -0
- package/dist/services/session.js +93 -0
- package/dist/services/skills.js +287 -0
- package/dist/services/standing-orders.js +29 -0
- package/dist/services/subagents.js +142 -0
- package/dist/services/sudo.js +243 -0
- package/dist/services/telegram.js +113 -0
- package/dist/services/tool-discovery.js +214 -0
- package/dist/services/usage-tracker.js +137 -0
- package/dist/services/users.js +199 -0
- package/dist/services/voice.js +95 -0
- package/dist/tui/index.js +507 -0
- package/dist/web/canvas.js +30 -0
- package/dist/web/doctor-api.js +606 -0
- package/dist/web/openai-compat.js +252 -0
- package/dist/web/server.js +1351 -0
- package/dist/web/setup-api.js +1078 -0
- package/docs/mcp.example.json +16 -0
- package/docs/screenshots/00-Login.png +0 -0
- package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
- package/docs/screenshots/02-Chat.png +0 -0
- package/docs/screenshots/03-Dashboard-Overview.png +0 -0
- package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
- package/docs/screenshots/05-Personality-Editor.png +0 -0
- package/docs/screenshots/06-Memory-Manager.png +0 -0
- package/docs/screenshots/07-Active-Sessions.png +0 -0
- package/docs/screenshots/08-File-Browser.png +0 -0
- package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
- package/docs/screenshots/10-Custom-Tools.png +0 -0
- package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
- package/docs/screenshots/12-Messaging-Platforms.png +0 -0
- package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
- package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
- package/docs/screenshots/13-User-Management.png +0 -0
- package/docs/screenshots/14-Web-Terminal.png +0 -0
- package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
- package/docs/screenshots/16-Settings-and-Env.png +0 -0
- package/docs/screenshots/TG-commands.png +0 -0
- package/docs/screenshots/TG.png +0 -0
- package/docs/screenshots/_Mac-Installer.png +0 -0
- package/docs/tools.example.json +33 -0
- package/install.sh +165 -0
- package/package.json +190 -0
- package/plugins/calendar/index.js +270 -0
- package/plugins/email/index.js +231 -0
- package/plugins/finance/index.js +254 -0
- package/plugins/notes/index.js +227 -0
- package/plugins/smarthome/index.js +230 -0
- package/plugins/weather/index.js +122 -0
- package/skills/apple-notes/SKILL.md +31 -0
- package/skills/browse/SKILL.md +136 -0
- package/skills/code-project/SKILL.md +43 -0
- package/skills/data-analysis/SKILL.md +39 -0
- package/skills/document-creation/SKILL.md +48 -0
- package/skills/email-summary/SKILL.md +46 -0
- package/skills/github/SKILL.md +42 -0
- package/skills/summarize/SKILL.md +28 -0
- package/skills/system-admin/SKILL.md +39 -0
- package/skills/weather/SKILL.md +34 -0
- package/skills/web-research/SKILL.md +35 -0
- package/web/public/canvas.html +52 -0
- package/web/public/css/style.css +555 -0
- package/web/public/index.html +189 -0
- package/web/public/js/app.js +3102 -0
- package/web/public/js/i18n.js +1048 -0
- package/web/public/js/icons.js +104 -0
- package/web/public/login.html +48 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron Service — Persistent scheduled tasks.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Interval-based jobs (every 5m, 1h, etc.)
|
|
6
|
+
* - Cron expressions (0 9 * * 1 = every Monday 9am)
|
|
7
|
+
* - One-shot scheduled tasks (run once at a specific time)
|
|
8
|
+
* - Job types: reminder, shell, ai-query, http
|
|
9
|
+
* - Management via /cron command + Web UI
|
|
10
|
+
* - Persisted to docs/cron-jobs.json (survives restarts)
|
|
11
|
+
*/
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import { execSync } from "child_process";
|
|
14
|
+
import { dirname } from "path";
|
|
15
|
+
import { getRegistry } from "../engine.js";
|
|
16
|
+
import { CRON_FILE, BOT_ROOT } from "../paths.js";
|
|
17
|
+
// ── Storage ─────────────────────────────────────────────
|
|
18
|
+
function loadJobs() {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(CRON_FILE, "utf-8"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function saveJobs(jobs) {
|
|
27
|
+
const dir = dirname(CRON_FILE);
|
|
28
|
+
if (!fs.existsSync(dir))
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
30
|
+
fs.writeFileSync(CRON_FILE, JSON.stringify(jobs, null, 2));
|
|
31
|
+
}
|
|
32
|
+
// ── Cron Parsing ────────────────────────────────────────
|
|
33
|
+
/**
|
|
34
|
+
* Parse an interval string (5m, 1h, 30s, 2d) to milliseconds.
|
|
35
|
+
*/
|
|
36
|
+
function parseInterval(input) {
|
|
37
|
+
const match = input.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)s?$/i);
|
|
38
|
+
if (!match)
|
|
39
|
+
return null;
|
|
40
|
+
const value = parseFloat(match[1]);
|
|
41
|
+
const unit = match[2].toLowerCase();
|
|
42
|
+
const mult = { s: 1000, sec: 1000, m: 60_000, min: 60_000, h: 3_600_000, hr: 3_600_000, d: 86_400_000, day: 86_400_000 };
|
|
43
|
+
return value * (mult[unit] || 60_000);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Parse a cron expression and find the next run time.
|
|
47
|
+
* Supports: minute hour day month weekday
|
|
48
|
+
* Simple implementation — covers common cases.
|
|
49
|
+
*/
|
|
50
|
+
function nextCronRun(expression, after = new Date()) {
|
|
51
|
+
const parts = expression.trim().split(/\s+/);
|
|
52
|
+
if (parts.length !== 5)
|
|
53
|
+
return null;
|
|
54
|
+
const [minExpr, hourExpr, dayExpr, monthExpr, weekdayExpr] = parts;
|
|
55
|
+
function parseField(expr, min, max) {
|
|
56
|
+
if (expr === "*")
|
|
57
|
+
return Array.from({ length: max - min + 1 }, (_, i) => i + min);
|
|
58
|
+
if (expr.includes("/")) {
|
|
59
|
+
const [, step] = expr.split("/");
|
|
60
|
+
const s = parseInt(step);
|
|
61
|
+
return Array.from({ length: max - min + 1 }, (_, i) => i + min).filter(v => v % s === 0);
|
|
62
|
+
}
|
|
63
|
+
if (expr.includes(","))
|
|
64
|
+
return expr.split(",").map(Number);
|
|
65
|
+
if (expr.includes("-")) {
|
|
66
|
+
const [a, b] = expr.split("-").map(Number);
|
|
67
|
+
return Array.from({ length: b - a + 1 }, (_, i) => i + a);
|
|
68
|
+
}
|
|
69
|
+
return [parseInt(expr)];
|
|
70
|
+
}
|
|
71
|
+
const minutes = parseField(minExpr, 0, 59);
|
|
72
|
+
const hours = parseField(hourExpr, 0, 23);
|
|
73
|
+
const days = parseField(dayExpr, 1, 31);
|
|
74
|
+
const months = parseField(monthExpr, 1, 12);
|
|
75
|
+
const weekdays = parseField(weekdayExpr, 0, 6); // 0=Sun
|
|
76
|
+
// Search forward up to 366 days
|
|
77
|
+
const candidate = new Date(after);
|
|
78
|
+
candidate.setSeconds(0, 0);
|
|
79
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
80
|
+
for (let i = 0; i < 366 * 24 * 60; i++) {
|
|
81
|
+
const m = candidate.getMinutes();
|
|
82
|
+
const h = candidate.getHours();
|
|
83
|
+
const d = candidate.getDate();
|
|
84
|
+
const mo = candidate.getMonth() + 1;
|
|
85
|
+
const wd = candidate.getDay();
|
|
86
|
+
if (minutes.includes(m) && hours.includes(h) && days.includes(d) && months.includes(mo) && weekdays.includes(wd)) {
|
|
87
|
+
return candidate;
|
|
88
|
+
}
|
|
89
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Calculate next run time for a job.
|
|
95
|
+
*/
|
|
96
|
+
function calculateNextRun(job) {
|
|
97
|
+
if (!job.enabled)
|
|
98
|
+
return null;
|
|
99
|
+
// Interval-based
|
|
100
|
+
const intervalMs = parseInterval(job.schedule);
|
|
101
|
+
if (intervalMs) {
|
|
102
|
+
const base = job.lastRunAt || job.createdAt;
|
|
103
|
+
return base + intervalMs;
|
|
104
|
+
}
|
|
105
|
+
// Cron expression
|
|
106
|
+
const next = nextCronRun(job.schedule);
|
|
107
|
+
return next ? next.getTime() : null;
|
|
108
|
+
}
|
|
109
|
+
let notifyCallback = null;
|
|
110
|
+
export function setNotifyCallback(fn) {
|
|
111
|
+
notifyCallback = fn;
|
|
112
|
+
}
|
|
113
|
+
async function executeJob(job) {
|
|
114
|
+
try {
|
|
115
|
+
switch (job.type) {
|
|
116
|
+
case "reminder":
|
|
117
|
+
case "message": {
|
|
118
|
+
const text = job.payload.text || "(no message)";
|
|
119
|
+
if (notifyCallback) {
|
|
120
|
+
await notifyCallback(job.target, `⏰ ${job.name}\n\n${text}`);
|
|
121
|
+
}
|
|
122
|
+
return { output: `Sent: ${text.slice(0, 100)}` };
|
|
123
|
+
}
|
|
124
|
+
case "shell": {
|
|
125
|
+
const cmd = job.payload.command || "echo 'no command'";
|
|
126
|
+
const output = execSync(cmd, {
|
|
127
|
+
timeout: 60_000,
|
|
128
|
+
stdio: "pipe",
|
|
129
|
+
env: { ...process.env, PATH: process.env.PATH + ":/opt/homebrew/bin:/usr/local/bin" },
|
|
130
|
+
}).toString().trim();
|
|
131
|
+
// Notify with output
|
|
132
|
+
if (notifyCallback && output) {
|
|
133
|
+
await notifyCallback(job.target, `🔧 ${job.name}\n\`\`\`\n${output.slice(0, 3000)}\n\`\`\``);
|
|
134
|
+
}
|
|
135
|
+
return { output: output.slice(0, 5000) };
|
|
136
|
+
}
|
|
137
|
+
case "http": {
|
|
138
|
+
const url = job.payload.url || "";
|
|
139
|
+
const method = job.payload.method || "GET";
|
|
140
|
+
const headers = job.payload.headers || {};
|
|
141
|
+
const fetchOpts = { method, headers };
|
|
142
|
+
if (job.payload.body && method !== "GET") {
|
|
143
|
+
fetchOpts.body = job.payload.body;
|
|
144
|
+
}
|
|
145
|
+
const res = await fetch(url, fetchOpts);
|
|
146
|
+
const text = await res.text();
|
|
147
|
+
const output = `HTTP ${res.status}: ${text.slice(0, 2000)}`;
|
|
148
|
+
if (notifyCallback) {
|
|
149
|
+
await notifyCallback(job.target, `🌐 ${job.name}\n${output.slice(0, 500)}`);
|
|
150
|
+
}
|
|
151
|
+
return { output };
|
|
152
|
+
}
|
|
153
|
+
case "ai-query": {
|
|
154
|
+
// AI queries run through the actual AI engine (Claude SDK)
|
|
155
|
+
const prompt = job.payload.prompt || "";
|
|
156
|
+
try {
|
|
157
|
+
const registry = getRegistry();
|
|
158
|
+
const queryOpts = {
|
|
159
|
+
prompt,
|
|
160
|
+
systemPrompt: `You are Alvin Bot, an autonomous AI assistant. You are currently executing a scheduled cron job ("${job.name}"). Reply concisely. Use Telegram-compatible Markdown. You have access to tools (Bash, files, etc.) — use them if needed.`,
|
|
161
|
+
effort: "high",
|
|
162
|
+
workingDir: BOT_ROOT,
|
|
163
|
+
};
|
|
164
|
+
let fullResponse = "";
|
|
165
|
+
for await (const chunk of registry.queryWithFallback(queryOpts)) {
|
|
166
|
+
if (chunk.type === "text" && chunk.text) {
|
|
167
|
+
fullResponse = chunk.text;
|
|
168
|
+
}
|
|
169
|
+
if (chunk.type === "error") {
|
|
170
|
+
throw new Error(chunk.error || "AI query failed");
|
|
171
|
+
}
|
|
172
|
+
if (chunk.type === "done") {
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Send AI response to target
|
|
177
|
+
if (notifyCallback && fullResponse.trim()) {
|
|
178
|
+
// Split long responses into chunks (Telegram limit ~4096 chars)
|
|
179
|
+
const maxLen = 3900;
|
|
180
|
+
if (fullResponse.length <= maxLen) {
|
|
181
|
+
await notifyCallback(job.target, fullResponse);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const parts = [];
|
|
185
|
+
for (let i = 0; i < fullResponse.length; i += maxLen) {
|
|
186
|
+
parts.push(fullResponse.slice(i, i + maxLen));
|
|
187
|
+
}
|
|
188
|
+
for (const part of parts) {
|
|
189
|
+
await notifyCallback(job.target, part);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { output: fullResponse.slice(0, 500) };
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
197
|
+
if (notifyCallback) {
|
|
198
|
+
await notifyCallback(job.target, `❌ AI-Query Error (${job.name}): ${error}`);
|
|
199
|
+
}
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
default:
|
|
204
|
+
return { output: "", error: `Unknown job type: ${job.type}` };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
209
|
+
if (notifyCallback) {
|
|
210
|
+
await notifyCallback(job.target, `❌ Cron Error (${job.name}): ${error}`);
|
|
211
|
+
}
|
|
212
|
+
return { output: "", error };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// ── Scheduler Loop ──────────────────────────────────────
|
|
216
|
+
let schedulerTimer = null;
|
|
217
|
+
const runningJobs = new Set(); // Guard against overlapping executions
|
|
218
|
+
export function startScheduler() {
|
|
219
|
+
if (schedulerTimer)
|
|
220
|
+
return;
|
|
221
|
+
// Check every 30 seconds for due jobs
|
|
222
|
+
schedulerTimer = setInterval(async () => {
|
|
223
|
+
const jobs = loadJobs();
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
let changed = false;
|
|
226
|
+
for (const job of jobs) {
|
|
227
|
+
if (!job.enabled)
|
|
228
|
+
continue;
|
|
229
|
+
// Skip if this job is already running
|
|
230
|
+
if (runningJobs.has(job.id))
|
|
231
|
+
continue;
|
|
232
|
+
// Calculate next run if not set
|
|
233
|
+
if (!job.nextRunAt) {
|
|
234
|
+
job.nextRunAt = calculateNextRun(job);
|
|
235
|
+
changed = true;
|
|
236
|
+
}
|
|
237
|
+
if (job.nextRunAt && now >= job.nextRunAt) {
|
|
238
|
+
console.log(`Cron: Running job "${job.name}" (${job.id})`);
|
|
239
|
+
// Mark as running + clear nextRunAt BEFORE async execution to prevent re-trigger
|
|
240
|
+
runningJobs.add(job.id);
|
|
241
|
+
job.nextRunAt = null;
|
|
242
|
+
saveJobs(jobs);
|
|
243
|
+
try {
|
|
244
|
+
const result = await executeJob(job);
|
|
245
|
+
// Re-load jobs in case they were modified during execution
|
|
246
|
+
const freshJobs = loadJobs();
|
|
247
|
+
const freshJob = freshJobs.find(j => j.id === job.id);
|
|
248
|
+
if (freshJob) {
|
|
249
|
+
freshJob.lastRunAt = now;
|
|
250
|
+
freshJob.lastResult = result.output.slice(0, 500);
|
|
251
|
+
freshJob.lastError = result.error || null;
|
|
252
|
+
freshJob.runCount++;
|
|
253
|
+
if (freshJob.oneShot) {
|
|
254
|
+
freshJob.enabled = false;
|
|
255
|
+
freshJob.nextRunAt = null;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
freshJob.nextRunAt = calculateNextRun(freshJob);
|
|
259
|
+
}
|
|
260
|
+
saveJobs(freshJobs);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
runningJobs.delete(job.id);
|
|
265
|
+
}
|
|
266
|
+
continue; // Skip the outer changed/save since we save inside
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (changed)
|
|
270
|
+
saveJobs(jobs);
|
|
271
|
+
}, 30_000);
|
|
272
|
+
console.log("⏰ Cron scheduler started (30s interval)");
|
|
273
|
+
}
|
|
274
|
+
export function stopScheduler() {
|
|
275
|
+
if (schedulerTimer) {
|
|
276
|
+
clearInterval(schedulerTimer);
|
|
277
|
+
schedulerTimer = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// ── Public CRUD API ─────────────────────────────────────
|
|
281
|
+
function generateId() {
|
|
282
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
283
|
+
}
|
|
284
|
+
export function createJob(input) {
|
|
285
|
+
const job = {
|
|
286
|
+
id: generateId(),
|
|
287
|
+
name: input.name,
|
|
288
|
+
type: input.type,
|
|
289
|
+
schedule: input.schedule,
|
|
290
|
+
oneShot: input.oneShot ?? false,
|
|
291
|
+
payload: input.payload,
|
|
292
|
+
target: input.target,
|
|
293
|
+
enabled: input.enabled ?? true,
|
|
294
|
+
createdAt: Date.now(),
|
|
295
|
+
lastRunAt: null,
|
|
296
|
+
lastResult: null,
|
|
297
|
+
lastError: null,
|
|
298
|
+
nextRunAt: null,
|
|
299
|
+
runCount: 0,
|
|
300
|
+
createdBy: input.createdBy || "unknown",
|
|
301
|
+
};
|
|
302
|
+
// Calculate first run
|
|
303
|
+
job.nextRunAt = calculateNextRun(job);
|
|
304
|
+
const jobs = loadJobs();
|
|
305
|
+
jobs.push(job);
|
|
306
|
+
saveJobs(jobs);
|
|
307
|
+
return job;
|
|
308
|
+
}
|
|
309
|
+
export function listJobs() {
|
|
310
|
+
return loadJobs();
|
|
311
|
+
}
|
|
312
|
+
export function getJob(id) {
|
|
313
|
+
return loadJobs().find(j => j.id === id);
|
|
314
|
+
}
|
|
315
|
+
export function updateJob(id, updates) {
|
|
316
|
+
const jobs = loadJobs();
|
|
317
|
+
const idx = jobs.findIndex(j => j.id === id);
|
|
318
|
+
if (idx < 0)
|
|
319
|
+
return null;
|
|
320
|
+
Object.assign(jobs[idx], updates);
|
|
321
|
+
if (updates.schedule || updates.enabled !== undefined) {
|
|
322
|
+
jobs[idx].nextRunAt = calculateNextRun(jobs[idx]);
|
|
323
|
+
}
|
|
324
|
+
saveJobs(jobs);
|
|
325
|
+
return jobs[idx];
|
|
326
|
+
}
|
|
327
|
+
export function deleteJob(id) {
|
|
328
|
+
const jobs = loadJobs();
|
|
329
|
+
const filtered = jobs.filter(j => j.id !== id);
|
|
330
|
+
if (filtered.length === jobs.length)
|
|
331
|
+
return false;
|
|
332
|
+
saveJobs(filtered);
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
export function toggleJob(id) {
|
|
336
|
+
const jobs = loadJobs();
|
|
337
|
+
const job = jobs.find(j => j.id === id);
|
|
338
|
+
if (!job)
|
|
339
|
+
return null;
|
|
340
|
+
job.enabled = !job.enabled;
|
|
341
|
+
job.nextRunAt = calculateNextRun(job);
|
|
342
|
+
saveJobs(jobs);
|
|
343
|
+
return job;
|
|
344
|
+
}
|
|
345
|
+
export function runJobNow(id) {
|
|
346
|
+
const job = getJob(id);
|
|
347
|
+
if (!job)
|
|
348
|
+
return null;
|
|
349
|
+
return executeJob(job);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Convert a cron expression or interval string to a human-readable German description.
|
|
353
|
+
*/
|
|
354
|
+
export function humanReadableSchedule(schedule) {
|
|
355
|
+
// Interval strings (5m, 1h, 30s, 2d)
|
|
356
|
+
const intervalMatch = schedule.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)s?$/i);
|
|
357
|
+
if (intervalMatch) {
|
|
358
|
+
const value = parseFloat(intervalMatch[1]);
|
|
359
|
+
const unit = intervalMatch[2].toLowerCase();
|
|
360
|
+
const labels = {
|
|
361
|
+
s: ["second", "seconds"], sec: ["second", "seconds"],
|
|
362
|
+
m: ["minute", "minutes"], min: ["minute", "minutes"],
|
|
363
|
+
h: ["hour", "hours"], hr: ["hour", "hours"],
|
|
364
|
+
d: ["day", "days"], day: ["day", "days"],
|
|
365
|
+
};
|
|
366
|
+
const [sing, plur] = labels[unit] || ["?", "?"];
|
|
367
|
+
return `Every ${value} ${value === 1 ? sing : plur}`;
|
|
368
|
+
}
|
|
369
|
+
// Cron expression: MIN HOUR DAY MONTH WEEKDAY
|
|
370
|
+
const parts = schedule.trim().split(/\s+/);
|
|
371
|
+
if (parts.length !== 5)
|
|
372
|
+
return schedule;
|
|
373
|
+
const [minExpr, hourExpr, dayExpr, monthExpr, weekdayExpr] = parts;
|
|
374
|
+
const weekdayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
375
|
+
const monthNames = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
376
|
+
// Helper: format time from hour + minute expressions
|
|
377
|
+
function formatTime(h, m) {
|
|
378
|
+
if (h === "*" && m === "*")
|
|
379
|
+
return "";
|
|
380
|
+
const hh = h === "*" ? "*" : h.padStart(2, "0");
|
|
381
|
+
const mm = m === "*" ? "00" : m.padStart(2, "0");
|
|
382
|
+
return `${hh}:${mm}`;
|
|
383
|
+
}
|
|
384
|
+
// Helper: expand comma/range fields to readable list
|
|
385
|
+
function expandField(expr, names) {
|
|
386
|
+
if (expr === "*")
|
|
387
|
+
return "";
|
|
388
|
+
const vals = expr.split(",").map(v => {
|
|
389
|
+
if (v.includes("-")) {
|
|
390
|
+
const [a, b] = v.split("-");
|
|
391
|
+
if (names)
|
|
392
|
+
return `${names[+a]}–${names[+b]}`;
|
|
393
|
+
return `${a}–${b}`;
|
|
394
|
+
}
|
|
395
|
+
return names ? (names[+v] || v) : v;
|
|
396
|
+
});
|
|
397
|
+
return vals.join(", ");
|
|
398
|
+
}
|
|
399
|
+
const time = formatTime(hourExpr, minExpr);
|
|
400
|
+
const hasStep = [minExpr, hourExpr].some(e => e.includes("/"));
|
|
401
|
+
// Every X minutes/hours
|
|
402
|
+
if (minExpr.includes("/") && hourExpr === "*" && dayExpr === "*" && monthExpr === "*" && weekdayExpr === "*") {
|
|
403
|
+
const step = minExpr.split("/")[1];
|
|
404
|
+
return `Every ${step} min`;
|
|
405
|
+
}
|
|
406
|
+
if (hourExpr.includes("/") && dayExpr === "*" && monthExpr === "*" && weekdayExpr === "*") {
|
|
407
|
+
const step = hourExpr.split("/")[1];
|
|
408
|
+
return `Every ${step}h`;
|
|
409
|
+
}
|
|
410
|
+
// Build description
|
|
411
|
+
const descParts = [];
|
|
412
|
+
// Weekday specific
|
|
413
|
+
if (weekdayExpr !== "*") {
|
|
414
|
+
const days = expandField(weekdayExpr, weekdayNames);
|
|
415
|
+
if (weekdayExpr === "1-5")
|
|
416
|
+
descParts.push("Weekdays");
|
|
417
|
+
else if (weekdayExpr === "0,6" || weekdayExpr === "6,0")
|
|
418
|
+
descParts.push("Weekends");
|
|
419
|
+
else
|
|
420
|
+
descParts.push(`Every ${days}`);
|
|
421
|
+
}
|
|
422
|
+
// Day of month specific
|
|
423
|
+
else if (dayExpr !== "*") {
|
|
424
|
+
const dayList = expandField(dayExpr);
|
|
425
|
+
if (monthExpr !== "*") {
|
|
426
|
+
const monthList = expandField(monthExpr, monthNames);
|
|
427
|
+
descParts.push(`On the ${dayList}. of ${monthList}`);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
descParts.push(`On the ${dayList}. of every month`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Month specific only
|
|
434
|
+
else if (monthExpr !== "*") {
|
|
435
|
+
const monthList = expandField(monthExpr, monthNames);
|
|
436
|
+
descParts.push(`In ${monthList}`);
|
|
437
|
+
}
|
|
438
|
+
// Daily (all wildcards except time)
|
|
439
|
+
else if (!hasStep) {
|
|
440
|
+
descParts.push("Daily");
|
|
441
|
+
}
|
|
442
|
+
if (time && !hasStep)
|
|
443
|
+
descParts.push(time);
|
|
444
|
+
return descParts.join(", ") || schedule;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Format next run time as human-readable.
|
|
448
|
+
*/
|
|
449
|
+
export function formatNextRun(nextRunAt) {
|
|
450
|
+
if (!nextRunAt)
|
|
451
|
+
return "—";
|
|
452
|
+
const diff = nextRunAt - Date.now();
|
|
453
|
+
if (diff < 0)
|
|
454
|
+
return "overdue";
|
|
455
|
+
if (diff < 60_000)
|
|
456
|
+
return `in ${Math.round(diff / 1000)}s`;
|
|
457
|
+
if (diff < 3_600_000)
|
|
458
|
+
return `in ${Math.round(diff / 60_000)} Min`;
|
|
459
|
+
if (diff < 86_400_000)
|
|
460
|
+
return `in ${(diff / 3_600_000).toFixed(1)}h`;
|
|
461
|
+
return `in ${(diff / 86_400_000).toFixed(1)} days`;
|
|
462
|
+
}
|