aktueller stand
This commit is contained in:
@@ -11,7 +11,8 @@
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"uuid": "^9.0.1"
|
||||
"uuid": "^9.0.1",
|
||||
"nodemailer": "^6.9.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
|
||||
@@ -30,6 +30,9 @@ const DAILY_BOOKMARK_TITLE_MAX_LENGTH = 160;
|
||||
const DAILY_BOOKMARK_URL_MAX_LENGTH = 800;
|
||||
const DAILY_BOOKMARK_NOTES_MAX_LENGTH = 800;
|
||||
const DAILY_BOOKMARK_MARKER_MAX_LENGTH = 120;
|
||||
const AUTOMATION_TYPE_REQUEST = 'request';
|
||||
const AUTOMATION_TYPE_EMAIL = 'email';
|
||||
const AUTOMATION_TYPE_FLOW = 'flow';
|
||||
const AUTOMATION_MAX_NAME_LENGTH = 160;
|
||||
const AUTOMATION_MAX_URL_LENGTH = 2000;
|
||||
const AUTOMATION_MAX_BODY_LENGTH = 12000;
|
||||
@@ -40,6 +43,9 @@ const AUTOMATION_DEFAULT_INTERVAL_MINUTES = 60;
|
||||
const AUTOMATION_MAX_JITTER_MINUTES = 120;
|
||||
const AUTOMATION_MAX_RESPONSE_PREVIEW = 4000;
|
||||
const AUTOMATION_WORKER_INTERVAL_MS = 30000;
|
||||
const AUTOMATION_MAX_STEPS = 3;
|
||||
const AUTOMATION_MAX_EMAIL_TO_LENGTH = 320;
|
||||
const AUTOMATION_MAX_EMAIL_SUBJECT_LENGTH = 320;
|
||||
const SPORTS_SCORING_DEFAULTS = {
|
||||
enabled: 1,
|
||||
threshold: 5,
|
||||
@@ -90,6 +96,54 @@ if (!fs.existsSync(screenshotDir)) {
|
||||
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
}
|
||||
|
||||
const automationConfigPath = path.join(__dirname, 'data', 'automation-config.json');
|
||||
const defaultAutomationConfig = {
|
||||
smtp: {
|
||||
host: '',
|
||||
port: 587,
|
||||
secure: false,
|
||||
user: '',
|
||||
pass: '',
|
||||
from: ''
|
||||
}
|
||||
};
|
||||
|
||||
function ensureAutomationConfigFile() {
|
||||
try {
|
||||
if (!fs.existsSync(automationConfigPath)) {
|
||||
const dir = path.dirname(automationConfigPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(automationConfigPath, JSON.stringify(defaultAutomationConfig, null, 2), 'utf8');
|
||||
console.log('Automation-Config angelegt unter', automationConfigPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Konnte Automation-Config nicht erstellen:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
ensureAutomationConfigFile();
|
||||
|
||||
function loadAutomationConfig() {
|
||||
try {
|
||||
const raw = fs.readFileSync(automationConfigPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? parsed : { ...defaultAutomationConfig };
|
||||
} catch (error) {
|
||||
return { ...defaultAutomationConfig };
|
||||
}
|
||||
}
|
||||
|
||||
const automationConfig = loadAutomationConfig();
|
||||
|
||||
let nodemailer = null;
|
||||
try {
|
||||
nodemailer = require('nodemailer');
|
||||
} catch (error) {
|
||||
nodemailer = null;
|
||||
}
|
||||
|
||||
// Middleware - Enhanced CORS for extension
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
@@ -1292,10 +1346,15 @@ db.exec(`
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'request',
|
||||
method TEXT NOT NULL DEFAULT 'GET',
|
||||
url_template TEXT NOT NULL,
|
||||
url_template TEXT,
|
||||
headers_json TEXT,
|
||||
body_template TEXT,
|
||||
email_to TEXT,
|
||||
email_subject_template TEXT,
|
||||
email_body_template TEXT,
|
||||
steps_json TEXT,
|
||||
interval_minutes INTEGER NOT NULL DEFAULT ${AUTOMATION_DEFAULT_INTERVAL_MINUTES},
|
||||
jitter_minutes INTEGER DEFAULT 0,
|
||||
start_at DATETIME,
|
||||
@@ -1311,6 +1370,12 @@ db.exec(`
|
||||
);
|
||||
`);
|
||||
|
||||
ensureColumn('automation_requests', 'type', 'type TEXT NOT NULL DEFAULT \'request\'');
|
||||
ensureColumn('automation_requests', 'email_to', 'email_to TEXT');
|
||||
ensureColumn('automation_requests', 'email_subject_template', 'email_subject_template TEXT');
|
||||
ensureColumn('automation_requests', 'email_body_template', 'email_body_template TEXT');
|
||||
ensureColumn('automation_requests', 'steps_json', 'steps_json TEXT');
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_automation_requests_next_run
|
||||
ON automation_requests(next_run_at);
|
||||
@@ -1342,10 +1407,15 @@ const listAutomationRequestsStmt = db.prepare(`
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
method,
|
||||
url_template,
|
||||
headers_json,
|
||||
body_template,
|
||||
email_to,
|
||||
email_subject_template,
|
||||
email_body_template,
|
||||
steps_json,
|
||||
interval_minutes,
|
||||
jitter_minutes,
|
||||
start_at,
|
||||
@@ -1356,6 +1426,11 @@ const listAutomationRequestsStmt = db.prepare(`
|
||||
last_status_code,
|
||||
last_error,
|
||||
next_run_at,
|
||||
(
|
||||
SELECT COUNT(1)
|
||||
FROM automation_request_runs r
|
||||
WHERE r.request_id = automation_requests.id
|
||||
) AS runs_count,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM automation_requests
|
||||
@@ -1367,10 +1442,15 @@ const getAutomationRequestStmt = db.prepare(`
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
method,
|
||||
url_template,
|
||||
headers_json,
|
||||
body_template,
|
||||
email_to,
|
||||
email_subject_template,
|
||||
email_body_template,
|
||||
steps_json,
|
||||
interval_minutes,
|
||||
jitter_minutes,
|
||||
start_at,
|
||||
@@ -1389,11 +1469,13 @@ const getAutomationRequestStmt = db.prepare(`
|
||||
|
||||
const insertAutomationRequestStmt = db.prepare(`
|
||||
INSERT INTO automation_requests (
|
||||
id, name, description, method, url_template, headers_json, body_template,
|
||||
id, name, description, type, method, url_template, headers_json, body_template,
|
||||
email_to, email_subject_template, email_body_template, steps_json,
|
||||
interval_minutes, jitter_minutes, start_at, run_until, active, last_run_at,
|
||||
last_status, last_status_code, last_error, next_run_at
|
||||
) VALUES (
|
||||
@id, @name, @description, @method, @url_template, @headers_json, @body_template,
|
||||
@id, @name, @description, @type, @method, @url_template, @headers_json, @body_template,
|
||||
@email_to, @email_subject_template, @email_body_template, @steps_json,
|
||||
@interval_minutes, @jitter_minutes, @start_at, @run_until, @active, @last_run_at,
|
||||
@last_status, @last_status_code, @last_error, @next_run_at
|
||||
)
|
||||
@@ -1403,10 +1485,15 @@ const updateAutomationRequestStmt = db.prepare(`
|
||||
UPDATE automation_requests
|
||||
SET name = @name,
|
||||
description = @description,
|
||||
type = @type,
|
||||
method = @method,
|
||||
url_template = @url_template,
|
||||
headers_json = @headers_json,
|
||||
body_template = @body_template,
|
||||
email_to = @email_to,
|
||||
email_subject_template = @email_subject_template,
|
||||
email_body_template = @email_body_template,
|
||||
steps_json = @steps_json,
|
||||
interval_minutes = @interval_minutes,
|
||||
jitter_minutes = @jitter_minutes,
|
||||
start_at = @start_at,
|
||||
@@ -1486,7 +1573,6 @@ ensureColumn('ai_credentials', 'usage_24h_count', 'usage_24h_count INTEGER DEFAU
|
||||
ensureColumn('ai_credentials', 'usage_24h_reset_at', 'usage_24h_reset_at DATETIME');
|
||||
ensureColumn('search_seen_posts', 'manually_hidden', 'manually_hidden INTEGER NOT NULL DEFAULT 0');
|
||||
ensureColumn('search_seen_posts', 'sports_auto_hidden', 'sports_auto_hidden INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_usage_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -1927,6 +2013,14 @@ function renderAutomationTemplate(template, context = {}) {
|
||||
return shifted.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const dayOffsetMatch = key.match(/^day([+-]\d+)?$/);
|
||||
if (dayOffsetMatch) {
|
||||
const offset = dayOffsetMatch[1] ? parseInt(dayOffsetMatch[1], 10) : 0;
|
||||
const shifted = new Date(baseDate);
|
||||
shifted.setDate(baseDate.getDate() + (Number.isNaN(offset) ? 0 : offset));
|
||||
return String(shifted.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'today':
|
||||
case 'date':
|
||||
@@ -2082,11 +2176,24 @@ function serializeAutomationRequest(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type || AUTOMATION_TYPE_REQUEST,
|
||||
description: row.description || '',
|
||||
method: row.method || 'GET',
|
||||
url_template: row.url_template,
|
||||
headers,
|
||||
body_template: row.body_template || '',
|
||||
email_to: row.email_to || '',
|
||||
email_subject_template: row.email_subject_template || '',
|
||||
email_body_template: row.email_body_template || '',
|
||||
steps: row.steps_json ? (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(row.steps_json);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
})() : [],
|
||||
runs_count: row.runs_count || 0,
|
||||
interval_minutes: clampAutomationIntervalMinutes(row.interval_minutes),
|
||||
jitter_minutes: clampAutomationJitterMinutes(row.jitter_minutes || 0),
|
||||
start_at: row.start_at ? ensureIsoDate(row.start_at) : null,
|
||||
@@ -2124,6 +2231,14 @@ function normalizeAutomationPayload(payload, existing = {}) {
|
||||
const errors = [];
|
||||
const normalized = {};
|
||||
|
||||
const rawType = typeof payload.type === 'string'
|
||||
? payload.type.trim().toLowerCase()
|
||||
: (existing.type || AUTOMATION_TYPE_REQUEST);
|
||||
const type = [AUTOMATION_TYPE_REQUEST, AUTOMATION_TYPE_EMAIL, AUTOMATION_TYPE_FLOW].includes(rawType)
|
||||
? rawType
|
||||
: AUTOMATION_TYPE_REQUEST;
|
||||
normalized.type = type;
|
||||
|
||||
const nameSource = typeof payload.name === 'string'
|
||||
? payload.name
|
||||
: existing.name || '';
|
||||
@@ -2152,11 +2267,17 @@ function normalizeAutomationPayload(payload, existing = {}) {
|
||||
const rawUrl = typeof payload.url_template === 'string'
|
||||
? payload.url_template
|
||||
: (typeof payload.url === 'string' ? payload.url : existing.url_template || '');
|
||||
const urlTemplate = rawUrl.trim();
|
||||
if (!urlTemplate) {
|
||||
const urlTemplate = (rawUrl || '').trim();
|
||||
|
||||
if (type === AUTOMATION_TYPE_REQUEST || type === AUTOMATION_TYPE_FLOW) {
|
||||
if (!urlTemplate && type === AUTOMATION_TYPE_REQUEST) {
|
||||
errors.push('URL-Template fehlt');
|
||||
}
|
||||
normalized.url_template = urlTemplate
|
||||
? truncateString(urlTemplate, AUTOMATION_MAX_URL_LENGTH)
|
||||
: null;
|
||||
} else {
|
||||
normalized.url_template = truncateString(urlTemplate, AUTOMATION_MAX_URL_LENGTH);
|
||||
normalized.url_template = null;
|
||||
}
|
||||
|
||||
const headersInput = payload.headers
|
||||
@@ -2184,6 +2305,82 @@ function normalizeAutomationPayload(payload, existing = {}) {
|
||||
: '';
|
||||
}
|
||||
|
||||
if (type === AUTOMATION_TYPE_EMAIL) {
|
||||
const toValue = (payload.email_to || payload.to || existing.email_to || '').trim();
|
||||
if (!toValue) {
|
||||
errors.push('E-Mail Empfänger fehlt');
|
||||
} else {
|
||||
normalized.email_to = truncateString(toValue, AUTOMATION_MAX_EMAIL_TO_LENGTH);
|
||||
}
|
||||
const subjectValue = (payload.email_subject_template || payload.subject || existing.email_subject_template || '').trim();
|
||||
if (!subjectValue) {
|
||||
errors.push('E-Mail Betreff fehlt');
|
||||
} else {
|
||||
normalized.email_subject_template = truncateString(subjectValue, AUTOMATION_MAX_EMAIL_SUBJECT_LENGTH);
|
||||
}
|
||||
const bodyValue = typeof payload.email_body_template === 'string'
|
||||
? payload.email_body_template
|
||||
: (typeof payload.body_template === 'string' ? payload.body_template : existing.email_body_template || '');
|
||||
if (!bodyValue || !String(bodyValue).trim()) {
|
||||
errors.push('E-Mail Body fehlt');
|
||||
} else {
|
||||
normalized.email_body_template = truncateString(String(bodyValue), AUTOMATION_MAX_BODY_LENGTH);
|
||||
}
|
||||
normalized.steps_json = null;
|
||||
normalized.url_template = '';
|
||||
normalized.headers_json = null;
|
||||
normalized.body_template = null;
|
||||
} else if (type === AUTOMATION_TYPE_FLOW) {
|
||||
const stepsInput = Array.isArray(payload.steps)
|
||||
? payload.steps
|
||||
: (typeof payload.steps_json === 'string' ? (() => {
|
||||
try { return JSON.parse(payload.steps_json); } catch (err) { return []; }
|
||||
})() : []);
|
||||
|
||||
const steps = [];
|
||||
for (const rawStep of stepsInput) {
|
||||
if (!rawStep || typeof rawStep !== 'object') continue;
|
||||
const stepUrl = typeof rawStep.url === 'string' ? rawStep.url.trim() : '';
|
||||
if (!stepUrl) continue;
|
||||
const stepMethod = typeof rawStep.method === 'string'
|
||||
? rawStep.method.toUpperCase()
|
||||
: 'GET';
|
||||
const allowed = allowedMethods.includes(stepMethod) ? stepMethod : 'GET';
|
||||
const stepHeaders = normalizeAutomationHeaders(rawStep.headers || rawStep.headers_json || {});
|
||||
const stepHeadersSerialized = serializeAutomationHeaders(stepHeaders);
|
||||
steps.push({
|
||||
method: allowed,
|
||||
url: truncateString(stepUrl, AUTOMATION_MAX_URL_LENGTH),
|
||||
headers: stepHeadersSerialized ? JSON.parse(stepHeadersSerialized) : {},
|
||||
body: typeof rawStep.body === 'string'
|
||||
? truncateString(rawStep.body, AUTOMATION_MAX_BODY_LENGTH)
|
||||
: ''
|
||||
});
|
||||
if (steps.length >= AUTOMATION_MAX_STEPS) break;
|
||||
}
|
||||
|
||||
if (!steps.length) {
|
||||
errors.push('Mindestens ein Schritt mit URL ist erforderlich');
|
||||
} else {
|
||||
try {
|
||||
const serializedSteps = JSON.stringify(steps);
|
||||
normalized.steps_json = serializedSteps;
|
||||
} catch (error) {
|
||||
errors.push('Schritte konnten nicht gespeichert werden');
|
||||
}
|
||||
}
|
||||
|
||||
normalized.email_to = null;
|
||||
normalized.email_subject_template = null;
|
||||
normalized.email_body_template = null;
|
||||
normalized.url_template = normalized.url_template || '';
|
||||
} else {
|
||||
normalized.email_to = null;
|
||||
normalized.email_subject_template = null;
|
||||
normalized.email_body_template = null;
|
||||
normalized.steps_json = null;
|
||||
}
|
||||
|
||||
const scheduleType = typeof payload.schedule_type === 'string'
|
||||
? payload.schedule_type
|
||||
: payload.interval_type;
|
||||
@@ -3173,6 +3370,14 @@ function broadcastPostDeletion(postId, options = {}) {
|
||||
broadcastSseEvent(payload);
|
||||
}
|
||||
|
||||
function broadcastAutomationEvent(eventType, payload = {}) {
|
||||
if (!eventType) {
|
||||
return;
|
||||
}
|
||||
const data = { type: eventType, ...payload };
|
||||
broadcastSseEvent(data);
|
||||
}
|
||||
|
||||
const automationRunningRequests = new Set();
|
||||
let automationWorkerTimer = null;
|
||||
let automationWorkerBusy = false;
|
||||
@@ -3196,19 +3401,26 @@ async function executeAutomationRequest(request, options = {}) {
|
||||
automationRunningRequests.add(current.id);
|
||||
|
||||
const context = buildAutomationTemplateContext(new Date());
|
||||
const method = (current.method || 'GET').toUpperCase();
|
||||
const url = renderAutomationTemplate(current.url_template, context);
|
||||
const headers = renderAutomationHeaders(current.headers_json, context);
|
||||
const shouldSendBody = !['GET', 'HEAD'].includes(method);
|
||||
const body = shouldSendBody && current.body_template
|
||||
? renderAutomationTemplate(current.body_template, context)
|
||||
: null;
|
||||
|
||||
const startedAt = new Date();
|
||||
let status = 'success';
|
||||
let statusCode = null;
|
||||
let responseText = '';
|
||||
let errorMessage = null;
|
||||
const stepResults = [];
|
||||
|
||||
async function executeHttpStep(step, stepIndex, inheritedContext) {
|
||||
const method = (step.method || 'GET').toUpperCase();
|
||||
const url = renderAutomationTemplate(step.url_template || step.url, inheritedContext);
|
||||
const headers = renderAutomationHeaders(step.headers_json || serializeAutomationHeaders(step.headers || {}), inheritedContext);
|
||||
const shouldSendBody = !['GET', 'HEAD'].includes(method);
|
||||
const body = shouldSendBody && (step.body_template || step.body)
|
||||
? renderAutomationTemplate(step.body_template || step.body, inheritedContext)
|
||||
: null;
|
||||
|
||||
let localStatus = 'success';
|
||||
let localStatusCode = null;
|
||||
let localResponseText = '';
|
||||
let localError = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
@@ -3217,15 +3429,159 @@ async function executeAutomationRequest(request, options = {}) {
|
||||
body: shouldSendBody && body !== null ? body : undefined,
|
||||
redirect: 'follow'
|
||||
});
|
||||
statusCode = response.status;
|
||||
localStatusCode = response.status;
|
||||
try {
|
||||
responseText = await response.text();
|
||||
localResponseText = await response.text();
|
||||
} catch (error) {
|
||||
responseText = '';
|
||||
localResponseText = '';
|
||||
}
|
||||
if (!response.ok) {
|
||||
localStatus = 'error';
|
||||
localError = `HTTP ${response.status}`;
|
||||
}
|
||||
} catch (error) {
|
||||
localStatus = 'error';
|
||||
localError = error.message || 'Unbekannter Fehler';
|
||||
}
|
||||
|
||||
let parsedJson = null;
|
||||
if (localResponseText) {
|
||||
try {
|
||||
parsedJson = JSON.parse(localResponseText);
|
||||
} catch (error) {
|
||||
parsedJson = null;
|
||||
}
|
||||
}
|
||||
|
||||
const resultContext = {
|
||||
...inheritedContext,
|
||||
[`step${stepIndex}_text`]: localResponseText,
|
||||
[`step${stepIndex}_status`]: localStatus,
|
||||
[`step${stepIndex}_status_code`]: localStatusCode,
|
||||
[`step${stepIndex}_json`]: parsedJson || null
|
||||
};
|
||||
|
||||
return {
|
||||
status: localStatus,
|
||||
statusCode: localStatusCode,
|
||||
responseText: localResponseText,
|
||||
error: localError,
|
||||
context: resultContext
|
||||
};
|
||||
}
|
||||
|
||||
async function executeEmailStep(stepContext) {
|
||||
if (!nodemailer) {
|
||||
throw new Error('E-Mail Versand nicht verfügbar (nodemailer fehlt)');
|
||||
}
|
||||
const {
|
||||
SMTP_HOST,
|
||||
SMTP_PORT,
|
||||
SMTP_USER,
|
||||
SMTP_PASS,
|
||||
SMTP_SECURE,
|
||||
SMTP_FROM
|
||||
} = process.env;
|
||||
|
||||
const configSmtp = (automationConfig && automationConfig.smtp) || {};
|
||||
|
||||
const host = SMTP_HOST || configSmtp.host;
|
||||
if (!host) {
|
||||
throw new Error('SMTP_HOST nicht gesetzt und keine Konfiguration in automation-config.json');
|
||||
}
|
||||
|
||||
const port = SMTP_PORT ? Number(SMTP_PORT) : (configSmtp.port || 587);
|
||||
const secure = SMTP_SECURE === 'true' || SMTP_SECURE === '1'
|
||||
? true
|
||||
: (typeof configSmtp.secure === 'boolean' ? configSmtp.secure : port === 465);
|
||||
const user = SMTP_USER || configSmtp.user || '';
|
||||
const pass = SMTP_PASS || configSmtp.pass || '';
|
||||
const from = SMTP_FROM || configSmtp.from || user || 'automation@example.com';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
auth: user ? { user, pass } : undefined
|
||||
});
|
||||
|
||||
const to = renderAutomationTemplate(current.email_to || '', stepContext);
|
||||
const subject = renderAutomationTemplate(current.email_subject_template || '', stepContext);
|
||||
const body = renderAutomationTemplate(current.email_body_template || '', stepContext);
|
||||
const bodyHtml = body ? body.replace(/\n/g, '<br>') : '';
|
||||
|
||||
if (!to || !subject || !body) {
|
||||
throw new Error('E-Mail Felder unvollständig');
|
||||
}
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
text: body,
|
||||
html: bodyHtml || body
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
statusCode: info && info.accepted ? 200 : null,
|
||||
responseText: info ? JSON.stringify({ messageId: info.messageId }) : '',
|
||||
error: null,
|
||||
context: { ...stepContext }
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (current.type === AUTOMATION_TYPE_EMAIL) {
|
||||
const emailResult = await executeEmailStep(context);
|
||||
status = emailResult.status;
|
||||
statusCode = emailResult.statusCode;
|
||||
responseText = emailResult.responseText;
|
||||
errorMessage = emailResult.error;
|
||||
} else if (current.type === AUTOMATION_TYPE_FLOW) {
|
||||
let flowContext = { ...context };
|
||||
const steps = current.steps_json ? (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(current.steps_json);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
})() : [];
|
||||
let stepIndex = 1;
|
||||
for (const step of steps) {
|
||||
const stepResult = await executeHttpStep({
|
||||
method: step.method || step.http_method || 'GET',
|
||||
url_template: step.url_template || step.url,
|
||||
headers_json: serializeAutomationHeaders(step.headers || {}),
|
||||
body_template: step.body_template || step.body
|
||||
}, stepIndex, flowContext);
|
||||
stepResults.push(stepResult);
|
||||
flowContext = { ...flowContext, ...stepResult.context };
|
||||
if (stepResult.status === 'error') {
|
||||
status = 'error';
|
||||
errorMessage = `HTTP ${response.status}`;
|
||||
errorMessage = stepResult.error;
|
||||
statusCode = stepResult.statusCode;
|
||||
responseText = stepResult.responseText;
|
||||
break;
|
||||
} else {
|
||||
statusCode = stepResult.statusCode;
|
||||
responseText = stepResult.responseText;
|
||||
}
|
||||
stepIndex += 1;
|
||||
}
|
||||
} else {
|
||||
const result = await executeHttpStep({
|
||||
method: current.method,
|
||||
url_template: current.url_template,
|
||||
headers_json: current.headers_json,
|
||||
body_template: current.body_template
|
||||
}, 1, context);
|
||||
status = result.status;
|
||||
statusCode = result.statusCode;
|
||||
responseText = result.responseText;
|
||||
errorMessage = result.error;
|
||||
stepResults.push(result);
|
||||
}
|
||||
} catch (error) {
|
||||
status = 'error';
|
||||
@@ -3259,6 +3615,15 @@ async function executeAutomationRequest(request, options = {}) {
|
||||
)
|
||||
: null;
|
||||
|
||||
broadcastAutomationEvent('automation-run', {
|
||||
request_id: current.id,
|
||||
status,
|
||||
status_code: statusCode,
|
||||
next_run_at: nextRunAt,
|
||||
last_run_at: runRecord.started_at,
|
||||
runs_count: (current.runs_count || 0) + 1
|
||||
});
|
||||
|
||||
try {
|
||||
updateAutomationRequestStmt.run({
|
||||
...current,
|
||||
|
||||
@@ -17,6 +17,7 @@ COPY dashboard.js /usr/share/nginx/html/
|
||||
COPY settings.js /usr/share/nginx/html/
|
||||
COPY daily-bookmarks.js /usr/share/nginx/html/
|
||||
COPY automation.js /usr/share/nginx/html/
|
||||
COPY vendor /usr/share/nginx/html/vendor/
|
||||
COPY assets /usr/share/nginx/html/assets/
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -28,7 +28,7 @@ body {
|
||||
}
|
||||
|
||||
.auto-shell {
|
||||
max-width: 1300px;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -177,6 +177,9 @@ body {
|
||||
.field.inline {
|
||||
min-width: 0;
|
||||
}
|
||||
.field[data-section] {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
@@ -257,6 +260,7 @@ small {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auto-table th {
|
||||
@@ -264,6 +268,48 @@ small {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auto-table th[data-sort-column="runs"],
|
||||
.auto-table td.runs-count {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.auto-table .sort-indicator {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 5px solid transparent;
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
transform: rotate(45deg);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.auto-table th.sort-asc .sort-indicator {
|
||||
border-top: 6px solid var(--accent-2);
|
||||
transform: rotate(225deg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.auto-table th.sort-desc .sort-indicator {
|
||||
border-top: 6px solid var(--accent-2);
|
||||
transform: rotate(45deg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.table-filter-row input,
|
||||
.table-filter-row select {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
background: #0c1427;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auto-table tr.is-selected {
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
}
|
||||
@@ -307,7 +353,34 @@ small {
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.hidden-value {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
opacity: 0.95;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.icon-btn.danger {
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
.primary-btn,
|
||||
@@ -355,6 +428,15 @@ small {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
background: #0c1427;
|
||||
color: var(--text);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.runs-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -551,6 +633,32 @@ small {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.placeholder-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.placeholder-table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.placeholder-table td.placeholder-key {
|
||||
width: 30%;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.placeholder-hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.auto-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -18,15 +18,8 @@
|
||||
<a class="back-link" href="index.html">← Zurück zur App</a>
|
||||
<p class="eyebrow">Automatisierte Requests</p>
|
||||
<h1>Request Automationen</h1>
|
||||
<p class="subline">Plane HTTP-Requests mit Zeitplan, Varianz und Variablen-Templates.</p>
|
||||
<div class="hero-pills">
|
||||
<span class="pill">Jitter & Intervalle</span>
|
||||
<span class="pill">Import aus cURL / fetch</span>
|
||||
<span class="pill">Platzhalter {{date}} {{uuid}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button class="ghost-btn" id="runSelectedBtn" type="button">▶ Ausgewählte jetzt ausführen</button>
|
||||
<button class="ghost-btn" id="openImportBtn" type="button">📥 Vorlage importieren</button>
|
||||
<button class="primary-btn" id="newAutomationBtn" type="button">+ Neue Automation</button>
|
||||
</div>
|
||||
@@ -42,22 +35,38 @@
|
||||
<h2>Automationen</h2>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<input id="tableFilterInput" class="filter-input" type="search" placeholder="Filtern nach Name/Typ…" />
|
||||
<button class="ghost-btn" id="refreshBtn" type="button">Aktualisieren</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="listStatus" class="list-status" aria-live="polite"></div>
|
||||
<div class="table-wrap">
|
||||
<div class="table-wrap" id="automationTable">
|
||||
<table class="auto-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Nächster Lauf</th>
|
||||
<th>Letzter Lauf</th>
|
||||
<th>Status</th>
|
||||
<th data-sort-column="name">Name<span class="sort-indicator"></span></th>
|
||||
<th data-sort-column="next">Nächster Lauf<span class="sort-indicator"></span></th>
|
||||
<th data-sort-column="last">Letzter Lauf<span class="sort-indicator"></span></th>
|
||||
<th data-sort-column="status">Status<span class="sort-indicator"></span></th>
|
||||
<th data-sort-column="runs">#Läufe<span class="sort-indicator"></span></th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
<tr class="table-filter-row">
|
||||
<th><input id="filterName" type="search" placeholder="Name/Typ/E-Mail/URL"></th>
|
||||
<th><input id="filterNext" type="search" placeholder="z.B. heute"></th>
|
||||
<th><input id="filterLast" type="search" placeholder="z.B. HTTP 200"></th>
|
||||
<th>
|
||||
<select id="filterStatus">
|
||||
<option value="">Alle</option>
|
||||
<option value="success">OK</option>
|
||||
<option value="error">Fehler</option>
|
||||
</select>
|
||||
</th>
|
||||
<th><input id="filterRuns" type="number" min="0" placeholder="≥"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="requestTableBody"></tbody>
|
||||
<tbody id="requestTableBody" class="list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
@@ -87,6 +96,14 @@
|
||||
<button class="ghost-btn" id="modalCloseBtn" type="button">×</button>
|
||||
</div>
|
||||
<form id="automationForm" class="form-grid" novalidate>
|
||||
<div class="field">
|
||||
<label for="typeSelect">Typ</label>
|
||||
<select id="typeSelect">
|
||||
<option value="request">HTTP Request</option>
|
||||
<option value="email">E-Mail</option>
|
||||
<option value="flow">Flow (bis 3 Schritte)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nameInput">Name *</label>
|
||||
<input id="nameInput" type="text" placeholder="API Ping (stündlich)" required maxlength="160">
|
||||
@@ -95,11 +112,11 @@
|
||||
<label for="descriptionInput">Notizen</label>
|
||||
<textarea id="descriptionInput" rows="2" placeholder="Kurzbeschreibung oder Zweck"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field" data-section="http">
|
||||
<label for="urlInput">URL-Template *</label>
|
||||
<input id="urlInput" type="url" placeholder="https://api.example.com/{{date}}/trigger" required>
|
||||
</div>
|
||||
<div class="field inline">
|
||||
<div class="field inline" data-section="http">
|
||||
<label for="methodSelect">Methode</label>
|
||||
<select id="methodSelect">
|
||||
<option value="GET">GET</option>
|
||||
@@ -117,14 +134,76 @@
|
||||
<span class="switch-label">Plan aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field" data-section="http">
|
||||
<label for="headersInput">Headers (Key: Value pro Zeile oder JSON)</label>
|
||||
<textarea id="headersInput" rows="3" placeholder="Authorization: Bearer {{token}}\nContent-Type: application/json"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field" data-section="http">
|
||||
<label for="bodyInput">Body (optional, Templates möglich)</label>
|
||||
<textarea id="bodyInput" rows="5" placeholder='{"date":"{{date}}","id":"{{uuid}}"}'></textarea>
|
||||
</div>
|
||||
<div class="field full" data-section="email">
|
||||
<label for="emailToInput">E-Mail Empfänger *</label>
|
||||
<input id="emailToInput" type="text" placeholder="max@example.com, lisa@example.com">
|
||||
</div>
|
||||
<div class="field full" data-section="email">
|
||||
<label for="emailSubjectInput">Betreff *</label>
|
||||
<input id="emailSubjectInput" type="text" placeholder="Status Update {{date}}">
|
||||
</div>
|
||||
<div class="field full" data-section="email">
|
||||
<label for="emailBodyInput">Body *</label>
|
||||
<textarea id="emailBodyInput" rows="6" placeholder="Hallo,\nheutiger Status: {{uuid}}"></textarea>
|
||||
</div>
|
||||
<div class="field full" data-section="flow">
|
||||
<div class="template-hint">
|
||||
<p class="template-title">Flow Schritte</p>
|
||||
<p class="template-copy">Max. 3 Schritte, Kontext steht als {{step1_json}}, {{step1_text}}, {{step1_status_code}} usw. im nächsten Schritt zur Verfügung.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" data-section="flow">
|
||||
<label for="flowStep1Url">Step 1 URL *</label>
|
||||
<input id="flowStep1Url" type="url" placeholder="https://api.example.com/first">
|
||||
</div>
|
||||
<div class="field inline" data-section="flow">
|
||||
<label for="flowStep1Method">Step 1 Methode</label>
|
||||
<select id="flowStep1Method">
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" data-section="flow">
|
||||
<label for="flowStep1Headers">Step 1 Headers</label>
|
||||
<textarea id="flowStep1Headers" rows="2" placeholder="Authorization: Bearer {{token}}"></textarea>
|
||||
</div>
|
||||
<div class="field" data-section="flow">
|
||||
<label for="flowStep1Body">Step 1 Body</label>
|
||||
<textarea id="flowStep1Body" rows="3" placeholder='{"id":"{{uuid}}"}'></textarea>
|
||||
</div>
|
||||
<div class="field" data-section="flow">
|
||||
<label for="flowStep2Url">Step 2 URL (optional)</label>
|
||||
<input id="flowStep2Url" type="url" placeholder="https://api.example.com/second">
|
||||
</div>
|
||||
<div class="field inline" data-section="flow">
|
||||
<label for="flowStep2Method">Step 2 Methode</label>
|
||||
<select id="flowStep2Method">
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" data-section="flow">
|
||||
<label for="flowStep2Headers">Step 2 Headers</label>
|
||||
<textarea id="flowStep2Headers" rows="2" placeholder="Authorization: Bearer {{token}}"></textarea>
|
||||
</div>
|
||||
<div class="field" data-section="flow">
|
||||
<label for="flowStep2Body">Step 2 Body</label>
|
||||
<textarea id="flowStep2Body" rows="3" placeholder='{"fromStep1":"{{step1_json.id}}"}'></textarea>
|
||||
</div>
|
||||
<div class="field inline">
|
||||
<label for="intervalPreset">Intervall</label>
|
||||
<select id="intervalPreset">
|
||||
@@ -153,7 +232,10 @@
|
||||
<div class="field full">
|
||||
<div class="template-hint">
|
||||
<p class="template-title">Platzhalter</p>
|
||||
<p class="template-copy">{{date}}, {{datetime}}, {{uuid}}, {{timestamp}}, {{weekday}}, {{day}}, {{month}}, {{year}}, {{date+1}}</p>
|
||||
<table class="placeholder-table">
|
||||
<tbody id="placeholderTableBody"></tbody>
|
||||
</table>
|
||||
<p class="placeholder-hint">Beispiele beziehen sich auf den aktuellen Zeitpunkt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field full">
|
||||
@@ -212,6 +294,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="vendor/list.min.js"></script>
|
||||
<script src="automation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -38,9 +38,29 @@
|
||||
const formModal = document.getElementById('formModal');
|
||||
const modalBackdrop = document.getElementById('formModalBackdrop');
|
||||
const modalCloseBtn = document.getElementById('modalCloseBtn');
|
||||
const typeSelect = document.getElementById('typeSelect');
|
||||
const emailSection = document.querySelector('[data-section="email"]');
|
||||
const flowSection = document.querySelector('[data-section="flow"]');
|
||||
const httpSection = document.querySelector('[data-section="http"]');
|
||||
const emailToInput = document.getElementById('emailToInput');
|
||||
const emailSubjectInput = document.getElementById('emailSubjectInput');
|
||||
const emailBodyInput = document.getElementById('emailBodyInput');
|
||||
const flowStep1Url = document.getElementById('flowStep1Url');
|
||||
const flowStep1Method = document.getElementById('flowStep1Method');
|
||||
const flowStep1Headers = document.getElementById('flowStep1Headers');
|
||||
const flowStep1Body = document.getElementById('flowStep1Body');
|
||||
const flowStep2Url = document.getElementById('flowStep2Url');
|
||||
const flowStep2Method = document.getElementById('flowStep2Method');
|
||||
const flowStep2Headers = document.getElementById('flowStep2Headers');
|
||||
const flowStep2Body = document.getElementById('flowStep2Body');
|
||||
|
||||
const requestTableBody = document.getElementById('requestTableBody');
|
||||
const listStatus = document.getElementById('listStatus');
|
||||
const filterName = document.getElementById('filterName');
|
||||
const filterNext = document.getElementById('filterNext');
|
||||
const filterLast = document.getElementById('filterLast');
|
||||
const filterStatus = document.getElementById('filterStatus');
|
||||
const filterRuns = document.getElementById('filterRuns');
|
||||
const runsList = document.getElementById('runsList');
|
||||
const runsStatus = document.getElementById('runsStatus');
|
||||
const heroStats = document.getElementById('heroStats');
|
||||
@@ -49,7 +69,6 @@
|
||||
const resetFormBtn = document.getElementById('resetFormBtn');
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
const newAutomationBtn = document.getElementById('newAutomationBtn');
|
||||
const runSelectedBtn = document.getElementById('runSelectedBtn');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const importInput = document.getElementById('importInput');
|
||||
const importStatus = document.getElementById('importStatus');
|
||||
@@ -62,6 +81,11 @@
|
||||
const previewHeaders = document.getElementById('previewHeaders');
|
||||
const previewBody = document.getElementById('previewBody');
|
||||
const refreshPreviewBtn = document.getElementById('refreshPreviewBtn');
|
||||
const placeholderTableBody = document.getElementById('placeholderTableBody');
|
||||
|
||||
let sortState = { key: 'next', dir: 'asc' };
|
||||
let listInstance = null;
|
||||
let sse = null;
|
||||
|
||||
function toDateTimeLocal(value) {
|
||||
if (!value) return '';
|
||||
@@ -221,6 +245,14 @@
|
||||
return shifted.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const dayOffsetMatch = key.match(/^day([+-]\d+)?$/);
|
||||
if (dayOffsetMatch) {
|
||||
const offset = dayOffsetMatch[1] ? parseInt(dayOffsetMatch[1], 10) : 0;
|
||||
const shifted = new Date(baseDate);
|
||||
shifted.setDate(baseDate.getDate() + (Number.isNaN(offset) ? 0 : offset));
|
||||
return String(shifted.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'today':
|
||||
case 'date':
|
||||
@@ -267,23 +299,91 @@
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function renderPlaceholderTable(context) {
|
||||
if (!placeholderTableBody) return;
|
||||
const rows = [
|
||||
{ key: '{{date}}', value: context.date },
|
||||
{ key: '{{date+1}}', value: renderTemplate('{{date+1}}', context) },
|
||||
{ key: '{{day}}', value: context.day },
|
||||
{ key: '{{day-1}}', value: renderTemplate('{{day-1}}', context) },
|
||||
{ key: '{{datetime}}', value: context.datetime },
|
||||
{ key: '{{iso}}', value: context.iso },
|
||||
{ key: '{{timestamp}}', value: context.timestamp },
|
||||
{ key: '{{uuid}}', value: renderTemplate('{{uuid}}', context) },
|
||||
{ key: '{{year}}', value: context.year },
|
||||
{ key: '{{month}}', value: context.month },
|
||||
{ key: '{{day}}', value: context.day },
|
||||
{ key: '{{hour}}', value: context.hour },
|
||||
{ key: '{{minute}}', value: context.minute },
|
||||
{ key: '{{weekday}}', value: context.weekday },
|
||||
{ key: '{{weekday_short}}', value: context.weekday_short }
|
||||
];
|
||||
placeholderTableBody.innerHTML = rows.map((row) => `
|
||||
<tr>
|
||||
<td class="placeholder-key">${row.key}</td>
|
||||
<td>${row.value}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function buildPayloadFromForm() {
|
||||
return {
|
||||
const type = typeSelect ? typeSelect.value : 'request';
|
||||
const base = {
|
||||
type,
|
||||
name: nameInput.value.trim(),
|
||||
description: descriptionInput.value.trim(),
|
||||
method: methodSelect.value,
|
||||
url_template: urlInput.value.trim(),
|
||||
headers: parseHeadersInput(headersInput.value),
|
||||
body_template: bodyInput.value,
|
||||
schedule_type: intervalPreset.value !== 'custom' ? intervalPreset.value : undefined,
|
||||
interval_minutes: intervalPreset.value === 'custom'
|
||||
? Math.max(5, parseInt(intervalMinutesInput.value, 10) || DEFAULT_INTERVAL_MINUTES)
|
||||
: undefined,
|
||||
schedule_type: intervalPreset.value !== 'custom' ? intervalPreset.value : undefined,
|
||||
jitter_minutes: Math.max(0, parseInt(jitterInput.value, 10) || 0),
|
||||
start_at: parseDateInput(startAtInput.value),
|
||||
run_until: parseDateInput(runUntilInput.value),
|
||||
active: activeToggle.checked
|
||||
};
|
||||
|
||||
if (type === 'email') {
|
||||
return {
|
||||
...base,
|
||||
email_to: emailToInput?.value.trim(),
|
||||
email_subject_template: emailSubjectInput?.value.trim(),
|
||||
email_body_template: emailBodyInput?.value
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'flow') {
|
||||
const steps = [];
|
||||
const step1Url = flowStep1Url?.value.trim();
|
||||
if (step1Url) {
|
||||
steps.push({
|
||||
method: flowStep1Method?.value || 'GET',
|
||||
url: step1Url,
|
||||
headers: parseHeadersInput(flowStep1Headers?.value || ''),
|
||||
body: flowStep1Body?.value || ''
|
||||
});
|
||||
}
|
||||
const step2Url = flowStep2Url?.value.trim();
|
||||
if (step2Url) {
|
||||
steps.push({
|
||||
method: flowStep2Method?.value || 'GET',
|
||||
url: step2Url,
|
||||
headers: parseHeadersInput(flowStep2Headers?.value || ''),
|
||||
body: flowStep2Body?.value || ''
|
||||
});
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
steps
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
method: methodSelect.value,
|
||||
url_template: urlInput.value.trim(),
|
||||
headers: parseHeadersInput(headersInput.value),
|
||||
body_template: bodyInput.value
|
||||
};
|
||||
}
|
||||
|
||||
function applyPresetDisabling() {
|
||||
@@ -297,7 +397,51 @@
|
||||
|
||||
function refreshPreview() {
|
||||
if (!previewUrl || !previewHeaders || !previewBody) return;
|
||||
const type = typeSelect ? typeSelect.value : 'request';
|
||||
const context = buildTemplateContext();
|
||||
renderPlaceholderTable(context);
|
||||
if (type === 'email') {
|
||||
const renderedTo = renderTemplate(emailToInput?.value || '', context);
|
||||
const renderedSubject = renderTemplate(emailSubjectInput?.value || '', context);
|
||||
const renderedEmailBody = renderTemplate(emailBodyInput?.value || '', context);
|
||||
previewUrl.textContent = `To: ${renderedTo || '—'}\nSubject: ${renderedSubject || '—'}`;
|
||||
previewHeaders.textContent = '—';
|
||||
previewBody.textContent = renderedEmailBody || '—';
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'flow') {
|
||||
const steps = [];
|
||||
if (flowStep1Url?.value) {
|
||||
steps.push({
|
||||
label: 'Step 1',
|
||||
url: renderTemplate(flowStep1Url.value, context),
|
||||
method: flowStep1Method?.value || 'GET',
|
||||
headers: renderHeaderTemplates(flowStep1Headers?.value || '', context),
|
||||
body: renderTemplate(flowStep1Body?.value || '', context)
|
||||
});
|
||||
}
|
||||
if (flowStep2Url?.value) {
|
||||
steps.push({
|
||||
label: 'Step 2',
|
||||
url: renderTemplate(flowStep2Url.value, context),
|
||||
method: flowStep2Method?.value || 'GET',
|
||||
headers: renderHeaderTemplates(flowStep2Headers?.value || '', context),
|
||||
body: renderTemplate(flowStep2Body?.value || '', context)
|
||||
});
|
||||
}
|
||||
previewUrl.textContent = steps.length
|
||||
? steps.map((s) => `${s.label}: ${s.method} ${s.url || '—'}`).join('\n')
|
||||
: '—';
|
||||
previewHeaders.textContent = steps.length
|
||||
? steps.map((s) => `${s.label} Headers:\n${stringifyHeaders(s.headers) || '—'}`).join('\n\n')
|
||||
: '—';
|
||||
previewBody.textContent = steps.length
|
||||
? steps.map((s) => `${s.label} Body:\n${s.body || '—'}`).join('\n\n')
|
||||
: '—';
|
||||
return;
|
||||
}
|
||||
|
||||
const renderedUrl = renderTemplate(urlInput.value || '', context);
|
||||
const headersObj = renderHeaderTemplates(headersInput.value || '', context);
|
||||
const renderedBody = renderTemplate(bodyInput.value || '', context);
|
||||
@@ -310,7 +454,9 @@
|
||||
}
|
||||
|
||||
async function loadRequests() {
|
||||
if (!state.loading) {
|
||||
setStatus(listStatus, 'Lade Automationen...');
|
||||
}
|
||||
state.loading = true;
|
||||
try {
|
||||
const data = await apiFetchJSON('/automation/requests');
|
||||
@@ -366,40 +512,61 @@
|
||||
function renderRequests() {
|
||||
if (!requestTableBody) return;
|
||||
requestTableBody.innerHTML = '';
|
||||
const sorted = [...state.requests].sort((a, b) => {
|
||||
const aNext = a.next_run_at ? new Date(a.next_run_at).getTime() : Infinity;
|
||||
const bNext = b.next_run_at ? new Date(b.next_run_at).getTime() : Infinity;
|
||||
return aNext - bNext;
|
||||
});
|
||||
|
||||
if (!sorted.length) {
|
||||
requestTableBody.innerHTML = '<tr><td colspan="5">Keine Automationen vorhanden.</td></tr>';
|
||||
if (!state.requests.length) {
|
||||
requestTableBody.innerHTML = '<tr><td colspan="6">Keine Automationen vorhanden.</td></tr>';
|
||||
listInstance = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = sorted.map((req) => {
|
||||
const rows = state.requests.map((req) => {
|
||||
const isSelected = state.selectedId === req.id;
|
||||
const nextSort = req.next_run_at ? new Date(req.next_run_at).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
const lastSort = req.last_run_at ? new Date(req.last_run_at).getTime() : -Number.MAX_SAFE_INTEGER;
|
||||
const runsCount = Array.isArray(req.runs) ? req.runs.length : (req.runs_count || req.runsCount || 0);
|
||||
const statusBadge = req.last_status === 'success'
|
||||
? '<span class="badge success">OK</span>'
|
||||
: req.last_status === 'error'
|
||||
? '<span class="badge error">Fehler</span>'
|
||||
: '<span class="badge">—</span>';
|
||||
let subline = '';
|
||||
if (req.type === 'email') {
|
||||
subline = `E-Mail → ${req.email_to || '—'}`;
|
||||
} else if (req.type === 'flow') {
|
||||
const stepCount = Array.isArray(req.steps) ? req.steps.length : 0;
|
||||
subline = `Flow (${stepCount || '0'} Schritte)`;
|
||||
} else {
|
||||
subline = `${req.method || 'GET'} · ${req.url_template || '—'}`;
|
||||
}
|
||||
|
||||
return `
|
||||
<tr data-id="${req.id}" class="${isSelected ? 'is-selected' : ''}">
|
||||
<td>
|
||||
<div class="row-title">${req.name}</div>
|
||||
<div class="row-sub">${req.method || 'GET'} · ${req.url_template}</div>
|
||||
<td data-sort="${req.name || ''}">
|
||||
<div class="row-title name">${req.name}</div>
|
||||
<div class="row-sub">${subline}</div>
|
||||
<span class="type hidden-value">${req.type || ''}</span>
|
||||
<span class="email hidden-value">${req.email_to || ''}</span>
|
||||
<span class="url hidden-value">${req.url_template || ''}</span>
|
||||
<span class="steps hidden-value">${req.steps && req.steps.map((s) => s.url || s.url_template || '').join(' | ')}</span>
|
||||
</td>
|
||||
<td data-sort="${nextSort}">
|
||||
<span class="next" data-sort="${nextSort}">${formatRelative(req.next_run_at)}</span>
|
||||
<br><small>${formatDateTime(req.next_run_at)}</small>
|
||||
</td>
|
||||
<td data-sort="${lastSort}">
|
||||
<span class="last" data-sort="${lastSort}">${formatRelative(req.last_run_at)}</span>
|
||||
<br><small>${req.last_status_code || '—'}</small>
|
||||
</td>
|
||||
<td data-sort="${req.last_status || ''}"><span class="status" data-sort="${req.last_status || ''}">${statusBadge}</span></td>
|
||||
<td data-sort="${runsCount}" class="runs-count">
|
||||
<span class="runs hidden-value" data-sort="${runsCount}">${runsCount}</span>
|
||||
${runsCount}
|
||||
</td>
|
||||
<td>${formatRelative(req.next_run_at)}<br><small>${formatDateTime(req.next_run_at)}</small></td>
|
||||
<td>${formatRelative(req.last_run_at)}<br><small>${req.last_status_code || '—'}</small></td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="secondary-btn" data-action="edit" data-id="${req.id}" type="button">Bearbeiten</button>
|
||||
<button class="ghost-btn" data-action="run" data-id="${req.id}" type="button">Run</button>
|
||||
<button class="ghost-btn" data-action="toggle" data-id="${req.id}" type="button">${req.active ? 'Pause' : 'Aktivieren'}</button>
|
||||
<button class="ghost-btn" data-action="delete" data-id="${req.id}" type="button">Löschen</button>
|
||||
<button class="icon-btn" title="Bearbeiten" data-action="edit" data-id="${req.id}" type="button">✏️</button>
|
||||
<button class="icon-btn" title="Run" data-action="run" data-id="${req.id}" type="button">▶️</button>
|
||||
<button class="icon-btn" title="${req.active ? 'Pause' : 'Aktivieren'}" data-action="toggle" data-id="${req.id}" type="button">${req.active ? '⏸' : '▶'}</button>
|
||||
<button class="icon-btn danger" title="Löschen" data-action="delete" data-id="${req.id}" type="button">🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -408,9 +575,7 @@
|
||||
|
||||
requestTableBody.innerHTML = rows.join('');
|
||||
|
||||
if (runSelectedBtn) {
|
||||
runSelectedBtn.disabled = !state.selectedId;
|
||||
}
|
||||
initListInstance();
|
||||
}
|
||||
|
||||
function renderRuns() {
|
||||
@@ -447,6 +612,7 @@
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
editingId = null;
|
||||
if (typeSelect) typeSelect.value = 'request';
|
||||
intervalPreset.value = 'hourly';
|
||||
intervalMinutesInput.value = DEFAULT_INTERVAL_MINUTES;
|
||||
applyPresetDisabling();
|
||||
@@ -457,6 +623,15 @@
|
||||
runUntilInput.value = '';
|
||||
formModeLabel.textContent = 'Neue Automation';
|
||||
setStatus(formStatus, '');
|
||||
if (emailToInput) emailToInput.value = '';
|
||||
if (emailSubjectInput) emailSubjectInput.value = '';
|
||||
if (emailBodyInput) emailBodyInput.value = '';
|
||||
[flowStep1Url, flowStep1Headers, flowStep1Body, flowStep2Url, flowStep2Headers, flowStep2Body]
|
||||
.forEach((el) => { if (el) el.value = ''; });
|
||||
if (flowStep1Method) flowStep1Method.value = 'GET';
|
||||
if (flowStep2Method) flowStep2Method.value = 'GET';
|
||||
applyTypeVisibility();
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
function fillForm(request) {
|
||||
@@ -470,6 +645,30 @@
|
||||
headersInput.value = stringifyHeaders(request.headers);
|
||||
bodyInput.value = request.body_template || '';
|
||||
activeToggle.checked = !!request.active;
|
||||
if (typeSelect) {
|
||||
typeSelect.value = request.type || 'request';
|
||||
applyTypeVisibility();
|
||||
}
|
||||
if (request.type === 'email') {
|
||||
emailToInput.value = request.email_to || '';
|
||||
emailSubjectInput.value = request.email_subject_template || '';
|
||||
emailBodyInput.value = request.email_body_template || '';
|
||||
} else if (request.type === 'flow') {
|
||||
const steps = Array.isArray(request.steps) ? request.steps : [];
|
||||
const [s1, s2] = steps;
|
||||
if (s1) {
|
||||
flowStep1Url.value = s1.url || s1.url_template || '';
|
||||
flowStep1Method.value = s1.method || s1.http_method || 'GET';
|
||||
flowStep1Headers.value = stringifyHeaders(s1.headers || {});
|
||||
flowStep1Body.value = s1.body || s1.body_template || '';
|
||||
}
|
||||
if (s2) {
|
||||
flowStep2Url.value = s2.url || s2.url_template || '';
|
||||
flowStep2Method.value = s2.method || s2.http_method || 'GET';
|
||||
flowStep2Headers.value = stringifyHeaders(s2.headers || {});
|
||||
flowStep2Body.value = s2.body || s2.body_template || '';
|
||||
}
|
||||
}
|
||||
|
||||
if (request.interval_minutes === 60) {
|
||||
intervalPreset.value = 'hourly';
|
||||
@@ -490,10 +689,27 @@
|
||||
event.preventDefault();
|
||||
if (state.saving) return;
|
||||
const payload = buildPayloadFromForm();
|
||||
if (!payload.name || !payload.url_template) {
|
||||
setStatus(formStatus, 'Name und URL sind Pflichtfelder.', 'error');
|
||||
if (!payload.name) {
|
||||
setStatus(formStatus, 'Name ist ein Pflichtfeld.', 'error');
|
||||
return;
|
||||
}
|
||||
if (payload.type === 'request' && !payload.url_template) {
|
||||
setStatus(formStatus, 'URL ist ein Pflichtfeld für HTTP-Requests.', 'error');
|
||||
return;
|
||||
}
|
||||
if (payload.type === 'email') {
|
||||
if (!payload.email_to || !payload.email_subject_template || !payload.email_body_template) {
|
||||
setStatus(formStatus, 'E-Mail: Empfänger, Betreff und Body sind Pflichtfelder.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (payload.type === 'flow') {
|
||||
const hasStep = Array.isArray(payload.steps) && payload.steps.length > 0 && payload.steps[0].url;
|
||||
if (!hasStep) {
|
||||
setStatus(formStatus, 'Flow: Mindestens ein Schritt mit URL ist erforderlich.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state.saving = true;
|
||||
setStatus(formStatus, 'Speichere...');
|
||||
@@ -716,6 +932,72 @@
|
||||
closeImportModal();
|
||||
}
|
||||
|
||||
function updateSortIndicators() {
|
||||
const headers = document.querySelectorAll('[data-sort-column]');
|
||||
headers.forEach((th) => {
|
||||
th.classList.remove('sort-asc', 'sort-desc');
|
||||
const col = th.getAttribute('data-sort-column');
|
||||
if (col === sortState.key) {
|
||||
th.classList.add(sortState.dir === 'desc' ? 'sort-desc' : 'sort-asc');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initListInstance() {
|
||||
const container = document.getElementById('automationTable');
|
||||
if (!container) return;
|
||||
if (listInstance) {
|
||||
listInstance.remove();
|
||||
listInstance = null;
|
||||
}
|
||||
const options = {
|
||||
listClass: 'list',
|
||||
valueNames: [
|
||||
'name',
|
||||
'type',
|
||||
'email',
|
||||
'url',
|
||||
'steps',
|
||||
{ name: 'next', attr: 'data-sort' },
|
||||
{ name: 'last', attr: 'data-sort' },
|
||||
{ name: 'status', attr: 'data-sort' },
|
||||
{ name: 'runs', attr: 'data-sort' }
|
||||
]
|
||||
};
|
||||
listInstance = new List(container, options);
|
||||
applyFilters();
|
||||
listInstance.sort(sortState.key, { order: sortState.dir === 'desc' ? 'desc' : 'asc' });
|
||||
updateSortIndicators();
|
||||
listInstance.on('updated', updateSortIndicators);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
if (!listInstance) return;
|
||||
const searchName = (filterName?.value || '').toLowerCase().trim();
|
||||
const searchNext = (filterNext?.value || '').toLowerCase().trim();
|
||||
const searchLast = (filterLast?.value || '').toLowerCase().trim();
|
||||
const statusValue = (filterStatus?.value || '').toLowerCase().trim();
|
||||
const runsMin = filterRuns?.value ? Number(filterRuns.value) : null;
|
||||
|
||||
listInstance.filter((item) => {
|
||||
const v = item.values();
|
||||
const matchName = !searchName || [
|
||||
v.name || '',
|
||||
v.type || '',
|
||||
v.email || '',
|
||||
v.url || '',
|
||||
v.steps || ''
|
||||
].some((p) => String(p).toLowerCase().includes(searchName));
|
||||
|
||||
const matchNext = !searchNext || String(v.next || '').toLowerCase().includes(searchNext);
|
||||
const matchLast = !searchLast || `${v.last || ''}`.toLowerCase().includes(searchLast);
|
||||
const matchStatus = !statusValue || String(v.status || '').toLowerCase().includes(statusValue);
|
||||
const matchRuns = runsMin === null || Number(v.runs || 0) >= runsMin;
|
||||
|
||||
return matchName && matchNext && matchLast && matchStatus && matchRuns;
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedRequest() {
|
||||
if (!state.selectedId) return null;
|
||||
return state.requests.find((item) => item.id === state.selectedId) || null;
|
||||
@@ -759,6 +1041,26 @@
|
||||
importModal.hidden = true;
|
||||
}
|
||||
|
||||
function applyTypeVisibility() {
|
||||
const type = typeSelect ? typeSelect.value : 'request';
|
||||
document.querySelectorAll('[data-section="http"]').forEach((el) => {
|
||||
el.style.display = type === 'request' ? 'grid' : 'none';
|
||||
});
|
||||
document.querySelectorAll('[data-section="email"]').forEach((el) => {
|
||||
el.style.display = type === 'email' ? 'grid' : 'none';
|
||||
});
|
||||
document.querySelectorAll('[data-section="flow"]').forEach((el) => {
|
||||
el.style.display = type === 'flow' ? 'grid' : 'none';
|
||||
});
|
||||
// required toggles
|
||||
urlInput.required = type === 'request';
|
||||
methodSelect.required = type === 'request';
|
||||
emailToInput && (emailToInput.required = type === 'email');
|
||||
emailSubjectInput && (emailSubjectInput.required = type === 'email');
|
||||
emailBodyInput && (emailBodyInput.required = type === 'email');
|
||||
flowStep1Url && (flowStep1Url.required = type === 'flow');
|
||||
}
|
||||
|
||||
function handleTableClick(event) {
|
||||
const button = event.target.closest('[data-action]');
|
||||
if (button) {
|
||||
@@ -790,6 +1092,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleSseMessage(payload) {
|
||||
if (!payload || !payload.type) return;
|
||||
switch (payload.type) {
|
||||
case 'automation-run':
|
||||
case 'automation-upsert':
|
||||
loadRequests();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function startSse() {
|
||||
if (sse || typeof EventSource === 'undefined') return;
|
||||
try {
|
||||
sse = new EventSource(`${API_URL}/events`, { withCredentials: true });
|
||||
} catch (error) {
|
||||
console.warn('Konnte SSE nicht starten:', error);
|
||||
sse = null;
|
||||
return;
|
||||
}
|
||||
sse.addEventListener('message', (event) => {
|
||||
if (!event || !event.data) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
handleSseMessage(payload);
|
||||
} catch (error) {
|
||||
// ignore parse errors
|
||||
}
|
||||
});
|
||||
sse.addEventListener('error', () => {
|
||||
if (sse) {
|
||||
sse.close();
|
||||
sse = null;
|
||||
}
|
||||
setTimeout(startSse, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
applyPresetDisabling();
|
||||
resetForm();
|
||||
@@ -803,12 +1144,42 @@
|
||||
});
|
||||
newAutomationBtn.addEventListener('click', () => openFormModal('create'));
|
||||
refreshBtn.addEventListener('click', loadRequests);
|
||||
runSelectedBtn.addEventListener('click', () => runAutomation(state.selectedId));
|
||||
applyImportBtn?.addEventListener('click', applyImport);
|
||||
refreshPreviewBtn?.addEventListener('click', refreshPreview);
|
||||
[urlInput, headersInput, bodyInput].forEach((el) => {
|
||||
[urlInput, headersInput, bodyInput].forEach((el) => el?.addEventListener('input', refreshPreview));
|
||||
[emailToInput, emailSubjectInput, emailBodyInput].forEach((el) => el?.addEventListener('input', refreshPreview));
|
||||
[flowStep1Url, flowStep1Headers, flowStep1Body, flowStep1Method,
|
||||
flowStep2Url, flowStep2Headers, flowStep2Body, flowStep2Method].forEach((el) => {
|
||||
el?.addEventListener('input', refreshPreview);
|
||||
el?.addEventListener('change', refreshPreview);
|
||||
});
|
||||
typeSelect?.addEventListener('change', () => {
|
||||
applyTypeVisibility();
|
||||
refreshPreview();
|
||||
});
|
||||
[filterName, filterNext, filterLast, filterStatus].forEach((el) => {
|
||||
el?.addEventListener('input', applyFilters);
|
||||
el?.addEventListener('change', applyFilters);
|
||||
});
|
||||
filterRuns?.addEventListener('input', applyFilters);
|
||||
filterRuns?.addEventListener('change', applyFilters);
|
||||
document.querySelectorAll('[data-sort-column]').forEach((th) => {
|
||||
th.addEventListener('click', () => {
|
||||
const col = th.getAttribute('data-sort-column');
|
||||
if (!col) return;
|
||||
if (sortState.key === col) {
|
||||
sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortState.key = col;
|
||||
sortState.dir = 'asc';
|
||||
}
|
||||
if (listInstance) {
|
||||
listInstance.sort(col, { order: sortState.dir });
|
||||
}
|
||||
updateSortIndicators();
|
||||
});
|
||||
});
|
||||
applyTypeVisibility();
|
||||
openImportBtn?.addEventListener('click', openImportModal);
|
||||
importCloseBtn?.addEventListener('click', closeImportModal);
|
||||
importModalBackdrop?.addEventListener('click', closeImportModal);
|
||||
@@ -825,6 +1196,7 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
startSse();
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
1
web/vendor/list.min.js
vendored
Normal file
1
web/vendor/list.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!function(t){if(typeof window!=="undefined"){t(window)}else if(typeof global!=="undefined"){t(global)}}(function(window){function getValue(row,name,attr){var el=row.querySelector("."+name);if(!el){return""}if(attr){return el.getAttribute(attr)||""}return el.textContent||el.innerText||""}function normalizeOrder(order){return order==="desc"?"desc":"asc"}function List(container,options){if(!(this instanceof List))return new List(container,options);this.container=typeof container==="string"?document.querySelector(container):container;if(!this.container){throw new Error("List container not found")}this.listClass=options&&options.listClass?options.listClass:"list";this.valueNames=options&&options.valueNames?options.valueNames:[];this.listEl=this.container.querySelector("."+this.listClass);if(!this.listEl){throw new Error("List element not found")}this.items=Array.from(this.listEl.children);this.filteredItems=this.items.slice();this.sortOrder="asc";this.sortKey=null}List.prototype.filter=function(fn){this.filteredItems=[];for(var i=0;i<this.items.length;i++){var row=this.items[i];var values=this._buildValues(row);if(fn({values:function(){return values}})){this.filteredItems.push(row)}}this._render();return this};List.prototype.sort=function(key,opts){var order=normalizeOrder(opts&&opts.order);this.sortKey=key;this.sortOrder=order;var self=this;var kv=this._resolveKeySpec(key);this.filteredItems.sort(function(a,b){var va=getValue(a,kv.name,kv.attr);var vb=getValue(b,kv.name,kv.attr);var na=Number(va),nb=Number(vb);var cmp;if(!isNaN(na)&&!isNaN(nb)){cmp=na-nb}else{cmp=String(va).localeCompare(String(vb))}return order==="desc"?-cmp:cmp});this._render();return this};List.prototype._resolveKeySpec=function(key){for(var i=0;i<this.valueNames.length;i++){var spec=this.valueNames[i];if(typeof spec==="string"&&spec===key){return {name:key,attr:null}}if(spec&&spec.name===key){return {name:spec.name,attr:spec.attr||null}}}return {name:key,attr:null}};List.prototype._buildValues=function(row){var out={};for(var i=0;i<this.valueNames.length;i++){var spec=this.valueNames[i],name=typeof spec==="string"?spec:spec.name,attr=spec.attr||null;out[name]=getValue(row,name,attr)}return out};List.prototype._render=function(){var parent=this.listEl;parent.innerHTML="";for(var i=0;i<this.filteredItems.length;i++){parent.appendChild(this.filteredItems[i])}};window.List=List});
|
||||
Reference in New Issue
Block a user