mirror of
https://github.com/blamouche/obsidian-any-ai-code.git
synced 2026-07-22 06:53:38 +00:00
A new "Automations" affordance reads markdown files from a configurable vault folder, parses a frontmatter schedule (`interval` minutes or 5-field `cron`) plus optional `enabled`/`runtime`/`appendNewline`/`name`, and fires the file body into the running CLI on schedule or manually from a modal. Each fire (or skip / error) is logged to a ring-buffer history capped at 200 entries; the modal exposes the entries with status, last/next run, a per-row Run now button, and a History tab with Clear / Export-as-markdown. The scheduler runs at the plugin level (`activeWindow.setInterval`, 30 s tick) and stays alive whether the panel is open or not. The folder cache live-updates on vault create/modify/delete/rename. Runs skip cleanly when no CLI is running — the plugin never auto-starts a runtime. Implementation: pure logic in automation.ts (28 vitest cases), wiring in main.ts, AutomationsModal in automations-modal.ts. Adds `cron-parser` (runtime) and `yaml` (dev). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
267 lines
6.6 KiB
TypeScript
267 lines
6.6 KiB
TypeScript
import { CronExpressionParser } from "cron-parser";
|
|
|
|
export interface AutomationFrontmatter {
|
|
name?: string;
|
|
enabled?: boolean;
|
|
interval?: number;
|
|
cron?: string;
|
|
runtime?: string;
|
|
appendNewline?: boolean;
|
|
}
|
|
|
|
export interface ParsedAutomation {
|
|
path: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
interval: number | null;
|
|
cron: string | null;
|
|
runtime: string | null;
|
|
appendNewline: boolean;
|
|
body: string;
|
|
}
|
|
|
|
export interface AutomationParseError {
|
|
path: string;
|
|
name: string;
|
|
reason: string;
|
|
}
|
|
|
|
export type ParseResult =
|
|
| { ok: true; entry: ParsedAutomation }
|
|
| { ok: false; error: AutomationParseError };
|
|
|
|
export interface AutomationRunRecord {
|
|
ts: number;
|
|
path: string;
|
|
name: string;
|
|
source: "scheduler" | "manual";
|
|
status: "ran" | "skipped" | "error";
|
|
reason?: string;
|
|
runtimeId?: string | null;
|
|
promptPreview?: string;
|
|
}
|
|
|
|
const FRONTMATTER_RE = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n)?([\s\S]*)$/;
|
|
|
|
export interface FrontmatterParser {
|
|
(yaml: string): unknown;
|
|
}
|
|
|
|
export function splitFrontmatter(content: string): { yaml: string | null; body: string } {
|
|
const match = FRONTMATTER_RE.exec(content);
|
|
if (!match) {
|
|
return { yaml: null, body: content };
|
|
}
|
|
return { yaml: match[1], body: match[2] ?? "" };
|
|
}
|
|
|
|
export function basenameWithoutExt(filePath: string): string {
|
|
const lastSlash = filePath.lastIndexOf("/");
|
|
const file = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath;
|
|
const dot = file.lastIndexOf(".");
|
|
return dot > 0 ? file.slice(0, dot) : file;
|
|
}
|
|
|
|
export function parseAutomationFile(
|
|
content: string,
|
|
filePath: string,
|
|
parseYaml: FrontmatterParser
|
|
): ParseResult {
|
|
const fallbackName = basenameWithoutExt(filePath);
|
|
const { yaml, body } = splitFrontmatter(content);
|
|
|
|
if (yaml === null) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
path: filePath,
|
|
name: fallbackName,
|
|
reason: "Missing frontmatter block (expected `---` fenced YAML at top of file)."
|
|
}
|
|
};
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = parseYaml(yaml);
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
path: filePath,
|
|
name: fallbackName,
|
|
reason: `YAML parse error: ${(err as Error).message}`
|
|
}
|
|
};
|
|
}
|
|
|
|
if (!parsed || typeof parsed !== "object") {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
path: filePath,
|
|
name: fallbackName,
|
|
reason: "Frontmatter must be a YAML mapping."
|
|
}
|
|
};
|
|
}
|
|
|
|
const fm = parsed as AutomationFrontmatter;
|
|
const hasInterval = fm.interval !== undefined && fm.interval !== null;
|
|
const hasCron = typeof fm.cron === "string" && fm.cron.trim().length > 0;
|
|
|
|
if (hasInterval && hasCron) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
path: filePath,
|
|
name: typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : fallbackName,
|
|
reason: "`interval` and `cron` are mutually exclusive — set only one."
|
|
}
|
|
};
|
|
}
|
|
|
|
if (!hasInterval && !hasCron) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
path: filePath,
|
|
name: typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : fallbackName,
|
|
reason: "Missing schedule: set either `interval` (minutes) or `cron`."
|
|
}
|
|
};
|
|
}
|
|
|
|
let interval: number | null = null;
|
|
if (hasInterval) {
|
|
const raw = fm.interval;
|
|
if (typeof raw !== "number" || !Number.isFinite(raw) || !Number.isInteger(raw) || raw < 1) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
path: filePath,
|
|
name: typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : fallbackName,
|
|
reason: "`interval` must be an integer >= 1 (minutes)."
|
|
}
|
|
};
|
|
}
|
|
interval = raw;
|
|
}
|
|
|
|
let cron: string | null = null;
|
|
if (hasCron) {
|
|
const expr = (fm.cron as string).trim();
|
|
try {
|
|
CronExpressionParser.parse(expr);
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
path: filePath,
|
|
name: typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : fallbackName,
|
|
reason: `Invalid cron expression: ${(err as Error).message}`
|
|
}
|
|
};
|
|
}
|
|
cron = expr;
|
|
}
|
|
|
|
const trimmedBody = body.trim();
|
|
if (!trimmedBody) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
path: filePath,
|
|
name: typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : fallbackName,
|
|
reason: "Prompt body is empty (write the prompt below the frontmatter)."
|
|
}
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
entry: {
|
|
path: filePath,
|
|
name: typeof fm.name === "string" && fm.name.trim() ? fm.name.trim() : fallbackName,
|
|
enabled: typeof fm.enabled === "boolean" ? fm.enabled : true,
|
|
interval,
|
|
cron,
|
|
runtime: typeof fm.runtime === "string" && fm.runtime.trim() ? fm.runtime.trim() : null,
|
|
appendNewline: typeof fm.appendNewline === "boolean" ? fm.appendNewline : true,
|
|
body: trimmedBody
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the epoch ms of the next scheduled run, or null if the entry has no
|
|
* usable schedule. Cron schedules are advanced from `lastRun` (or `now` if never
|
|
* run); interval schedules add `interval * 60_000` to `lastRun`, or fire immediately
|
|
* when never run.
|
|
*/
|
|
export function computeNextRun(
|
|
entry: ParsedAutomation,
|
|
lastRun: number | null,
|
|
now: number
|
|
): number | null {
|
|
if (entry.interval !== null) {
|
|
if (lastRun === null) {
|
|
return now;
|
|
}
|
|
return lastRun + entry.interval * 60_000;
|
|
}
|
|
if (entry.cron !== null) {
|
|
try {
|
|
const reference = lastRun ?? now - 1;
|
|
const it = CronExpressionParser.parse(entry.cron, {
|
|
currentDate: new Date(reference)
|
|
});
|
|
return it.next().getTime();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function isDue(
|
|
entry: ParsedAutomation,
|
|
lastRun: number | null,
|
|
now: number
|
|
): boolean {
|
|
if (!entry.enabled) {
|
|
return false;
|
|
}
|
|
const next = computeNextRun(entry, lastRun, now);
|
|
return next !== null && next <= now;
|
|
}
|
|
|
|
export function describeSchedule(entry: ParsedAutomation): string {
|
|
if (entry.interval !== null) {
|
|
return entry.interval === 1 ? "every 1 min" : `every ${entry.interval} min`;
|
|
}
|
|
if (entry.cron !== null) {
|
|
return `cron: ${entry.cron}`;
|
|
}
|
|
return "no schedule";
|
|
}
|
|
|
|
export function pushHistory(
|
|
history: AutomationRunRecord[],
|
|
record: AutomationRunRecord,
|
|
limit: number
|
|
): AutomationRunRecord[] {
|
|
const next = [record, ...history];
|
|
if (next.length > limit) {
|
|
next.length = limit;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
export function buildPromptPreview(body: string, maxLen = 120): string {
|
|
const oneLine = body.replace(/\s+/g, " ").trim();
|
|
if (oneLine.length <= maxLen) {
|
|
return oneLine;
|
|
}
|
|
return `${oneLine.slice(0, maxLen - 1)}…`;
|
|
}
|