Remove the automations module (#333)
* Remove the automations module Drops the visual workflow builder end to end: actions, node runners, commands, jobs, models, observers, policy, resources, requests, routes, broadcast channel, scheduler entries, Horizon supervisor, Vue pages and components, canvas undo/redo history, CodeEditor, translations, factories and tests. A new migration rewrites posts.created_via = 'automation' to 'web' and drops the five automation tables. The CreatedVia::Automation enum case is removed. The 2026-08-21 social-account identity migration now skips its automation node repointing when the automations table no longer exists, so it stays re-runnable after the drop. Orphaned dependencies removed: simplepie/simplepie, @vue-flow/*, codemirror and @codemirror/*. * Drop the orphaned common.beta translation key * Remove automation leftovers: ResolvableUrl rule, feed fixtures, useShortcut, nav badge * Drop the unused chart wrapper and @unovis packages * Address review: keep the shipped migration untouched, drop the dead previewOnly chain - Restore 2026_08_21 migration to exactly what production ran; the rehearsal test now recreates the automations table it expects instead. - Mark the drop migration's down() irreversible like its siblings. - Simplify DropAutomationTablesMigrationTest to the sibling shape. - Remove previewOnly / aiGenerateVariants: the only caller that set the prop was the deleted automation Generate node. - Run pint over lang/*/common.php after the beta key removal. * Exercise the drop migration against the real automation tables and their FKs * Drop DuplicateIdentityRehearsalTest: it re-ran a frozen migration that reads the removed automations table
This commit is contained in:
parent
82da3fd64b
commit
d8149ef058
275 changed files with 95 additions and 24546 deletions
|
|
@ -35,7 +35,6 @@ ## What you get
|
||||||
| 📅 **One calendar, every network** | Plan a month at a glance, drag any post to a new slot, and publish natively to 12 platforms. No redirects, no "finish in the mobile app." |
|
| 📅 **One calendar, every network** | Plan a month at a glance, drag any post to a new slot, and publish natively to 12 platforms. No redirects, no "finish in the mobile app." |
|
||||||
| ✨ **An AI copilot that knows your brand** | Captions, hooks, full drafts, and multi-slide carousels in your tone, voice, and colors. It reads your brand profile on every generation. |
|
| ✨ **An AI copilot that knows your brand** | Captions, hooks, full drafts, and multi-slide carousels in your tone, voice, and colors. It reads your brand profile on every generation. |
|
||||||
| 🤖 **Built for AI agents** | A first-class MCP server and REST API. Claude, Cursor, ChatGPT, or your own scripts can draft, schedule, and publish for you. |
|
| 🤖 **Built for AI agents** | A first-class MCP server and REST API. Claude, Cursor, ChatGPT, or your own scripts can draft, schedule, and publish for you. |
|
||||||
| ⚙️ **Automations that run themselves** | A visual workflow builder: triggers, conditions, RSS, HTTP requests, and AI generation, all server-side. Set it once, let it post. |
|
|
||||||
| 🗂️ **Made for many clients** | Workspaces, roles, and approval flows so an agency or freelancer can run a roster of brands without the spreadsheets. |
|
| 🗂️ **Made for many clients** | Workspaces, roles, and approval flows so an agency or freelancer can run a roster of brands without the spreadsheets. |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
@ -47,7 +46,6 @@ ## Features
|
||||||
| **AI generate & review** | Draft from a prompt, get inline feedback before you publish. |
|
| **AI generate & review** | Draft from a prompt, get inline feedback before you publish. |
|
||||||
| **AI carousel builder** | Prompt to a multi-slide carousel with images, on-brand. |
|
| **AI carousel builder** | Prompt to a multi-slide carousel with images, on-brand. |
|
||||||
| **Brand profile** | Tone, voice, language, and colors applied to every AI call. |
|
| **Brand profile** | Tone, voice, language, and colors applied to every AI call. |
|
||||||
| **Automations** | Schedule / RSS triggers, conditions, publish steps, and HTTP requests. |
|
|
||||||
| **Asset library** | Reusable workspace media, plus Unsplash and Giphy search built in. |
|
| **Asset library** | Reusable workspace media, plus Unsplash and Giphy search built in. |
|
||||||
| **Signatures & labels** | Reusable hashtag and CTA blocks, color-coded post tags. |
|
| **Signatures & labels** | Reusable hashtag and CTA blocks, color-coded post tags. |
|
||||||
| **Team collaboration** | Owner / Admin / Member roles, comments with @mentions on drafts. |
|
| **Team collaboration** | Owner / Admin / Member roles, comments with @mentions on drafts. |
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Automation;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Node\Type as NodeType;
|
|
||||||
use App\Enums\Automation\Status;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Services\Automation\AutomationConfigValidator;
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class ActivateAutomation
|
|
||||||
{
|
|
||||||
public function __construct(private AutomationConfigValidator $configValidator) {}
|
|
||||||
|
|
||||||
public function __invoke(Automation $automation): Automation
|
|
||||||
{
|
|
||||||
$this->validate($automation);
|
|
||||||
|
|
||||||
$automation->update([
|
|
||||||
'status' => Status::Active,
|
|
||||||
'activated_at' => now(),
|
|
||||||
'paused_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $automation;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validate(Automation $automation): void
|
|
||||||
{
|
|
||||||
$nodes = $automation->nodes ?? [];
|
|
||||||
$connections = $automation->connections ?? [];
|
|
||||||
|
|
||||||
$triggers = collect($nodes)->where('type', NodeType::Trigger->value);
|
|
||||||
if ($triggers->count() !== 1) {
|
|
||||||
throw new DomainException(__('automations.errors.must_have_one_trigger'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$trigger = $triggers->first();
|
|
||||||
$hasTargetFromTrigger = collect($connections)->contains('source', $trigger['id']);
|
|
||||||
if (! $hasTargetFromTrigger) {
|
|
||||||
throw new DomainException(__('automations.errors.trigger_must_be_connected'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$issue = $this->configValidator->firstMessage($nodes);
|
|
||||||
|
|
||||||
if ($issue !== null) {
|
|
||||||
throw new DomainException($issue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Automation;
|
|
||||||
|
|
||||||
use App\Enums\Automation\ScheduleField;
|
|
||||||
use App\Enums\Automation\Status;
|
|
||||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class CreateAutomation
|
|
||||||
{
|
|
||||||
public function __invoke(Workspace $workspace, User $user, ?string $name = null): Automation
|
|
||||||
{
|
|
||||||
return Automation::create([
|
|
||||||
'workspace_id' => $workspace->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'name' => $name ?: __('automations.default_name'),
|
|
||||||
'status' => Status::Draft,
|
|
||||||
'nodes' => [$this->defaultTriggerNode()],
|
|
||||||
'connections' => [],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every automation has exactly one trigger — its entry point — so we seed it
|
|
||||||
* on creation. The trigger can't be added or deleted from the editor; only
|
|
||||||
* its type (schedule / post published / post scheduled) is configurable.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function defaultTriggerNode(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => (string) Str::uuid(),
|
|
||||||
'type' => 'trigger',
|
|
||||||
'position' => ['x' => 0, 'y' => 0],
|
|
||||||
'data' => [
|
|
||||||
'trigger_type' => TriggerType::Schedule->value,
|
|
||||||
'cron' => '0 9 * * *',
|
|
||||||
'schedule_field' => ScheduleField::Days->value,
|
|
||||||
'schedule_days_interval' => 1,
|
|
||||||
'schedule_hour' => 9,
|
|
||||||
'schedule_minute' => 0,
|
|
||||||
'schedule_timezone' => config('app.timezone'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Automation;
|
|
||||||
|
|
||||||
use App\Models\Automation;
|
|
||||||
|
|
||||||
class DeleteAutomation
|
|
||||||
{
|
|
||||||
public function __invoke(Automation $automation): void
|
|
||||||
{
|
|
||||||
$automation->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Automation;
|
|
||||||
|
|
||||||
use App\Actions\SocialAccount\ListPinterestBoards;
|
|
||||||
use App\Enums\SocialAccount\Platform;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\SocialAccount;
|
|
||||||
use App\Services\Social\TikTokCreatorInfo;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Support\Collection as SupportCollection;
|
|
||||||
|
|
||||||
class GetAutomationEditorData
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private TikTokCreatorInfo $tikTokCreatorInfo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* socialAccounts: Collection<int, SocialAccount>,
|
|
||||||
* pinterestBoards: SupportCollection<string, array{boards: list<array{id: string, name: string}>, truncated: bool}>,
|
|
||||||
* tiktokCreatorInfos: SupportCollection<string, mixed>,
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function __invoke(Automation $automation): array
|
|
||||||
{
|
|
||||||
$socialAccounts = $automation->workspace->socialAccounts()->active()->get();
|
|
||||||
|
|
||||||
$pinterestBoards = $socialAccounts
|
|
||||||
->where('platform', Platform::Pinterest)
|
|
||||||
->mapWithKeys(fn ($account) => [
|
|
||||||
$account->id => rescue(
|
|
||||||
fn () => ListPinterestBoards::execute($account),
|
|
||||||
['boards' => [], 'truncated' => false],
|
|
||||||
report: false,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tiktokCreatorInfos = $socialAccounts
|
|
||||||
->where('platform', Platform::TikTok)
|
|
||||||
->mapWithKeys(fn ($account) => [
|
|
||||||
$account->id => rescue(
|
|
||||||
fn () => $this->tikTokCreatorInfo->fetch($account),
|
|
||||||
null,
|
|
||||||
report: false,
|
|
||||||
),
|
|
||||||
])
|
|
||||||
->filter();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'socialAccounts' => $socialAccounts,
|
|
||||||
'pinterestBoards' => $pinterestBoards,
|
|
||||||
'tiktokCreatorInfos' => $tiktokCreatorInfos,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Automation;
|
|
||||||
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use Illuminate\Contracts\Pagination\CursorPaginator;
|
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
|
|
||||||
class GetAutomationInvocations
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Paginated, real (non-dry-run) executions for the Invocations tab, newest
|
|
||||||
* first, each carrying the count of node runs it produced so the list can
|
|
||||||
* render a "Workflow completed · N steps" summary without N+1 queries.
|
|
||||||
*
|
|
||||||
* @return LengthAwarePaginator<int, AutomationRun>
|
|
||||||
*/
|
|
||||||
public function __invoke(Automation $automation, ?string $status = null, ?string $search = null): LengthAwarePaginator|CursorPaginator
|
|
||||||
{
|
|
||||||
return $automation->runs()
|
|
||||||
->productionRuns()
|
|
||||||
->withCount('nodeRuns')
|
|
||||||
->when($status !== null, fn ($query) => $query->where('status', $status))
|
|
||||||
->when($search !== null && $search !== '', fn ($query) => $query->whereLike('id', "%{$search}%"))
|
|
||||||
->latest()
|
|
||||||
->paginate((int) config('app.pagination.default'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Automation;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Run\Status;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use App\Models\PostPlatform;
|
|
||||||
use Carbon\CarbonInterface;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
class GetAutomationMetrics
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Aggregate run health and flow output for the Metrics tab over the given
|
|
||||||
* (inclusive) date range. App timezone is UTC, so day bucketing aligns with
|
|
||||||
* the stored UTC timestamps without conversion.
|
|
||||||
*
|
|
||||||
* @return array{
|
|
||||||
* totals: array{runs: int, completed: int, failed: int, in_progress: int, success_rate: ?int, avg_duration_ms: ?int, posts_created: int},
|
|
||||||
* timeseries: array<int, array{date: string, started: int, completed: int, failed: int}>,
|
|
||||||
* platforms: array<int, array{platform: string, count: int}>,
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function __invoke(Automation $automation, CarbonInterface $start, CarbonInterface $end): array
|
|
||||||
{
|
|
||||||
$start = $start->copy()->startOfDay();
|
|
||||||
$end = $end->copy()->endOfDay();
|
|
||||||
$days = (int) $start->diffInDays($end) + 1;
|
|
||||||
|
|
||||||
$runs = $automation->runs()
|
|
||||||
->productionRuns()
|
|
||||||
->whereBetween('created_at', [$start, $end])
|
|
||||||
->get(['id', 'status', 'generated_post_id', 'created_at', 'started_at', 'finished_at']);
|
|
||||||
|
|
||||||
$completed = $runs->where('status', Status::Completed);
|
|
||||||
$failed = $runs->where('status', Status::Failed);
|
|
||||||
$inProgress = $runs->whereIn('status', [Status::Pending, Status::Running, Status::Waiting]);
|
|
||||||
|
|
||||||
$finished = $completed->count() + $failed->count();
|
|
||||||
$successRate = $finished > 0 ? (int) round($completed->count() / $finished * 100) : null;
|
|
||||||
|
|
||||||
$durations = $completed
|
|
||||||
->map(fn ($run) => $run->durationInMilliseconds())
|
|
||||||
->filter(fn ($ms) => $ms !== null);
|
|
||||||
$avgDurationMs = $durations->isNotEmpty() ? (int) round($durations->avg()) : null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'totals' => [
|
|
||||||
'runs' => $runs->count(),
|
|
||||||
'completed' => $completed->count(),
|
|
||||||
'failed' => $failed->count(),
|
|
||||||
'in_progress' => $inProgress->count(),
|
|
||||||
'success_rate' => $successRate,
|
|
||||||
'avg_duration_ms' => $avgDurationMs,
|
|
||||||
'posts_created' => $runs->whereNotNull('generated_post_id')->count(),
|
|
||||||
],
|
|
||||||
'timeseries' => $this->buildTimeseries($runs, $start, $days),
|
|
||||||
'platforms' => $this->buildPlatformBreakdown($runs->pluck('generated_post_id')->filter()->all()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zero-filled daily buckets so the chart line stays continuous across days
|
|
||||||
* with no runs.
|
|
||||||
*
|
|
||||||
* @param Collection<int, AutomationRun> $runs
|
|
||||||
* @return array<int, array{date: string, started: int, completed: int, failed: int}>
|
|
||||||
*/
|
|
||||||
private function buildTimeseries(Collection $runs, CarbonInterface $since, int $days): array
|
|
||||||
{
|
|
||||||
$series = [];
|
|
||||||
for ($i = 0; $i < $days; $i++) {
|
|
||||||
$date = $since->copy()->addDays($i)->format('Y-m-d');
|
|
||||||
$series[$date] = ['date' => $date, 'started' => 0, 'completed' => 0, 'failed' => 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($runs as $run) {
|
|
||||||
$date = $run->created_at->format('Y-m-d');
|
|
||||||
|
|
||||||
if (! isset($series[$date])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$series[$date]['started']++;
|
|
||||||
|
|
||||||
if ($run->status === Status::Completed) {
|
|
||||||
$series[$date]['completed']++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->status === Status::Failed) {
|
|
||||||
$series[$date]['failed']++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values($series);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count published platform targets across the posts this automation
|
|
||||||
* generated, so the chart shows where its output actually went.
|
|
||||||
*
|
|
||||||
* @param array<int, string> $postIds
|
|
||||||
* @return array<int, array{platform: string, count: int}>
|
|
||||||
*/
|
|
||||||
private function buildPlatformBreakdown(array $postIds): array
|
|
||||||
{
|
|
||||||
if ($postIds === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return PostPlatform::query()
|
|
||||||
->whereIn('post_id', $postIds)
|
|
||||||
->selectRaw('platform, count(*) as total')
|
|
||||||
->groupBy('platform')
|
|
||||||
->orderByDesc('total')
|
|
||||||
->get()
|
|
||||||
->map(fn ($row) => ['platform' => $row->platform->value, 'count' => (int) $row->total])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Automation;
|
|
||||||
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
||||||
|
|
||||||
class ListAutomations
|
|
||||||
{
|
|
||||||
public function __invoke(Workspace $workspace): LengthAwarePaginator
|
|
||||||
{
|
|
||||||
return Automation::query()
|
|
||||||
->where('workspace_id', $workspace->id)
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->paginate((int) config('app.pagination.default'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Automation;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Status;
|
|
||||||
use App\Models\Automation;
|
|
||||||
|
|
||||||
class PauseAutomation
|
|
||||||
{
|
|
||||||
public function __invoke(Automation $automation): Automation
|
|
||||||
{
|
|
||||||
$automation->update([
|
|
||||||
'status' => Status::Paused,
|
|
||||||
'paused_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $automation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Automation;
|
|
||||||
|
|
||||||
use App\Models\Automation;
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class UpdateAutomation
|
|
||||||
{
|
|
||||||
public function __invoke(Automation $automation, array $data): Automation
|
|
||||||
{
|
|
||||||
$this->detectCycles($data['nodes'] ?? [], $data['connections'] ?? []);
|
|
||||||
|
|
||||||
$automation->update([
|
|
||||||
'name' => $data['name'] ?? $automation->name,
|
|
||||||
'nodes' => $data['nodes'] ?? $automation->nodes,
|
|
||||||
'connections' => $data['connections'] ?? $automation->connections,
|
|
||||||
'variables' => $data['variables'] ?? $automation->variables,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $automation->fresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function detectCycles(array $nodes, array $connections): void
|
|
||||||
{
|
|
||||||
$adj = [];
|
|
||||||
foreach ($connections as $c) {
|
|
||||||
$adj[$c['source']][] = $c['target'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<string, string> $state state: 'white' (unvisited), 'gray' (in stack), 'black' (done) */
|
|
||||||
$state = [];
|
|
||||||
foreach ($nodes as $node) {
|
|
||||||
$state[$node['id']] = 'white';
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($nodes as $node) {
|
|
||||||
if ($state[$node['id']] === 'white' && $this->hasCycleFrom($node['id'], $adj, $state)) {
|
|
||||||
throw new DomainException(__('automations.errors.graph_contains_cycle'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasCycleFrom(string $node, array $adj, array &$state): bool
|
|
||||||
{
|
|
||||||
$state[$node] = 'gray';
|
|
||||||
|
|
||||||
foreach ($adj[$node] ?? [] as $next) {
|
|
||||||
if (! isset($state[$next])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($state[$next] === 'gray') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if ($state[$next] === 'white' && $this->hasCycleFrom($next, $adj, $state)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$state[$node] = 'black';
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Node;
|
|
||||||
|
|
||||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
|
||||||
use App\Enums\Automation\Condition\Handle;
|
|
||||||
use App\Enums\Automation\Condition\Operator;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use App\Services\Automation\ExpressionResolver;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class RunConditionNode
|
|
||||||
{
|
|
||||||
private const MAX_REGEX_LENGTH = 200;
|
|
||||||
|
|
||||||
public function __construct(private ExpressionResolver $resolver) {}
|
|
||||||
|
|
||||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|
||||||
{
|
|
||||||
$context = $run->resolverContext();
|
|
||||||
$field = $this->resolver->resolve((string) data_get($config, 'field', ''), $context);
|
|
||||||
$operator = Operator::from(data_get($config, 'operator', Operator::Equals->value));
|
|
||||||
$value = $this->resolver->resolve((string) data_get($config, 'value', ''), $context);
|
|
||||||
|
|
||||||
$matched = match ($operator) {
|
|
||||||
Operator::Contains => str_contains($field, $value),
|
|
||||||
Operator::NotContains => ! str_contains($field, $value),
|
|
||||||
Operator::Equals => $field === $value,
|
|
||||||
Operator::NotEquals => $field !== $value,
|
|
||||||
Operator::Matches => $this->safeRegexMatch($value, $field),
|
|
||||||
Operator::GreaterThan => is_numeric($field) && is_numeric($value) && (float) $field > (float) $value,
|
|
||||||
Operator::LessThan => is_numeric($field) && is_numeric($value) && (float) $field < (float) $value,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NodeRunResult::completed(
|
|
||||||
output: ['condition' => ['resolved_field' => $field, 'matched' => $matched]],
|
|
||||||
nextHandle: ($matched ? Handle::Yes : Handle::No)->value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function safeRegexMatch(string $pattern, string $subject): bool
|
|
||||||
{
|
|
||||||
if (strlen($pattern) > self::MAX_REGEX_LENGTH) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$escaped = str_replace('~', '\~', $pattern);
|
|
||||||
$regex = "~{$escaped}~u";
|
|
||||||
|
|
||||||
try {
|
|
||||||
$result = @preg_match($regex, $subject);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result === false || preg_last_error() !== PREG_NO_ERROR) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result === 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Node;
|
|
||||||
|
|
||||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
|
||||||
use App\Enums\Automation\DelayUnit;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
class RunDelayNode
|
|
||||||
{
|
|
||||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|
||||||
{
|
|
||||||
$duration = (int) data_get($config, 'duration', 0);
|
|
||||||
$unit = data_get($config, 'unit', DelayUnit::Minutes->value);
|
|
||||||
|
|
||||||
$until = match (DelayUnit::tryFrom((string) $unit)) {
|
|
||||||
DelayUnit::Minutes => now()->addMinutes($duration),
|
|
||||||
DelayUnit::Hours => now()->addHours($duration),
|
|
||||||
DelayUnit::Days => now()->addDays($duration),
|
|
||||||
default => throw new InvalidArgumentException("Unknown delay unit: {$unit}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
return NodeRunResult::sleep($until);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Node;
|
|
||||||
|
|
||||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
|
|
||||||
class RunEndNode
|
|
||||||
{
|
|
||||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|
||||||
{
|
|
||||||
$reason = data_get($config, 'reason');
|
|
||||||
|
|
||||||
return NodeRunResult::completed(output: [
|
|
||||||
'end' => [
|
|
||||||
'ended_at' => now()->toIso8601String(),
|
|
||||||
'reason' => $reason ?: null,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Node;
|
|
||||||
|
|
||||||
use App\Actions\Automation\Run\AdvanceAutomationRun;
|
|
||||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
|
||||||
use App\Enums\Automation\Run\Status as RunStatus;
|
|
||||||
use App\Models\AutomationNodeState;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use App\Services\Automation\ExpressionResolver;
|
|
||||||
use App\Services\Automation\FeedParser;
|
|
||||||
use App\Services\Brand\SafeHttpFetcher;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use RuntimeException;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches an RSS feed and progresses the run with the next-up unseen item.
|
|
||||||
*
|
|
||||||
* - Filters by a per-node watermark stored in `automation_node_states`, so the
|
|
||||||
* very first run on an old feed processes none of the historical items.
|
|
||||||
* - When the fetch returns N new items, the current run takes item[0]; the
|
|
||||||
* remaining N-1 items are spawned as sibling runs that resume at the node
|
|
||||||
* immediately after this Fetch (with `context.fetched` already populated),
|
|
||||||
* so each item ends up generating its own Post / etc.
|
|
||||||
* - When the feed yields no new items, the result short-circuits via the
|
|
||||||
* `no_items` handle; if the user hasn't wired anything to it, the run
|
|
||||||
* completes silently (handled by AdvanceAutomationRun's default branch).
|
|
||||||
*/
|
|
||||||
class RunFetchRssNode
|
|
||||||
{
|
|
||||||
private const ITEM_HANDLE = 'default';
|
|
||||||
|
|
||||||
private const NO_ITEMS_HANDLE = 'no_items';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private ExpressionResolver $resolver,
|
|
||||||
private SafeHttpFetcher $safeHttp,
|
|
||||||
private AdvanceAutomationRun $advance,
|
|
||||||
private FeedParser $parser,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|
||||||
{
|
|
||||||
$feedUrl = $this->resolver->resolve((string) data_get($config, 'feed_url', ''), $run->resolverContext());
|
|
||||||
|
|
||||||
if ($feedUrl === '') {
|
|
||||||
return NodeRunResult::failed(__('automations.errors.fetch_rss_missing_url'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// SafeHttpFetcher::get() re-validates every redirect hop against the SSRF
|
|
||||||
// guard (not just the initial URL), so a public feed that 302s to an
|
|
||||||
// internal host is never followed. It throws on a blocked hop, connection
|
|
||||||
// failure, non-2xx status, or an excessive redirect chain — all of which
|
|
||||||
// are legitimate "this feed couldn't be fetched" failures for this node.
|
|
||||||
try {
|
|
||||||
$response = $this->safeHttp->get($feedUrl);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), [
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$items = $this->parser->parse($response->body());
|
|
||||||
|
|
||||||
if ($items === null) {
|
|
||||||
return NodeRunResult::failed(__('automations.errors.fetch_rss_malformed'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$nodeId = (string) $run->current_node_id;
|
|
||||||
// Test runs (dry OR real-data) bypass the watermark entirely: they use an
|
|
||||||
// epoch watermark so every item is treated as new, process only the first
|
|
||||||
// item, and never spawn siblings or advance the watermark. This way a
|
|
||||||
// test ALWAYS shows real data flowing through (instead of "no new items")
|
|
||||||
// and never floods the feed or poisons the production watermark.
|
|
||||||
$isPreview = $run->is_manual || $run->is_dry_run;
|
|
||||||
$state = $isPreview ? null : AutomationNodeState::for($run->automation_id, $nodeId);
|
|
||||||
$watermark = $isPreview
|
|
||||||
? CarbonImmutable::createFromTimestamp(0)
|
|
||||||
: $this->parseWatermark(data_get($state->data, 'last_item_date'));
|
|
||||||
|
|
||||||
[$newItems, $newestSeen] = $this->collectNewItems($items, $watermark);
|
|
||||||
|
|
||||||
if ($state !== null && $newestSeen !== null) {
|
|
||||||
$state->update(['data' => array_merge($state->data ?? [], [
|
|
||||||
'last_item_date' => $newestSeen->toIso8601String(),
|
|
||||||
])]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($newItems === []) {
|
|
||||||
return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: self::NO_ITEMS_HANDLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
$total = count($newItems);
|
|
||||||
|
|
||||||
// A preview (manual/dry test) surfaces ONE item and never fans out, so it
|
|
||||||
// shows the newest item — what the user expects to test against. A real run
|
|
||||||
// takes the oldest new item and fans the rest out as siblings, preserving
|
|
||||||
// feed chronology across branches (items are sorted oldest-first).
|
|
||||||
if ($isPreview) {
|
|
||||||
return NodeRunResult::completed([
|
|
||||||
'fetch' => ['count' => $total, 'spawned' => 0],
|
|
||||||
'fetched' => end($newItems),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$first = array_shift($newItems);
|
|
||||||
$this->spawnSiblings($run, $nodeId, $newItems);
|
|
||||||
|
|
||||||
return NodeRunResult::completed([
|
|
||||||
'fetch' => ['count' => $total, 'spawned' => count($newItems)],
|
|
||||||
'fetched' => $first,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<array<string, mixed>> $parsed Normalized items from FeedParser.
|
|
||||||
* @return array{0: list<array<string, mixed>>, 1: ?CarbonImmutable}
|
|
||||||
*/
|
|
||||||
private function collectNewItems(array $parsed, CarbonImmutable $watermark): array
|
|
||||||
{
|
|
||||||
$items = [];
|
|
||||||
$newestSeen = null;
|
|
||||||
|
|
||||||
foreach ($parsed as $item) {
|
|
||||||
$key = (string) data_get($item, 'key', '');
|
|
||||||
if ($key === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$date = $this->parsePubDate((string) data_get($item, 'date', ''));
|
|
||||||
if ($date === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($newestSeen === null || $date->greaterThan($newestSeen)) {
|
|
||||||
$newestSeen = $date;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $date->greaterThan($watermark)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$item['_sort'] = $date->getTimestamp();
|
|
||||||
$items[] = $item;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process oldest-first so siblings inherit a stable order matching feed chronology.
|
|
||||||
usort($items, fn ($a, $b) => $a['_sort'] <=> $b['_sort']);
|
|
||||||
|
|
||||||
// Drop the internal sort key — downstream nodes shouldn't see it.
|
|
||||||
$items = array_map(function (array $item): array {
|
|
||||||
unset($item['_sort']);
|
|
||||||
|
|
||||||
return $item;
|
|
||||||
}, $items);
|
|
||||||
|
|
||||||
return [$items, $newestSeen];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array $items): void
|
|
||||||
{
|
|
||||||
if ($items === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each remaining item gets its own run that fans out across EVERY branch
|
|
||||||
// wired to the fetch node — matching how item[0] (the current run) fans
|
|
||||||
// out, so no branch silently drops items 2..N.
|
|
||||||
$targets = $this->advance->targetsFor($parent->automation, $fetchNodeId, self::ITEM_HANDLE);
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
|
||||||
$sibling = AutomationRun::create([
|
|
||||||
'automation_id' => $parent->automation_id,
|
|
||||||
'root_run_id' => $parent->rootId(),
|
|
||||||
'trigger_item_id' => $parent->trigger_item_id,
|
|
||||||
'generated_post_id' => $parent->generated_post_id,
|
|
||||||
'is_manual' => $parent->is_manual,
|
|
||||||
'is_dry_run' => $parent->is_dry_run,
|
|
||||||
'status' => RunStatus::Pending,
|
|
||||||
'context' => array_merge($parent->context ?? [], ['fetched' => $item]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($targets === []) {
|
|
||||||
$sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->advance->dispatchBranches($sibling, $targets);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parseWatermark(?string $stored): CarbonImmutable
|
|
||||||
{
|
|
||||||
if ($stored === null) {
|
|
||||||
return CarbonImmutable::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return CarbonImmutable::parse($stored);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return CarbonImmutable::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parsePubDate(string $raw): ?CarbonImmutable
|
|
||||||
{
|
|
||||||
if ($raw === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return CarbonImmutable::parse($raw);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,377 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Node;
|
|
||||||
|
|
||||||
use App\Actions\Post\CreatePost;
|
|
||||||
use App\Ai\Agents\PostContentGenerator;
|
|
||||||
use App\Ai\Agents\PostContentHumanizer;
|
|
||||||
use App\Ai\Templates\AiTemplateRegistry;
|
|
||||||
use App\Ai\Templates\TemplateContext;
|
|
||||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
|
||||||
use App\Enums\Ai\ContentStyle;
|
|
||||||
use App\Enums\Ai\GeneratorFormat;
|
|
||||||
use App\Enums\Post\CreatedVia;
|
|
||||||
use App\Enums\PostPlatform\ContentType;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use App\Models\SocialAccount;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Ai\RecordAiUsage;
|
|
||||||
use App\Services\Automation\ExpressionResolver;
|
|
||||||
use App\Services\Automation\GenerateNodeValidator;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class RunGenerateNode
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private ExpressionResolver $resolver,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|
||||||
{
|
|
||||||
$context = $run->resolverContext();
|
|
||||||
$prompt = $this->resolver->resolve((string) data_get($config, 'prompt_template', ''), $context);
|
|
||||||
|
|
||||||
$accountsConfig = $this->resolveAccountsConfig($config);
|
|
||||||
['format' => $format, 'slide_count' => $slideCount] = $this->deriveFormat($accountsConfig, $config);
|
|
||||||
|
|
||||||
$accountIds = array_values(array_filter(array_map(
|
|
||||||
fn ($a) => data_get($a, 'social_account_id'),
|
|
||||||
$accountsConfig,
|
|
||||||
)));
|
|
||||||
|
|
||||||
$workspace = $run->automation->workspace;
|
|
||||||
|
|
||||||
$activeAccounts = SocialAccount::query()
|
|
||||||
->whereIn('id', $accountIds)
|
|
||||||
->where('workspace_id', $workspace->id)
|
|
||||||
->active()
|
|
||||||
->get()
|
|
||||||
->keyBy('id');
|
|
||||||
|
|
||||||
$applyBrandVoice = (bool) data_get($config, 'use_brand_voice', true);
|
|
||||||
|
|
||||||
$platformContext = $this->resolvePlatformContext($accountsConfig);
|
|
||||||
|
|
||||||
$style = ContentStyle::tryFrom((string) data_get($config, 'style', ContentStyle::default()->value)) ?? ContentStyle::default();
|
|
||||||
$styleTemplate = app(AiTemplateRegistry::class)->find($style);
|
|
||||||
|
|
||||||
$platforms = [];
|
|
||||||
foreach ($accountsConfig as $entry) {
|
|
||||||
$accountId = data_get($entry, 'social_account_id');
|
|
||||||
if (! $accountId || ! $activeAccounts->has($accountId)) {
|
|
||||||
if ($accountId) {
|
|
||||||
Log::warning('RunGenerateNode: account no longer active, skipping', [
|
|
||||||
'automation_id' => $run->automation_id,
|
|
||||||
'social_account_id' => $accountId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$platforms[] = [
|
|
||||||
'social_account_id' => $accountId,
|
|
||||||
'content_type' => data_get($entry, 'content_type'),
|
|
||||||
'meta' => data_get($entry, 'meta', []),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$wantsImage = (int) data_get($config, 'target_slide_count', 1) >= 1;
|
|
||||||
|
|
||||||
$brandAccount = $platforms !== []
|
|
||||||
? $activeAccounts->get(data_get($platforms[0], 'social_account_id'))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$isCarousel = $format->isCarousel();
|
|
||||||
$imageCount = $isCarousel ? $slideCount : ($wantsImage ? 1 : 0);
|
|
||||||
|
|
||||||
$templateContext = new TemplateContext(
|
|
||||||
workspace: $workspace,
|
|
||||||
socialAccount: $brandAccount,
|
|
||||||
format: $platformContext ?? $format->value,
|
|
||||||
imageCount: $imageCount,
|
|
||||||
isCarousel: $isCarousel,
|
|
||||||
applyBrandVisuals: (bool) data_get($config, 'use_brand_visuals', true),
|
|
||||||
);
|
|
||||||
|
|
||||||
$agent = new PostContentGenerator(
|
|
||||||
workspace: $workspace,
|
|
||||||
format: $format,
|
|
||||||
slideCount: $slideCount,
|
|
||||||
platformContext: $platformContext,
|
|
||||||
applyBrandVoice: $applyBrandVoice,
|
|
||||||
template: $styleTemplate,
|
|
||||||
templateContext: $templateContext,
|
|
||||||
);
|
|
||||||
|
|
||||||
$generatorResponse = $agent->prompt($prompt);
|
|
||||||
|
|
||||||
RecordAiUsage::recordText(
|
|
||||||
workspace: $workspace,
|
|
||||||
promptTokens: $generatorResponse->usage->promptTokens,
|
|
||||||
completionTokens: $generatorResponse->usage->completionTokens,
|
|
||||||
provider: (string) $generatorResponse->meta->provider,
|
|
||||||
model: (string) $generatorResponse->meta->model,
|
|
||||||
metadata: ['agent' => 'post_generator', 'format' => $format->value, 'source' => 'automation'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$structured = $generatorResponse->structured ?? [];
|
|
||||||
|
|
||||||
$structured = $this->humanize($workspace, $structured, $format, $style, $applyBrandVoice, $platformContext);
|
|
||||||
|
|
||||||
$intendedImageCount = $this->intendedImageCount($format, $slideCount, $wantsImage, $structured, $brandAccount, $style);
|
|
||||||
|
|
||||||
if ($run->is_dry_run) {
|
|
||||||
$dryContent = $this->extractContent($structured, $format, $style);
|
|
||||||
|
|
||||||
return NodeRunResult::completed(output: [
|
|
||||||
'generated' => [
|
|
||||||
'post_id' => null,
|
|
||||||
'content' => $dryContent,
|
|
||||||
'dry_run' => true,
|
|
||||||
'image_count' => $intendedImageCount,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$generated = $styleTemplate->assemble($structured, $templateContext);
|
|
||||||
|
|
||||||
$user = $this->resolveUser($run);
|
|
||||||
|
|
||||||
$post = CreatePost::execute($workspace, $user, [
|
|
||||||
'content' => $generated->content,
|
|
||||||
'media' => $generated->media,
|
|
||||||
'platforms' => $platforms,
|
|
||||||
'created_via' => CreatedVia::Automation,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$run->update(['generated_post_id' => $post->id]);
|
|
||||||
|
|
||||||
return NodeRunResult::completed(output: [
|
|
||||||
'generated' => [
|
|
||||||
'post_id' => $post->id,
|
|
||||||
'content' => $generated->content,
|
|
||||||
'post_url' => route('app.posts.show', $post->id),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $structured
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function humanize(Workspace $workspace, array $structured, GeneratorFormat $format, ContentStyle $style, bool $applyBrandVoice = true, ?string $platformContext = null): array
|
|
||||||
{
|
|
||||||
if (! $style->humanizes()) {
|
|
||||||
return $structured;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$input = $format->isCarousel()
|
|
||||||
? [
|
|
||||||
'caption' => data_get($structured, 'caption', ''),
|
|
||||||
'slides' => array_map(
|
|
||||||
fn ($s) => [
|
|
||||||
'title' => data_get($s, 'title', ''),
|
|
||||||
'body' => data_get($s, 'body', ''),
|
|
||||||
],
|
|
||||||
data_get($structured, 'slides', []),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
'content' => data_get($structured, 'content', ''),
|
|
||||||
'image_title' => data_get($structured, 'image_title', ''),
|
|
||||||
'image_body' => data_get($structured, 'image_body', ''),
|
|
||||||
];
|
|
||||||
|
|
||||||
$humanizer = new PostContentHumanizer($workspace, $format, platformContext: $platformContext, applyBrandVoice: $applyBrandVoice);
|
|
||||||
$response = $humanizer->prompt(json_encode($input, JSON_UNESCAPED_UNICODE));
|
|
||||||
$humanized = $response->structured ?? [];
|
|
||||||
|
|
||||||
RecordAiUsage::recordText(
|
|
||||||
workspace: $workspace,
|
|
||||||
promptTokens: $response->usage->promptTokens,
|
|
||||||
completionTokens: $response->usage->completionTokens,
|
|
||||||
provider: (string) $response->meta->provider,
|
|
||||||
model: (string) $response->meta->model,
|
|
||||||
metadata: ['agent' => 'post_humanizer', 'format' => $format->value, 'source' => 'automation'],
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($format->isCarousel()) {
|
|
||||||
$structured['caption'] = data_get($humanized, 'caption', data_get($structured, 'caption', ''));
|
|
||||||
$originalSlides = data_get($structured, 'slides', []);
|
|
||||||
$humanizedSlides = data_get($humanized, 'slides', []);
|
|
||||||
|
|
||||||
foreach ($originalSlides as $i => $slide) {
|
|
||||||
if (isset($humanizedSlides[$i])) {
|
|
||||||
$originalSlides[$i]['title'] = data_get($humanizedSlides[$i], 'title', data_get($slide, 'title', ''));
|
|
||||||
$originalSlides[$i]['body'] = data_get($humanizedSlides[$i], 'body', data_get($slide, 'body', ''));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$structured['slides'] = $originalSlides;
|
|
||||||
} else {
|
|
||||||
$structured['content'] = data_get($humanized, 'content', data_get($structured, 'content', ''));
|
|
||||||
$structured['image_title'] = data_get($humanized, 'image_title', data_get($structured, 'image_title', ''));
|
|
||||||
$structured['image_body'] = data_get($humanized, 'image_body', data_get($structured, 'image_body', ''));
|
|
||||||
}
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::warning('RunGenerateNode: PostContentHumanizer failed, using generator output as-is', [
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $structured;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the post caption from the raw structured output without calling
|
|
||||||
* assemble() (which triggers image generation). Used for dry-run responses
|
|
||||||
* so no pipeline work happens during test runs.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $structured
|
|
||||||
*/
|
|
||||||
private function extractContent(array $structured, GeneratorFormat $format, ContentStyle $style): string
|
|
||||||
{
|
|
||||||
if ($style->isTweetCard()) {
|
|
||||||
return $format->isCarousel()
|
|
||||||
? (string) data_get($structured, 'caption', '')
|
|
||||||
: (string) data_get($structured, 'tweet_text', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $format->isCarousel()
|
|
||||||
? (string) data_get($structured, 'caption', '')
|
|
||||||
: (string) data_get($structured, 'content', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive the generator format and slide count from per-account content types.
|
|
||||||
*
|
|
||||||
* Carousel-capable content types:
|
|
||||||
* - instagram_feed (Instagram feed carousel = multi-image feed post)
|
|
||||||
* - linkedin_post (LinkedIn multi-image post — 2+ images)
|
|
||||||
* - linkedin_page_post (LinkedIn page multi-image post)
|
|
||||||
* - pinterest_carousel (Pinterest carousel pin)
|
|
||||||
* - tiktok_photo (TikTok photo carousel)
|
|
||||||
*
|
|
||||||
* When at least one account has a carousel-capable content type AND
|
|
||||||
* target_slide_count > 1, the generator is told to produce a carousel with
|
|
||||||
* that many slides. Otherwise it falls back to a single-post format.
|
|
||||||
*
|
|
||||||
* @param array<int, array{social_account_id: string, content_type: ?string, meta: array<string, mixed>}> $accountsConfig
|
|
||||||
* @param array<string, mixed> $config
|
|
||||||
* @return array{format: GeneratorFormat, slide_count: int}
|
|
||||||
*/
|
|
||||||
public function deriveFormat(array $accountsConfig, array $config): array
|
|
||||||
{
|
|
||||||
$maxImagesAcross = 0;
|
|
||||||
foreach ($accountsConfig as $entry) {
|
|
||||||
$contentType = ContentType::tryFrom((string) data_get($entry, 'content_type'));
|
|
||||||
if ($contentType instanceof ContentType && $contentType->supportsImage() && $contentType->maxMediaCount() > 1) {
|
|
||||||
$maxImagesAcross = max($maxImagesAcross, $contentType->maxMediaCount());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$targetSlideCount = (int) data_get($config, 'target_slide_count', 1);
|
|
||||||
|
|
||||||
if ($maxImagesAcross > 1 && $targetSlideCount > 1) {
|
|
||||||
$cap = min(GenerateNodeValidator::MAX_GENERATED_IMAGES, $maxImagesAcross);
|
|
||||||
|
|
||||||
return ['format' => GeneratorFormat::Carousel, 'slide_count' => min($targetSlideCount, $cap)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['format' => GeneratorFormat::Single, 'slide_count' => 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick the content type the generator should write for so the copy fits
|
|
||||||
* every selected network. A Generate node can target one or many accounts,
|
|
||||||
* each with its own content type, so we feed the generator the MOST
|
|
||||||
* RESTRICTIVE platform (smallest character cap) — content that fits X (280)
|
|
||||||
* also fits LinkedIn (3000). Returns null when no account carries a known
|
|
||||||
* content type, leaving the generator platform-agnostic.
|
|
||||||
*
|
|
||||||
* @param array<int, array{social_account_id: string, content_type: ?string, meta: array<string, mixed>}> $accountsConfig
|
|
||||||
*/
|
|
||||||
private function resolvePlatformContext(array $accountsConfig): ?string
|
|
||||||
{
|
|
||||||
return collect($accountsConfig)
|
|
||||||
->map(fn ($entry) => ContentType::tryFrom((string) data_get($entry, 'content_type')))
|
|
||||||
->filter()
|
|
||||||
->sortBy(fn (ContentType $contentType) => $contentType->platform()->maxContentLength())
|
|
||||||
->first()?->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Number of images that would be attached for the resolved format. Used as
|
|
||||||
* the dry-run indicator and mirrors the non-dry image generation branches:
|
|
||||||
* one per slide for carousels, one for single posts when images are enabled.
|
|
||||||
* Tweet styles always produce one image per slide/post when an account is set.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $structured
|
|
||||||
*/
|
|
||||||
private function intendedImageCount(GeneratorFormat $format, int $slideCount, bool $wantsImage, array $structured, ?SocialAccount $brandAccount, ContentStyle $style): int
|
|
||||||
{
|
|
||||||
if (! $brandAccount) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($style->isTweetCard()) {
|
|
||||||
return $format->isCarousel() ? $slideCount : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($format->isCarousel()) {
|
|
||||||
$slides = data_get($structured, 'slides', []);
|
|
||||||
|
|
||||||
return is_array($slides) ? count($slides) : $slideCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $wantsImage ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveUser(AutomationRun $run): User
|
|
||||||
{
|
|
||||||
if ($run->automation->user_id) {
|
|
||||||
return $run->automation->user;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $run->automation->workspace->owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the current `accounts` shape and fall back to the legacy
|
|
||||||
* `social_account_ids` array so older automations keep running until
|
|
||||||
* the user re-opens and saves the node.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $config
|
|
||||||
* @return array<int, array{social_account_id: string, content_type: ?string, meta: array<string, mixed>}>
|
|
||||||
*/
|
|
||||||
private function resolveAccountsConfig(array $config): array
|
|
||||||
{
|
|
||||||
$accounts = data_get($config, 'accounts');
|
|
||||||
|
|
||||||
if (is_array($accounts)) {
|
|
||||||
return array_values(array_map(fn ($entry) => [
|
|
||||||
'social_account_id' => (string) data_get($entry, 'social_account_id', ''),
|
|
||||||
'content_type' => data_get($entry, 'content_type'),
|
|
||||||
'meta' => (array) data_get($entry, 'meta', []),
|
|
||||||
], $accounts));
|
|
||||||
}
|
|
||||||
|
|
||||||
$legacy = data_get($config, 'social_account_ids', []);
|
|
||||||
|
|
||||||
if (! is_array($legacy)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_map(fn ($id) => [
|
|
||||||
'social_account_id' => (string) $id,
|
|
||||||
'content_type' => null,
|
|
||||||
'meta' => [],
|
|
||||||
], $legacy));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,473 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Node;
|
|
||||||
|
|
||||||
use App\Actions\Automation\Run\AdvanceAutomationRun;
|
|
||||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
|
||||||
use App\Enums\Automation\AuthType;
|
|
||||||
use App\Enums\Automation\HttpMethod;
|
|
||||||
use App\Enums\Automation\Run\Status as RunStatus;
|
|
||||||
use App\Models\AutomationNodeState;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use App\Services\Automation\ExpressionResolver;
|
|
||||||
use App\Services\Brand\SafeHttpFetcher;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Illuminate\Http\Client\PendingRequest;
|
|
||||||
use Illuminate\Http\Client\Response;
|
|
||||||
use Illuminate\Support\Facades\Crypt;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use RuntimeException;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generalized HTTP node — supersedes the old `fetch_json` polling-only node.
|
|
||||||
*
|
|
||||||
* Two execution modes share one config:
|
|
||||||
* - **Single request mode**: the response is a single object (or `items_path`
|
|
||||||
* resolves to nothing), so the parsed body is set on `context.fetched` as a
|
|
||||||
* single payload. Useful for enrichment ("look up user X before generating").
|
|
||||||
* - **List mode**: the response is a collection, so each item drives its own
|
|
||||||
* downstream branch. The collection is found by, in order:
|
|
||||||
* 1. `items_path` (dot notation, e.g. `data.items`; `*` iterates a top-level
|
|
||||||
* object map's values),
|
|
||||||
* 2. an empty `items_path` + a top-level JSON array,
|
|
||||||
* 3. an empty `items_path` + an NDJSON (one JSON object per line) body.
|
|
||||||
* The current run takes the first new item; the rest spawn sibling runs.
|
|
||||||
*
|
|
||||||
* New items are detected (so a feed isn't reprocessed every poll) by, in order:
|
|
||||||
* - `item_date_path` — a per-node date watermark (cheapest, bounded), or
|
|
||||||
* - `item_key_path` — a per-node set of seen keys (covers ids/uuids/urls).
|
|
||||||
* Either way the FIRST poll records the baseline and emits nothing, so pointing
|
|
||||||
* the node at an existing feed never floods on day one.
|
|
||||||
*
|
|
||||||
* Auth types: none, bearer, basic, api_key. Credentials are stored encrypted
|
|
||||||
* on the Automation model (see `Automation::booted()`) and decrypted here.
|
|
||||||
*/
|
|
||||||
class RunHttpRequestNode
|
|
||||||
{
|
|
||||||
private const ITEM_HANDLE = 'default';
|
|
||||||
|
|
||||||
private const NO_ITEMS_HANDLE = 'no_items';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upper bound on the per-node seen-key history. FIFO-evicts the oldest keys
|
|
||||||
* once exceeded — matching n8n's capped "history size" for its dedup store.
|
|
||||||
*/
|
|
||||||
private const MAX_SEEN_KEYS = 500;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private ExpressionResolver $resolver,
|
|
||||||
private SafeHttpFetcher $safeHttp,
|
|
||||||
private AdvanceAutomationRun $advance,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|
||||||
{
|
|
||||||
$url = (string) data_get($config, 'url', '');
|
|
||||||
$method = strtoupper((string) data_get($config, 'method', HttpMethod::Get->value));
|
|
||||||
$nodeId = (string) $run->current_node_id;
|
|
||||||
$context = $run->resolverContext();
|
|
||||||
|
|
||||||
if ($url === '') {
|
|
||||||
return NodeRunResult::failed(__('automations.errors.http_missing_url'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolvedUrl = $this->resolver->resolve($url, $context);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->safeHttp->guardAgainstSsrf($resolvedUrl);
|
|
||||||
} catch (RuntimeException) {
|
|
||||||
return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [
|
|
||||||
'reason' => 'url_not_allowed',
|
|
||||||
'url' => $resolvedUrl,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$request = $this->buildRequest($config, $context);
|
|
||||||
$jsonBody = $this->buildJsonBody($method, $config, $context);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = match (HttpMethod::tryFrom($method)) {
|
|
||||||
HttpMethod::Get => $request->get($resolvedUrl),
|
|
||||||
HttpMethod::Delete => $request->delete($resolvedUrl),
|
|
||||||
HttpMethod::Post => $request->post($resolvedUrl, $jsonBody),
|
|
||||||
HttpMethod::Put => $request->put($resolvedUrl, $jsonBody),
|
|
||||||
HttpMethod::Patch => $request->patch($resolvedUrl, $jsonBody),
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
return NodeRunResult::failed(__('automations.errors.http_request_exception'), ['message' => $e->getMessage()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($response === null) {
|
|
||||||
return NodeRunResult::failed("Unsupported HTTP method: {$method}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
return NodeRunResult::failed(__('automations.errors.http_request_failed'), [
|
|
||||||
'status' => $response->status(),
|
|
||||||
'body' => substr($response->body(), 0, 500),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = $this->decodeBody($response);
|
|
||||||
$itemsPath = is_string($raw = data_get($config, 'items_path')) ? trim($raw) : '';
|
|
||||||
|
|
||||||
// No path + a top-level array (or NDJSON list) → iterate it. No path + a
|
|
||||||
// single object/scalar → single-response mode: forward the whole body.
|
|
||||||
if ($itemsPath === '') {
|
|
||||||
if (! is_array($payload) || ! array_is_list($payload)) {
|
|
||||||
return NodeRunResult::completed([
|
|
||||||
'fetch' => ['count' => 1, 'spawned' => 0],
|
|
||||||
'fetched' => $payload,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->processItems($run, $nodeId, $config, $payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicit path: dot notation, or `*` to iterate a top-level object map.
|
|
||||||
$resolved = data_get($payload, $itemsPath);
|
|
||||||
|
|
||||||
if (! is_array($resolved)) {
|
|
||||||
return NodeRunResult::failed(__('automations.errors.http_items_path_not_array'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->processItems($run, $nodeId, $config, array_values($resolved));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes the response as JSON, falling back to NDJSON (one JSON value per
|
|
||||||
* line) so streaming/log-style list endpoints work too.
|
|
||||||
*/
|
|
||||||
private function decodeBody(Response $response): mixed
|
|
||||||
{
|
|
||||||
$json = $response->json();
|
|
||||||
|
|
||||||
if (is_array($json)) {
|
|
||||||
return $json;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->parseNdjson($response->body()) ?? $json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, mixed>|null The decoded list, or null when the body is
|
|
||||||
* not newline-delimited JSON.
|
|
||||||
*/
|
|
||||||
private function parseNdjson(string $body): ?array
|
|
||||||
{
|
|
||||||
$lines = preg_split('/\r\n|\r|\n/', trim($body)) ?: [];
|
|
||||||
$items = [];
|
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
$line = trim($line);
|
|
||||||
if ($line === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode($line, true);
|
|
||||||
if ($decoded === null && $line !== 'null') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$items[] = $decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
return count($items) > 1 ? $items : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $config
|
|
||||||
* @param array<int, mixed> $items
|
|
||||||
*/
|
|
||||||
private function processItems(AutomationRun $run, string $nodeId, array $config, array $items): NodeRunResult
|
|
||||||
{
|
|
||||||
$itemKeyPath = is_string($k = data_get($config, 'item_key_path')) ? trim($k) : '';
|
|
||||||
$itemDatePath = is_string($d = data_get($config, 'item_date_path')) ? trim($d) : '';
|
|
||||||
|
|
||||||
// Test runs (dry OR real-data) walk a single item so the user sees data
|
|
||||||
// flow, without spawning siblings, advancing watermarks or recording keys.
|
|
||||||
if ($run->is_manual || $run->is_dry_run) {
|
|
||||||
if ($items === []) {
|
|
||||||
return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: self::NO_ITEMS_HANDLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NodeRunResult::completed([
|
|
||||||
'fetch' => ['count' => count($items), 'spawned' => 0],
|
|
||||||
'fetched' => $items[0],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A date watermark is cheapest and bounded, so it wins when both are set;
|
|
||||||
// the seen-key set is the fallback for feeds without a usable date.
|
|
||||||
$newItems = match (true) {
|
|
||||||
$itemDatePath !== '' => $this->filterByDate($run->automation_id, $nodeId, $items, $itemDatePath),
|
|
||||||
$itemKeyPath !== '' => $this->filterBySeenKeys($run->automation_id, $nodeId, $items, $itemKeyPath),
|
|
||||||
default => $items,
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($newItems === []) {
|
|
||||||
return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: self::NO_ITEMS_HANDLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
$first = array_shift($newItems);
|
|
||||||
$this->spawnSiblings($run, $nodeId, $newItems);
|
|
||||||
|
|
||||||
return NodeRunResult::completed([
|
|
||||||
'fetch' => ['count' => count($newItems) + 1, 'spawned' => count($newItems)],
|
|
||||||
'fetched' => $first,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps only items newer than the per-node date watermark, then advances the
|
|
||||||
* watermark to the newest date seen. The first poll records the baseline and
|
|
||||||
* emits nothing, so an existing feed never floods on day one.
|
|
||||||
*
|
|
||||||
* @param array<int, mixed> $items
|
|
||||||
* @return array<int, mixed>
|
|
||||||
*/
|
|
||||||
private function filterByDate(string $automationId, string $nodeId, array $items, string $datePath): array
|
|
||||||
{
|
|
||||||
$state = AutomationNodeState::for($automationId, $nodeId);
|
|
||||||
$isFirstPoll = ! array_key_exists('last_item_date', (array) $state->data);
|
|
||||||
$watermark = $this->parseWatermark(data_get($state->data, 'last_item_date'));
|
|
||||||
$newest = null;
|
|
||||||
$new = [];
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
|
||||||
$date = $this->parseDate(data_get($item, $datePath));
|
|
||||||
if ($date === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($newest === null || $date->greaterThan($newest)) {
|
|
||||||
$newest = $date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The first poll only records the baseline (the newest item's date),
|
|
||||||
// emitting nothing — so an existing feed never floods on day one, even
|
|
||||||
// if some items are dated slightly ahead of the server clock.
|
|
||||||
if (! $isFirstPoll && $date->greaterThan($watermark)) {
|
|
||||||
$new[] = $item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($newest !== null) {
|
|
||||||
$state->update(['data' => array_merge($state->data ?? [], [
|
|
||||||
'last_item_date' => $newest->toIso8601String(),
|
|
||||||
])]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $new;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps only items whose key hasn't been seen before, recording the new keys
|
|
||||||
* in a FIFO-capped per-node set. The first poll records every key but emits
|
|
||||||
* nothing (baseline), matching the date-watermark semantics so pointing the
|
|
||||||
* node at an existing feed never floods on day one.
|
|
||||||
*
|
|
||||||
* @param array<int, mixed> $items
|
|
||||||
* @return array<int, mixed>
|
|
||||||
*/
|
|
||||||
private function filterBySeenKeys(string $automationId, string $nodeId, array $items, string $keyPath): array
|
|
||||||
{
|
|
||||||
$state = AutomationNodeState::for($automationId, $nodeId);
|
|
||||||
$isFirstPoll = ! array_key_exists('seen_keys', (array) $state->data);
|
|
||||||
$hashes = array_values((array) data_get($state->data, 'seen_keys', []));
|
|
||||||
$seen = array_flip($hashes);
|
|
||||||
$new = [];
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
|
||||||
$hash = $this->keyHash($item, $keyPath);
|
|
||||||
if (isset($seen[$hash])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$seen[$hash] = true;
|
|
||||||
$hashes[] = $hash;
|
|
||||||
|
|
||||||
if (! $isFirstPoll) {
|
|
||||||
$new[] = $item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count($hashes) > self::MAX_SEEN_KEYS) {
|
|
||||||
$hashes = array_slice($hashes, count($hashes) - self::MAX_SEEN_KEYS);
|
|
||||||
}
|
|
||||||
|
|
||||||
$state->update(['data' => array_merge((array) $state->data, ['seen_keys' => $hashes])]);
|
|
||||||
|
|
||||||
return $new;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stable hash for an item's dedup key. Objects use `item_key_path` (falling
|
|
||||||
* back to the whole item); scalars use their own value. Hashing keeps the
|
|
||||||
* stored set compact and avoids persisting raw payloads.
|
|
||||||
*/
|
|
||||||
private function keyHash(mixed $item, string $keyPath): string
|
|
||||||
{
|
|
||||||
if (is_array($item)) {
|
|
||||||
$key = $keyPath !== '' ? data_get($item, $keyPath) : null;
|
|
||||||
// Fall back to the whole item when no usable key is present; serialize
|
|
||||||
// guards against json_encode returning false on malformed UTF-8.
|
|
||||||
$key = ($key === null || $key === '') ? (json_encode($item) ?: serialize($item)) : (string) $key;
|
|
||||||
} else {
|
|
||||||
$key = (string) $item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return md5($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $config
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private function buildRequest(array $config, array $context): PendingRequest
|
|
||||||
{
|
|
||||||
$request = Http::asJson();
|
|
||||||
|
|
||||||
$headers = [];
|
|
||||||
foreach ((array) data_get($config, 'headers', []) as $k => $v) {
|
|
||||||
$headers[$k] = $this->resolver->resolve((string) $v, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
$authType = AuthType::tryFrom((string) data_get($config, 'auth_type', AuthType::None->value));
|
|
||||||
if ($authType === AuthType::Bearer) {
|
|
||||||
$token = $this->decrypt((string) data_get($config, 'auth_token', ''));
|
|
||||||
if ($token !== '') {
|
|
||||||
$request = $request->withToken($this->resolver->resolve($token, $context));
|
|
||||||
}
|
|
||||||
} elseif ($authType === AuthType::Basic) {
|
|
||||||
$user = (string) data_get($config, 'auth_username', '');
|
|
||||||
$pass = $this->decrypt((string) data_get($config, 'auth_password', ''));
|
|
||||||
if ($user !== '' || $pass !== '') {
|
|
||||||
$request = $request->withBasicAuth(
|
|
||||||
$this->resolver->resolve($user, $context),
|
|
||||||
$this->resolver->resolve($pass, $context),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} elseif ($authType === AuthType::ApiKey) {
|
|
||||||
$headerName = (string) data_get($config, 'auth_header_name', 'X-API-Key');
|
|
||||||
$token = $this->decrypt((string) data_get($config, 'auth_token', ''));
|
|
||||||
if ($token !== '') {
|
|
||||||
$headers[$headerName] = $this->resolver->resolve($token, $context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($headers !== []) {
|
|
||||||
$request = $request->withHeaders($headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $request
|
|
||||||
->withUserAgent(config('trypost.user_agent'))
|
|
||||||
->withOptions($this->safeHttp->redirectGuardOptions());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $config
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function buildJsonBody(string $method, array $config, array $context): array
|
|
||||||
{
|
|
||||||
if (! in_array(HttpMethod::tryFrom($method), HttpMethod::withBody(), true)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$template = (string) data_get($config, 'body_template', '');
|
|
||||||
if ($template === '') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the JSON body template first, then resolve placeholders in its
|
|
||||||
// string leaves so data containing quotes/newlines can't corrupt it.
|
|
||||||
$decodedTemplate = json_decode($template, true);
|
|
||||||
|
|
||||||
if (! is_array($decodedTemplate)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->resolver->resolveStructured($decodedTemplate, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, mixed> $items
|
|
||||||
*/
|
|
||||||
private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array $items): void
|
|
||||||
{
|
|
||||||
if ($items === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each remaining item gets its own run that fans out across EVERY branch
|
|
||||||
// wired to this node — matching how item[0] (the current run) fans out.
|
|
||||||
$targets = $this->advance->targetsFor($parent->automation, $fetchNodeId, self::ITEM_HANDLE);
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
|
||||||
$sibling = AutomationRun::create([
|
|
||||||
'automation_id' => $parent->automation_id,
|
|
||||||
'root_run_id' => $parent->rootId(),
|
|
||||||
'trigger_item_id' => $parent->trigger_item_id,
|
|
||||||
'generated_post_id' => $parent->generated_post_id,
|
|
||||||
'is_manual' => $parent->is_manual,
|
|
||||||
'is_dry_run' => $parent->is_dry_run,
|
|
||||||
'status' => RunStatus::Pending,
|
|
||||||
'context' => array_merge($parent->context ?? [], ['fetched' => $item]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($targets === []) {
|
|
||||||
$sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->advance->dispatchBranches($sibling, $targets);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function decrypt(string $value): string
|
|
||||||
{
|
|
||||||
if ($value === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Crypt::decryptString($value);
|
|
||||||
} catch (Throwable) {
|
|
||||||
// Value isn't an encrypted payload (legacy plain text or already decrypted).
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parseWatermark(?string $stored): CarbonImmutable
|
|
||||||
{
|
|
||||||
if ($stored === null) {
|
|
||||||
return CarbonImmutable::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return CarbonImmutable::parse($stored);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return CarbonImmutable::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parseDate(mixed $raw): ?CarbonImmutable
|
|
||||||
{
|
|
||||||
if (! is_string($raw) && ! is_numeric($raw)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return CarbonImmutable::parse((string) $raw);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Node;
|
|
||||||
|
|
||||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
|
||||||
use App\Enums\Automation\Publish\Mode;
|
|
||||||
use App\Enums\Post\Status as PostStatus;
|
|
||||||
use App\Jobs\PublishPost;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use App\Models\Post;
|
|
||||||
|
|
||||||
class RunPublishNode
|
|
||||||
{
|
|
||||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|
||||||
{
|
|
||||||
$mode = Mode::from(data_get($config, 'mode', 'now'));
|
|
||||||
|
|
||||||
// Dry runs never have a generated Post (RunGenerateNode skipped
|
|
||||||
// persistence). Mirror the call site without touching the DB or
|
|
||||||
// queueing PublishPost.
|
|
||||||
if ($run->is_dry_run) {
|
|
||||||
return NodeRunResult::completed(output: [
|
|
||||||
'publish' => ['mode' => $mode->value, 'post_id' => null, 'dry_run' => true],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$post = $run->generatedPost;
|
|
||||||
|
|
||||||
if ($post === null) {
|
|
||||||
return NodeRunResult::failed(__('automations.errors.no_generated_post'));
|
|
||||||
}
|
|
||||||
|
|
||||||
match ($mode) {
|
|
||||||
Mode::Now => $this->publishNow($post),
|
|
||||||
Mode::Scheduled => $this->schedule($post, (int) data_get($config, 'scheduled_offset')),
|
|
||||||
Mode::Draft => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NodeRunResult::completed(output: [
|
|
||||||
'publish' => ['mode' => $mode->value, 'post_id' => $post->id],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function publishNow(Post $post): void
|
|
||||||
{
|
|
||||||
$post->update(['status' => PostStatus::Publishing]);
|
|
||||||
PublishPost::dispatch($post);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function schedule(Post $post, int $offsetMinutes): void
|
|
||||||
{
|
|
||||||
$post->update([
|
|
||||||
'status' => PostStatus::Scheduled,
|
|
||||||
'scheduled_at' => now()->addMinutes($offsetMinutes),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Run;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Run\Status;
|
|
||||||
use App\Jobs\Automation\ProcessAutomationNode;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
|
|
||||||
class AdvanceAutomationRun
|
|
||||||
{
|
|
||||||
public function __invoke(AutomationRun $run, string $fromNodeId, string $handle = 'default'): void
|
|
||||||
{
|
|
||||||
$targets = $this->targetsFor($run->automation, $fromNodeId, $handle);
|
|
||||||
|
|
||||||
if ($targets === []) {
|
|
||||||
$run->update([
|
|
||||||
'status' => Status::Completed,
|
|
||||||
'finished_at' => now(),
|
|
||||||
'current_node_id' => null,
|
|
||||||
'error' => [
|
|
||||||
'reason' => 'no_matching_edge',
|
|
||||||
'handle' => $handle,
|
|
||||||
'node_id' => $fromNodeId,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->dispatchBranches($run, $targets);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every node id connected to `$fromNodeId` via the given handle. A node can
|
|
||||||
* fan out to several targets (e.g. a trigger calling RSS and HTTP at once).
|
|
||||||
*
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public function targetsFor(Automation $automation, string $fromNodeId, string $handle = 'default'): array
|
|
||||||
{
|
|
||||||
return collect($automation->connections ?? [])
|
|
||||||
->filter(fn ($c) => data_get($c, 'source') === $fromNodeId && data_get($c, 'source_handle', 'default') === $handle)
|
|
||||||
->pluck('target')
|
|
||||||
->filter()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Continues the run on the first branch and forks a sibling run — sharing the
|
|
||||||
* accumulated context — for every additional branch, so all targets execute.
|
|
||||||
*
|
|
||||||
* @param array<int, string> $targets
|
|
||||||
*/
|
|
||||||
public function dispatchBranches(AutomationRun $run, array $targets): void
|
|
||||||
{
|
|
||||||
$first = array_shift($targets);
|
|
||||||
|
|
||||||
foreach ($targets as $target) {
|
|
||||||
$sibling = AutomationRun::create([
|
|
||||||
'automation_id' => $run->automation_id,
|
|
||||||
'root_run_id' => $run->rootId(),
|
|
||||||
'trigger_item_id' => $run->trigger_item_id,
|
|
||||||
'generated_post_id' => $run->generated_post_id,
|
|
||||||
'is_manual' => $run->is_manual,
|
|
||||||
'is_dry_run' => $run->is_dry_run,
|
|
||||||
'status' => Status::Pending,
|
|
||||||
'context' => $run->context,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProcessAutomationNode::dispatch($sibling, $target);
|
|
||||||
}
|
|
||||||
|
|
||||||
ProcessAutomationNode::dispatch($run, $first);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Run;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Run\Status;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use App\Models\AutomationTriggerItem;
|
|
||||||
|
|
||||||
class DispatchAutomationRun
|
|
||||||
{
|
|
||||||
public function __construct(private AdvanceAutomationRun $advance) {}
|
|
||||||
|
|
||||||
public function __invoke(Automation $automation, AutomationTriggerItem $triggerItem): AutomationRun
|
|
||||||
{
|
|
||||||
$targets = $this->triggerTargets($automation);
|
|
||||||
|
|
||||||
$run = AutomationRun::create([
|
|
||||||
'automation_id' => $automation->id,
|
|
||||||
'trigger_item_id' => $triggerItem->id,
|
|
||||||
'status' => Status::Pending,
|
|
||||||
'context' => ['trigger' => $triggerItem->payload],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($targets === []) {
|
|
||||||
$run->update([
|
|
||||||
'status' => Status::Failed,
|
|
||||||
'error' => ['message' => __('automations.errors.no_trigger_connection')],
|
|
||||||
'finished_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $run;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->advance->dispatchBranches($run, $targets);
|
|
||||||
|
|
||||||
return $run;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function triggerTargets(Automation $automation): array
|
|
||||||
{
|
|
||||||
$triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger');
|
|
||||||
|
|
||||||
if ($triggerNode === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->advance->targetsFor($automation, $triggerNode['id']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Run;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Run\Status;
|
|
||||||
use App\Jobs\Automation\ProcessAutomationNode;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class RetryRunFromNode
|
|
||||||
{
|
|
||||||
public function __invoke(AutomationRun $run, string $nodeId): void
|
|
||||||
{
|
|
||||||
if ($run->status !== Status::Failed) {
|
|
||||||
throw new DomainException(__('automations.errors.only_failed_can_retry'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$run->update([
|
|
||||||
'status' => Status::Pending,
|
|
||||||
'error' => null,
|
|
||||||
'finished_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProcessAutomationNode::dispatch($run, $nodeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Run;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Run\Status;
|
|
||||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use App\Models\Post;
|
|
||||||
use App\Services\Automation\AutomationConfigValidator;
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kicks off a manual run from the editor without waiting for the real trigger
|
|
||||||
* to fire. Mirrors n8n's "Execute workflow" — the same job pipeline runs, but
|
|
||||||
* the trigger payload is synthesized so the user can see exactly what each
|
|
||||||
* node does end-to-end. Runs are flagged `is_manual=true` so they're filtered
|
|
||||||
* from production audit views.
|
|
||||||
*
|
|
||||||
* When `$withRealData` is false (the default), the run is marked `is_dry_run`
|
|
||||||
* so side-effectful nodes (publish, generate, watermark advancement, sibling
|
|
||||||
* spawning) short-circuit. Dry-run rows are kept briefly so the editor test
|
|
||||||
* panel can show the completed result, then reaped by the scheduled
|
|
||||||
* `automation:prune-dry-runs` command once past its grace window.
|
|
||||||
*/
|
|
||||||
class TestAutomation
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private AdvanceAutomationRun $advance,
|
|
||||||
private AutomationConfigValidator $configValidator,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function __invoke(Automation $automation, bool $withRealData = false): AutomationRun
|
|
||||||
{
|
|
||||||
$issue = $this->configValidator->firstMessage($automation->nodes ?? []);
|
|
||||||
|
|
||||||
if ($issue !== null) {
|
|
||||||
throw new DomainException($issue);
|
|
||||||
}
|
|
||||||
|
|
||||||
$triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger');
|
|
||||||
$context = ['trigger' => $this->synthesizePayload($automation, $triggerNode ?? [])];
|
|
||||||
|
|
||||||
$targets = $triggerNode !== null
|
|
||||||
? $this->advance->targetsFor($automation, $triggerNode['id'])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
$run = AutomationRun::create([
|
|
||||||
'automation_id' => $automation->id,
|
|
||||||
'status' => Status::Pending,
|
|
||||||
'is_manual' => true,
|
|
||||||
'is_dry_run' => ! $withRealData,
|
|
||||||
'context' => $context,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($targets === []) {
|
|
||||||
$run->update([
|
|
||||||
'status' => Status::Failed,
|
|
||||||
'error' => ['message' => __('automations.errors.no_trigger_connection')],
|
|
||||||
'finished_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $run;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->advance->dispatchBranches($run, $targets);
|
|
||||||
|
|
||||||
return $run;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $triggerNode
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function synthesizePayload(Automation $automation, array $triggerNode): array
|
|
||||||
{
|
|
||||||
$type = data_get($triggerNode, 'data.trigger_type');
|
|
||||||
|
|
||||||
return match ($type) {
|
|
||||||
TriggerType::PostPublished->value, TriggerType::PostScheduled->value => $this->synthesizePostPayload($automation, (string) $type),
|
|
||||||
default => ['event' => $type ?? TriggerType::Schedule->value, 'fired_at' => now()->toIso8601String(), 'manual' => true],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picks the most recent post in the automation's workspace so the test run
|
|
||||||
* reflects something the user actually sees. Falls back to a placeholder
|
|
||||||
* payload when the workspace has no posts yet.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function synthesizePostPayload(Automation $automation, string $event): array
|
|
||||||
{
|
|
||||||
$post = Post::query()
|
|
||||||
->where('workspace_id', $automation->workspace_id)
|
|
||||||
->latest()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$base = [
|
|
||||||
'event' => $event,
|
|
||||||
'fired_at' => now()->toIso8601String(),
|
|
||||||
'manual' => true,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($post === null) {
|
|
||||||
return array_merge($base, ['post' => null, 'fetch_error' => 'no posts in workspace']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_merge($base, [
|
|
||||||
'post' => [
|
|
||||||
'id' => $post->id,
|
|
||||||
'content' => $post->content,
|
|
||||||
'status' => $post->status->value,
|
|
||||||
'scheduled_at' => $post->scheduled_at?->toIso8601String(),
|
|
||||||
'published_at' => $post->published_at?->toIso8601String(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Trigger;
|
|
||||||
|
|
||||||
use App\Actions\Automation\Run\AdvanceAutomationRun;
|
|
||||||
use App\Enums\Automation\Node\Type as NodeType;
|
|
||||||
use App\Enums\Automation\Run\Status as RunStatus;
|
|
||||||
use App\Enums\Automation\Status as AutomationStatus;
|
|
||||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use App\Models\Post;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walks the workspace's active automations and dispatches a run for each one
|
|
||||||
* whose Trigger node matches the given post-related event (PostPublished /
|
|
||||||
* PostScheduled). The post payload is placed at `context.trigger.post` so
|
|
||||||
* downstream nodes can reference it via templates like `{{ trigger.post.id }}`.
|
|
||||||
*
|
|
||||||
* V1 limitation: every post fires triggers, regardless of whether the post
|
|
||||||
* itself was created by an automation. If a future use case introduces loops
|
|
||||||
* (automation X publishes → trigger fires → X publishes again), we'll need a
|
|
||||||
* `posts.created_by_automation_run_id` column to skip them.
|
|
||||||
*/
|
|
||||||
class DispatchPostTriggerAutomations
|
|
||||||
{
|
|
||||||
public function __construct(private AdvanceAutomationRun $advance) {}
|
|
||||||
|
|
||||||
public function __invoke(Post $post, TriggerType $triggerType): void
|
|
||||||
{
|
|
||||||
$automations = Automation::query()
|
|
||||||
->where('workspace_id', $post->workspace_id)
|
|
||||||
->where('status', AutomationStatus::Active)
|
|
||||||
->where('trigger_type', $triggerType->value)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($automations as $automation) {
|
|
||||||
$triggerNode = collect($automation->nodes ?? [])->firstWhere('type', NodeType::Trigger->value);
|
|
||||||
|
|
||||||
if ($triggerNode === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->dispatchRun($automation, $triggerNode, $post);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function dispatchRun(Automation $automation, array $triggerNode, Post $post): void
|
|
||||||
{
|
|
||||||
$context = [
|
|
||||||
'trigger' => [
|
|
||||||
'event' => $triggerNode['data']['trigger_type'],
|
|
||||||
'fired_at' => now()->toIso8601String(),
|
|
||||||
'post' => [
|
|
||||||
'id' => $post->id,
|
|
||||||
'content' => $post->content,
|
|
||||||
'status' => $post->status->value,
|
|
||||||
'scheduled_at' => $post->scheduled_at?->toIso8601String(),
|
|
||||||
'published_at' => $post->published_at?->toIso8601String(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$run = AutomationRun::create([
|
|
||||||
'automation_id' => $automation->id,
|
|
||||||
'status' => RunStatus::Pending,
|
|
||||||
'context' => $context,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$targets = $this->advance->targetsFor($automation, $triggerNode['id']);
|
|
||||||
|
|
||||||
if ($targets === []) {
|
|
||||||
$run->update([
|
|
||||||
'status' => RunStatus::Failed,
|
|
||||||
'error' => ['message' => __('automations.errors.no_trigger_connection')],
|
|
||||||
'finished_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->advance->dispatchBranches($run, $targets);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\Trigger;
|
|
||||||
|
|
||||||
use App\Actions\Automation\TriggerItem\EnrollTriggerItem;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use Cron\CronExpression;
|
|
||||||
|
|
||||||
class FireScheduleTrigger
|
|
||||||
{
|
|
||||||
public function __construct(private EnrollTriggerItem $enroll) {}
|
|
||||||
|
|
||||||
public function __invoke(Automation $automation): bool
|
|
||||||
{
|
|
||||||
$triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger');
|
|
||||||
$cron = data_get($triggerNode, 'data.cron');
|
|
||||||
$timezone = data_get($triggerNode, 'data.schedule_timezone', config('app.timezone'));
|
|
||||||
|
|
||||||
if ($cron === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$expression = new CronExpression($cron);
|
|
||||||
|
|
||||||
if (! $expression->isDue(now(), $timezone)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$key = now()->format('Y-m-d\TH:i');
|
|
||||||
$payload = ['fired_at' => now()->toIso8601String()];
|
|
||||||
|
|
||||||
return ($this->enroll)($automation, $key, $payload) !== null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Automation\TriggerItem;
|
|
||||||
|
|
||||||
use App\Actions\Automation\Run\DispatchAutomationRun;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use App\Models\AutomationTriggerItem;
|
|
||||||
|
|
||||||
class EnrollTriggerItem
|
|
||||||
{
|
|
||||||
public function __construct(private DispatchAutomationRun $dispatchRun) {}
|
|
||||||
|
|
||||||
public function __invoke(Automation $automation, string $itemKey, array $payload): ?AutomationRun
|
|
||||||
{
|
|
||||||
$existing = AutomationTriggerItem::where('automation_id', $automation->id)
|
|
||||||
->where('item_key', $itemKey)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existing !== null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$item = AutomationTriggerItem::create([
|
|
||||||
'automation_id' => $automation->id,
|
|
||||||
'item_key' => $itemKey,
|
|
||||||
'payload' => $payload,
|
|
||||||
'first_seen_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return ($this->dispatchRun)($automation, $item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -26,8 +26,7 @@ class CreatePost
|
||||||
* `label_ids[]` are attached after creation so the same set of UUIDs
|
* `label_ids[]` are attached after creation so the same set of UUIDs
|
||||||
* works for REST, MCP, and web callers.
|
* works for REST, MCP, and web callers.
|
||||||
*
|
*
|
||||||
* `created_via` records which entry point created the post (web, mcp,
|
* `created_via` records which entry point created the post (web, mcp, or api). Analytical only — null when omitted.
|
||||||
* api, or automation). Analytical only — null when omitted.
|
|
||||||
*
|
*
|
||||||
* @param array{
|
* @param array{
|
||||||
* content?: ?string,
|
* content?: ?string,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Broadcasting;
|
|
||||||
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\User;
|
|
||||||
|
|
||||||
class AutomationChannel
|
|
||||||
{
|
|
||||||
public function join(User $user, Automation $automation): bool
|
|
||||||
{
|
|
||||||
return $automation->workspace->hasMember($user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Console\Commands\Automation;
|
|
||||||
|
|
||||||
use App\Actions\Automation\Trigger\FireScheduleTrigger;
|
|
||||||
use App\Enums\Automation\Status;
|
|
||||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use Illuminate\Console\Attributes\Description;
|
|
||||||
use Illuminate\Console\Attributes\Signature;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
#[Signature('automation:fire-schedule')]
|
|
||||||
#[Description('Fire scheduled automations whose cron matches now')]
|
|
||||||
class FireScheduleTriggers extends Command
|
|
||||||
{
|
|
||||||
public function handle(FireScheduleTrigger $fire): int
|
|
||||||
{
|
|
||||||
Automation::query()
|
|
||||||
->where('status', Status::Active)
|
|
||||||
->where('trigger_type', TriggerType::Schedule->value)
|
|
||||||
->chunkById(50, function ($automations) use ($fire) {
|
|
||||||
foreach ($automations as $automation) {
|
|
||||||
$fire($automation);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Console\Commands\Automation;
|
|
||||||
|
|
||||||
use App\Actions\Automation\Run\AdvanceAutomationRun;
|
|
||||||
use App\Enums\Automation\Run\Status;
|
|
||||||
use App\Enums\Automation\Status as AutomationStatus;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use Illuminate\Console\Attributes\Description;
|
|
||||||
use Illuminate\Console\Attributes\Signature;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
#[Signature('automation:process-delays')]
|
|
||||||
#[Description('Wake up runs that finished their delay window')]
|
|
||||||
class ProcessAutomationDelays extends Command
|
|
||||||
{
|
|
||||||
public function handle(AdvanceAutomationRun $advance): int
|
|
||||||
{
|
|
||||||
AutomationRun::query()
|
|
||||||
->where('status', Status::Waiting)
|
|
||||||
->where('next_action_at', '<=', now())
|
|
||||||
->where(fn ($query) => $query
|
|
||||||
->where('is_manual', true)
|
|
||||||
->orWhereHas('automation', fn ($inner) => $inner->where('status', AutomationStatus::Active)))
|
|
||||||
->chunkById(50, function ($runs) use ($advance) {
|
|
||||||
foreach ($runs as $run) {
|
|
||||||
$claimed = AutomationRun::query()
|
|
||||||
->whereKey($run->id)
|
|
||||||
->where('status', Status::Waiting)
|
|
||||||
->update([
|
|
||||||
'status' => Status::Running,
|
|
||||||
'next_action_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($claimed === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run->refresh();
|
|
||||||
$advance($run, $run->current_node_id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Console\Commands\Automation;
|
|
||||||
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use Illuminate\Console\Attributes\Description;
|
|
||||||
use Illuminate\Console\Attributes\Signature;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
#[Signature('automation:prune-dry-runs')]
|
|
||||||
#[Description('Delete terminal dry-run automation runs older than the test-panel grace window')]
|
|
||||||
class PruneDryRunAutomationRuns extends Command
|
|
||||||
{
|
|
||||||
private const GRACE_MINUTES = 10;
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$count = AutomationRun::query()
|
|
||||||
->where('is_dry_run', true)
|
|
||||||
->whereNotNull('finished_at')
|
|
||||||
->where('finished_at', '<=', now()->subMinutes(self::GRACE_MINUTES))
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
$this->info("Pruned {$count} dry-run automation runs.");
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Console\Commands\Automation;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Run\Status;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use Illuminate\Console\Attributes\Description;
|
|
||||||
use Illuminate\Console\Attributes\Signature;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
#[Signature('automation:recover-stuck-runs')]
|
|
||||||
#[Description('Fail automation runs stuck running or pending for more than 1 hour')]
|
|
||||||
class RecoverStuckAutomationRuns extends Command
|
|
||||||
{
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$count = AutomationRun::query()
|
|
||||||
->whereIn('status', [Status::Running, Status::Pending])
|
|
||||||
->where('updated_at', '<=', now()->subHour())
|
|
||||||
->update([
|
|
||||||
'status' => Status::Failed,
|
|
||||||
'error' => ['reason' => 'stuck'],
|
|
||||||
'finished_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->info("Recovered {$count} stuck automation runs.");
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\DataTransferObjects\Automation;
|
|
||||||
|
|
||||||
use App\Enums\Automation\NodeRun\Status;
|
|
||||||
use DateTimeInterface;
|
|
||||||
|
|
||||||
class NodeRunResult
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly Status $status,
|
|
||||||
public readonly array $output = [],
|
|
||||||
public readonly string $nextHandle = 'default',
|
|
||||||
public readonly ?DateTimeInterface $sleepUntil = null,
|
|
||||||
public readonly ?array $error = null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public static function completed(array $output = [], string $nextHandle = 'default'): self
|
|
||||||
{
|
|
||||||
return new self(Status::Completed, $output, $nextHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function sleep(DateTimeInterface $until): self
|
|
||||||
{
|
|
||||||
return new self(Status::Completed, sleepUntil: $until);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function failed(string $message, ?array $extra = null): self
|
|
||||||
{
|
|
||||||
return new self(Status::Failed, error: array_merge(['message' => $message], $extra ?? []));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums\Automation;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication strategies for the HTTP Request node. Mirrors the frontend
|
|
||||||
* AuthType const (resources/js/types/automation/auth-type.ts).
|
|
||||||
*/
|
|
||||||
enum AuthType: string
|
|
||||||
{
|
|
||||||
case None = 'none';
|
|
||||||
case Bearer = 'bearer';
|
|
||||||
case Basic = 'basic';
|
|
||||||
case ApiKey = 'api_key';
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums\Automation\Condition;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Output handles of a Condition node. The matched branch determines which
|
|
||||||
* handle the run continues down — these values must mirror the `id`s of the
|
|
||||||
* handles rendered in the frontend ConditionNode (resources/js/types/automation
|
|
||||||
* /condition-handle.ts) and the `source_handle` stored on edges.
|
|
||||||
*/
|
|
||||||
enum Handle: string
|
|
||||||
{
|
|
||||||
case Yes = 'yes';
|
|
||||||
case No = 'no';
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums\Automation\Condition;
|
|
||||||
|
|
||||||
enum Operator: string
|
|
||||||
{
|
|
||||||
case Contains = 'contains';
|
|
||||||
case NotContains = 'not_contains';
|
|
||||||
case Equals = 'equals';
|
|
||||||
case NotEquals = 'not_equals';
|
|
||||||
case Matches = 'matches';
|
|
||||||
case GreaterThan = 'greater_than';
|
|
||||||
case LessThan = 'less_than';
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums\Automation;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Time units for the Delay node. Mirrors the frontend DelayUnit const
|
|
||||||
* (resources/js/types/automation/delay-unit.ts).
|
|
||||||
*/
|
|
||||||
enum DelayUnit: string
|
|
||||||
{
|
|
||||||
case Minutes = 'minutes';
|
|
||||||
case Hours = 'hours';
|
|
||||||
case Days = 'days';
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums\Automation;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP verbs available to the HTTP Request node. Mirrors the
|
|
||||||
* frontend HttpMethod const (resources/js/types/automation/http-method.ts).
|
|
||||||
*/
|
|
||||||
enum HttpMethod: string
|
|
||||||
{
|
|
||||||
case Get = 'GET';
|
|
||||||
case Post = 'POST';
|
|
||||||
case Put = 'PUT';
|
|
||||||
case Patch = 'PATCH';
|
|
||||||
case Delete = 'DELETE';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verbs that carry a request body.
|
|
||||||
*
|
|
||||||
* @return array<int, self>
|
|
||||||
*/
|
|
||||||
public static function withBody(): array
|
|
||||||
{
|
|
||||||
return [self::Post, self::Put, self::Patch];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums\Automation\Node;
|
|
||||||
|
|
||||||
enum Type: string
|
|
||||||
{
|
|
||||||
case Trigger = 'trigger';
|
|
||||||
case Generate = 'generate';
|
|
||||||
case Delay = 'delay';
|
|
||||||
case Condition = 'condition';
|
|
||||||
case Publish = 'publish';
|
|
||||||
case End = 'end';
|
|
||||||
case FetchRss = 'fetch_rss';
|
|
||||||
case HttpRequest = 'http_request';
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums\Automation\NodeRun;
|
|
||||||
|
|
||||||
enum Status: string
|
|
||||||
{
|
|
||||||
case Running = 'running';
|
|
||||||
case Completed = 'completed';
|
|
||||||
case Failed = 'failed';
|
|
||||||
case Skipped = 'skipped';
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums\Automation\Publish;
|
|
||||||
|
|
||||||
enum Mode: string
|
|
||||||
{
|
|
||||||
case Now = 'now';
|
|
||||||
case Scheduled = 'scheduled';
|
|
||||||
case Draft = 'draft';
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums\Automation\Run;
|
|
||||||
|
|
||||||
enum Status: string
|
|
||||||
{
|
|
||||||
case Pending = 'pending';
|
|
||||||
case Running = 'running';
|
|
||||||
case Waiting = 'waiting';
|
|
||||||
case Completed = 'completed';
|
|
||||||
case Failed = 'failed';
|
|
||||||
case Cancelled = 'cancelled';
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums\Automation;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule interval units for a Schedule trigger. Mirrors the frontend
|
|
||||||
* ScheduleField const (resources/js/types/automation/schedule-field.ts).
|
|
||||||
*/
|
|
||||||
enum ScheduleField: string
|
|
||||||
{
|
|
||||||
case Minutes = 'minutes';
|
|
||||||
case Hours = 'hours';
|
|
||||||
case Days = 'days';
|
|
||||||
case Weeks = 'weeks';
|
|
||||||
case Months = 'months';
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums\Automation;
|
|
||||||
|
|
||||||
enum Status: string
|
|
||||||
{
|
|
||||||
case Draft = 'draft';
|
|
||||||
case Active = 'active';
|
|
||||||
case Paused = 'paused';
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Enums\Automation\Trigger;
|
|
||||||
|
|
||||||
enum Type: string
|
|
||||||
{
|
|
||||||
case Schedule = 'schedule';
|
|
||||||
case PostPublished = 'post_published';
|
|
||||||
case PostScheduled = 'post_scheduled';
|
|
||||||
}
|
|
||||||
|
|
@ -9,5 +9,4 @@ enum CreatedVia: string
|
||||||
case Web = 'web';
|
case Web = 'web';
|
||||||
case Mcp = 'mcp';
|
case Mcp = 'mcp';
|
||||||
case Api = 'api';
|
case Api = 'api';
|
||||||
case Automation = 'automation';
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Events;
|
|
||||||
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
|
||||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight signal that a run (or one of its node runs) advanced. Carries
|
|
||||||
* only identifiers — the client refetches the full state via the show-run
|
|
||||||
* endpoint to keep resource serialization centralized.
|
|
||||||
*/
|
|
||||||
class AutomationRunUpdated implements ShouldBroadcast
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
|
||||||
|
|
||||||
public function __construct(public AutomationRun $run) {}
|
|
||||||
|
|
||||||
public function broadcastAs(): string
|
|
||||||
{
|
|
||||||
return 'automation.run.updated';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function broadcastOn(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
new PrivateChannel("automation.{$this->run->automation_id}"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function broadcastWith(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'run_id' => $this->run->id,
|
|
||||||
'root_run_id' => $this->run->rootId(),
|
|
||||||
'automation_id' => $this->run->automation_id,
|
|
||||||
'status' => $this->run->status->value,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function broadcastQueue(): string
|
|
||||||
{
|
|
||||||
return 'broadcasts';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,278 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\App;
|
|
||||||
|
|
||||||
use App\Actions\Automation\Automation\ActivateAutomation;
|
|
||||||
use App\Actions\Automation\Automation\CreateAutomation;
|
|
||||||
use App\Actions\Automation\Automation\DeleteAutomation;
|
|
||||||
use App\Actions\Automation\Automation\GetAutomationEditorData;
|
|
||||||
use App\Actions\Automation\Automation\GetAutomationInvocations;
|
|
||||||
use App\Actions\Automation\Automation\GetAutomationMetrics;
|
|
||||||
use App\Actions\Automation\Automation\ListAutomations;
|
|
||||||
use App\Actions\Automation\Automation\PauseAutomation;
|
|
||||||
use App\Actions\Automation\Automation\UpdateAutomation;
|
|
||||||
use App\Actions\Automation\Run\RetryRunFromNode;
|
|
||||||
use App\Actions\Automation\Run\TestAutomation;
|
|
||||||
use App\Enums\Automation\Status;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\App\Automations\ActivateAutomationRequest;
|
|
||||||
use App\Http\Requests\App\Automations\InspectFeedRequest;
|
|
||||||
use App\Http\Requests\App\Automations\PauseAutomationRequest;
|
|
||||||
use App\Http\Requests\App\Automations\RetryRunRequest;
|
|
||||||
use App\Http\Requests\App\Automations\StoreAutomationRequest;
|
|
||||||
use App\Http\Requests\App\Automations\TestAutomationRequest;
|
|
||||||
use App\Http\Requests\App\Automations\UpdateAutomationRequest;
|
|
||||||
use App\Http\Resources\App\Automation\FeedInspectionResource;
|
|
||||||
use App\Http\Resources\App\PlatformConfigResource;
|
|
||||||
use App\Http\Resources\App\SocialAccountResource;
|
|
||||||
use App\Http\Resources\AutomationInvocationResource;
|
|
||||||
use App\Http\Resources\AutomationNodeRunResource;
|
|
||||||
use App\Http\Resources\AutomationResource;
|
|
||||||
use App\Http\Resources\AutomationRunResource;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\AutomationNodeRun;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use App\Services\Automation\ExpressionResolver;
|
|
||||||
use App\Services\Automation\FeedParser;
|
|
||||||
use App\Services\Brand\SafeHttpFetcher;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Response as HttpResponse;
|
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response;
|
|
||||||
use RuntimeException;
|
|
||||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
|
||||||
|
|
||||||
class AutomationController extends Controller
|
|
||||||
{
|
|
||||||
public function index(ListAutomations $list): Response
|
|
||||||
{
|
|
||||||
$this->authorize('viewAny', Automation::class);
|
|
||||||
|
|
||||||
$workspace = request()->user()->currentWorkspace;
|
|
||||||
|
|
||||||
$automations = Inertia::scroll(fn () => AutomationResource::collection(
|
|
||||||
$list($workspace)
|
|
||||||
));
|
|
||||||
|
|
||||||
return Inertia::render('automations/Index', [
|
|
||||||
'automations' => $automations,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreAutomationRequest $request, CreateAutomation $create): RedirectResponse
|
|
||||||
{
|
|
||||||
$this->authorize('create', Automation::class);
|
|
||||||
|
|
||||||
$automation = $create(
|
|
||||||
$request->user()->currentWorkspace,
|
|
||||||
$request->user(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return redirect()->route('app.automations.workflow', $automation->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Automation $automation): RedirectResponse
|
|
||||||
{
|
|
||||||
$this->authorize('view', $automation);
|
|
||||||
|
|
||||||
// A draft opens on the builder to be set up; a live automation (active
|
|
||||||
// or paused) opens on its metrics, where the user watches it run.
|
|
||||||
$tab = $automation->status === Status::Draft ? 'workflow' : 'metrics';
|
|
||||||
|
|
||||||
return redirect()->route("app.automations.{$tab}", $automation->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function workflow(Automation $automation, GetAutomationEditorData $editorData): Response
|
|
||||||
{
|
|
||||||
$this->authorize('update', $automation);
|
|
||||||
|
|
||||||
['socialAccounts' => $socialAccounts, 'pinterestBoards' => $pinterestBoards, 'tiktokCreatorInfos' => $tiktokCreatorInfos] = $editorData($automation);
|
|
||||||
|
|
||||||
$platformConfigs = $socialAccounts->mapWithKeys(fn ($account) => [
|
|
||||||
$account->id => new PlatformConfigResource($account),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return Inertia::render('automations/Form', [
|
|
||||||
'automation' => AutomationResource::make($automation),
|
|
||||||
'socialAccounts' => SocialAccountResource::collection($socialAccounts),
|
|
||||||
'platformConfigs' => $platformConfigs,
|
|
||||||
'pinterestBoards' => $pinterestBoards,
|
|
||||||
'tiktokCreatorInfos' => $tiktokCreatorInfos,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function invocations(Automation $automation, GetAutomationInvocations $invocations): Response
|
|
||||||
{
|
|
||||||
$this->authorize('view', $automation);
|
|
||||||
|
|
||||||
$status = request()->string('status')->toString() ?: null;
|
|
||||||
$search = request()->string('search')->toString() ?: null;
|
|
||||||
|
|
||||||
return Inertia::render('automations/Invocations', [
|
|
||||||
'automation' => AutomationResource::make($automation),
|
|
||||||
'invocations' => Inertia::scroll(fn () => AutomationInvocationResource::collection(
|
|
||||||
$invocations($automation, $status, $search)
|
|
||||||
)),
|
|
||||||
'filters' => [
|
|
||||||
'status' => $status,
|
|
||||||
'search' => $search,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function settings(Automation $automation): Response
|
|
||||||
{
|
|
||||||
$this->authorize('view', $automation);
|
|
||||||
|
|
||||||
return Inertia::render('automations/Settings', [
|
|
||||||
'automation' => AutomationResource::make($automation),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function metrics(Automation $automation, GetAutomationMetrics $metrics): Response
|
|
||||||
{
|
|
||||||
$this->authorize('view', $automation);
|
|
||||||
|
|
||||||
$end = (request()->date('end') ?? now())->startOfDay();
|
|
||||||
$start = (request()->date('start') ?? now()->subDays(6))->startOfDay();
|
|
||||||
|
|
||||||
if ($start->greaterThan($end)) {
|
|
||||||
[$start, $end] = [$end, $start];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap the window so a hand-edited URL can't request a multi-year, daily
|
|
||||||
// bucketed series.
|
|
||||||
if ($start->diffInDays($end) > 366) {
|
|
||||||
$start = $end->copy()->subDays(366);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Inertia::render('automations/Metrics', [
|
|
||||||
'automation' => AutomationResource::make($automation),
|
|
||||||
'metrics' => $metrics($automation, $start, $end),
|
|
||||||
'filters' => [
|
|
||||||
'start' => $start->toDateString(),
|
|
||||||
'end' => $end->toDateString(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateAutomationRequest $request, Automation $automation, UpdateAutomation $update): RedirectResponse
|
|
||||||
{
|
|
||||||
$this->authorize('update', $automation);
|
|
||||||
|
|
||||||
$update($automation, $request->validated());
|
|
||||||
|
|
||||||
return back();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Automation $automation, DeleteAutomation $delete): RedirectResponse
|
|
||||||
{
|
|
||||||
$this->authorize('delete', $automation);
|
|
||||||
$delete($automation);
|
|
||||||
|
|
||||||
session()->flash('flash.banner', __('automations.flash.deleted'));
|
|
||||||
session()->flash('flash.bannerStyle', 'success');
|
|
||||||
|
|
||||||
return redirect()->route('app.automations.index');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function activate(ActivateAutomationRequest $request, Automation $automation, ActivateAutomation $activate): RedirectResponse
|
|
||||||
{
|
|
||||||
$this->authorize('activate', $automation);
|
|
||||||
|
|
||||||
$activate($automation);
|
|
||||||
|
|
||||||
return back();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function pause(PauseAutomationRequest $request, Automation $automation, PauseAutomation $pause): RedirectResponse
|
|
||||||
{
|
|
||||||
$this->authorize('pause', $automation);
|
|
||||||
|
|
||||||
$pause($automation);
|
|
||||||
|
|
||||||
return back();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function retryRun(
|
|
||||||
RetryRunRequest $request,
|
|
||||||
RetryRunFromNode $retry,
|
|
||||||
Automation $automation,
|
|
||||||
AutomationRun $run,
|
|
||||||
): HttpResponse {
|
|
||||||
$this->authorize('update', $automation);
|
|
||||||
abort_unless($run->automation_id === $automation->id, 404);
|
|
||||||
|
|
||||||
$nodeId = $request->validated('node_id') ?? $run->current_node_id;
|
|
||||||
$retry($run, $nodeId);
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test(TestAutomationRequest $request, Automation $automation, TestAutomation $test): JsonResponse
|
|
||||||
{
|
|
||||||
$this->authorize('update', $automation);
|
|
||||||
|
|
||||||
$run = $test($automation, (bool) $request->validated('with_real_data', false));
|
|
||||||
|
|
||||||
return response()->json(['run_id' => $run->id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function inspectFeed(
|
|
||||||
InspectFeedRequest $request,
|
|
||||||
Automation $automation,
|
|
||||||
ExpressionResolver $resolver,
|
|
||||||
SafeHttpFetcher $safeHttp,
|
|
||||||
FeedParser $parser,
|
|
||||||
): JsonResponse|FeedInspectionResource {
|
|
||||||
$this->authorize('update', $automation);
|
|
||||||
|
|
||||||
$feedUrl = $resolver->resolve(
|
|
||||||
$request->validated('feed_url'),
|
|
||||||
['variables' => $automation->resolvedVariables()],
|
|
||||||
);
|
|
||||||
|
|
||||||
// SafeHttpFetcher::get() bundles the SSRF guard, a request timeout, a
|
|
||||||
// redirect cap and a branded user-agent — so a slow or hostile feed can't
|
|
||||||
// hang this synchronous request. It throws on SSRF, timeout or non-2xx.
|
|
||||||
try {
|
|
||||||
$response = $safeHttp->get($feedUrl);
|
|
||||||
} catch (RuntimeException) {
|
|
||||||
return response()->json(['message' => __('automations.errors.fetch_rss_request_failed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
$items = $parser->parse($response->body());
|
|
||||||
|
|
||||||
if ($items === null) {
|
|
||||||
return response()->json(['message' => __('automations.errors.fetch_rss_malformed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FeedInspectionResource($items[0] ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function showRun(Automation $automation, AutomationRun $run): JsonResponse
|
|
||||||
{
|
|
||||||
$this->authorize('view', $automation);
|
|
||||||
abort_unless($run->automation_id === $automation->id, 404);
|
|
||||||
|
|
||||||
// Aggregate the node runs of every branch forked by a fan-out so the test
|
|
||||||
// panel shows the whole execution, not just the branch the root walked.
|
|
||||||
$rootId = $run->rootId();
|
|
||||||
|
|
||||||
$nodeRuns = AutomationNodeRun::query()
|
|
||||||
->whereHas('run', fn ($query) => $query
|
|
||||||
->where('id', $rootId)
|
|
||||||
->orWhere('root_run_id', $rootId))
|
|
||||||
->orderBy('started_at')
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'run' => AutomationRunResource::make($run)->resolve(),
|
|
||||||
'node_runs' => AutomationNodeRunResource::collection($nodeRuns)->resolve(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\App\Automations;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class ActivateAutomationRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\App\Automations;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class InspectFeedRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'feed_url' => ['required', 'string', 'max:2048'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\App\Automations;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class PauseAutomationRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\App\Automations;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class RetryRunRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'node_id' => ['nullable', 'string', 'max:255'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\App\Automations;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class StoreAutomationRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\App\Automations;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class TestAutomationRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'with_real_data' => ['nullable', 'boolean'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\App\Automations;
|
|
||||||
|
|
||||||
use App\Enums\Ai\ContentStyle;
|
|
||||||
use App\Enums\Automation\AuthType;
|
|
||||||
use App\Enums\Automation\Condition\Operator as ConditionOperator;
|
|
||||||
use App\Enums\Automation\DelayUnit;
|
|
||||||
use App\Enums\Automation\HttpMethod;
|
|
||||||
use App\Enums\Automation\Node\Type as NodeType;
|
|
||||||
use App\Enums\Automation\Publish\Mode as PublishMode;
|
|
||||||
use App\Enums\Automation\ScheduleField;
|
|
||||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
|
||||||
use App\Rules\ResolvableUrl;
|
|
||||||
use App\Services\Automation\AutomationConfigValidator;
|
|
||||||
use App\Services\Automation\GenerateNodeValidator;
|
|
||||||
use Illuminate\Contracts\Validation\Validator;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class UpdateAutomationRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
$rules = [
|
|
||||||
'name' => ['sometimes', 'string', 'max:120'],
|
|
||||||
'nodes' => ['sometimes', 'array'],
|
|
||||||
'nodes.*.id' => ['required', 'string'],
|
|
||||||
'nodes.*.type' => ['required', 'string', Rule::in(array_column(NodeType::cases(), 'value'))],
|
|
||||||
'nodes.*.position' => ['required', 'array'],
|
|
||||||
'nodes.*.position.x' => ['required', 'numeric'],
|
|
||||||
'nodes.*.position.y' => ['required', 'numeric'],
|
|
||||||
'nodes.*.data' => ['required', 'array'],
|
|
||||||
'connections' => ['sometimes', 'array'],
|
|
||||||
'connections.*.id' => ['required', 'string'],
|
|
||||||
'connections.*.source' => ['required', 'string'],
|
|
||||||
'connections.*.target' => ['required', 'string'],
|
|
||||||
'connections.*.source_handle' => ['nullable', 'string'],
|
|
||||||
'connections.*.target_handle' => ['nullable', 'string'],
|
|
||||||
'variables' => ['sometimes', 'array', 'max:50'],
|
|
||||||
'variables.*.key' => ['required', 'string', 'max:60', 'regex:/^[A-Za-z_][A-Za-z0-9_]*$/', 'distinct'],
|
|
||||||
'variables.*.value' => ['nullable', 'string'],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Per-node data validation. We build these dynamically so each node's
|
|
||||||
// type drives the shape of its `data` payload, and so errors come back
|
|
||||||
// with full paths like `nodes.2.data.feed_url` for the frontend to map.
|
|
||||||
$nodes = $this->input('nodes', []);
|
|
||||||
if (is_array($nodes)) {
|
|
||||||
foreach ($nodes as $i => $node) {
|
|
||||||
$type = data_get($node, 'type');
|
|
||||||
foreach ($this->dataRulesForNodeType($type, (int) $i) as $field => $fieldRules) {
|
|
||||||
$rules["nodes.{$i}.data.{$field}"] = $fieldRules;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rules;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Block saving a node whose config can't run: a Generate node whose image
|
|
||||||
* count doesn't fit a selected account's content-type. Each issue is keyed
|
|
||||||
* to the field the frontend surfaces it under.
|
|
||||||
*/
|
|
||||||
public function withValidator(Validator $validator): void
|
|
||||||
{
|
|
||||||
$validator->after(function (Validator $validator): void {
|
|
||||||
$nodes = $this->input('nodes', []);
|
|
||||||
|
|
||||||
if (! is_array($nodes)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (app(AutomationConfigValidator::class)->issues($nodes) as $issue) {
|
|
||||||
$validator->errors()->add("nodes.{$issue['node_index']}.data.{$issue['field']}", $issue['message']);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function attributes(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'nodes.*.data.feed_url' => 'Feed URL',
|
|
||||||
'nodes.*.data.url' => 'URL',
|
|
||||||
'nodes.*.data.cron' => 'cron expression',
|
|
||||||
'nodes.*.data.duration' => 'duration',
|
|
||||||
'nodes.*.data.unit' => 'unit',
|
|
||||||
'nodes.*.data.field' => 'field',
|
|
||||||
'nodes.*.data.operator' => 'operator',
|
|
||||||
'nodes.*.data.mode' => 'mode',
|
|
||||||
'nodes.*.data.method' => 'method',
|
|
||||||
'nodes.*.data.trigger_type' => 'trigger type',
|
|
||||||
'nodes.*.data.prompt_template' => 'prompt template',
|
|
||||||
'nodes.*.data.accounts' => 'accounts',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array<int, mixed>>
|
|
||||||
*/
|
|
||||||
private function dataRulesForNodeType(?string $type, int $i): array
|
|
||||||
{
|
|
||||||
return match ($type) {
|
|
||||||
NodeType::Trigger->value => [
|
|
||||||
'trigger_type' => ['required', Rule::in(array_column(TriggerType::cases(), 'value'))],
|
|
||||||
'cron' => ['required_if:nodes.'.$i.'.data.trigger_type,'.TriggerType::Schedule->value, 'string'],
|
|
||||||
'schedule_field' => ['sometimes', Rule::in(array_column(ScheduleField::cases(), 'value'))],
|
|
||||||
'schedule_minutes_interval' => ['sometimes', 'integer', 'min:1', 'max:59'],
|
|
||||||
'schedule_hours_interval' => ['sometimes', 'integer', 'min:1', 'max:23'],
|
|
||||||
'schedule_days_interval' => ['sometimes', 'integer', 'min:1', 'max:31'],
|
|
||||||
'schedule_hour' => ['sometimes', 'integer', 'min:0', 'max:23'],
|
|
||||||
'schedule_minute' => ['sometimes', 'integer', 'min:0', 'max:59'],
|
|
||||||
'schedule_weekdays' => ['sometimes', 'array'],
|
|
||||||
'schedule_weekdays.*' => ['integer', 'min:0', 'max:6'],
|
|
||||||
'schedule_day_of_month' => ['sometimes', 'integer', 'min:1', 'max:31'],
|
|
||||||
'schedule_timezone' => ['sometimes', 'string', 'timezone'],
|
|
||||||
],
|
|
||||||
NodeType::FetchRss->value => [
|
|
||||||
'feed_url' => ['required', new ResolvableUrl],
|
|
||||||
'discovered_fields' => ['sometimes', 'array'],
|
|
||||||
'discovered_fields.*.path' => ['required', 'string'],
|
|
||||||
'discovered_fields.*.sample' => ['nullable', 'string'],
|
|
||||||
],
|
|
||||||
NodeType::HttpRequest->value => [
|
|
||||||
'url' => ['required', new ResolvableUrl],
|
|
||||||
'method' => ['required', Rule::in(array_column(HttpMethod::cases(), 'value'))],
|
|
||||||
'auth_type' => ['required', Rule::in(array_column(AuthType::cases(), 'value'))],
|
|
||||||
'auth_token' => ['nullable', 'string'],
|
|
||||||
'auth_username' => ['nullable', 'string'],
|
|
||||||
'auth_password' => ['nullable', 'string'],
|
|
||||||
'auth_header_name' => ['nullable', 'string'],
|
|
||||||
'body_template' => ['nullable', 'string'],
|
|
||||||
'headers' => ['nullable', 'array'],
|
|
||||||
'headers.*' => ['string'],
|
|
||||||
'items_path' => ['nullable', 'string'],
|
|
||||||
'item_key_path' => ['nullable', 'string'],
|
|
||||||
'item_date_path' => ['nullable', 'string'],
|
|
||||||
],
|
|
||||||
NodeType::Generate->value => [
|
|
||||||
'accounts' => ['required', 'array', 'min:1'],
|
|
||||||
'prompt_template' => ['required', 'string'],
|
|
||||||
'target_slide_count' => ['nullable', 'integer', 'min:0', 'max:'.GenerateNodeValidator::MAX_GENERATED_IMAGES],
|
|
||||||
'use_brand_voice' => ['sometimes', 'boolean'],
|
|
||||||
'use_brand_visuals' => ['sometimes', 'boolean'],
|
|
||||||
'style' => ['sometimes', Rule::in(array_column(ContentStyle::cases(), 'value'))],
|
|
||||||
],
|
|
||||||
NodeType::Delay->value => [
|
|
||||||
'duration' => ['required', 'integer', 'min:1'],
|
|
||||||
'unit' => ['required', Rule::in(array_column(DelayUnit::cases(), 'value'))],
|
|
||||||
],
|
|
||||||
NodeType::Condition->value => [
|
|
||||||
'field' => ['required', 'string'],
|
|
||||||
'operator' => ['required', Rule::in(array_column(ConditionOperator::cases(), 'value'))],
|
|
||||||
'value' => ['nullable', 'string'],
|
|
||||||
],
|
|
||||||
NodeType::Publish->value => [
|
|
||||||
'mode' => ['required', Rule::in(array_column(PublishMode::cases(), 'value'))],
|
|
||||||
'scheduled_offset' => ['required_if:nodes.'.$i.'.data.mode,'.PublishMode::Scheduled->value, 'integer', 'min:0'],
|
|
||||||
],
|
|
||||||
NodeType::End->value => [
|
|
||||||
'reason' => ['nullable', 'string'],
|
|
||||||
],
|
|
||||||
default => [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Resources\App\Automation;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flattens the first parsed feed item into a catalog of `{{ fetched.* }}` paths
|
|
||||||
* with sample values, so the editor can offer them as expression completions.
|
|
||||||
*
|
|
||||||
* @property array<string, mixed> $resource The first normalized feed item.
|
|
||||||
*/
|
|
||||||
class FeedInspectionResource extends JsonResource
|
|
||||||
{
|
|
||||||
private const SAMPLE_MAX_LENGTH = 120;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{fields: list<array{path: string, sample: string}>}
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'fields' => $this->flatten((array) $this->resource),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $item
|
|
||||||
* @return list<array{path: string, sample: string}>
|
|
||||||
*/
|
|
||||||
private function flatten(array $item, string $prefix = 'fetched'): array
|
|
||||||
{
|
|
||||||
$fields = [];
|
|
||||||
|
|
||||||
foreach ($item as $key => $value) {
|
|
||||||
$path = "{$prefix}.{$key}";
|
|
||||||
|
|
||||||
if (is_array($value) && $this->isAssoc($value)) {
|
|
||||||
$fields = array_merge($fields, $this->flatten($value, $path));
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fields[] = ['path' => $path, 'sample' => $this->sample($value)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int|string, mixed> $value
|
|
||||||
*/
|
|
||||||
private function isAssoc(array $value): bool
|
|
||||||
{
|
|
||||||
return $value !== [] && ! array_is_list($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sample(mixed $value): string
|
|
||||||
{
|
|
||||||
$text = is_array($value)
|
|
||||||
? (json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '')
|
|
||||||
: (string) $value;
|
|
||||||
|
|
||||||
return str($text)->squish()->limit(self::SAMPLE_MAX_LENGTH)->value();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single execution row for the Invocations tab: enough to render the list
|
|
||||||
* (status, timing, step count, error summary) without loading every node run.
|
|
||||||
*/
|
|
||||||
class AutomationInvocationResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'status' => $this->status->value,
|
|
||||||
'is_manual' => (bool) $this->is_manual,
|
|
||||||
'node_run_count' => (int) ($this->node_runs_count ?? 0),
|
|
||||||
'duration_ms' => $this->durationInMilliseconds(),
|
|
||||||
'error_message' => is_array($this->error) ? ($this->error['message'] ?? null) : $this->error,
|
|
||||||
'created_at' => $this->created_at,
|
|
||||||
'started_at' => $this->started_at,
|
|
||||||
'finished_at' => $this->finished_at,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
class AutomationNodeRunResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'node_id' => $this->node_id,
|
|
||||||
'node_type' => $this->node_type->value,
|
|
||||||
'status' => $this->status->value,
|
|
||||||
'input' => $this->input,
|
|
||||||
'output' => $this->output,
|
|
||||||
'error' => $this->error,
|
|
||||||
'started_at' => $this->started_at,
|
|
||||||
'finished_at' => $this->finished_at,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Resources;
|
|
||||||
|
|
||||||
use App\Models\Automation;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
class AutomationResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'workspace_id' => $this->workspace_id,
|
|
||||||
'name' => $this->name,
|
|
||||||
'status' => $this->status->value,
|
|
||||||
'nodes' => $this->maskSensitiveNodeFields($this->nodes ?? []),
|
|
||||||
'connections' => $this->connections ?? [],
|
|
||||||
'variables' => $this->maskVariables($this->variables ?? []),
|
|
||||||
'activated_at' => $this->activated_at,
|
|
||||||
'paused_at' => $this->paused_at,
|
|
||||||
'created_at' => $this->created_at,
|
|
||||||
'updated_at' => $this->updated_at,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace any stored credentials with a placeholder before they leave
|
|
||||||
* the server. The frontend treats the placeholder as "keep current" on
|
|
||||||
* save (see Automation::booted()), so editing other fields doesn't wipe
|
|
||||||
* the stored secret.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $nodes
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function maskSensitiveNodeFields(array $nodes): array
|
|
||||||
{
|
|
||||||
foreach ($nodes as &$node) {
|
|
||||||
foreach (Automation::SENSITIVE_NODE_FIELDS as $field) {
|
|
||||||
if (data_get($node, "data.{$field}") !== null && data_get($node, "data.{$field}") !== '') {
|
|
||||||
data_set($node, "data.{$field}", Automation::SENSITIVE_PLACEHOLDER);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace stored variable values with the placeholder so secrets never
|
|
||||||
* leave the server. The frontend references variables by key
|
|
||||||
* (`{{ variables.KEY }}`), so the masked value doesn't hinder reuse, and
|
|
||||||
* re-saving the placeholder keeps the stored ciphertext.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $variables
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function maskVariables(array $variables): array
|
|
||||||
{
|
|
||||||
foreach ($variables as &$variable) {
|
|
||||||
if (data_get($variable, 'value') !== null && data_get($variable, 'value') !== '') {
|
|
||||||
$variable['value'] = Automation::SENSITIVE_PLACEHOLDER;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $variables;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
class AutomationRunResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'automation_id' => $this->automation_id,
|
|
||||||
'trigger_item_id' => $this->trigger_item_id,
|
|
||||||
'current_node_id' => $this->current_node_id,
|
|
||||||
'status' => $this->status->value,
|
|
||||||
'is_manual' => (bool) $this->is_manual,
|
|
||||||
'is_dry_run' => (bool) $this->is_dry_run,
|
|
||||||
'next_action_at' => $this->next_action_at,
|
|
||||||
'generated_post_id' => $this->generated_post_id,
|
|
||||||
'context' => $this->context,
|
|
||||||
'error' => $this->error,
|
|
||||||
'started_at' => $this->started_at,
|
|
||||||
'finished_at' => $this->finished_at,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
class AutomationTriggerItemResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'item_key' => $this->item_key,
|
|
||||||
'payload' => $this->payload,
|
|
||||||
'first_seen_at' => $this->first_seen_at,
|
|
||||||
'run' => AutomationRunResource::make($this->whenLoaded('run')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Jobs\Automation;
|
|
||||||
|
|
||||||
use App\Actions\Automation\Trigger\DispatchPostTriggerAutomations;
|
|
||||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
|
||||||
use App\Models\Post;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class DispatchPostTriggerAutomationsJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public int $tries = 1;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public Post $post,
|
|
||||||
public TriggerType $triggerType,
|
|
||||||
) {
|
|
||||||
$this->onQueue('automations');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(DispatchPostTriggerAutomations $dispatch): void
|
|
||||||
{
|
|
||||||
$dispatch($this->post, $this->triggerType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Jobs\Automation;
|
|
||||||
|
|
||||||
use App\Actions\Automation\Node\RunConditionNode;
|
|
||||||
use App\Actions\Automation\Node\RunDelayNode;
|
|
||||||
use App\Actions\Automation\Node\RunEndNode;
|
|
||||||
use App\Actions\Automation\Node\RunFetchRssNode;
|
|
||||||
use App\Actions\Automation\Node\RunGenerateNode;
|
|
||||||
use App\Actions\Automation\Node\RunHttpRequestNode;
|
|
||||||
use App\Actions\Automation\Node\RunPublishNode;
|
|
||||||
use App\Actions\Automation\Run\AdvanceAutomationRun;
|
|
||||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
|
||||||
use App\Enums\Automation\Node\Type as NodeType;
|
|
||||||
use App\Enums\Automation\NodeRun\Status as NodeRunStatus;
|
|
||||||
use App\Enums\Automation\Run\Status as RunStatus;
|
|
||||||
use App\Enums\Automation\Status as AutomationStatus;
|
|
||||||
use App\Models\AutomationNodeRun;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use LogicException;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class ProcessAutomationNode implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public int $tries = 1;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public AutomationRun $run,
|
|
||||||
public string $nodeId,
|
|
||||||
) {
|
|
||||||
$this->onQueue('automations');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(AdvanceAutomationRun $advance): void
|
|
||||||
{
|
|
||||||
$this->run->refresh();
|
|
||||||
|
|
||||||
if (! in_array($this->run->status, [RunStatus::Pending, RunStatus::Running, RunStatus::Waiting], true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->run->is_manual && $this->run->automation->status !== AutomationStatus::Active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$node = collect($this->run->automation->nodes ?? [])->firstWhere('id', $this->nodeId);
|
|
||||||
|
|
||||||
if ($node === null) {
|
|
||||||
$this->run->update([
|
|
||||||
'status' => RunStatus::Failed,
|
|
||||||
'error' => ['message' => __('automations.errors.node_no_longer_exists', ['node_id' => $this->nodeId])],
|
|
||||||
'finished_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nodeType = NodeType::tryFrom((string) data_get($node, 'type', ''));
|
|
||||||
|
|
||||||
if ($nodeType === null) {
|
|
||||||
$this->run->update([
|
|
||||||
'status' => RunStatus::Failed,
|
|
||||||
'error' => ['message' => __('automations.errors.node_no_longer_exists', ['node_id' => $this->nodeId])],
|
|
||||||
'finished_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->run->update([
|
|
||||||
'status' => RunStatus::Running,
|
|
||||||
'current_node_id' => $this->nodeId,
|
|
||||||
'started_at' => $this->run->started_at ?? now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$nodeRun = AutomationNodeRun::create([
|
|
||||||
'run_id' => $this->run->id,
|
|
||||||
'node_id' => $this->nodeId,
|
|
||||||
'node_type' => $nodeType,
|
|
||||||
'status' => NodeRunStatus::Running,
|
|
||||||
'input' => $this->run->context,
|
|
||||||
'started_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$result = $this->executeNode($nodeType, $node['data'] ?? []);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$result = NodeRunResult::failed($e->getMessage(), ['class' => $e::class]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$nodeRun->update([
|
|
||||||
'status' => $result->status,
|
|
||||||
'output' => $result->output,
|
|
||||||
'error' => $result->error,
|
|
||||||
'finished_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($result->status === NodeRunStatus::Failed) {
|
|
||||||
$this->run->update([
|
|
||||||
'status' => RunStatus::Failed,
|
|
||||||
'error' => array_merge(['node_id' => $this->nodeId], $result->error ?? []),
|
|
||||||
'finished_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->run->update([
|
|
||||||
'context' => array_merge($this->run->context ?? [], $result->output),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($result->sleepUntil !== null) {
|
|
||||||
$this->run->update([
|
|
||||||
'status' => RunStatus::Waiting,
|
|
||||||
'next_action_at' => $result->sleepUntil,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$advance($this->run, $this->nodeId, $result->nextHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function failed(?Throwable $e): void
|
|
||||||
{
|
|
||||||
$this->run->refresh();
|
|
||||||
|
|
||||||
if (in_array($this->run->status, [RunStatus::Completed, RunStatus::Failed], true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->run->update([
|
|
||||||
'status' => RunStatus::Failed,
|
|
||||||
'error' => ['message' => $e?->getMessage() ?? 'job failed', 'node_id' => $this->nodeId],
|
|
||||||
'finished_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function executeNode(NodeType $type, array $config): NodeRunResult
|
|
||||||
{
|
|
||||||
$handler = match ($type) {
|
|
||||||
NodeType::Generate => app(RunGenerateNode::class),
|
|
||||||
NodeType::Delay => app(RunDelayNode::class),
|
|
||||||
NodeType::Condition => app(RunConditionNode::class),
|
|
||||||
NodeType::Publish => app(RunPublishNode::class),
|
|
||||||
NodeType::End => app(RunEndNode::class),
|
|
||||||
NodeType::FetchRss => app(RunFetchRssNode::class),
|
|
||||||
NodeType::HttpRequest => app(RunHttpRequestNode::class),
|
|
||||||
NodeType::Trigger => throw new LogicException('Trigger nodes are not executed as run steps.'),
|
|
||||||
};
|
|
||||||
|
|
||||||
return $handler($this->run, $config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Node\Type as NodeType;
|
|
||||||
use App\Enums\Automation\Status;
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Support\Facades\Crypt;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class Automation extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
use HasUuids;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fields inside `nodes[].data` that hold sensitive credentials. Stored
|
|
||||||
* encrypted on save and never returned to the frontend in plain text —
|
|
||||||
* AutomationResource masks them with PLACEHOLDER on output.
|
|
||||||
*/
|
|
||||||
public const SENSITIVE_NODE_FIELDS = ['auth_token', 'auth_password'];
|
|
||||||
|
|
||||||
public const SENSITIVE_PLACEHOLDER = '••••••••';
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'status' => Status::class,
|
|
||||||
'nodes' => 'array',
|
|
||||||
'connections' => 'array',
|
|
||||||
'variables' => 'array',
|
|
||||||
'activated_at' => 'datetime',
|
|
||||||
'paused_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected static function booted(): void
|
|
||||||
{
|
|
||||||
static::saving(function (self $automation): void {
|
|
||||||
$automation->nodes = self::encryptSensitiveFields(
|
|
||||||
$automation->nodes ?? [],
|
|
||||||
$automation->getOriginal('nodes') ?? [],
|
|
||||||
);
|
|
||||||
$automation->variables = self::encryptVariables(
|
|
||||||
$automation->variables ?? [],
|
|
||||||
$automation->getOriginal('variables') ?? [],
|
|
||||||
);
|
|
||||||
$automation->trigger_type = self::deriveTriggerType($automation->nodes ?? []);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Workflow variables decrypted into a `key => value` map for use during a
|
|
||||||
* run (e.g. `{{ variables.API_KEY }}` resolution). Encrypted at rest and
|
|
||||||
* never returned to the frontend in plain text.
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function resolvedVariables(): array
|
|
||||||
{
|
|
||||||
$resolved = [];
|
|
||||||
|
|
||||||
foreach ($this->variables ?? [] as $variable) {
|
|
||||||
$key = data_get($variable, 'key');
|
|
||||||
if (! is_string($key) || $key === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$resolved[$key] = self::decryptValue((string) data_get($variable, 'value', ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function triggerItems(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(AutomationTriggerItem::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function runs(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(AutomationRun::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walks both the incoming and stored node lists and reconciles sensitive
|
|
||||||
* fields: a PLACEHOLDER value means "user didn't change it" (frontend
|
|
||||||
* never received the real value) so we keep the existing ciphertext. Plain
|
|
||||||
* text values get encrypted; already-encrypted strings pass through.
|
|
||||||
*
|
|
||||||
* Denormalize the trigger node's type into an indexed column so the
|
|
||||||
* scheduler can filter by it in SQL instead of decoding every automation's
|
|
||||||
* `nodes` JSON each minute. Recomputed on every save so it cannot drift.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $nodes
|
|
||||||
*/
|
|
||||||
private static function deriveTriggerType(array $nodes): ?string
|
|
||||||
{
|
|
||||||
$triggerNode = collect($nodes)->firstWhere('type', NodeType::Trigger->value);
|
|
||||||
|
|
||||||
return data_get($triggerNode, 'data.trigger_type');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, array<string, mixed>> $incoming
|
|
||||||
* @param array<int, array<string, mixed>>|string $original
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private static function encryptSensitiveFields(array $incoming, array|string $original): array
|
|
||||||
{
|
|
||||||
$original = is_array($original) ? $original : (json_decode($original, true) ?: []);
|
|
||||||
$originalById = collect($original)->keyBy('id');
|
|
||||||
|
|
||||||
foreach ($incoming as &$node) {
|
|
||||||
$originalNode = $originalById->get($node['id'] ?? null);
|
|
||||||
foreach (self::SENSITIVE_NODE_FIELDS as $field) {
|
|
||||||
$value = data_get($node, "data.{$field}");
|
|
||||||
if (! is_string($value) || $value === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($value === self::SENSITIVE_PLACEHOLDER) {
|
|
||||||
data_set($node, "data.{$field}", data_get($originalNode, "data.{$field}", ''));
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (self::looksEncrypted($value)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
data_set($node, "data.{$field}", Crypt::encryptString($value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $incoming;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconciles workflow variable values exactly like node credentials, matched
|
|
||||||
* by variable `key`: a PLACEHOLDER value keeps the existing ciphertext,
|
|
||||||
* plaintext gets encrypted, already-encrypted strings pass through.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $incoming
|
|
||||||
* @param array<int, array<string, mixed>>|string $original
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private static function encryptVariables(array $incoming, array|string $original): array
|
|
||||||
{
|
|
||||||
$original = is_array($original) ? $original : (json_decode($original, true) ?: []);
|
|
||||||
$originalByKey = collect($original)->keyBy('key');
|
|
||||||
|
|
||||||
foreach ($incoming as &$variable) {
|
|
||||||
$value = data_get($variable, 'value');
|
|
||||||
if (! is_string($value) || $value === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($value === self::SENSITIVE_PLACEHOLDER) {
|
|
||||||
$variable['value'] = (string) data_get($originalByKey->get($variable['key'] ?? null), 'value', '');
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (self::looksEncrypted($value)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$variable['value'] = Crypt::encryptString($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $incoming;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function decryptValue(string $value): string
|
|
||||||
{
|
|
||||||
if ($value === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Crypt::decryptString($value);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quick check for Laravel's `Crypt::encryptString` output without paying
|
|
||||||
* the cost of a full decrypt attempt. Laravel wraps payloads as base64
|
|
||||||
* JSON beginning with the canonical `eyJpdiI` ("{"iv":"...) prefix.
|
|
||||||
*/
|
|
||||||
private static function looksEncrypted(string $value): bool
|
|
||||||
{
|
|
||||||
if (! str_starts_with($value, 'eyJ')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Crypt::decryptString($value);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Node\Type as NodeType;
|
|
||||||
use App\Enums\Automation\NodeRun\Status;
|
|
||||||
use App\Observers\AutomationNodeRunObserver;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
#[ObservedBy([AutomationNodeRunObserver::class])]
|
|
||||||
class AutomationNodeRun extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
use HasUuids;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'status' => Status::class,
|
|
||||||
'node_type' => NodeType::class,
|
|
||||||
'input' => 'array',
|
|
||||||
'output' => 'array',
|
|
||||||
'error' => 'array',
|
|
||||||
'started_at' => 'datetime',
|
|
||||||
'finished_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function run(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(AutomationRun::class, 'run_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class AutomationNodeState extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
use HasUuids;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'data' => 'array',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function automation(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Automation::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Idempotent lookup for the state row of a given node within an automation,
|
|
||||||
* creating an empty row on first access. Use this in poll/fire actions that
|
|
||||||
* need to read or update an internal watermark.
|
|
||||||
*/
|
|
||||||
public static function for(string $automationId, string $nodeId): self
|
|
||||||
{
|
|
||||||
return self::firstOrCreate(
|
|
||||||
['automation_id' => $automationId, 'node_id' => $nodeId],
|
|
||||||
['data' => []],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Run\Status;
|
|
||||||
use App\Observers\AutomationRunObserver;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
#[ObservedBy([AutomationRunObserver::class])]
|
|
||||||
class AutomationRun extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
use HasUuids;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'status' => Status::class,
|
|
||||||
'context' => 'array',
|
|
||||||
'error' => 'array',
|
|
||||||
'is_manual' => 'boolean',
|
|
||||||
'is_dry_run' => 'boolean',
|
|
||||||
'next_action_at' => 'datetime',
|
|
||||||
'started_at' => 'datetime',
|
|
||||||
'finished_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function automation(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Automation::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Id of the run that started this execution. Fan-out forks sibling runs that
|
|
||||||
* all point back at the same root, so callers can treat every branch of one
|
|
||||||
* test/trigger as a single family. The root run points at itself.
|
|
||||||
*/
|
|
||||||
public function rootId(): string
|
|
||||||
{
|
|
||||||
return $this->root_run_id ?? $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wall-clock execution time, or null while the run hasn't both started and
|
|
||||||
* finished. Single source of truth for the Invocations list and metrics.
|
|
||||||
*/
|
|
||||||
public function durationInMilliseconds(): ?int
|
|
||||||
{
|
|
||||||
if ($this->started_at === null || $this->finished_at === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $this->started_at->diffInMilliseconds($this->finished_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Context for template (`{{ ... }}`) resolution: the run context plus the
|
|
||||||
* automation's workflow variables, merged in-memory. Variables are NEVER
|
|
||||||
* persisted into the run context (they're encrypted at rest and would
|
|
||||||
* otherwise leak in plaintext via the run/node-run API), so we compute this
|
|
||||||
* on demand at resolve time only.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function resolverContext(): array
|
|
||||||
{
|
|
||||||
return array_merge(
|
|
||||||
$this->context ?? [],
|
|
||||||
['variables' => $this->automation->resolvedVariables()],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function triggerItem(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(AutomationTriggerItem::class, 'trigger_item_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function generatedPost(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Post::class, 'generated_post_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function nodeRuns(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(AutomationNodeRun::class, 'run_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Real, production executions only — excludes manual test runs (both dry
|
|
||||||
* runs and "with real data" tests are flagged is_manual). The Invocations
|
|
||||||
* and Metrics tabs report on these, not on runs the user triggered to test
|
|
||||||
* the editor.
|
|
||||||
*/
|
|
||||||
public function scopeProductionRuns(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('is_manual', false)->where('is_dry_run', false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
||||||
|
|
||||||
class AutomationTriggerItem extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
use HasUuids;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'payload' => 'array',
|
|
||||||
'first_seen_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function automation(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Automation::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function run(): HasOne
|
|
||||||
{
|
|
||||||
return $this->hasOne(AutomationRun::class, 'trigger_item_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Observers;
|
|
||||||
|
|
||||||
use App\Events\AutomationRunUpdated;
|
|
||||||
use App\Models\AutomationNodeRun;
|
|
||||||
|
|
||||||
class AutomationNodeRunObserver
|
|
||||||
{
|
|
||||||
public function created(AutomationNodeRun $nodeRun): void
|
|
||||||
{
|
|
||||||
AutomationRunUpdated::dispatch($nodeRun->run);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updated(AutomationNodeRun $nodeRun): void
|
|
||||||
{
|
|
||||||
if ($nodeRun->wasChanged(['status', 'output', 'error', 'finished_at'])) {
|
|
||||||
AutomationRunUpdated::dispatch($nodeRun->run);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Observers;
|
|
||||||
|
|
||||||
use App\Events\AutomationRunUpdated;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
|
|
||||||
class AutomationRunObserver
|
|
||||||
{
|
|
||||||
public function created(AutomationRun $run): void
|
|
||||||
{
|
|
||||||
AutomationRunUpdated::dispatch($run);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updated(AutomationRun $run): void
|
|
||||||
{
|
|
||||||
// Only broadcast when the user-visible state changes — skip noisy
|
|
||||||
// updates like `context` mutations from intermediate node merges.
|
|
||||||
if ($run->wasChanged(['status', 'current_node_id', 'finished_at', 'next_action_at', 'error'])) {
|
|
||||||
AutomationRunUpdated::dispatch($run);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,12 +4,10 @@
|
||||||
|
|
||||||
namespace App\Observers;
|
namespace App\Observers;
|
||||||
|
|
||||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
|
||||||
use App\Enums\Post\Status as PostStatus;
|
use App\Enums\Post\Status as PostStatus;
|
||||||
use App\Events\OnboardingStatusUpdated;
|
use App\Events\OnboardingStatusUpdated;
|
||||||
use App\Events\PostCreated;
|
use App\Events\PostCreated;
|
||||||
use App\Events\PostStatusChanged;
|
use App\Events\PostStatusChanged;
|
||||||
use App\Jobs\Automation\DispatchPostTriggerAutomationsJob;
|
|
||||||
use App\Models\Account;
|
use App\Models\Account;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
@ -35,16 +33,6 @@ public function saved(Post $post): void
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$triggerType = match ($post->status) {
|
|
||||||
PostStatus::Published => TriggerType::PostPublished,
|
|
||||||
PostStatus::Scheduled => TriggerType::PostScheduled,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($triggerType !== null) {
|
|
||||||
DispatchPostTriggerAutomationsJob::dispatch($post, $triggerType)->afterCommit();
|
|
||||||
}
|
|
||||||
|
|
||||||
$previousStatus = $this->previousStatus($post);
|
$previousStatus = $this->previousStatus($post);
|
||||||
|
|
||||||
DB::afterCommit(fn () => PostStatusChanged::dispatch($post, $previousStatus));
|
DB::afterCommit(fn () => PostStatusChanged::dispatch($post, $previousStatus));
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\User;
|
|
||||||
|
|
||||||
class AutomationPolicy
|
|
||||||
{
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
return $user->currentWorkspace !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, Automation $automation): bool
|
|
||||||
{
|
|
||||||
return $automation->workspace_id === $user->current_workspace_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(User $user): bool
|
|
||||||
{
|
|
||||||
return $user->currentWorkspace !== null
|
|
||||||
&& $user->can('createPost', $user->currentWorkspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(User $user, Automation $automation): bool
|
|
||||||
{
|
|
||||||
return $automation->workspace_id === $user->current_workspace_id
|
|
||||||
&& $user->can('createPost', $user->currentWorkspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(User $user, Automation $automation): bool
|
|
||||||
{
|
|
||||||
return $automation->workspace_id === $user->current_workspace_id
|
|
||||||
&& $user->can('createPost', $user->currentWorkspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function activate(User $user, Automation $automation): bool
|
|
||||||
{
|
|
||||||
return $this->update($user, $automation);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function pause(User $user, Automation $automation): bool
|
|
||||||
{
|
|
||||||
return $this->update($user, $automation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,11 +8,6 @@
|
||||||
use App\Models\AccessToken;
|
use App\Models\AccessToken;
|
||||||
use App\Models\Account;
|
use App\Models\Account;
|
||||||
use App\Models\AiUsageLog;
|
use App\Models\AiUsageLog;
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\AutomationNodeRun;
|
|
||||||
use App\Models\AutomationNodeState;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use App\Models\AutomationTriggerItem;
|
|
||||||
use App\Models\Invite;
|
use App\Models\Invite;
|
||||||
use App\Models\Media;
|
use App\Models\Media;
|
||||||
use App\Models\Notification;
|
use App\Models\Notification;
|
||||||
|
|
@ -100,11 +95,6 @@ protected function configureMorphMap(): void
|
||||||
'accessToken' => AccessToken::class,
|
'accessToken' => AccessToken::class,
|
||||||
'account' => Account::class,
|
'account' => Account::class,
|
||||||
'aiUsageLog' => AiUsageLog::class,
|
'aiUsageLog' => AiUsageLog::class,
|
||||||
'automation' => Automation::class,
|
|
||||||
'automationNodeRun' => AutomationNodeRun::class,
|
|
||||||
'automationNodeState' => AutomationNodeState::class,
|
|
||||||
'automationRun' => AutomationRun::class,
|
|
||||||
'automationTriggerItem' => AutomationTriggerItem::class,
|
|
||||||
'invite' => Invite::class,
|
'invite' => Invite::class,
|
||||||
'media' => Media::class,
|
'media' => Media::class,
|
||||||
'notification' => Notification::class,
|
'notification' => Notification::class,
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Rules;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a URL that may contain `{{ ... }}` workflow-variable expressions.
|
|
||||||
* The expressions are resolved at runtime, so they're replaced with a placeholder
|
|
||||||
* before checking the result is a valid URL — letting users template the host or
|
|
||||||
* query (e.g. `https://host/feed.xml?channel_id={{ variables.CHANNEL_ID }}`).
|
|
||||||
*/
|
|
||||||
class ResolvableUrl implements ValidationRule
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
|
||||||
*/
|
|
||||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
|
||||||
{
|
|
||||||
if (! is_string($value)) {
|
|
||||||
$fail('validation.url')->translate();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidate = preg_replace('/\{\{\s*[\w.]+\s*\}\}/', 'placeholder', $value);
|
|
||||||
$scheme = strtolower((string) parse_url($candidate, PHP_URL_SCHEME));
|
|
||||||
|
|
||||||
// Require a real http(s) URL once expressions are substituted, so the rule
|
|
||||||
// is no weaker than the plain `url` rule it replaces (rejects file://,
|
|
||||||
// javascript://, etc.) while still allowing templated hosts/queries.
|
|
||||||
if (filter_var($candidate, FILTER_VALIDATE_URL) === false || ! in_array($scheme, ['http', 'https'], true)) {
|
|
||||||
$fail('validation.url')->translate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Automation;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Node\Type as NodeType;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single source of truth for per-node config validation. Walks an automation's
|
|
||||||
* nodes and reports every config issue, delegating to the type-specific
|
|
||||||
* validators. Shared by save (field-keyed errors), activation, and the editor
|
|
||||||
* test run so a misconfigured node is rejected up front with a clear message
|
|
||||||
* instead of failing midway through execution.
|
|
||||||
*/
|
|
||||||
final class AutomationConfigValidator
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private GenerateNodeValidator $generateValidator,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every config issue across the given nodes, in node order.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $nodes
|
|
||||||
* @return list<array{node_index: int, field: string, message: string}>
|
|
||||||
*/
|
|
||||||
public function issues(array $nodes): array
|
|
||||||
{
|
|
||||||
$issues = [];
|
|
||||||
|
|
||||||
foreach ($nodes as $index => $node) {
|
|
||||||
$config = (array) data_get($node, 'data', []);
|
|
||||||
|
|
||||||
[$field, $message] = match (data_get($node, 'type')) {
|
|
||||||
NodeType::Generate->value => ['accounts', $this->generateValidator->issueFor($config)],
|
|
||||||
default => [null, null],
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($message !== null) {
|
|
||||||
$issues[] = ['node_index' => $index, 'field' => $field, 'message' => $message];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $issues;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The first config issue's message, or null when every node is runnable.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $nodes
|
|
||||||
*/
|
|
||||||
public function firstMessage(array $nodes): ?string
|
|
||||||
{
|
|
||||||
return $this->issues($nodes)[0]['message'] ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Automation;
|
|
||||||
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
class ExpressionResolver
|
|
||||||
{
|
|
||||||
public function resolve(string $template, array $context): string
|
|
||||||
{
|
|
||||||
return preg_replace_callback(
|
|
||||||
'/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/',
|
|
||||||
fn ($matches) => $this->resolveVariable($matches[1], $context),
|
|
||||||
$template,
|
|
||||||
) ?? $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves `{{ ... }}` placeholders inside an already-decoded JSON structure
|
|
||||||
* (arrays/strings), resolving only string leaves. The caller json_encodes the
|
|
||||||
* result, so values are never string-interpolated into raw JSON — quotes,
|
|
||||||
* `&`, newlines etc. in the data can't corrupt the payload.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function resolveStructured(mixed $value, array $context): mixed
|
|
||||||
{
|
|
||||||
if (is_string($value)) {
|
|
||||||
return $this->resolve($value, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($value)) {
|
|
||||||
return array_map(fn ($item) => $this->resolveStructured($item, $context), $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveVariable(string $path, array $context): string
|
|
||||||
{
|
|
||||||
if ($path === 'now') {
|
|
||||||
return Carbon::now()->toIso8601String();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($path === 'today') {
|
|
||||||
return Carbon::today()->toDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = data_get($context, $path);
|
|
||||||
|
|
||||||
if ($value === null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_scalar($value)) {
|
|
||||||
return (string) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// json_encode returns false on malformed UTF-8 (plausible for scraped
|
|
||||||
// feed/HTTP payloads); the method must still return a string.
|
|
||||||
return json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Automation;
|
|
||||||
|
|
||||||
use SimplePie\Item;
|
|
||||||
use SimplePie\SimplePie;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an RSS 2.0 / Atom 1.0 / RDF feed body into normalized item arrays.
|
|
||||||
*
|
|
||||||
* Each item carries two merged layers:
|
|
||||||
* - **Aliases**: stable cross-format keys (title, link, date, content, author, …)
|
|
||||||
* resolved through SimplePie's normalized getters, so a downstream node works the
|
|
||||||
* same whether the feed is RSS or Atom.
|
|
||||||
* - **Raw**: every namespaced extension tag (yt:videoId, media:thumbnail,
|
|
||||||
* dc:creator, content:encoded, …) flattened with a `prefix_localname` convention,
|
|
||||||
* so feed-specific data stays reachable via `{{ fetched.<field> }}`.
|
|
||||||
*
|
|
||||||
* Aliases win on key collisions. The HTTP fetch and SSRF guard stay in the caller;
|
|
||||||
* the body is handed to SimplePie via `set_raw_data()` so the SSRF guard isn't bypassed.
|
|
||||||
*/
|
|
||||||
class FeedParser
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Namespace URI → short prefix for the raw layer.
|
|
||||||
*/
|
|
||||||
private const NS_PREFIX = [
|
|
||||||
'http://search.yahoo.com/mrss/' => 'media',
|
|
||||||
'http://www.youtube.com/xml/schemas/2015' => 'yt',
|
|
||||||
'http://purl.org/dc/elements/1.1/' => 'dc',
|
|
||||||
'http://purl.org/dc/terms/' => 'dc',
|
|
||||||
'http://purl.org/rss/1.0/modules/content/' => 'content',
|
|
||||||
'http://www.itunes.com/dtds/podcast-1.0.dtd' => 'itunes',
|
|
||||||
'https://podcastindex.org/namespace/1.0' => 'podcast',
|
|
||||||
'http://purl.org/rss/1.0/modules/slash/' => 'slash',
|
|
||||||
'http://wellformedweb.org/CommentAPI/' => 'wfw',
|
|
||||||
'http://www.georss.org/georss' => 'georss',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Namespaces treated as the feed "core" — their tags get no prefix, so RSS and
|
|
||||||
* Atom land on the same raw key names (and are then overridden by aliases).
|
|
||||||
*/
|
|
||||||
private const NS_CORE = [
|
|
||||||
'',
|
|
||||||
'http://www.w3.org/2005/Atom',
|
|
||||||
'http://purl.org/rss/1.0/',
|
|
||||||
'http://backend.userland.com/rss2',
|
|
||||||
'http://my.netscape.com/rdf/simple/0.9/',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array<string, mixed>>|null Null when the body is not a usable feed
|
|
||||||
* (invalid XML, or any parse failure).
|
|
||||||
*/
|
|
||||||
public function parse(string $body): ?array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$feed = new SimplePie;
|
|
||||||
$feed->enable_cache(false);
|
|
||||||
$feed->set_raw_data($body);
|
|
||||||
|
|
||||||
if (! @$feed->init()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_map(fn (Item $item): array => $this->normalize($item), $feed->get_items());
|
|
||||||
} catch (Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function normalize(Item $item): array
|
|
||||||
{
|
|
||||||
$enclosure = $item->get_enclosure();
|
|
||||||
|
|
||||||
$aliases = [
|
|
||||||
'key' => $item->get_id(false, false) ?: $item->get_permalink(),
|
|
||||||
'title' => $item->get_title(),
|
|
||||||
'link' => $item->get_permalink(),
|
|
||||||
'date' => $item->get_date('c'),
|
|
||||||
'pubDate' => $item->get_date('c'),
|
|
||||||
'content' => $item->get_content(),
|
|
||||||
'description' => $item->get_description(),
|
|
||||||
'author' => $item->get_author()?->get_name(),
|
|
||||||
'id' => $item->get_id(false, false),
|
|
||||||
'image' => $enclosure?->get_thumbnail() ?: null,
|
|
||||||
'categories' => array_values(array_filter(array_map(
|
|
||||||
fn ($category) => $category->get_label(),
|
|
||||||
$item->get_categories() ?? [],
|
|
||||||
))),
|
|
||||||
'enclosure' => $enclosure === null ? null : array_filter([
|
|
||||||
'url' => $enclosure->get_link(),
|
|
||||||
'type' => $enclosure->get_type(),
|
|
||||||
'length' => $enclosure->get_length(),
|
|
||||||
], fn ($value) => $value !== null),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Aliases win on collision, so merge them over the raw layer.
|
|
||||||
return array_merge($this->rawFields($item), $aliases);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function rawFields(Item $item): array
|
|
||||||
{
|
|
||||||
return $this->flattenChildren((array) data_get($item->data, 'child', []));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $children Namespace-URI keyed tag map.
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function flattenChildren(array $children): array
|
|
||||||
{
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($children as $namespace => $tags) {
|
|
||||||
$prefix = $this->prefixFor((string) $namespace);
|
|
||||||
|
|
||||||
foreach ($tags as $tag => $nodes) {
|
|
||||||
$key = $prefix === '' ? (string) $tag : "{$prefix}_{$tag}";
|
|
||||||
$values = array_map(fn ($node) => $this->flattenNode((array) $node), $nodes);
|
|
||||||
$out[$key] = count($values) === 1 ? $values[0] : $values;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $node
|
|
||||||
*/
|
|
||||||
private function flattenNode(array $node): mixed
|
|
||||||
{
|
|
||||||
$attribs = $this->attributes($node);
|
|
||||||
$children = (array) data_get($node, 'child', []);
|
|
||||||
|
|
||||||
if ($children !== []) {
|
|
||||||
return array_merge($this->flattenChildren($children), $attribs);
|
|
||||||
}
|
|
||||||
|
|
||||||
$text = trim((string) data_get($node, 'data', ''));
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
$text !== '' && $attribs === [] => $text,
|
|
||||||
$text === '' && $attribs !== [] => $attribs,
|
|
||||||
$text !== '' && $attribs !== [] => array_merge(['_text' => $text], $attribs),
|
|
||||||
default => $text,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $node
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function attributes(array $node): array
|
|
||||||
{
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ((array) data_get($node, 'attribs', []) as $attrs) {
|
|
||||||
foreach ((array) $attrs as $name => $value) {
|
|
||||||
$out[(string) $name] = (string) $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function prefixFor(string $namespace): string
|
|
||||||
{
|
|
||||||
if (in_array($namespace, self::NS_CORE, true)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::NS_PREFIX[$namespace] ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Automation;
|
|
||||||
|
|
||||||
use App\Enums\PostPlatform\ContentType;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backend mirror of the Generate node's frontend compliance: validates that the
|
|
||||||
* number of AI images intended for each selected account fits that account's
|
|
||||||
* content-type media rules. Uses the ContentType enum as the single source of
|
|
||||||
* truth (same `maxMediaCount`/`supportsImage`/`requiresMedia` the publish flow
|
|
||||||
* uses) and reuses the post editor's compliance messages so the wording matches
|
|
||||||
* exactly. AI image generation is capped at MAX_GENERATED_IMAGES regardless of
|
|
||||||
* how many a platform technically allows.
|
|
||||||
*/
|
|
||||||
final class GenerateNodeValidator
|
|
||||||
{
|
|
||||||
public const MAX_GENERATED_IMAGES = 10;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First compliance issue for a generate node's config, or null when valid.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $config
|
|
||||||
*/
|
|
||||||
public function issueFor(array $config): ?string
|
|
||||||
{
|
|
||||||
$accounts = data_get($config, 'accounts');
|
|
||||||
|
|
||||||
if (! is_array($accounts) || $accounts === []) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single source of truth: 0 = text-only, 1 = single image, 2+ = carousel.
|
|
||||||
$imageCount = (int) data_get($config, 'target_slide_count', 1);
|
|
||||||
|
|
||||||
foreach ($accounts as $entry) {
|
|
||||||
$contentType = ContentType::tryFrom((string) data_get($entry, 'content_type'));
|
|
||||||
|
|
||||||
if (! $contentType instanceof ContentType) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$issue = $this->issueForAccount($contentType, $imageCount);
|
|
||||||
|
|
||||||
if ($issue !== null) {
|
|
||||||
return $issue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function issueForAccount(ContentType $contentType, int $imageCount): ?string
|
|
||||||
{
|
|
||||||
// Generate only produces images — video-only formats (Reel, Video Pin, …)
|
|
||||||
// can never be satisfied by this node.
|
|
||||||
if (! $contentType->supportsImage()) {
|
|
||||||
return __('automations.errors.generate_image_format_required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$min = $contentType->minMediaCount();
|
|
||||||
|
|
||||||
if ($min > 0 && $imageCount < $min) {
|
|
||||||
return __('posts.edit.compliance.too_few_files', ['min' => (string) $min]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($contentType->requiresMedia() && $imageCount === 0) {
|
|
||||||
return __('posts.edit.compliance.requires_media');
|
|
||||||
}
|
|
||||||
|
|
||||||
$max = min(self::MAX_GENERATED_IMAGES, $contentType->maxMediaCount());
|
|
||||||
|
|
||||||
if ($imageCount > $max) {
|
|
||||||
return __('posts.edit.compliance.too_many_files', ['max' => (string) $max]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -55,7 +55,6 @@
|
||||||
"posthog/posthog-php": "^4.1",
|
"posthog/posthog-php": "^4.1",
|
||||||
"predis/predis": "^3.3",
|
"predis/predis": "^3.3",
|
||||||
"sendkit/sendkit-laravel": "^1.1",
|
"sendkit/sendkit-laravel": "^1.1",
|
||||||
"simplepie/simplepie": "^1.9",
|
|
||||||
"socialiteproviders/facebook": "^4.1",
|
"socialiteproviders/facebook": "^4.1",
|
||||||
"socialiteproviders/instagram": "^5.1",
|
"socialiteproviders/instagram": "^5.1",
|
||||||
"socialiteproviders/linkedin": "^5.0",
|
"socialiteproviders/linkedin": "^5.0",
|
||||||
|
|
|
||||||
83
composer.lock
generated
83
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "0a41ea1ff205782a01973b2ad02fd76c",
|
"content-hash": "ef65b49279941f3458f444863f0ce6e4",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "aws/aws-crt-php",
|
"name": "aws/aws-crt-php",
|
||||||
|
|
@ -7244,87 +7244,6 @@
|
||||||
},
|
},
|
||||||
"time": "2026-03-20T00:53:21+00:00"
|
"time": "2026-03-20T00:53:21+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "simplepie/simplepie",
|
|
||||||
"version": "1.9.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/simplepie/simplepie.git",
|
|
||||||
"reference": "76cccb1b2c5dcaf44f304c925ab30c0f48643992"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/simplepie/simplepie/zipball/76cccb1b2c5dcaf44f304c925ab30c0f48643992",
|
|
||||||
"reference": "76cccb1b2c5dcaf44f304c925ab30c0f48643992",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-pcre": "*",
|
|
||||||
"ext-xml": "*",
|
|
||||||
"ext-xmlreader": "*",
|
|
||||||
"php": ">=7.2.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"donatj/mock-webserver": "^2.7",
|
|
||||||
"friendsofphp/php-cs-fixer": "^2.19 || ^3.8",
|
|
||||||
"mf2/mf2": "^0.5.0",
|
|
||||||
"phpstan/phpstan": "~1.12.2",
|
|
||||||
"phpunit/phpunit": "^8 || ^9 || ^10",
|
|
||||||
"psr/http-client": "^1.0",
|
|
||||||
"psr/http-factory": "^1.0",
|
|
||||||
"psr/simple-cache": "^1 || ^2 || ^3"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"ext-curl": "",
|
|
||||||
"ext-iconv": "",
|
|
||||||
"ext-intl": "",
|
|
||||||
"ext-mbstring": "",
|
|
||||||
"mf2/mf2": "Microformat module that allows for parsing HTML for microformats"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-0": {
|
|
||||||
"SimplePie": "library"
|
|
||||||
},
|
|
||||||
"psr-4": {
|
|
||||||
"SimplePie\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"BSD-3-Clause"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Ryan Parman",
|
|
||||||
"homepage": "http://ryanparman.com/",
|
|
||||||
"role": "Creator, alumnus developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Sam Sneddon",
|
|
||||||
"homepage": "https://gsnedders.com/",
|
|
||||||
"role": "Alumnus developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Ryan McCue",
|
|
||||||
"email": "me@ryanmccue.info",
|
|
||||||
"homepage": "http://ryanmccue.info/",
|
|
||||||
"role": "Developer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "A simple Atom/RSS parsing library for PHP",
|
|
||||||
"homepage": "http://simplepie.org/",
|
|
||||||
"keywords": [
|
|
||||||
"atom",
|
|
||||||
"feeds",
|
|
||||||
"rss"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/simplepie/simplepie/issues",
|
|
||||||
"source": "https://github.com/simplepie/simplepie/tree/1.9.0"
|
|
||||||
},
|
|
||||||
"time": "2025-09-12T06:34:27+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "socialiteproviders/facebook",
|
"name": "socialiteproviders/facebook",
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
|
|
|
||||||
|
|
@ -256,21 +256,6 @@
|
||||||
'nice' => 0,
|
'nice' => 0,
|
||||||
],
|
],
|
||||||
|
|
||||||
'automations' => [
|
|
||||||
'connection' => 'redis',
|
|
||||||
'queue' => ['automations'],
|
|
||||||
'balance' => 'auto',
|
|
||||||
'autoScalingStrategy' => 'time',
|
|
||||||
'minProcesses' => 1,
|
|
||||||
'maxProcesses' => 3,
|
|
||||||
'timeout' => 630,
|
|
||||||
'maxTime' => 0,
|
|
||||||
'maxJobs' => 0,
|
|
||||||
'memory' => 256,
|
|
||||||
'tries' => 1,
|
|
||||||
'nice' => 0,
|
|
||||||
],
|
|
||||||
|
|
||||||
'webhooks' => [
|
'webhooks' => [
|
||||||
'connection' => 'redis',
|
'connection' => 'redis',
|
||||||
'queue' => ['webhooks'],
|
'queue' => ['webhooks'],
|
||||||
|
|
@ -307,12 +292,6 @@
|
||||||
'balanceCooldown' => 3,
|
'balanceCooldown' => 3,
|
||||||
],
|
],
|
||||||
|
|
||||||
'automations' => [
|
|
||||||
'maxProcesses' => 5,
|
|
||||||
'balanceMaxShift' => 1,
|
|
||||||
'balanceCooldown' => 3,
|
|
||||||
],
|
|
||||||
|
|
||||||
'webhooks' => [
|
'webhooks' => [
|
||||||
'maxProcesses' => 3,
|
'maxProcesses' => 3,
|
||||||
'balanceMaxShift' => 1,
|
'balanceMaxShift' => 1,
|
||||||
|
|
@ -333,10 +312,6 @@
|
||||||
'maxProcesses' => 2,
|
'maxProcesses' => 2,
|
||||||
],
|
],
|
||||||
|
|
||||||
'automations' => [
|
|
||||||
'maxProcesses' => 2,
|
|
||||||
],
|
|
||||||
|
|
||||||
'webhooks' => [
|
'webhooks' => [
|
||||||
'maxProcesses' => 1,
|
'maxProcesses' => 1,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@
|
||||||
|
|
|
|
||||||
| SafeHttpFetcher blocks requests to private/reserved IP ranges (SSRF
|
| SafeHttpFetcher blocks requests to private/reserved IP ranges (SSRF
|
||||||
| protection) by default. Self-hosted operators who need to fetch from
|
| protection) by default. Self-hosted operators who need to fetch from
|
||||||
| their own internal network (e.g. an internal RSS feed or webhook) can
|
| their own internal network (e.g. an internal webhook endpoint) can
|
||||||
| opt in here. Leave disabled unless you understand the SSRF risk.
|
| opt in here. Leave disabled unless you understand the SSRF risk.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
@ -145,9 +145,9 @@
|
||||||
| Outbound User-Agent
|
| Outbound User-Agent
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
|
||||||
| Branded User-Agent applied to outbound HTTP from automation nodes
|
| Branded User-Agent applied to outbound HTTP from workspace webhooks so
|
||||||
| (http_request) and workspace webhooks so recipients know the request came from
|
| recipients know the request came from TryPost.it. Self-hosters can
|
||||||
| TryPost.it. Self-hosters can override it.
|
| override it.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Status;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
class AutomationFactory extends Factory
|
|
||||||
{
|
|
||||||
protected $model = Automation::class;
|
|
||||||
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'workspace_id' => Workspace::factory(),
|
|
||||||
'user_id' => User::factory(),
|
|
||||||
'name' => fake()->sentence(3),
|
|
||||||
'status' => Status::Draft,
|
|
||||||
'nodes' => [],
|
|
||||||
'connections' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function active(): static
|
|
||||||
{
|
|
||||||
return $this->state(fn () => [
|
|
||||||
'status' => Status::Active,
|
|
||||||
'activated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function paused(): static
|
|
||||||
{
|
|
||||||
return $this->state(fn () => [
|
|
||||||
'status' => Status::Paused,
|
|
||||||
'paused_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function withScheduleTrigger(string $cron = '0 9 * * *'): static
|
|
||||||
{
|
|
||||||
return $this->state(fn () => [
|
|
||||||
'nodes' => [
|
|
||||||
[
|
|
||||||
'id' => 'trigger_1',
|
|
||||||
'type' => 'trigger',
|
|
||||||
'position' => ['x' => 0, 'y' => 0],
|
|
||||||
'data' => [
|
|
||||||
'trigger_type' => 'schedule',
|
|
||||||
'cron' => $cron,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'connections' => [],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Node\Type as NodeType;
|
|
||||||
use App\Enums\Automation\NodeRun\Status;
|
|
||||||
use App\Models\AutomationNodeRun;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
class AutomationNodeRunFactory extends Factory
|
|
||||||
{
|
|
||||||
protected $model = AutomationNodeRun::class;
|
|
||||||
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'run_id' => AutomationRun::factory(),
|
|
||||||
'node_id' => 'node_'.fake()->randomNumber(6),
|
|
||||||
'node_type' => NodeType::Generate,
|
|
||||||
'status' => Status::Running,
|
|
||||||
'input' => [],
|
|
||||||
'started_at' => now(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\AutomationNodeState;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
class AutomationNodeStateFactory extends Factory
|
|
||||||
{
|
|
||||||
protected $model = AutomationNodeState::class;
|
|
||||||
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'automation_id' => Automation::factory(),
|
|
||||||
'node_id' => 'node_'.fake()->randomNumber(6),
|
|
||||||
'data' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Enums\Automation\Run\Status;
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\AutomationRun;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
class AutomationRunFactory extends Factory
|
|
||||||
{
|
|
||||||
protected $model = AutomationRun::class;
|
|
||||||
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'automation_id' => Automation::factory(),
|
|
||||||
'status' => Status::Pending,
|
|
||||||
'is_manual' => false,
|
|
||||||
'is_dry_run' => false,
|
|
||||||
'context' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function running(string $nodeId = 'node_1'): static
|
|
||||||
{
|
|
||||||
return $this->state(fn () => [
|
|
||||||
'status' => Status::Running,
|
|
||||||
'current_node_id' => $nodeId,
|
|
||||||
'started_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function waiting(\DateTimeInterface $until): static
|
|
||||||
{
|
|
||||||
return $this->state(fn () => [
|
|
||||||
'status' => Status::Waiting,
|
|
||||||
'next_action_at' => $until,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function completed(): static
|
|
||||||
{
|
|
||||||
return $this->state(fn () => [
|
|
||||||
'status' => Status::Completed,
|
|
||||||
'finished_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Models\Automation;
|
|
||||||
use App\Models\AutomationTriggerItem;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
class AutomationTriggerItemFactory extends Factory
|
|
||||||
{
|
|
||||||
protected $model = AutomationTriggerItem::class;
|
|
||||||
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'automation_id' => Automation::factory(),
|
|
||||||
'item_key' => fake()->uuid(),
|
|
||||||
'payload' => [
|
|
||||||
'title' => fake()->sentence(),
|
|
||||||
'url' => fake()->url(),
|
|
||||||
],
|
|
||||||
'first_seen_at' => now(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('posts')->where('created_via', 'automation')->update(['created_via' => 'web']);
|
||||||
|
|
||||||
|
Schema::dropIfExists('automation_node_states');
|
||||||
|
Schema::dropIfExists('automation_node_runs');
|
||||||
|
Schema::dropIfExists('automation_runs');
|
||||||
|
Schema::dropIfExists('automation_trigger_items');
|
||||||
|
Schema::dropIfExists('automations');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Irreversible: the automations module and its data are gone for good.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,402 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'mobile_notice' => 'يعمل محرر الأتمتة بشكل أفضل على شاشة أكبر. افتحه على جهاز كمبيوتر مكتبي لإنشاء سير عملك.',
|
|
||||||
'title' => 'الأتمتة',
|
|
||||||
'default_name' => 'أتمتة جديدة',
|
|
||||||
|
|
||||||
'actions' => [
|
|
||||||
'new' => 'أتمتة جديدة',
|
|
||||||
'edit' => 'تعديل',
|
|
||||||
'save' => 'حفظ',
|
|
||||||
'activate' => 'تفعيل',
|
|
||||||
'pause' => 'إيقاف مؤقت',
|
|
||||||
'delete' => 'حذف',
|
|
||||||
'retry' => 'إعادة المحاولة',
|
|
||||||
'guide' => 'تعرّف على آلية العمل',
|
|
||||||
],
|
|
||||||
|
|
||||||
'tabs' => [
|
|
||||||
'build' => 'البناء',
|
|
||||||
'variables' => 'المتغيرات',
|
|
||||||
'test' => 'اختبار',
|
|
||||||
],
|
|
||||||
|
|
||||||
'nav' => [
|
|
||||||
'workflow' => 'سير العمل',
|
|
||||||
'invocations' => 'عمليات التشغيل',
|
|
||||||
'metrics' => 'المقاييس',
|
|
||||||
'settings' => 'الإعدادات',
|
|
||||||
],
|
|
||||||
|
|
||||||
'settings' => [
|
|
||||||
'general' => 'عام',
|
|
||||||
'general_description' => 'إعادة تسمية هذه الأتمتة.',
|
|
||||||
'name_label' => 'الاسم',
|
|
||||||
'name_saved' => 'تمت إعادة تسمية الأتمتة.',
|
|
||||||
'status_title' => 'الحالة',
|
|
||||||
'status_description' => 'فعّلها لبدء تشغيلها، أو أوقفها مؤقتًا للتوقف.',
|
|
||||||
'activated_at' => 'تم التفعيل :date',
|
|
||||||
'paused_at' => 'تم الإيقاف المؤقت :date',
|
|
||||||
'created_at' => 'تم الإنشاء :date',
|
|
||||||
'danger_title' => 'منطقة الخطر',
|
|
||||||
'danger_description' => 'إجراءات لا يمكن التراجع عنها.',
|
|
||||||
'delete_title' => 'حذف هذه الأتمتة',
|
|
||||||
'delete_description' => 'يزيل الأتمتة وسجل تشغيلها نهائيًا.',
|
|
||||||
],
|
|
||||||
|
|
||||||
'status_run' => [
|
|
||||||
'pending' => 'قيد الانتظار',
|
|
||||||
'running' => 'قيد التشغيل',
|
|
||||||
'waiting' => 'في الانتظار',
|
|
||||||
'completed' => 'مكتمل',
|
|
||||||
'failed' => 'فشل',
|
|
||||||
'cancelled' => 'مُلغى',
|
|
||||||
],
|
|
||||||
|
|
||||||
'node_type' => [
|
|
||||||
'trigger' => 'مُشغّل',
|
|
||||||
'generate' => 'إنشاء محتوى',
|
|
||||||
'delay' => 'تأخير',
|
|
||||||
'condition' => 'شرط',
|
|
||||||
'publish' => 'نشر',
|
|
||||||
'end' => 'إنهاء',
|
|
||||||
'fetch_rss' => 'جلب RSS',
|
|
||||||
'http_request' => 'طلب HTTP',
|
|
||||||
],
|
|
||||||
|
|
||||||
'invocations' => [
|
|
||||||
'empty' => 'لا توجد عمليات تشغيل بعد.',
|
|
||||||
'refresh' => 'تحديث',
|
|
||||||
'search_placeholder' => 'البحث عبر معرّف التشغيل…',
|
|
||||||
'copied' => 'تم نسخ معرّف التشغيل.',
|
|
||||||
'loading' => 'جارٍ تحميل الخطوات…',
|
|
||||||
'no_steps' => 'لم يتم تسجيل أي خطوات.',
|
|
||||||
'load_error' => 'تعذر تحميل الخطوات.',
|
|
||||||
'steps' => '{0}لا خطوات|{1}خطوة واحدة|{2}خطوتان|[3,10]:count خطوات|[11,*]:count خطوة',
|
|
||||||
'filter' => [
|
|
||||||
'all' => 'جميع الحالات',
|
|
||||||
],
|
|
||||||
'columns' => [
|
|
||||||
'timestamp' => 'الطابع الزمني',
|
|
||||||
'run' => 'التشغيل',
|
|
||||||
'status' => 'الحالة',
|
|
||||||
'message' => 'آخر رسالة',
|
|
||||||
'duration' => 'المدة',
|
|
||||||
],
|
|
||||||
'summary' => [
|
|
||||||
'completed' => 'اكتمل سير العمل',
|
|
||||||
'failed' => 'فشل سير العمل',
|
|
||||||
'running' => 'سير العمل قيد التشغيل',
|
|
||||||
'cancelled' => 'تم إلغاء سير العمل',
|
|
||||||
'pending' => 'سير العمل قيد الانتظار',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'metrics' => [
|
|
||||||
'overview' => 'نظرة عامة',
|
|
||||||
'runs_over_time' => 'عمليات التشغيل عبر الزمن',
|
|
||||||
'posts_by_platform' => 'المنشورات حسب المنصة',
|
|
||||||
'no_posts' => 'لم يتم نشر أي منشورات في هذه الفترة.',
|
|
||||||
'cards' => [
|
|
||||||
'runs' => 'إجمالي عمليات التشغيل',
|
|
||||||
'completed' => 'مكتملة',
|
|
||||||
'failed' => 'فاشلة',
|
|
||||||
'in_progress' => 'قيد التنفيذ',
|
|
||||||
'success_rate' => 'معدل النجاح',
|
|
||||||
'avg_duration' => 'متوسط المدة',
|
|
||||||
'posts_created' => 'المنشورات المُنشأة',
|
|
||||||
],
|
|
||||||
'legend' => [
|
|
||||||
'started' => 'بدأت',
|
|
||||||
'completed' => 'اكتملت',
|
|
||||||
'failed' => 'فشلت',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'categories' => [
|
|
||||||
'sources' => 'المصادر',
|
|
||||||
'content' => 'المحتوى',
|
|
||||||
'flow' => 'التدفق',
|
|
||||||
'output' => 'الإخراج',
|
|
||||||
],
|
|
||||||
|
|
||||||
'variables' => [
|
|
||||||
'title' => 'متغيرات سير العمل',
|
|
||||||
'hint' => 'قيم قابلة لإعادة الاستخدام يُشار إليها في أي مكان عبر {{ variables.KEY }}. تُخزَّن مشفّرة.',
|
|
||||||
'empty' => 'لا توجد متغيرات بعد.',
|
|
||||||
'key' => 'المفتاح',
|
|
||||||
'value' => 'القيمة',
|
|
||||||
'key_placeholder' => 'API_KEY',
|
|
||||||
'value_placeholder' => 'القيمة',
|
|
||||||
'add' => 'متغير جديد',
|
|
||||||
],
|
|
||||||
|
|
||||||
'expr' => [
|
|
||||||
'trigger_event' => 'اسم حدث المُشغّل',
|
|
||||||
'trigger_fired_at' => 'وقت إطلاق المُشغّل',
|
|
||||||
'trigger_post_id' => 'معرّف المنشور المُشغِّل',
|
|
||||||
'trigger_post_content' => 'محتوى المنشور المُشغِّل',
|
|
||||||
'trigger_post_status' => 'حالة المنشور المُشغِّل',
|
|
||||||
'trigger_post_scheduled_at' => 'وقت جدولة المنشور',
|
|
||||||
'trigger_post_published_at' => 'وقت نشر المنشور',
|
|
||||||
'fetched_title' => 'عنوان العنصر المجلوب',
|
|
||||||
'fetched_link' => 'رابط العنصر المجلوب',
|
|
||||||
'fetched_date' => 'تاريخ نشر العنصر المجلوب',
|
|
||||||
'fetched_content' => 'المحتوى الكامل للعنصر المجلوب',
|
|
||||||
'fetched_description' => 'ملخص العنصر المجلوب',
|
|
||||||
'fetched_author' => 'كاتب العنصر المجلوب',
|
|
||||||
'fetched_image' => 'رابط صورة العنصر المجلوب',
|
|
||||||
'fetched_categories' => 'فئات العنصر المجلوب',
|
|
||||||
'fetched_enclosure' => 'وسائط العنصر المجلوب (صوت/فيديو/ملف)',
|
|
||||||
'fetched_pubdate' => 'تاريخ نشر العنصر المجلوب',
|
|
||||||
'fetched_http' => 'عنصر HTTP المجلوب (أضِف حقلًا)',
|
|
||||||
'generated_content' => 'محتوى المنشور المُنشأ بالذكاء الاصطناعي',
|
|
||||||
'generated_post_url' => 'رابط المنشور المُنشأ بالذكاء الاصطناعي',
|
|
||||||
'variable' => 'متغير سير العمل',
|
|
||||||
'now' => 'التاريخ والوقت الحالي',
|
|
||||||
],
|
|
||||||
|
|
||||||
'test' => [
|
|
||||||
'description' => 'يشغّل الأتمتة من البداية إلى النهاية باستخدام حمولة مُشغِّل مُصطنعة. مفيد للتحقق من كل عقدة دون انتظار الجدول أو الخلاصة الحقيقية.',
|
|
||||||
'starting' => 'جارٍ بدء تشغيل الاختبار…',
|
|
||||||
'in_progress' => 'قيد التنفيذ',
|
|
||||||
'completed' => 'مكتمل',
|
|
||||||
'failed' => 'فشل',
|
|
||||||
'waiting' => 'في الانتظار',
|
|
||||||
'close' => 'إغلاق',
|
|
||||||
'no_node_runs' => 'في انتظار بدء العقدة الأولى…',
|
|
||||||
'node_input' => 'المدخلات',
|
|
||||||
'node_output' => 'المخرجات',
|
|
||||||
'node_error' => 'خطأ',
|
|
||||||
'no_new_items' => 'لا توجد عناصر جديدة — لم يُشغَّل أي شيء لاحق.',
|
|
||||||
'error_starting' => 'تعذر بدء تشغيل الاختبار.',
|
|
||||||
'with_real_data' => 'ببيانات حقيقية',
|
|
||||||
'run' => 'تشغيل الاختبار',
|
|
||||||
'idle_hint' => 'اضغط على "تشغيل الاختبار" لتنفيذ الأتمتة من البداية إلى النهاية.',
|
|
||||||
'real_data_hint' => 'سيقوم هذا الاختبار بنشر المنشورات، وتقديم علامات الاستطلاع، وإطلاق تأثيرات جانبية خارجية.',
|
|
||||||
'dry_badge' => 'تشغيل تجريبي',
|
|
||||||
],
|
|
||||||
|
|
||||||
'status' => [
|
|
||||||
'draft' => 'مسودة',
|
|
||||||
'active' => 'نشط',
|
|
||||||
'paused' => 'متوقف مؤقتًا',
|
|
||||||
],
|
|
||||||
|
|
||||||
'index' => [
|
|
||||||
'empty_title' => 'لا توجد عمليات أتمتة بعد',
|
|
||||||
'empty_description' => 'أنشئ أول أتمتة لك لبدء النشر تلقائيًا.',
|
|
||||||
'columns' => [
|
|
||||||
'name' => 'الاسم',
|
|
||||||
'status' => 'الحالة',
|
|
||||||
'created' => 'تاريخ الإنشاء',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'form' => [
|
|
||||||
'activate_error_fallback' => 'تعذر تفعيل الأتمتة.',
|
|
||||||
'pause_error_fallback' => 'تعذر إيقاف الأتمتة مؤقتًا.',
|
|
||||||
'save_error_fallback' => 'تعذر حفظ الأتمتة.',
|
|
||||||
'save_success' => 'تم حفظ الأتمتة.',
|
|
||||||
'empty_canvas_title' => 'ابدأ ببناء أتمتتك',
|
|
||||||
'empty_canvas_description' => 'اسحب عقدة من اللوحة الجانبية للبدء.',
|
|
||||||
'name_placeholder' => 'أتمتة بلا عنوان',
|
|
||||||
],
|
|
||||||
|
|
||||||
'nodes' => [
|
|
||||||
'trigger' => 'مُشغّل',
|
|
||||||
'generate' => 'إنشاء',
|
|
||||||
'delay' => 'تأخير',
|
|
||||||
'condition' => 'شرط',
|
|
||||||
'publish' => 'نشر',
|
|
||||||
'end' => 'إنهاء',
|
|
||||||
'end_summary' => 'يوقف الأتمتة هنا',
|
|
||||||
'fetch_rss' => 'جلب RSS',
|
|
||||||
'http_request' => 'طلب HTTP',
|
|
||||||
'handles' => [
|
|
||||||
'items' => 'يحتوي على عناصر',
|
|
||||||
'no_items' => 'لا عناصر',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'config' => [
|
|
||||||
'select_placeholder' => 'اختر…',
|
|
||||||
'invalid_json' => 'هذا ليس JSON صالحًا بعد.',
|
|
||||||
'expand_editor' => 'توسيع المحرر',
|
|
||||||
'minimize_editor' => 'تصغير',
|
|
||||||
|
|
||||||
'trigger' => [
|
|
||||||
'type' => 'نوع المُشغّل',
|
|
||||||
'types' => [
|
|
||||||
'schedule' => 'جدولة',
|
|
||||||
'post_published' => 'عند نشر منشور',
|
|
||||||
'post_scheduled' => 'عند جدولة منشور',
|
|
||||||
],
|
|
||||||
'post_published_hint' => 'يعمل كلما تم نشر أي منشور في مساحة العمل هذه. يصبح المنشور المنشور متاحًا في {{ trigger.post }} للعقد اللاحقة.',
|
|
||||||
'post_scheduled_hint' => 'يعمل كلما تمت جدولة أي منشور في مساحة العمل هذه. يكون المنشور المجدول متاحًا في {{ trigger.post }}.',
|
|
||||||
|
|
||||||
'schedule' => [
|
|
||||||
'field' => 'فاصل المُشغّل',
|
|
||||||
'fields' => [
|
|
||||||
'minutes' => 'دقائق',
|
|
||||||
'hours' => 'ساعات',
|
|
||||||
'days' => 'أيام',
|
|
||||||
'weeks' => 'أسابيع',
|
|
||||||
'months' => 'أشهر',
|
|
||||||
],
|
|
||||||
'minutes_interval' => 'الدقائق بين عمليات التشغيل',
|
|
||||||
'hours_interval' => 'الساعات بين عمليات التشغيل',
|
|
||||||
'days_interval' => 'الأيام بين عمليات التشغيل',
|
|
||||||
'hour' => 'التشغيل عند الساعة',
|
|
||||||
'minute' => 'التشغيل عند الدقيقة',
|
|
||||||
'weekdays' => 'التشغيل في أيام الأسبوع',
|
|
||||||
'day_of_month' => 'يوم من الشهر',
|
|
||||||
'weekday_names' => [
|
|
||||||
'sun' => 'أحد',
|
|
||||||
'mon' => 'اثنين',
|
|
||||||
'tue' => 'ثلاثاء',
|
|
||||||
'wed' => 'أربعاء',
|
|
||||||
'thu' => 'خميس',
|
|
||||||
'fri' => 'جمعة',
|
|
||||||
'sat' => 'سبت',
|
|
||||||
],
|
|
||||||
'summary' => [
|
|
||||||
'every_n_minutes' => '{1}يعمل كل دقيقة|{2}يعمل كل دقيقتين|[3,10]يعمل كل :count دقائق|[11,*]يعمل كل :count دقيقة',
|
|
||||||
'every_n_hours' => '{1}يعمل كل ساعة عند الدقيقة :minute|{2}يعمل كل ساعتين عند الدقيقة :minute|[3,10]يعمل كل :count ساعات عند الدقيقة :minute|[11,*]يعمل كل :count ساعة عند الدقيقة :minute',
|
|
||||||
'every_n_days' => '{1}يعمل كل يوم عند :time|{2}يعمل كل يومين عند :time|[3,10]يعمل كل :count أيام عند :time|[11,*]يعمل كل :count يوم عند :time',
|
|
||||||
'weekly' => 'يعمل كل :days عند :time',
|
|
||||||
'monthly' => 'يعمل في اليوم :day من كل شهر عند :time',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'generate' => [
|
|
||||||
'social_accounts' => 'الحسابات الاجتماعية',
|
|
||||||
'social_accounts_empty' => 'لا توجد حسابات اجتماعية متصلة. اربط واحدًا أولًا.',
|
|
||||||
'target_slide_count' => 'الشرائح المراد إنشاؤها',
|
|
||||||
'prompt_template' => 'قالب الموجّه',
|
|
||||||
'prompt_template_hint' => 'اكتب {{ لإدراج بيانات من الخطوات السابقة.',
|
|
||||||
'image_count' => 'الصور المراد إنشاؤها',
|
|
||||||
'image_count_hint' => '0 = منشور نصي فقط (بلا صورة). 1 = صورة واحدة. 2 أو أكثر = عرض دائري.',
|
|
||||||
'use_brand_voice' => 'استخدام صوت العلامة التجارية',
|
|
||||||
'use_brand_voice_hint' => 'طبّق وصف علامتك التجارية وصوتها. أوقفه للتنسيق الأمين لمصادر الجهات الخارجية (الأخبار، RSS).',
|
|
||||||
'use_brand_visuals' => 'استخدام العناصر المرئية للعلامة التجارية',
|
|
||||||
'use_brand_visuals_hint' => 'وجّه صور الذكاء الاصطناعي بألوان علامتك التجارية وهويتها. أوقفه للصور المحايدة المدفوعة بموضوع المنشور فقط.',
|
|
||||||
'style' => 'النمط',
|
|
||||||
'account_summary' => '{1}حساب واحد · :format|{2}حسابان · :format|[3,10]:count حسابات · :format|[11,*]:count حساب · :format',
|
|
||||||
'formats' => [
|
|
||||||
'single' => 'مفرد',
|
|
||||||
'carousel' => 'عرض دائري',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'delay' => [
|
|
||||||
'duration' => 'المدة',
|
|
||||||
'unit' => 'الوحدة',
|
|
||||||
'units' => [
|
|
||||||
'minutes' => 'دقائق',
|
|
||||||
'hours' => 'ساعات',
|
|
||||||
'days' => 'أيام',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'condition' => [
|
|
||||||
'field' => 'الحقل',
|
|
||||||
'operator' => 'العامل',
|
|
||||||
'operators' => [
|
|
||||||
'contains' => 'يحتوي على',
|
|
||||||
'not_contains' => 'لا يحتوي على',
|
|
||||||
'equals' => 'يساوي',
|
|
||||||
'not_equals' => 'لا يساوي',
|
|
||||||
'matches' => 'يطابق (تعبير نمطي)',
|
|
||||||
'greater_than' => 'أكبر من',
|
|
||||||
'less_than' => 'أصغر من',
|
|
||||||
],
|
|
||||||
'value' => 'القيمة',
|
|
||||||
],
|
|
||||||
'publish' => [
|
|
||||||
'mode' => 'الوضع',
|
|
||||||
'modes' => [
|
|
||||||
'now' => 'نشر الآن',
|
|
||||||
'scheduled' => 'جدولة',
|
|
||||||
'draft' => 'حفظ كمسودة',
|
|
||||||
],
|
|
||||||
'scheduled_offset' => 'الإزاحة عن المُشغّل (بالدقائق)',
|
|
||||||
'offset_summary' => ':mode · +:offset د',
|
|
||||||
],
|
|
||||||
'end' => [
|
|
||||||
'reason' => 'السبب (اختياري)',
|
|
||||||
'reason_placeholder' => 'مثال: تمت تصفيته بواسطة شرط',
|
|
||||||
],
|
|
||||||
'fetch_rss' => [
|
|
||||||
'feed_url' => 'رابط الخلاصة',
|
|
||||||
'feed_url_hint' => 'في التشغيل الأول، تُضبط العلامة على "الآن" حتى لا تُغرِق العناصر التاريخية العقد اللاحقة. تشاهد عمليات التشغيل اللاحقة فقط العناصر الأحدث من الاستطلاع السابق.',
|
|
||||||
'inspect' => 'فحص الخلاصة',
|
|
||||||
'inspecting' => 'جارٍ الفحص…',
|
|
||||||
'inspect_hint' => 'اجلب عينة لاكتشاف الحقول المتاحة للاستخدام في العقد اللاحقة.',
|
|
||||||
'inspect_error' => 'تعذرت قراءة هذه الخلاصة. تحقق من الرابط وحاول مرة أخرى.',
|
|
||||||
'discovered_fields' => 'الحقول المتاحة',
|
|
||||||
'discovered_empty' => 'لم يتم العثور على حقول في أحدث عنصر.',
|
|
||||||
],
|
|
||||||
'http_request' => [
|
|
||||||
'url' => 'الرابط',
|
|
||||||
'method' => 'الطريقة',
|
|
||||||
'auth_type' => 'المصادقة',
|
|
||||||
'auth' => [
|
|
||||||
'none' => 'بلا (عام)',
|
|
||||||
'bearer' => 'رمز Bearer',
|
|
||||||
'basic' => 'مصادقة أساسية',
|
|
||||||
'api_key' => 'ترويسة مفتاح API',
|
|
||||||
],
|
|
||||||
'bearer_token' => 'رمز Bearer',
|
|
||||||
'basic_username' => 'اسم المستخدم',
|
|
||||||
'basic_password' => 'كلمة المرور',
|
|
||||||
'api_key_header' => 'اسم الترويسة',
|
|
||||||
'api_key_value' => 'مفتاح API',
|
|
||||||
'body_template' => 'قالب النص (JSON)',
|
|
||||||
'headers' => 'الترويسات',
|
|
||||||
'header_name' => 'اسم الترويسة',
|
|
||||||
'header_value' => 'القيمة',
|
|
||||||
'add_header' => 'إضافة ترويسة',
|
|
||||||
'polling_section' => 'القائمة وإزالة التكرار (اختياري)',
|
|
||||||
'polling_hint' => 'عندما تكون الاستجابة قائمة، يشغّل كل عنصر سير العمل بشكل منفصل. يشغّل الكائن الواحد مرة واحدة.',
|
|
||||||
'items_path' => 'مسار العناصر',
|
|
||||||
'items_path_hint' => 'اتركه فارغًا إذا كانت الاستجابة مصفوفة بالفعل. استخدم مسارًا منقّطًا (مثل data.items) لمصفوفة متداخلة، أو * لكائن مفهرس بالمعرّف.',
|
|
||||||
'item_key_path' => 'مسار مفتاح العنصر',
|
|
||||||
'item_key_path_hint' => 'مسار JSON لمعرّف فريد (مثل id). تُتخطى العناصر التي سبقت رؤيتها، لذا تظل الخلاصة بلا تواريخ تُمرّر الإدخالات الجديدة فقط.',
|
|
||||||
'item_date_path' => 'مسار تاريخ العنصر',
|
|
||||||
'item_date_path_hint' => 'مسار JSON للطابع الزمني للعنصر (مثل published_at). يُفضَّل على مسار المفتاح عند توفره. يسجّل الاستطلاع الأول الأساس ولا يمرّر شيئًا، لذا لا تُغرِق الخلاصة الموجودة في اليوم الأول.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'delete' => [
|
|
||||||
'title' => 'حذف الأتمتة',
|
|
||||||
'description' => 'هل أنت متأكد من رغبتك في حذف هذه الأتمتة؟ ستتم إزالة جميع عمليات التشغيل وعناصر التشغيل أيضًا. لا يمكن التراجع عن هذا الإجراء.',
|
|
||||||
'confirm' => 'حذف',
|
|
||||||
'cancel' => 'إلغاء',
|
|
||||||
],
|
|
||||||
|
|
||||||
'flash' => [
|
|
||||||
'deleted' => 'تم حذف الأتمتة بنجاح!',
|
|
||||||
],
|
|
||||||
|
|
||||||
'errors' => [
|
|
||||||
'no_active_social_accounts' => 'لا توجد حسابات اجتماعية نشطة مُهيّأة لهذه الأتمتة.',
|
|
||||||
'must_have_one_trigger' => 'يجب أن تحتوي الأتمتة على عقدة مُشغّل واحدة بالضبط.',
|
|
||||||
'trigger_must_be_connected' => 'يجب أن تكون عقدة المُشغّل متصلة بعقدة واحدة على الأقل.',
|
|
||||||
'graph_contains_cycle' => 'يحتوي مخطط الأتمتة على حلقة.',
|
|
||||||
'only_failed_can_retry' => 'يمكن إعادة محاولة عمليات التشغيل الفاشلة فقط.',
|
|
||||||
'no_generated_post' => 'لم يتم العثور على منشور مُنشأ في التشغيل.',
|
|
||||||
'url_not_allowed' => 'رابط الطلب يشير إلى عنوان خاص أو غير قابل للوصول وتم حظره.',
|
|
||||||
'node_no_longer_exists' => 'العقدة :node_id لم تعد موجودة في الأتمتة.',
|
|
||||||
'no_trigger_connection' => 'لا توجد عقدة متصلة بعقدة المُشغّل.',
|
|
||||||
'fetch_rss_missing_url' => 'عقدة جلب RSS تفتقد إلى رابط خلاصة.',
|
|
||||||
'fetch_rss_request_failed' => 'فشل طلب خلاصة RSS.',
|
|
||||||
'fetch_rss_malformed' => 'خلاصة RSS مشوّهة.',
|
|
||||||
'http_missing_url' => 'عقدة طلب HTTP تفتقد إلى رابط.',
|
|
||||||
'http_request_exception' => 'أطلق طلب HTTP استثناءً.',
|
|
||||||
'http_request_failed' => 'فشل طلب HTTP.',
|
|
||||||
'http_items_path_not_array' => 'لم يُفضِ مسار العناصر إلى قائمة.',
|
|
||||||
'generate_image_format_required' => 'توليد الذكاء الاصطناعي ينشئ صورًا فقط. اختر تنسيق صورة (وليس فيديو).',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
@ -6,8 +6,6 @@
|
||||||
|
|
||||||
'back' => 'رجوع',
|
'back' => 'رجوع',
|
||||||
|
|
||||||
'beta' => 'تجريبي',
|
|
||||||
|
|
||||||
'confirm_modal' => [
|
'confirm_modal' => [
|
||||||
'cannot_be_undone' => 'لا يمكن التراجع عن هذا الإجراء.',
|
'cannot_be_undone' => 'لا يمكن التراجع عن هذا الإجراء.',
|
||||||
'type' => 'اكتب',
|
'type' => 'اكتب',
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@
|
||||||
'others' => 'أخرى',
|
'others' => 'أخرى',
|
||||||
],
|
],
|
||||||
'analytics' => 'التحليلات',
|
'analytics' => 'التحليلات',
|
||||||
'automations' => 'الأتمتة',
|
|
||||||
'onboarding' => 'البدء',
|
'onboarding' => 'البدء',
|
||||||
'onboarding_hint' => 'أكمل الإعداد',
|
'onboarding_hint' => 'أكمل الإعداد',
|
||||||
'posts' => [
|
'posts' => [
|
||||||
|
|
|
||||||
|
|
@ -1,402 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'mobile_notice' => 'Der Automatisierungs-Editor funktioniert am besten auf einem größeren Bildschirm. Öffne ihn auf einem Desktop, um deinen Workflow zu erstellen.',
|
|
||||||
'title' => 'Automatisierungen',
|
|
||||||
'default_name' => 'Neue Automatisierung',
|
|
||||||
|
|
||||||
'actions' => [
|
|
||||||
'new' => 'Neue Automatisierung',
|
|
||||||
'edit' => 'Bearbeiten',
|
|
||||||
'save' => 'Speichern',
|
|
||||||
'activate' => 'Aktivieren',
|
|
||||||
'pause' => 'Pausieren',
|
|
||||||
'delete' => 'Löschen',
|
|
||||||
'retry' => 'Erneut versuchen',
|
|
||||||
'guide' => 'So funktioniert es',
|
|
||||||
],
|
|
||||||
|
|
||||||
'tabs' => [
|
|
||||||
'build' => 'Erstellen',
|
|
||||||
'variables' => 'Variablen',
|
|
||||||
'test' => 'Test',
|
|
||||||
],
|
|
||||||
|
|
||||||
'nav' => [
|
|
||||||
'workflow' => 'Workflow',
|
|
||||||
'invocations' => 'Ausführungen',
|
|
||||||
'metrics' => 'Kennzahlen',
|
|
||||||
'settings' => 'Einstellungen',
|
|
||||||
],
|
|
||||||
|
|
||||||
'settings' => [
|
|
||||||
'general' => 'Allgemein',
|
|
||||||
'general_description' => 'Benenne diese Automatisierung um.',
|
|
||||||
'name_label' => 'Name',
|
|
||||||
'name_saved' => 'Automatisierung umbenannt.',
|
|
||||||
'status_title' => 'Status',
|
|
||||||
'status_description' => 'Aktiviere sie, um sie auszuführen, oder pausiere sie, um sie zu stoppen.',
|
|
||||||
'activated_at' => 'Aktiviert :date',
|
|
||||||
'paused_at' => 'Pausiert :date',
|
|
||||||
'created_at' => 'Erstellt :date',
|
|
||||||
'danger_title' => 'Gefahrenzone',
|
|
||||||
'danger_description' => 'Unumkehrbare Aktionen.',
|
|
||||||
'delete_title' => 'Diese Automatisierung löschen',
|
|
||||||
'delete_description' => 'Entfernt die Automatisierung und ihren Ausführungsverlauf dauerhaft.',
|
|
||||||
],
|
|
||||||
|
|
||||||
'status_run' => [
|
|
||||||
'pending' => 'Ausstehend',
|
|
||||||
'running' => 'Läuft',
|
|
||||||
'waiting' => 'Wartet',
|
|
||||||
'completed' => 'Abgeschlossen',
|
|
||||||
'failed' => 'Fehlgeschlagen',
|
|
||||||
'cancelled' => 'Abgebrochen',
|
|
||||||
],
|
|
||||||
|
|
||||||
'node_type' => [
|
|
||||||
'trigger' => 'Trigger',
|
|
||||||
'generate' => 'Inhalt generieren',
|
|
||||||
'delay' => 'Verzögerung',
|
|
||||||
'condition' => 'Bedingung',
|
|
||||||
'publish' => 'Veröffentlichen',
|
|
||||||
'end' => 'Ende',
|
|
||||||
'fetch_rss' => 'RSS abrufen',
|
|
||||||
'http_request' => 'HTTP-Anfrage',
|
|
||||||
],
|
|
||||||
|
|
||||||
'invocations' => [
|
|
||||||
'empty' => 'Noch keine Ausführungen.',
|
|
||||||
'refresh' => 'Aktualisieren',
|
|
||||||
'search_placeholder' => 'Nach Ausführungs-ID suchen…',
|
|
||||||
'copied' => 'Ausführungs-ID kopiert.',
|
|
||||||
'loading' => 'Schritte werden geladen…',
|
|
||||||
'no_steps' => 'Keine Schritte aufgezeichnet.',
|
|
||||||
'load_error' => 'Schritte konnten nicht geladen werden.',
|
|
||||||
'steps' => '{0}Keine Schritte|{1}:count Schritt|[2,*]:count Schritte',
|
|
||||||
'filter' => [
|
|
||||||
'all' => 'Alle Status',
|
|
||||||
],
|
|
||||||
'columns' => [
|
|
||||||
'timestamp' => 'Zeitstempel',
|
|
||||||
'run' => 'Ausführung',
|
|
||||||
'status' => 'Status',
|
|
||||||
'message' => 'Letzte Meldung',
|
|
||||||
'duration' => 'Dauer',
|
|
||||||
],
|
|
||||||
'summary' => [
|
|
||||||
'completed' => 'Workflow abgeschlossen',
|
|
||||||
'failed' => 'Workflow fehlgeschlagen',
|
|
||||||
'running' => 'Workflow läuft',
|
|
||||||
'cancelled' => 'Workflow abgebrochen',
|
|
||||||
'pending' => 'Workflow ausstehend',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'metrics' => [
|
|
||||||
'overview' => 'Übersicht',
|
|
||||||
'runs_over_time' => 'Ausführungen im Zeitverlauf',
|
|
||||||
'posts_by_platform' => 'Beiträge nach Plattform',
|
|
||||||
'no_posts' => 'In diesem Zeitraum wurden keine Beiträge veröffentlicht.',
|
|
||||||
'cards' => [
|
|
||||||
'runs' => 'Ausführungen gesamt',
|
|
||||||
'completed' => 'Abgeschlossen',
|
|
||||||
'failed' => 'Fehlgeschlagen',
|
|
||||||
'in_progress' => 'In Bearbeitung',
|
|
||||||
'success_rate' => 'Erfolgsquote',
|
|
||||||
'avg_duration' => 'Durchschn. Dauer',
|
|
||||||
'posts_created' => 'Erstellte Beiträge',
|
|
||||||
],
|
|
||||||
'legend' => [
|
|
||||||
'started' => 'Gestartet',
|
|
||||||
'completed' => 'Abgeschlossen',
|
|
||||||
'failed' => 'Fehlgeschlagen',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'categories' => [
|
|
||||||
'sources' => 'Quellen',
|
|
||||||
'content' => 'Inhalt',
|
|
||||||
'flow' => 'Ablauf',
|
|
||||||
'output' => 'Ausgabe',
|
|
||||||
],
|
|
||||||
|
|
||||||
'variables' => [
|
|
||||||
'title' => 'Workflow-Variablen',
|
|
||||||
'hint' => 'Wiederverwendbare Werte, die überall mit {{ variables.KEY }} referenziert werden. Verschlüsselt gespeichert.',
|
|
||||||
'empty' => 'Noch keine Variablen.',
|
|
||||||
'key' => 'Schlüssel',
|
|
||||||
'value' => 'Wert',
|
|
||||||
'key_placeholder' => 'API_KEY',
|
|
||||||
'value_placeholder' => 'Wert',
|
|
||||||
'add' => 'Neue Variable',
|
|
||||||
],
|
|
||||||
|
|
||||||
'expr' => [
|
|
||||||
'trigger_event' => 'Name des Trigger-Events',
|
|
||||||
'trigger_fired_at' => 'Wann der Trigger ausgelöst wurde',
|
|
||||||
'trigger_post_id' => 'ID des auslösenden Beitrags',
|
|
||||||
'trigger_post_content' => 'Inhalt des auslösenden Beitrags',
|
|
||||||
'trigger_post_status' => 'Status des auslösenden Beitrags',
|
|
||||||
'trigger_post_scheduled_at' => 'Wann der Beitrag geplant ist',
|
|
||||||
'trigger_post_published_at' => 'Wann der Beitrag veröffentlicht wurde',
|
|
||||||
'fetched_title' => 'Titel des abgerufenen Eintrags',
|
|
||||||
'fetched_link' => 'Link des abgerufenen Eintrags',
|
|
||||||
'fetched_date' => 'Veröffentlichungsdatum des abgerufenen Eintrags',
|
|
||||||
'fetched_content' => 'Vollständiger Inhalt des abgerufenen Eintrags',
|
|
||||||
'fetched_description' => 'Zusammenfassung des abgerufenen Eintrags',
|
|
||||||
'fetched_author' => 'Autor des abgerufenen Eintrags',
|
|
||||||
'fetched_image' => 'Bild-URL des abgerufenen Eintrags',
|
|
||||||
'fetched_categories' => 'Kategorien des abgerufenen Eintrags',
|
|
||||||
'fetched_enclosure' => 'Medien des abgerufenen Eintrags (Audio/Video/Datei)',
|
|
||||||
'fetched_pubdate' => 'Veröffentlichungsdatum des abgerufenen Eintrags',
|
|
||||||
'fetched_http' => 'Abgerufener HTTP-Eintrag (Feld anhängen)',
|
|
||||||
'generated_content' => 'KI-generierter Beitragsinhalt',
|
|
||||||
'generated_post_url' => 'URL des KI-generierten Beitrags',
|
|
||||||
'variable' => 'Workflow-Variable',
|
|
||||||
'now' => 'Aktuelles Datum & Uhrzeit',
|
|
||||||
],
|
|
||||||
|
|
||||||
'test' => [
|
|
||||||
'description' => 'Führt die Automatisierung durchgängig mit einer synthetisierten Trigger-Nutzlast aus. Nützlich, um jeden Node zu validieren, ohne auf den echten Zeitplan oder Feed zu warten.',
|
|
||||||
'starting' => 'Testlauf wird gestartet…',
|
|
||||||
'in_progress' => 'In Bearbeitung',
|
|
||||||
'completed' => 'Abgeschlossen',
|
|
||||||
'failed' => 'Fehlgeschlagen',
|
|
||||||
'waiting' => 'Wartet',
|
|
||||||
'close' => 'Schließen',
|
|
||||||
'no_node_runs' => 'Warten auf den Start des ersten Nodes…',
|
|
||||||
'node_input' => 'Eingabe',
|
|
||||||
'node_output' => 'Ausgabe',
|
|
||||||
'node_error' => 'Fehler',
|
|
||||||
'no_new_items' => 'Keine neuen Einträge – nichts Nachgelagertes wurde ausgeführt.',
|
|
||||||
'error_starting' => 'Der Testlauf konnte nicht gestartet werden.',
|
|
||||||
'with_real_data' => 'Mit echten Daten',
|
|
||||||
'run' => 'Test ausführen',
|
|
||||||
'idle_hint' => 'Klicke auf „Test ausführen", um die Automatisierung durchgängig auszuführen.',
|
|
||||||
'real_data_hint' => 'Dieser Test veröffentlicht Beiträge, setzt Polling-Markierungen fort und löst externe Seiteneffekte aus.',
|
|
||||||
'dry_badge' => 'Probelauf',
|
|
||||||
],
|
|
||||||
|
|
||||||
'status' => [
|
|
||||||
'draft' => 'Entwurf',
|
|
||||||
'active' => 'Aktiv',
|
|
||||||
'paused' => 'Pausiert',
|
|
||||||
],
|
|
||||||
|
|
||||||
'index' => [
|
|
||||||
'empty_title' => 'Noch keine Automatisierungen',
|
|
||||||
'empty_description' => 'Erstelle deine erste Automatisierung, um auf Autopilot zu veröffentlichen.',
|
|
||||||
'columns' => [
|
|
||||||
'name' => 'Name',
|
|
||||||
'status' => 'Status',
|
|
||||||
'created' => 'Erstellt',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'form' => [
|
|
||||||
'activate_error_fallback' => 'Automatisierung konnte nicht aktiviert werden.',
|
|
||||||
'pause_error_fallback' => 'Automatisierung konnte nicht pausiert werden.',
|
|
||||||
'save_error_fallback' => 'Automatisierung konnte nicht gespeichert werden.',
|
|
||||||
'save_success' => 'Automatisierung gespeichert.',
|
|
||||||
'empty_canvas_title' => 'Beginne mit dem Aufbau deiner Automatisierung',
|
|
||||||
'empty_canvas_description' => 'Ziehe einen Node aus dem linken Bereich, um zu starten.',
|
|
||||||
'name_placeholder' => 'Unbenannte Automatisierung',
|
|
||||||
],
|
|
||||||
|
|
||||||
'nodes' => [
|
|
||||||
'trigger' => 'Trigger',
|
|
||||||
'generate' => 'Generieren',
|
|
||||||
'delay' => 'Verzögerung',
|
|
||||||
'condition' => 'Bedingung',
|
|
||||||
'publish' => 'Veröffentlichen',
|
|
||||||
'end' => 'Ende',
|
|
||||||
'end_summary' => 'Stoppt die Automatisierung hier',
|
|
||||||
'fetch_rss' => 'RSS abrufen',
|
|
||||||
'http_request' => 'HTTP-Anfrage',
|
|
||||||
'handles' => [
|
|
||||||
'items' => 'hat Einträge',
|
|
||||||
'no_items' => 'keine Einträge',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'config' => [
|
|
||||||
'select_placeholder' => 'Auswählen…',
|
|
||||||
'invalid_json' => 'Das ist noch kein gültiges JSON.',
|
|
||||||
'expand_editor' => 'Editor vergrößern',
|
|
||||||
'minimize_editor' => 'Verkleinern',
|
|
||||||
|
|
||||||
'trigger' => [
|
|
||||||
'type' => 'Trigger-Typ',
|
|
||||||
'types' => [
|
|
||||||
'schedule' => 'Zeitplan',
|
|
||||||
'post_published' => 'Wenn ein Beitrag veröffentlicht wird',
|
|
||||||
'post_scheduled' => 'Wenn ein Beitrag geplant wird',
|
|
||||||
],
|
|
||||||
'post_published_hint' => 'Läuft, wann immer ein Beitrag in diesem Workspace veröffentlicht wird. Der veröffentlichte Beitrag steht unter {{ trigger.post }} für nachgelagerte Nodes zur Verfügung.',
|
|
||||||
'post_scheduled_hint' => 'Läuft, wann immer ein Beitrag in diesem Workspace geplant wird. Der geplante Beitrag steht unter {{ trigger.post }} zur Verfügung.',
|
|
||||||
|
|
||||||
'schedule' => [
|
|
||||||
'field' => 'Trigger-Intervall',
|
|
||||||
'fields' => [
|
|
||||||
'minutes' => 'Minuten',
|
|
||||||
'hours' => 'Stunden',
|
|
||||||
'days' => 'Tage',
|
|
||||||
'weeks' => 'Wochen',
|
|
||||||
'months' => 'Monate',
|
|
||||||
],
|
|
||||||
'minutes_interval' => 'Minuten zwischen den Triggern',
|
|
||||||
'hours_interval' => 'Stunden zwischen den Triggern',
|
|
||||||
'days_interval' => 'Tage zwischen den Triggern',
|
|
||||||
'hour' => 'Auslösen zur Stunde',
|
|
||||||
'minute' => 'Auslösen zur Minute',
|
|
||||||
'weekdays' => 'An Wochentagen auslösen',
|
|
||||||
'day_of_month' => 'Tag des Monats',
|
|
||||||
'weekday_names' => [
|
|
||||||
'sun' => 'So',
|
|
||||||
'mon' => 'Mo',
|
|
||||||
'tue' => 'Di',
|
|
||||||
'wed' => 'Mi',
|
|
||||||
'thu' => 'Do',
|
|
||||||
'fri' => 'Fr',
|
|
||||||
'sat' => 'Sa',
|
|
||||||
],
|
|
||||||
'summary' => [
|
|
||||||
'every_n_minutes' => 'Läuft jede Minute|Läuft alle :count Minuten',
|
|
||||||
'every_n_hours' => 'Läuft jede Stunde zur Minute :minute|Läuft alle :count Stunden zur Minute :minute',
|
|
||||||
'every_n_days' => 'Läuft täglich um :time|Läuft alle :count Tage um :time',
|
|
||||||
'weekly' => 'Läuft :days um :time',
|
|
||||||
'monthly' => 'Läuft an Tag :day jedes Monats um :time',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'generate' => [
|
|
||||||
'social_accounts' => 'Social-Media-Konten',
|
|
||||||
'social_accounts_empty' => 'Keine verbundenen Social-Media-Konten. Verbinde zuerst eines.',
|
|
||||||
'target_slide_count' => 'Zu generierende Slides',
|
|
||||||
'prompt_template' => 'Prompt-Vorlage',
|
|
||||||
'prompt_template_hint' => 'Tippe {{, um Daten aus vorherigen Schritten einzufügen.',
|
|
||||||
'image_count' => 'Zu generierende Bilder',
|
|
||||||
'image_count_hint' => '0 = reiner Textbeitrag (kein Bild). 1 = einzelnes Bild. 2+ = Karussell.',
|
|
||||||
'use_brand_voice' => 'Markenton verwenden',
|
|
||||||
'use_brand_voice_hint' => 'Wende deine Markenbeschreibung und deinen Markenton an. Deaktiviere dies für die originalgetreue Kuratierung von Drittquellen (News, RSS).',
|
|
||||||
'use_brand_visuals' => 'Marken-Visuals verwenden',
|
|
||||||
'use_brand_visuals_hint' => 'Steuere KI-Bilder mit deinen Markenfarben und deiner Markenidentität. Deaktiviere dies für neutrale Bilder, die nur vom Beitragsthema bestimmt werden.',
|
|
||||||
'style' => 'Stil',
|
|
||||||
'account_summary' => ':count Konto · :format|:count Konten · :format',
|
|
||||||
'formats' => [
|
|
||||||
'single' => 'Einzeln',
|
|
||||||
'carousel' => 'Karussell',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'delay' => [
|
|
||||||
'duration' => 'Dauer',
|
|
||||||
'unit' => 'Einheit',
|
|
||||||
'units' => [
|
|
||||||
'minutes' => 'Minuten',
|
|
||||||
'hours' => 'Stunden',
|
|
||||||
'days' => 'Tage',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'condition' => [
|
|
||||||
'field' => 'Feld',
|
|
||||||
'operator' => 'Operator',
|
|
||||||
'operators' => [
|
|
||||||
'contains' => 'enthält',
|
|
||||||
'not_contains' => 'enthält nicht',
|
|
||||||
'equals' => 'ist gleich',
|
|
||||||
'not_equals' => 'ist ungleich',
|
|
||||||
'matches' => 'entspricht (Regex)',
|
|
||||||
'greater_than' => 'größer als',
|
|
||||||
'less_than' => 'kleiner als',
|
|
||||||
],
|
|
||||||
'value' => 'Wert',
|
|
||||||
],
|
|
||||||
'publish' => [
|
|
||||||
'mode' => 'Modus',
|
|
||||||
'modes' => [
|
|
||||||
'now' => 'Jetzt veröffentlichen',
|
|
||||||
'scheduled' => 'Planen',
|
|
||||||
'draft' => 'Als Entwurf speichern',
|
|
||||||
],
|
|
||||||
'scheduled_offset' => 'Versatz zum Trigger (Minuten)',
|
|
||||||
'offset_summary' => ':mode · +:offset Min.',
|
|
||||||
],
|
|
||||||
'end' => [
|
|
||||||
'reason' => 'Grund (optional)',
|
|
||||||
'reason_placeholder' => 'z. B. Durch Bedingung herausgefiltert',
|
|
||||||
],
|
|
||||||
'fetch_rss' => [
|
|
||||||
'feed_url' => 'Feed-URL',
|
|
||||||
'feed_url_hint' => 'Beim ersten Durchlauf wird die Markierung auf "now" gesetzt, damit historische Einträge die nachgelagerten Nodes nicht überfluten. Nachfolgende Durchläufe sehen nur Einträge, die neuer sind als die vorherige Abfrage.',
|
|
||||||
'inspect' => 'Feed prüfen',
|
|
||||||
'inspecting' => 'Wird geprüft…',
|
|
||||||
'inspect_hint' => 'Rufe ein Beispiel ab, um die verfügbaren Felder für nachgelagerte Nodes zu ermitteln.',
|
|
||||||
'inspect_error' => 'Dieser Feed konnte nicht gelesen werden. Prüfe die URL und versuche es erneut.',
|
|
||||||
'discovered_fields' => 'Verfügbare Felder',
|
|
||||||
'discovered_empty' => 'Keine Felder im neuesten Eintrag gefunden.',
|
|
||||||
],
|
|
||||||
'http_request' => [
|
|
||||||
'url' => 'URL',
|
|
||||||
'method' => 'Methode',
|
|
||||||
'auth_type' => 'Authentifizierung',
|
|
||||||
'auth' => [
|
|
||||||
'none' => 'Keine (öffentlich)',
|
|
||||||
'bearer' => 'Bearer-Token',
|
|
||||||
'basic' => 'Basic Auth',
|
|
||||||
'api_key' => 'API-Key-Header',
|
|
||||||
],
|
|
||||||
'bearer_token' => 'Bearer-Token',
|
|
||||||
'basic_username' => 'Benutzername',
|
|
||||||
'basic_password' => 'Passwort',
|
|
||||||
'api_key_header' => 'Header-Name',
|
|
||||||
'api_key_value' => 'API-Key',
|
|
||||||
'body_template' => 'Body-Vorlage (JSON)',
|
|
||||||
'headers' => 'Header',
|
|
||||||
'header_name' => 'Header-Name',
|
|
||||||
'header_value' => 'Wert',
|
|
||||||
'add_header' => 'Header hinzufügen',
|
|
||||||
'polling_section' => 'Liste & Deduplizierung (optional)',
|
|
||||||
'polling_hint' => 'Wenn die Antwort eine Liste ist, durchläuft jeder Eintrag den Workflow separat. Ein einzelnes Objekt wird einmal ausgeführt.',
|
|
||||||
'items_path' => 'Pfad zu den Einträgen',
|
|
||||||
'items_path_hint' => 'Leer lassen, wenn die Antwort bereits ein Array ist. Verwende einen Punkt-Pfad (z. B. data.items) für ein verschachteltes Array oder * für ein nach ID indiziertes Objekt.',
|
|
||||||
'item_key_path' => 'Pfad zum Eintragsschlüssel',
|
|
||||||
'item_key_path_hint' => 'JSON-Pfad zu einer eindeutigen ID (z. B. id). Bereits gesehene Einträge werden übersprungen, sodass ein Feed ohne Datumsangaben trotzdem nur neue Einträge weiterleitet.',
|
|
||||||
'item_date_path' => 'Pfad zum Eintragsdatum',
|
|
||||||
'item_date_path_hint' => 'JSON-Pfad zum Zeitstempel des Eintrags (z. B. published_at). Wird, sofern verfügbar, dem Schlüssel-Pfad vorgezogen. Die erste Abfrage erfasst den Ausgangswert und leitet nichts weiter, sodass ein bestehender Feed am ersten Tag niemals überflutet.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'delete' => [
|
|
||||||
'title' => 'Automatisierung löschen',
|
|
||||||
'description' => 'Möchtest du diese Automatisierung wirklich löschen? Alle Ausführungen und Trigger-Einträge werden ebenfalls entfernt. Diese Aktion kann nicht rückgängig gemacht werden.',
|
|
||||||
'confirm' => 'Löschen',
|
|
||||||
'cancel' => 'Abbrechen',
|
|
||||||
],
|
|
||||||
|
|
||||||
'flash' => [
|
|
||||||
'deleted' => 'Automatisierung erfolgreich gelöscht!',
|
|
||||||
],
|
|
||||||
|
|
||||||
'errors' => [
|
|
||||||
'no_active_social_accounts' => 'Für diese Automatisierung sind keine aktiven Social-Media-Konten konfiguriert.',
|
|
||||||
'must_have_one_trigger' => 'Eine Automatisierung muss genau einen Trigger-Node haben.',
|
|
||||||
'trigger_must_be_connected' => 'Der Trigger-Node muss mit mindestens einem Node verbunden sein.',
|
|
||||||
'graph_contains_cycle' => 'Der Automatisierungsgraph enthält einen Zyklus.',
|
|
||||||
'only_failed_can_retry' => 'Nur fehlgeschlagene Ausführungen können wiederholt werden.',
|
|
||||||
'no_generated_post' => 'Bei der Ausführung wurde kein generierter Beitrag gefunden.',
|
|
||||||
'url_not_allowed' => 'Die Anfrage-URL verweist auf eine private oder nicht erreichbare Adresse und wurde blockiert.',
|
|
||||||
'node_no_longer_exists' => 'Node :node_id existiert in der Automatisierung nicht mehr.',
|
|
||||||
'no_trigger_connection' => 'Kein Node mit dem Trigger-Node verbunden.',
|
|
||||||
'fetch_rss_missing_url' => 'Dem Node „RSS abrufen" fehlt eine Feed-URL.',
|
|
||||||
'fetch_rss_request_failed' => 'Die Anfrage an den RSS-Feed ist fehlgeschlagen.',
|
|
||||||
'fetch_rss_malformed' => 'Der RSS-Feed ist fehlerhaft.',
|
|
||||||
'http_missing_url' => 'Dem HTTP-Anfrage-Node fehlt eine URL.',
|
|
||||||
'http_request_exception' => 'Die HTTP-Anfrage hat eine Ausnahme ausgelöst.',
|
|
||||||
'http_request_failed' => 'Die HTTP-Anfrage ist fehlgeschlagen.',
|
|
||||||
'http_items_path_not_array' => 'Der Pfad zu den Einträgen ergab keine Liste.',
|
|
||||||
'generate_image_format_required' => 'KI-Generierung erstellt nur Bilder. Wähle ein Bildformat (kein Video).',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
@ -6,8 +6,6 @@
|
||||||
|
|
||||||
'back' => 'Zurück',
|
'back' => 'Zurück',
|
||||||
|
|
||||||
'beta' => 'Beta',
|
|
||||||
|
|
||||||
'confirm_modal' => [
|
'confirm_modal' => [
|
||||||
'cannot_be_undone' => 'Dies kann nicht rückgängig gemacht werden.',
|
'cannot_be_undone' => 'Dies kann nicht rückgängig gemacht werden.',
|
||||||
'type' => 'Gib',
|
'type' => 'Gib',
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@
|
||||||
'others' => 'Sonstiges',
|
'others' => 'Sonstiges',
|
||||||
],
|
],
|
||||||
'analytics' => 'Analytics',
|
'analytics' => 'Analytics',
|
||||||
'automations' => 'Automatisierungen',
|
|
||||||
'onboarding' => 'Erste Schritte',
|
'onboarding' => 'Erste Schritte',
|
||||||
'onboarding_hint' => 'Einrichtung abschließen',
|
'onboarding_hint' => 'Einrichtung abschließen',
|
||||||
'posts' => [
|
'posts' => [
|
||||||
|
|
|
||||||
|
|
@ -1,402 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'mobile_notice' => 'Ο επεξεργαστής αυτοματισμών λειτουργεί καλύτερα σε μεγαλύτερη οθόνη. Άνοιξέ τον σε υπολογιστή για να δημιουργήσεις τη ροή εργασίας σου.',
|
|
||||||
'title' => 'Αυτοματισμοί',
|
|
||||||
'default_name' => 'Νέος αυτοματισμός',
|
|
||||||
|
|
||||||
'actions' => [
|
|
||||||
'new' => 'Νέος αυτοματισμός',
|
|
||||||
'edit' => 'Επεξεργασία',
|
|
||||||
'save' => 'Αποθήκευση',
|
|
||||||
'activate' => 'Ενεργοποίηση',
|
|
||||||
'pause' => 'Παύση',
|
|
||||||
'delete' => 'Διαγραφή',
|
|
||||||
'retry' => 'Επανάληψη',
|
|
||||||
'guide' => 'Μάθετε πώς λειτουργεί',
|
|
||||||
],
|
|
||||||
|
|
||||||
'tabs' => [
|
|
||||||
'build' => 'Δημιουργία',
|
|
||||||
'variables' => 'Μεταβλητές',
|
|
||||||
'test' => 'Δοκιμή',
|
|
||||||
],
|
|
||||||
|
|
||||||
'nav' => [
|
|
||||||
'workflow' => 'Ροή εργασίας',
|
|
||||||
'invocations' => 'Εκτελέσεις',
|
|
||||||
'metrics' => 'Μετρήσεις',
|
|
||||||
'settings' => 'Ρυθμίσεις',
|
|
||||||
],
|
|
||||||
|
|
||||||
'settings' => [
|
|
||||||
'general' => 'Γενικά',
|
|
||||||
'general_description' => 'Μετονομάστε αυτόν τον αυτοματισμό.',
|
|
||||||
'name_label' => 'Όνομα',
|
|
||||||
'name_saved' => 'Ο αυτοματισμός μετονομάστηκε.',
|
|
||||||
'status_title' => 'Κατάσταση',
|
|
||||||
'status_description' => 'Ενεργοποιήστε για να ξεκινήσει η εκτέλεση ή κάντε παύση για να σταματήσει.',
|
|
||||||
'activated_at' => 'Ενεργοποιήθηκε :date',
|
|
||||||
'paused_at' => 'Σε παύση :date',
|
|
||||||
'created_at' => 'Δημιουργήθηκε :date',
|
|
||||||
'danger_title' => 'Ζώνη κινδύνου',
|
|
||||||
'danger_description' => 'Μη αναστρέψιμες ενέργειες.',
|
|
||||||
'delete_title' => 'Διαγραφή αυτού του αυτοματισμού',
|
|
||||||
'delete_description' => 'Αφαιρεί οριστικά τον αυτοματισμό και το ιστορικό εκτελέσεών του.',
|
|
||||||
],
|
|
||||||
|
|
||||||
'status_run' => [
|
|
||||||
'pending' => 'Σε εκκρεμότητα',
|
|
||||||
'running' => 'Σε εξέλιξη',
|
|
||||||
'waiting' => 'Σε αναμονή',
|
|
||||||
'completed' => 'Ολοκληρώθηκε',
|
|
||||||
'failed' => 'Απέτυχε',
|
|
||||||
'cancelled' => 'Ακυρώθηκε',
|
|
||||||
],
|
|
||||||
|
|
||||||
'node_type' => [
|
|
||||||
'trigger' => 'Έναυσμα',
|
|
||||||
'generate' => 'Δημιουργία περιεχομένου',
|
|
||||||
'delay' => 'Καθυστέρηση',
|
|
||||||
'condition' => 'Συνθήκη',
|
|
||||||
'publish' => 'Δημοσίευση',
|
|
||||||
'end' => 'Τέλος',
|
|
||||||
'fetch_rss' => 'Ανάκτηση RSS',
|
|
||||||
'http_request' => 'Αίτημα HTTP',
|
|
||||||
],
|
|
||||||
|
|
||||||
'invocations' => [
|
|
||||||
'empty' => 'Δεν υπάρχουν εκτελέσεις ακόμη.',
|
|
||||||
'refresh' => 'Ανανέωση',
|
|
||||||
'search_placeholder' => 'Αναζήτηση με ID εκτέλεσης…',
|
|
||||||
'copied' => 'Το ID εκτέλεσης αντιγράφηκε.',
|
|
||||||
'loading' => 'Φόρτωση βημάτων…',
|
|
||||||
'no_steps' => 'Δεν καταγράφηκαν βήματα.',
|
|
||||||
'load_error' => 'Δεν ήταν δυνατή η φόρτωση των βημάτων.',
|
|
||||||
'steps' => '{0}Κανένα βήμα|{1}:count βήμα|[2,*]:count βήματα',
|
|
||||||
'filter' => [
|
|
||||||
'all' => 'Όλες οι καταστάσεις',
|
|
||||||
],
|
|
||||||
'columns' => [
|
|
||||||
'timestamp' => 'Χρονική σήμανση',
|
|
||||||
'run' => 'Εκτέλεση',
|
|
||||||
'status' => 'Κατάσταση',
|
|
||||||
'message' => 'Τελευταίο μήνυμα',
|
|
||||||
'duration' => 'Διάρκεια',
|
|
||||||
],
|
|
||||||
'summary' => [
|
|
||||||
'completed' => 'Η ροή εργασίας ολοκληρώθηκε',
|
|
||||||
'failed' => 'Η ροή εργασίας απέτυχε',
|
|
||||||
'running' => 'Η ροή εργασίας εκτελείται',
|
|
||||||
'cancelled' => 'Η ροή εργασίας ακυρώθηκε',
|
|
||||||
'pending' => 'Η ροή εργασίας εκκρεμεί',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'metrics' => [
|
|
||||||
'overview' => 'Επισκόπηση',
|
|
||||||
'runs_over_time' => 'Εκτελέσεις με την πάροδο του χρόνου',
|
|
||||||
'posts_by_platform' => 'Δημοσιεύσεις ανά πλατφόρμα',
|
|
||||||
'no_posts' => 'Δεν δημοσιεύτηκαν δημοσιεύσεις σε αυτή την περίοδο.',
|
|
||||||
'cards' => [
|
|
||||||
'runs' => 'Συνολικές εκτελέσεις',
|
|
||||||
'completed' => 'Ολοκληρώθηκαν',
|
|
||||||
'failed' => 'Απέτυχαν',
|
|
||||||
'in_progress' => 'Σε εξέλιξη',
|
|
||||||
'success_rate' => 'Ποσοστό επιτυχίας',
|
|
||||||
'avg_duration' => 'Μέση διάρκεια',
|
|
||||||
'posts_created' => 'Δημοσιεύσεις που δημιουργήθηκαν',
|
|
||||||
],
|
|
||||||
'legend' => [
|
|
||||||
'started' => 'Ξεκίνησαν',
|
|
||||||
'completed' => 'Ολοκληρώθηκαν',
|
|
||||||
'failed' => 'Απέτυχαν',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'categories' => [
|
|
||||||
'sources' => 'Πηγές',
|
|
||||||
'content' => 'Περιεχόμενο',
|
|
||||||
'flow' => 'Ροή',
|
|
||||||
'output' => 'Έξοδος',
|
|
||||||
],
|
|
||||||
|
|
||||||
'variables' => [
|
|
||||||
'title' => 'Μεταβλητές ροής εργασίας',
|
|
||||||
'hint' => 'Επαναχρησιμοποιήσιμες τιμές που αναφέρονται οπουδήποτε με {{ variables.KEY }}. Αποθηκεύονται κρυπτογραφημένες.',
|
|
||||||
'empty' => 'Δεν υπάρχουν μεταβλητές ακόμη.',
|
|
||||||
'key' => 'Κλειδί',
|
|
||||||
'value' => 'Τιμή',
|
|
||||||
'key_placeholder' => 'API_KEY',
|
|
||||||
'value_placeholder' => 'Τιμή',
|
|
||||||
'add' => 'Νέα μεταβλητή',
|
|
||||||
],
|
|
||||||
|
|
||||||
'expr' => [
|
|
||||||
'trigger_event' => 'Όνομα συμβάντος εναύσματος',
|
|
||||||
'trigger_fired_at' => 'Πότε ενεργοποιήθηκε το έναυσμα',
|
|
||||||
'trigger_post_id' => 'ID δημοσίευσης εναύσματος',
|
|
||||||
'trigger_post_content' => 'Περιεχόμενο δημοσίευσης εναύσματος',
|
|
||||||
'trigger_post_status' => 'Κατάσταση δημοσίευσης εναύσματος',
|
|
||||||
'trigger_post_scheduled_at' => 'Πότε είναι προγραμματισμένη η δημοσίευση',
|
|
||||||
'trigger_post_published_at' => 'Πότε δημοσιεύτηκε η δημοσίευση',
|
|
||||||
'fetched_title' => 'Τίτλος ανακτηθέντος στοιχείου',
|
|
||||||
'fetched_link' => 'Σύνδεσμος ανακτηθέντος στοιχείου',
|
|
||||||
'fetched_date' => 'Ημερομηνία δημοσίευσης ανακτηθέντος στοιχείου',
|
|
||||||
'fetched_content' => 'Πλήρες περιεχόμενο ανακτηθέντος στοιχείου',
|
|
||||||
'fetched_description' => 'Σύνοψη ανακτηθέντος στοιχείου',
|
|
||||||
'fetched_author' => 'Συντάκτης ανακτηθέντος στοιχείου',
|
|
||||||
'fetched_image' => 'URL εικόνας ανακτηθέντος στοιχείου',
|
|
||||||
'fetched_categories' => 'Κατηγορίες ανακτηθέντος στοιχείου',
|
|
||||||
'fetched_enclosure' => 'Πολυμέσα ανακτηθέντος στοιχείου (ήχος/βίντεο/αρχείο)',
|
|
||||||
'fetched_pubdate' => 'Ημερομηνία δημοσίευσης ανακτηθέντος στοιχείου',
|
|
||||||
'fetched_http' => 'Ανακτηθέν στοιχείο HTTP (προσθέστε ένα πεδίο)',
|
|
||||||
'generated_content' => 'Περιεχόμενο δημοσίευσης που δημιούργησε το AI',
|
|
||||||
'generated_post_url' => 'URL δημοσίευσης που δημιούργησε το AI',
|
|
||||||
'variable' => 'Μεταβλητή ροής εργασίας',
|
|
||||||
'now' => 'Τρέχουσα ημερομηνία και ώρα',
|
|
||||||
],
|
|
||||||
|
|
||||||
'test' => [
|
|
||||||
'description' => 'Εκτελεί τον αυτοματισμό από άκρη σε άκρη χρησιμοποιώντας ένα συνθετικό payload εναύσματος. Χρήσιμο για την επικύρωση κάθε κόμβου χωρίς αναμονή για το πραγματικό χρονοδιάγραμμα ή τη ροή.',
|
|
||||||
'starting' => 'Έναρξη δοκιμαστικής εκτέλεσης…',
|
|
||||||
'in_progress' => 'Σε εξέλιξη',
|
|
||||||
'completed' => 'Ολοκληρώθηκε',
|
|
||||||
'failed' => 'Απέτυχε',
|
|
||||||
'waiting' => 'Σε αναμονή',
|
|
||||||
'close' => 'Κλείσιμο',
|
|
||||||
'no_node_runs' => 'Αναμονή για την έναρξη του πρώτου κόμβου…',
|
|
||||||
'node_input' => 'Είσοδος',
|
|
||||||
'node_output' => 'Έξοδος',
|
|
||||||
'node_error' => 'Σφάλμα',
|
|
||||||
'no_new_items' => 'Δεν υπάρχουν νέα στοιχεία — τίποτα δεν εκτελέστηκε παρακάτω.',
|
|
||||||
'error_starting' => 'Δεν ήταν δυνατή η έναρξη της δοκιμαστικής εκτέλεσης.',
|
|
||||||
'with_real_data' => 'Με πραγματικά δεδομένα',
|
|
||||||
'run' => 'Εκτέλεση δοκιμής',
|
|
||||||
'idle_hint' => 'Πατήστε Εκτέλεση δοκιμής για να εκτελέσετε τον αυτοματισμό από άκρη σε άκρη.',
|
|
||||||
'real_data_hint' => 'Αυτή η δοκιμή θα δημοσιεύσει δημοσιεύσεις, θα προωθήσει τα σημεία ελέγχου polling και θα ενεργοποιήσει εξωτερικές παρενέργειες.',
|
|
||||||
'dry_badge' => 'Δοκιμαστική εκτέλεση',
|
|
||||||
],
|
|
||||||
|
|
||||||
'status' => [
|
|
||||||
'draft' => 'Πρόχειρο',
|
|
||||||
'active' => 'Ενεργός',
|
|
||||||
'paused' => 'Σε παύση',
|
|
||||||
],
|
|
||||||
|
|
||||||
'index' => [
|
|
||||||
'empty_title' => 'Δεν υπάρχουν αυτοματισμοί ακόμη',
|
|
||||||
'empty_description' => 'Δημιουργήστε τον πρώτο σας αυτοματισμό για να ξεκινήσετε να δημοσιεύετε αυτόματα.',
|
|
||||||
'columns' => [
|
|
||||||
'name' => 'Όνομα',
|
|
||||||
'status' => 'Κατάσταση',
|
|
||||||
'created' => 'Δημιουργήθηκε',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'form' => [
|
|
||||||
'activate_error_fallback' => 'Δεν ήταν δυνατή η ενεργοποίηση του αυτοματισμού.',
|
|
||||||
'pause_error_fallback' => 'Δεν ήταν δυνατή η παύση του αυτοματισμού.',
|
|
||||||
'save_error_fallback' => 'Δεν ήταν δυνατή η αποθήκευση του αυτοματισμού.',
|
|
||||||
'save_success' => 'Ο αυτοματισμός αποθηκεύτηκε.',
|
|
||||||
'empty_canvas_title' => 'Ξεκινήστε να δημιουργείτε τον αυτοματισμό σας',
|
|
||||||
'empty_canvas_description' => 'Σύρετε έναν κόμβο από τον αριστερό πίνακα για να ξεκινήσετε.',
|
|
||||||
'name_placeholder' => 'Αυτοματισμός χωρίς τίτλο',
|
|
||||||
],
|
|
||||||
|
|
||||||
'nodes' => [
|
|
||||||
'trigger' => 'Έναυσμα',
|
|
||||||
'generate' => 'Δημιουργία',
|
|
||||||
'delay' => 'Καθυστέρηση',
|
|
||||||
'condition' => 'Συνθήκη',
|
|
||||||
'publish' => 'Δημοσίευση',
|
|
||||||
'end' => 'Τέλος',
|
|
||||||
'end_summary' => 'Σταματά τον αυτοματισμό εδώ',
|
|
||||||
'fetch_rss' => 'Ανάκτηση RSS',
|
|
||||||
'http_request' => 'Αίτημα HTTP',
|
|
||||||
'handles' => [
|
|
||||||
'items' => 'έχει στοιχεία',
|
|
||||||
'no_items' => 'κανένα στοιχείο',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'config' => [
|
|
||||||
'select_placeholder' => 'Επιλέξτε…',
|
|
||||||
'invalid_json' => 'Αυτό δεν είναι ακόμη έγκυρο JSON.',
|
|
||||||
'expand_editor' => 'Ανάπτυξη επεξεργαστή',
|
|
||||||
'minimize_editor' => 'Ελαχιστοποίηση',
|
|
||||||
|
|
||||||
'trigger' => [
|
|
||||||
'type' => 'Τύπος εναύσματος',
|
|
||||||
'types' => [
|
|
||||||
'schedule' => 'Χρονοδιάγραμμα',
|
|
||||||
'post_published' => 'Όταν δημοσιεύεται μια δημοσίευση',
|
|
||||||
'post_scheduled' => 'Όταν προγραμματίζεται μια δημοσίευση',
|
|
||||||
],
|
|
||||||
'post_published_hint' => 'Εκτελείται κάθε φορά που δημοσιεύεται οποιαδήποτε δημοσίευση σε αυτό το workspace. Η δημοσιευμένη δημοσίευση γίνεται διαθέσιμη στο {{ trigger.post }} για τους επόμενους κόμβους.',
|
|
||||||
'post_scheduled_hint' => 'Εκτελείται κάθε φορά που προγραμματίζεται οποιαδήποτε δημοσίευση σε αυτό το workspace. Η προγραμματισμένη δημοσίευση είναι διαθέσιμη στο {{ trigger.post }}.',
|
|
||||||
|
|
||||||
'schedule' => [
|
|
||||||
'field' => 'Διάστημα εναύσματος',
|
|
||||||
'fields' => [
|
|
||||||
'minutes' => 'Λεπτά',
|
|
||||||
'hours' => 'Ώρες',
|
|
||||||
'days' => 'Ημέρες',
|
|
||||||
'weeks' => 'Εβδομάδες',
|
|
||||||
'months' => 'Μήνες',
|
|
||||||
],
|
|
||||||
'minutes_interval' => 'Λεπτά μεταξύ εναυσμάτων',
|
|
||||||
'hours_interval' => 'Ώρες μεταξύ εναυσμάτων',
|
|
||||||
'days_interval' => 'Ημέρες μεταξύ εναυσμάτων',
|
|
||||||
'hour' => 'Έναυσμα στην ώρα',
|
|
||||||
'minute' => 'Έναυσμα στο λεπτό',
|
|
||||||
'weekdays' => 'Έναυσμα σε ημέρες της εβδομάδας',
|
|
||||||
'day_of_month' => 'Ημέρα του μήνα',
|
|
||||||
'weekday_names' => [
|
|
||||||
'sun' => 'Κυρ',
|
|
||||||
'mon' => 'Δευ',
|
|
||||||
'tue' => 'Τρί',
|
|
||||||
'wed' => 'Τετ',
|
|
||||||
'thu' => 'Πέμ',
|
|
||||||
'fri' => 'Παρ',
|
|
||||||
'sat' => 'Σάβ',
|
|
||||||
],
|
|
||||||
'summary' => [
|
|
||||||
'every_n_minutes' => 'Εκτελείται κάθε λεπτό|Εκτελείται κάθε :count λεπτά',
|
|
||||||
'every_n_hours' => 'Εκτελείται κάθε ώρα στο λεπτό :minute|Εκτελείται κάθε :count ώρες στο λεπτό :minute',
|
|
||||||
'every_n_days' => 'Εκτελείται κάθε ημέρα στις :time|Εκτελείται κάθε :count ημέρες στις :time',
|
|
||||||
'weekly' => 'Εκτελείται κάθε :days στις :time',
|
|
||||||
'monthly' => 'Εκτελείται την ημέρα :day κάθε μήνα στις :time',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'generate' => [
|
|
||||||
'social_accounts' => 'Λογαριασμοί κοινωνικών δικτύων',
|
|
||||||
'social_accounts_empty' => 'Δεν υπάρχουν συνδεδεμένοι λογαριασμοί κοινωνικών δικτύων. Συνδέστε πρώτα έναν.',
|
|
||||||
'target_slide_count' => 'Slides προς δημιουργία',
|
|
||||||
'prompt_template' => 'Πρότυπο prompt',
|
|
||||||
'prompt_template_hint' => 'Πληκτρολογήστε {{ για εισαγωγή δεδομένων από προηγούμενα βήματα.',
|
|
||||||
'image_count' => 'Εικόνες προς δημιουργία',
|
|
||||||
'image_count_hint' => '0 = δημοσίευση μόνο με κείμενο (χωρίς εικόνα). 1 = μία εικόνα. 2+ = carousel.',
|
|
||||||
'use_brand_voice' => 'Χρήση φωνής μάρκας',
|
|
||||||
'use_brand_voice_hint' => 'Εφαρμόστε την περιγραφή και τη φωνή της μάρκας σας. Απενεργοποιήστε το για πιστή επιμέλεια πηγών τρίτων (ειδήσεις, RSS).',
|
|
||||||
'use_brand_visuals' => 'Χρήση οπτικών στοιχείων μάρκας',
|
|
||||||
'use_brand_visuals_hint' => 'Κατευθύνετε τις εικόνες AI με τα χρώματα και την ταυτότητα της μάρκας σας. Απενεργοποιήστε το για ουδέτερες εικόνες που καθορίζονται μόνο από το θέμα της δημοσίευσης.',
|
|
||||||
'style' => 'Ύφος',
|
|
||||||
'account_summary' => ':count λογαριασμός · :format|:count λογαριασμοί · :format',
|
|
||||||
'formats' => [
|
|
||||||
'single' => 'μεμονωμένο',
|
|
||||||
'carousel' => 'carousel',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'delay' => [
|
|
||||||
'duration' => 'Διάρκεια',
|
|
||||||
'unit' => 'Μονάδα',
|
|
||||||
'units' => [
|
|
||||||
'minutes' => 'Λεπτά',
|
|
||||||
'hours' => 'Ώρες',
|
|
||||||
'days' => 'Ημέρες',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'condition' => [
|
|
||||||
'field' => 'Πεδίο',
|
|
||||||
'operator' => 'Τελεστής',
|
|
||||||
'operators' => [
|
|
||||||
'contains' => 'περιέχει',
|
|
||||||
'not_contains' => 'δεν περιέχει',
|
|
||||||
'equals' => 'ισούται με',
|
|
||||||
'not_equals' => 'δεν ισούται με',
|
|
||||||
'matches' => 'ταιριάζει (regex)',
|
|
||||||
'greater_than' => 'μεγαλύτερο από',
|
|
||||||
'less_than' => 'μικρότερο από',
|
|
||||||
],
|
|
||||||
'value' => 'Τιμή',
|
|
||||||
],
|
|
||||||
'publish' => [
|
|
||||||
'mode' => 'Λειτουργία',
|
|
||||||
'modes' => [
|
|
||||||
'now' => 'Δημοσίευση τώρα',
|
|
||||||
'scheduled' => 'Χρονοδιάγραμμα',
|
|
||||||
'draft' => 'Αποθήκευση ως πρόχειρο',
|
|
||||||
],
|
|
||||||
'scheduled_offset' => 'Μετατόπιση από το έναυσμα (λεπτά)',
|
|
||||||
'offset_summary' => ':mode · +:offset λεπτά',
|
|
||||||
],
|
|
||||||
'end' => [
|
|
||||||
'reason' => 'Αιτία (προαιρετικό)',
|
|
||||||
'reason_placeholder' => 'π.χ. Φιλτραρίστηκε από τη συνθήκη',
|
|
||||||
],
|
|
||||||
'fetch_rss' => [
|
|
||||||
'feed_url' => 'URL ροής',
|
|
||||||
'feed_url_hint' => 'Στην πρώτη εκτέλεση, το σημείο ελέγχου ορίζεται στο «τώρα» ώστε τα παλαιότερα στοιχεία να μην πλημμυρίσουν τους επόμενους κόμβους. Οι επόμενες εκτελέσεις βλέπουν μόνο στοιχεία νεότερα από το προηγούμενο poll.',
|
|
||||||
'inspect' => 'Επιθεώρηση ροής',
|
|
||||||
'inspecting' => 'Επιθεώρηση…',
|
|
||||||
'inspect_hint' => 'Ανακτήστε ένα δείγμα για να ανακαλύψετε τα διαθέσιμα πεδία προς χρήση στους επόμενους κόμβους.',
|
|
||||||
'inspect_error' => 'Δεν ήταν δυνατή η ανάγνωση αυτής της ροής. Ελέγξτε τη διεύθυνση URL και δοκιμάστε ξανά.',
|
|
||||||
'discovered_fields' => 'Διαθέσιμα πεδία',
|
|
||||||
'discovered_empty' => 'Δεν βρέθηκαν πεδία στο πιο πρόσφατο στοιχείο.',
|
|
||||||
],
|
|
||||||
'http_request' => [
|
|
||||||
'url' => 'URL',
|
|
||||||
'method' => 'Μέθοδος',
|
|
||||||
'auth_type' => 'Ταυτοποίηση',
|
|
||||||
'auth' => [
|
|
||||||
'none' => 'Καμία (δημόσιο)',
|
|
||||||
'bearer' => 'Bearer token',
|
|
||||||
'basic' => 'Basic auth',
|
|
||||||
'api_key' => 'Κεφαλίδα κλειδιού API',
|
|
||||||
],
|
|
||||||
'bearer_token' => 'Bearer token',
|
|
||||||
'basic_username' => 'Όνομα χρήστη',
|
|
||||||
'basic_password' => 'Κωδικός πρόσβασης',
|
|
||||||
'api_key_header' => 'Όνομα κεφαλίδας',
|
|
||||||
'api_key_value' => 'Κλειδί API',
|
|
||||||
'body_template' => 'Πρότυπο σώματος (JSON)',
|
|
||||||
'headers' => 'Κεφαλίδες',
|
|
||||||
'header_name' => 'Όνομα κεφαλίδας',
|
|
||||||
'header_value' => 'Τιμή',
|
|
||||||
'add_header' => 'Προσθήκη κεφαλίδας',
|
|
||||||
'polling_section' => 'Λίστα και αφαίρεση διπλότυπων (προαιρετικό)',
|
|
||||||
'polling_hint' => 'Όταν η απόκριση είναι λίστα, κάθε στοιχείο εκτελεί τη ροή εργασίας ξεχωριστά. Ένα μεμονωμένο αντικείμενο εκτελείται μία φορά.',
|
|
||||||
'items_path' => 'Διαδρομή στοιχείων',
|
|
||||||
'items_path_hint' => 'Αφήστε το κενό αν η απόκριση είναι ήδη πίνακας. Χρησιμοποιήστε διαδρομή με τελείες (π.χ. data.items) για εμφωλευμένο πίνακα ή * για αντικείμενο με κλειδί το id.',
|
|
||||||
'item_key_path' => 'Διαδρομή κλειδιού στοιχείου',
|
|
||||||
'item_key_path_hint' => 'Διαδρομή JSON προς ένα μοναδικό id (π.χ. id). Τα στοιχεία που έχουν ήδη εμφανιστεί παραλείπονται, ώστε μια ροή χωρίς ημερομηνίες να προωθεί μόνο νέες καταχωρίσεις.',
|
|
||||||
'item_date_path' => 'Διαδρομή ημερομηνίας στοιχείου',
|
|
||||||
'item_date_path_hint' => 'Διαδρομή JSON προς τη χρονική σήμανση του στοιχείου (π.χ. published_at). Προτιμάται έναντι της διαδρομής κλειδιού όταν είναι διαθέσιμη. Το πρώτο poll καταγράφει τη γραμμή βάσης και δεν προωθεί τίποτα, ώστε μια υπάρχουσα ροή να μην πλημμυρίζει την πρώτη ημέρα.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'delete' => [
|
|
||||||
'title' => 'Διαγραφή αυτοματισμού',
|
|
||||||
'description' => 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτόν τον αυτοματισμό; Όλες οι εκτελέσεις και τα στοιχεία εναύσματος θα αφαιρεθούν επίσης. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.',
|
|
||||||
'confirm' => 'Διαγραφή',
|
|
||||||
'cancel' => 'Ακύρωση',
|
|
||||||
],
|
|
||||||
|
|
||||||
'flash' => [
|
|
||||||
'deleted' => 'Ο αυτοματισμός διαγράφηκε με επιτυχία!',
|
|
||||||
],
|
|
||||||
|
|
||||||
'errors' => [
|
|
||||||
'no_active_social_accounts' => 'Δεν έχουν ρυθμιστεί ενεργοί λογαριασμοί κοινωνικών δικτύων για αυτόν τον αυτοματισμό.',
|
|
||||||
'must_have_one_trigger' => 'Ο αυτοματισμός πρέπει να έχει ακριβώς έναν κόμβο εναύσματος.',
|
|
||||||
'trigger_must_be_connected' => 'Ο κόμβος εναύσματος πρέπει να είναι συνδεδεμένος με τουλάχιστον έναν κόμβο.',
|
|
||||||
'graph_contains_cycle' => 'Το γράφημα του αυτοματισμού περιέχει κύκλο.',
|
|
||||||
'only_failed_can_retry' => 'Μόνο οι αποτυχημένες εκτελέσεις μπορούν να επαναληφθούν.',
|
|
||||||
'no_generated_post' => 'Δεν βρέθηκε δημιουργημένη δημοσίευση στην εκτέλεση.',
|
|
||||||
'url_not_allowed' => 'Η διεύθυνση URL του αιτήματος δείχνει σε ιδιωτική ή μη προσβάσιμη διεύθυνση και αποκλείστηκε.',
|
|
||||||
'node_no_longer_exists' => 'Ο κόμβος :node_id δεν υπάρχει πλέον στον αυτοματισμό.',
|
|
||||||
'no_trigger_connection' => 'Κανένας κόμβος δεν είναι συνδεδεμένος με τον κόμβο εναύσματος.',
|
|
||||||
'fetch_rss_missing_url' => 'Από τον κόμβο Ανάκτησης RSS λείπει μια διεύθυνση URL ροής.',
|
|
||||||
'fetch_rss_request_failed' => 'Το αίτημα της ροής RSS απέτυχε.',
|
|
||||||
'fetch_rss_malformed' => 'Η ροή RSS είναι δυσμορφική.',
|
|
||||||
'http_missing_url' => 'Από τον κόμβο αιτήματος HTTP λείπει μια διεύθυνση URL.',
|
|
||||||
'http_request_exception' => 'Το αίτημα HTTP προκάλεσε εξαίρεση.',
|
|
||||||
'http_request_failed' => 'Το αίτημα HTTP απέτυχε.',
|
|
||||||
'http_items_path_not_array' => 'Η διαδρομή στοιχείων δεν αντιστοιχήθηκε σε λίστα.',
|
|
||||||
'generate_image_format_required' => 'Η δημιουργία AI παράγει μόνο εικόνες. Επίλεξε μορφή εικόνας (όχι βίντεο).',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
@ -6,8 +6,6 @@
|
||||||
|
|
||||||
'back' => 'Πίσω',
|
'back' => 'Πίσω',
|
||||||
|
|
||||||
'beta' => 'Βήτα',
|
|
||||||
|
|
||||||
'confirm_modal' => [
|
'confirm_modal' => [
|
||||||
'cannot_be_undone' => 'Αυτό δεν μπορεί να αναιρεθεί.',
|
'cannot_be_undone' => 'Αυτό δεν μπορεί να αναιρεθεί.',
|
||||||
'type' => 'Πληκτρολογήστε',
|
'type' => 'Πληκτρολογήστε',
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@
|
||||||
'others' => 'Άλλα',
|
'others' => 'Άλλα',
|
||||||
],
|
],
|
||||||
'analytics' => 'Στατιστικά',
|
'analytics' => 'Στατιστικά',
|
||||||
'automations' => 'Αυτοματισμοί',
|
|
||||||
'onboarding' => 'Ξεκινώντας',
|
'onboarding' => 'Ξεκινώντας',
|
||||||
'onboarding_hint' => 'Ολοκλήρωση ρύθμισης',
|
'onboarding_hint' => 'Ολοκλήρωση ρύθμισης',
|
||||||
'posts' => [
|
'posts' => [
|
||||||
|
|
|
||||||
|
|
@ -1,402 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'mobile_notice' => 'The automation editor works best on a larger screen. Open it on a desktop to build your workflow.',
|
|
||||||
'title' => 'Automations',
|
|
||||||
'default_name' => 'New automation',
|
|
||||||
|
|
||||||
'actions' => [
|
|
||||||
'new' => 'New automation',
|
|
||||||
'edit' => 'Edit',
|
|
||||||
'save' => 'Save',
|
|
||||||
'activate' => 'Activate',
|
|
||||||
'pause' => 'Pause',
|
|
||||||
'delete' => 'Delete',
|
|
||||||
'retry' => 'Retry',
|
|
||||||
'guide' => 'Learn how it works',
|
|
||||||
],
|
|
||||||
|
|
||||||
'tabs' => [
|
|
||||||
'build' => 'Build',
|
|
||||||
'variables' => 'Variables',
|
|
||||||
'test' => 'Test',
|
|
||||||
],
|
|
||||||
|
|
||||||
'nav' => [
|
|
||||||
'workflow' => 'Workflow',
|
|
||||||
'invocations' => 'Invocations',
|
|
||||||
'metrics' => 'Metrics',
|
|
||||||
'settings' => 'Settings',
|
|
||||||
],
|
|
||||||
|
|
||||||
'settings' => [
|
|
||||||
'general' => 'General',
|
|
||||||
'general_description' => 'Rename this automation.',
|
|
||||||
'name_label' => 'Name',
|
|
||||||
'name_saved' => 'Automation renamed.',
|
|
||||||
'status_title' => 'Status',
|
|
||||||
'status_description' => 'Activate to start running it, or pause to stop.',
|
|
||||||
'activated_at' => 'Activated :date',
|
|
||||||
'paused_at' => 'Paused :date',
|
|
||||||
'created_at' => 'Created :date',
|
|
||||||
'danger_title' => 'Danger zone',
|
|
||||||
'danger_description' => 'Irreversible actions.',
|
|
||||||
'delete_title' => 'Delete this automation',
|
|
||||||
'delete_description' => 'Permanently removes the automation and its run history.',
|
|
||||||
],
|
|
||||||
|
|
||||||
'status_run' => [
|
|
||||||
'pending' => 'Pending',
|
|
||||||
'running' => 'Running',
|
|
||||||
'waiting' => 'Waiting',
|
|
||||||
'completed' => 'Completed',
|
|
||||||
'failed' => 'Failed',
|
|
||||||
'cancelled' => 'Cancelled',
|
|
||||||
],
|
|
||||||
|
|
||||||
'node_type' => [
|
|
||||||
'trigger' => 'Trigger',
|
|
||||||
'generate' => 'Generate content',
|
|
||||||
'delay' => 'Delay',
|
|
||||||
'condition' => 'Condition',
|
|
||||||
'publish' => 'Publish',
|
|
||||||
'end' => 'End',
|
|
||||||
'fetch_rss' => 'Fetch RSS',
|
|
||||||
'http_request' => 'HTTP request',
|
|
||||||
],
|
|
||||||
|
|
||||||
'invocations' => [
|
|
||||||
'empty' => 'No invocations yet.',
|
|
||||||
'refresh' => 'Refresh',
|
|
||||||
'search_placeholder' => 'Search by run ID…',
|
|
||||||
'copied' => 'Run ID copied.',
|
|
||||||
'loading' => 'Loading steps…',
|
|
||||||
'no_steps' => 'No steps recorded.',
|
|
||||||
'load_error' => 'Could not load steps.',
|
|
||||||
'steps' => '{0}No steps|{1}:count step|[2,*]:count steps',
|
|
||||||
'filter' => [
|
|
||||||
'all' => 'All statuses',
|
|
||||||
],
|
|
||||||
'columns' => [
|
|
||||||
'timestamp' => 'Timestamp',
|
|
||||||
'run' => 'Run',
|
|
||||||
'status' => 'Status',
|
|
||||||
'message' => 'Last message',
|
|
||||||
'duration' => 'Duration',
|
|
||||||
],
|
|
||||||
'summary' => [
|
|
||||||
'completed' => 'Workflow completed',
|
|
||||||
'failed' => 'Workflow failed',
|
|
||||||
'running' => 'Workflow running',
|
|
||||||
'cancelled' => 'Workflow cancelled',
|
|
||||||
'pending' => 'Workflow pending',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'metrics' => [
|
|
||||||
'overview' => 'Overview',
|
|
||||||
'runs_over_time' => 'Runs over time',
|
|
||||||
'posts_by_platform' => 'Posts by platform',
|
|
||||||
'no_posts' => 'No posts published in this period.',
|
|
||||||
'cards' => [
|
|
||||||
'runs' => 'Total runs',
|
|
||||||
'completed' => 'Completed',
|
|
||||||
'failed' => 'Failed',
|
|
||||||
'in_progress' => 'In progress',
|
|
||||||
'success_rate' => 'Success rate',
|
|
||||||
'avg_duration' => 'Avg duration',
|
|
||||||
'posts_created' => 'Posts created',
|
|
||||||
],
|
|
||||||
'legend' => [
|
|
||||||
'started' => 'Started',
|
|
||||||
'completed' => 'Completed',
|
|
||||||
'failed' => 'Failed',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'categories' => [
|
|
||||||
'sources' => 'Sources',
|
|
||||||
'content' => 'Content',
|
|
||||||
'flow' => 'Flow',
|
|
||||||
'output' => 'Output',
|
|
||||||
],
|
|
||||||
|
|
||||||
'variables' => [
|
|
||||||
'title' => 'Workflow variables',
|
|
||||||
'hint' => 'Reusable values referenced anywhere with {{ variables.KEY }}. Stored encrypted.',
|
|
||||||
'empty' => 'No variables yet.',
|
|
||||||
'key' => 'Key',
|
|
||||||
'value' => 'Value',
|
|
||||||
'key_placeholder' => 'API_KEY',
|
|
||||||
'value_placeholder' => 'Value',
|
|
||||||
'add' => 'New variable',
|
|
||||||
],
|
|
||||||
|
|
||||||
'expr' => [
|
|
||||||
'trigger_event' => 'Trigger event name',
|
|
||||||
'trigger_fired_at' => 'When the trigger fired',
|
|
||||||
'trigger_post_id' => 'Triggering post ID',
|
|
||||||
'trigger_post_content' => 'Triggering post content',
|
|
||||||
'trigger_post_status' => 'Triggering post status',
|
|
||||||
'trigger_post_scheduled_at' => 'When the post is scheduled',
|
|
||||||
'trigger_post_published_at' => 'When the post was published',
|
|
||||||
'fetched_title' => 'Fetched item title',
|
|
||||||
'fetched_link' => 'Fetched item link',
|
|
||||||
'fetched_date' => 'Fetched item publish date',
|
|
||||||
'fetched_content' => 'Fetched item full content',
|
|
||||||
'fetched_description' => 'Fetched item summary',
|
|
||||||
'fetched_author' => 'Fetched item author',
|
|
||||||
'fetched_image' => 'Fetched item image URL',
|
|
||||||
'fetched_categories' => 'Fetched item categories',
|
|
||||||
'fetched_enclosure' => 'Fetched item media (audio/video/file)',
|
|
||||||
'fetched_pubdate' => 'Fetched item publish date',
|
|
||||||
'fetched_http' => 'Fetched HTTP item (append a field)',
|
|
||||||
'generated_content' => 'AI-generated post content',
|
|
||||||
'generated_post_url' => 'AI-generated post URL',
|
|
||||||
'variable' => 'Workflow variable',
|
|
||||||
'now' => 'Current date & time',
|
|
||||||
],
|
|
||||||
|
|
||||||
'test' => [
|
|
||||||
'description' => 'Runs the automation end-to-end using a synthesized trigger payload. Useful for validating each node without waiting for the real schedule or feed.',
|
|
||||||
'starting' => 'Starting test run…',
|
|
||||||
'in_progress' => 'In progress',
|
|
||||||
'completed' => 'Completed',
|
|
||||||
'failed' => 'Failed',
|
|
||||||
'waiting' => 'Waiting',
|
|
||||||
'close' => 'Close',
|
|
||||||
'no_node_runs' => 'Waiting for the first node to start…',
|
|
||||||
'node_input' => 'Input',
|
|
||||||
'node_output' => 'Output',
|
|
||||||
'node_error' => 'Error',
|
|
||||||
'no_new_items' => 'No new items — nothing downstream ran.',
|
|
||||||
'error_starting' => 'Could not start the test run.',
|
|
||||||
'with_real_data' => 'With real data',
|
|
||||||
'run' => 'Run test',
|
|
||||||
'idle_hint' => 'Hit Run test to execute the automation end-to-end.',
|
|
||||||
'real_data_hint' => 'This test will publish posts, advance polling watermarks, and trigger external side effects.',
|
|
||||||
'dry_badge' => 'Dry run',
|
|
||||||
],
|
|
||||||
|
|
||||||
'status' => [
|
|
||||||
'draft' => 'Draft',
|
|
||||||
'active' => 'Active',
|
|
||||||
'paused' => 'Paused',
|
|
||||||
],
|
|
||||||
|
|
||||||
'index' => [
|
|
||||||
'empty_title' => 'No automations yet',
|
|
||||||
'empty_description' => 'Create your first automation to start publishing on autopilot.',
|
|
||||||
'columns' => [
|
|
||||||
'name' => 'Name',
|
|
||||||
'status' => 'Status',
|
|
||||||
'created' => 'Created',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'form' => [
|
|
||||||
'activate_error_fallback' => 'Could not activate automation.',
|
|
||||||
'pause_error_fallback' => 'Could not pause automation.',
|
|
||||||
'save_error_fallback' => 'Could not save automation.',
|
|
||||||
'save_success' => 'Automation saved.',
|
|
||||||
'empty_canvas_title' => 'Start building your automation',
|
|
||||||
'empty_canvas_description' => 'Drag a node from the left panel to get started.',
|
|
||||||
'name_placeholder' => 'Untitled automation',
|
|
||||||
],
|
|
||||||
|
|
||||||
'nodes' => [
|
|
||||||
'trigger' => 'Trigger',
|
|
||||||
'generate' => 'Generate',
|
|
||||||
'delay' => 'Delay',
|
|
||||||
'condition' => 'Condition',
|
|
||||||
'publish' => 'Publish',
|
|
||||||
'end' => 'End',
|
|
||||||
'end_summary' => 'Stops the automation here',
|
|
||||||
'fetch_rss' => 'Fetch RSS',
|
|
||||||
'http_request' => 'HTTP Request',
|
|
||||||
'handles' => [
|
|
||||||
'items' => 'has items',
|
|
||||||
'no_items' => 'no items',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'config' => [
|
|
||||||
'select_placeholder' => 'Select…',
|
|
||||||
'invalid_json' => 'This isn’t valid JSON yet.',
|
|
||||||
'expand_editor' => 'Expand editor',
|
|
||||||
'minimize_editor' => 'Minimize',
|
|
||||||
|
|
||||||
'trigger' => [
|
|
||||||
'type' => 'Trigger type',
|
|
||||||
'types' => [
|
|
||||||
'schedule' => 'Schedule',
|
|
||||||
'post_published' => 'When a post is published',
|
|
||||||
'post_scheduled' => 'When a post is scheduled',
|
|
||||||
],
|
|
||||||
'post_published_hint' => 'Runs whenever any post in this workspace is published. The published post becomes available at {{ trigger.post }} for downstream nodes.',
|
|
||||||
'post_scheduled_hint' => 'Runs whenever any post in this workspace is scheduled. The scheduled post is available at {{ trigger.post }}.',
|
|
||||||
|
|
||||||
'schedule' => [
|
|
||||||
'field' => 'Trigger interval',
|
|
||||||
'fields' => [
|
|
||||||
'minutes' => 'Minutes',
|
|
||||||
'hours' => 'Hours',
|
|
||||||
'days' => 'Days',
|
|
||||||
'weeks' => 'Weeks',
|
|
||||||
'months' => 'Months',
|
|
||||||
],
|
|
||||||
'minutes_interval' => 'Minutes between triggers',
|
|
||||||
'hours_interval' => 'Hours between triggers',
|
|
||||||
'days_interval' => 'Days between triggers',
|
|
||||||
'hour' => 'Trigger at hour',
|
|
||||||
'minute' => 'Trigger at minute',
|
|
||||||
'weekdays' => 'Trigger on weekdays',
|
|
||||||
'day_of_month' => 'Day of month',
|
|
||||||
'weekday_names' => [
|
|
||||||
'sun' => 'Sun',
|
|
||||||
'mon' => 'Mon',
|
|
||||||
'tue' => 'Tue',
|
|
||||||
'wed' => 'Wed',
|
|
||||||
'thu' => 'Thu',
|
|
||||||
'fri' => 'Fri',
|
|
||||||
'sat' => 'Sat',
|
|
||||||
],
|
|
||||||
'summary' => [
|
|
||||||
'every_n_minutes' => 'Runs every minute|Runs every :count minutes',
|
|
||||||
'every_n_hours' => 'Runs every hour at minute :minute|Runs every :count hours at minute :minute',
|
|
||||||
'every_n_days' => 'Runs every day at :time|Runs every :count days at :time',
|
|
||||||
'weekly' => 'Runs every :days at :time',
|
|
||||||
'monthly' => 'Runs on day :day of every month at :time',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'generate' => [
|
|
||||||
'social_accounts' => 'Social accounts',
|
|
||||||
'social_accounts_empty' => 'No connected social accounts. Connect one first.',
|
|
||||||
'target_slide_count' => 'Slides to generate',
|
|
||||||
'prompt_template' => 'Prompt template',
|
|
||||||
'prompt_template_hint' => 'Type {{ to insert data from earlier steps.',
|
|
||||||
'image_count' => 'Images to generate',
|
|
||||||
'image_count_hint' => '0 = text-only post (no image). 1 = single image. 2+ = carousel.',
|
|
||||||
'use_brand_voice' => 'Use brand voice',
|
|
||||||
'use_brand_voice_hint' => 'Apply your brand description and voice. Turn off for faithful curation of third-party sources (news, RSS).',
|
|
||||||
'use_brand_visuals' => 'Use brand visuals',
|
|
||||||
'use_brand_visuals_hint' => 'Steer AI images with your brand colors and identity. Turn off for neutral imagery driven only by the post topic.',
|
|
||||||
'style' => 'Style',
|
|
||||||
'account_summary' => ':count account · :format|:count accounts · :format',
|
|
||||||
'formats' => [
|
|
||||||
'single' => 'single',
|
|
||||||
'carousel' => 'carousel',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'delay' => [
|
|
||||||
'duration' => 'Duration',
|
|
||||||
'unit' => 'Unit',
|
|
||||||
'units' => [
|
|
||||||
'minutes' => 'Minutes',
|
|
||||||
'hours' => 'Hours',
|
|
||||||
'days' => 'Days',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'condition' => [
|
|
||||||
'field' => 'Field',
|
|
||||||
'operator' => 'Operator',
|
|
||||||
'operators' => [
|
|
||||||
'contains' => 'contains',
|
|
||||||
'not_contains' => 'not contains',
|
|
||||||
'equals' => 'equals',
|
|
||||||
'not_equals' => 'not equals',
|
|
||||||
'matches' => 'matches (regex)',
|
|
||||||
'greater_than' => 'greater than',
|
|
||||||
'less_than' => 'less than',
|
|
||||||
],
|
|
||||||
'value' => 'Value',
|
|
||||||
],
|
|
||||||
'publish' => [
|
|
||||||
'mode' => 'Mode',
|
|
||||||
'modes' => [
|
|
||||||
'now' => 'Publish now',
|
|
||||||
'scheduled' => 'Schedule',
|
|
||||||
'draft' => 'Save as draft',
|
|
||||||
],
|
|
||||||
'scheduled_offset' => 'Offset from trigger (minutes)',
|
|
||||||
'offset_summary' => ':mode · +:offset min',
|
|
||||||
],
|
|
||||||
'end' => [
|
|
||||||
'reason' => 'Reason (optional)',
|
|
||||||
'reason_placeholder' => 'e.g. Filtered out by condition',
|
|
||||||
],
|
|
||||||
'fetch_rss' => [
|
|
||||||
'feed_url' => 'Feed URL',
|
|
||||||
'feed_url_hint' => 'On first run, the watermark is set to "now" so historical items don\'t flood downstream nodes. Subsequent runs only see items newer than the previous poll.',
|
|
||||||
'inspect' => 'Inspect feed',
|
|
||||||
'inspecting' => 'Inspecting…',
|
|
||||||
'inspect_hint' => 'Fetch a sample to discover the available fields for use in downstream nodes.',
|
|
||||||
'inspect_error' => 'Could not read this feed. Check the URL and try again.',
|
|
||||||
'discovered_fields' => 'Available fields',
|
|
||||||
'discovered_empty' => 'No fields found in the latest item.',
|
|
||||||
],
|
|
||||||
'http_request' => [
|
|
||||||
'url' => 'URL',
|
|
||||||
'method' => 'Method',
|
|
||||||
'auth_type' => 'Authentication',
|
|
||||||
'auth' => [
|
|
||||||
'none' => 'None (public)',
|
|
||||||
'bearer' => 'Bearer token',
|
|
||||||
'basic' => 'Basic auth',
|
|
||||||
'api_key' => 'API key header',
|
|
||||||
],
|
|
||||||
'bearer_token' => 'Bearer token',
|
|
||||||
'basic_username' => 'Username',
|
|
||||||
'basic_password' => 'Password',
|
|
||||||
'api_key_header' => 'Header name',
|
|
||||||
'api_key_value' => 'API key',
|
|
||||||
'body_template' => 'Body template (JSON)',
|
|
||||||
'headers' => 'Headers',
|
|
||||||
'header_name' => 'Header name',
|
|
||||||
'header_value' => 'Value',
|
|
||||||
'add_header' => 'Add header',
|
|
||||||
'polling_section' => 'List & deduplication (optional)',
|
|
||||||
'polling_hint' => 'When the response is a list, each item runs the workflow separately. A single object runs once.',
|
|
||||||
'items_path' => 'Items path',
|
|
||||||
'items_path_hint' => 'Leave blank if the response is already an array. Use a dotted path (e.g. data.items) for a nested array, or * for an object keyed by id.',
|
|
||||||
'item_key_path' => 'Item key path',
|
|
||||||
'item_key_path_hint' => 'JSON path to a unique id (e.g. id). Items already seen are skipped, so a feed without dates still only forwards new entries.',
|
|
||||||
'item_date_path' => 'Item date path',
|
|
||||||
'item_date_path_hint' => 'JSON path to the item timestamp (e.g. published_at). Preferred over the key path when available. The first poll records the baseline and forwards nothing, so an existing feed never floods on day one.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'delete' => [
|
|
||||||
'title' => 'Delete automation',
|
|
||||||
'description' => 'Are you sure you want to delete this automation? All runs and trigger items will also be removed. This action cannot be undone.',
|
|
||||||
'confirm' => 'Delete',
|
|
||||||
'cancel' => 'Cancel',
|
|
||||||
],
|
|
||||||
|
|
||||||
'flash' => [
|
|
||||||
'deleted' => 'Automation deleted successfully!',
|
|
||||||
],
|
|
||||||
|
|
||||||
'errors' => [
|
|
||||||
'no_active_social_accounts' => 'No active social accounts configured for this automation.',
|
|
||||||
'must_have_one_trigger' => 'Automation must have exactly one trigger node.',
|
|
||||||
'trigger_must_be_connected' => 'Trigger node must be connected to at least one node.',
|
|
||||||
'graph_contains_cycle' => 'Automation graph contains a cycle.',
|
|
||||||
'only_failed_can_retry' => 'Only failed runs can be retried.',
|
|
||||||
'no_generated_post' => 'No generated post found on run.',
|
|
||||||
'url_not_allowed' => 'The request URL points to a private or unreachable address and was blocked.',
|
|
||||||
'node_no_longer_exists' => 'Node :node_id no longer exists in the automation.',
|
|
||||||
'no_trigger_connection' => 'No node connected to the Trigger node.',
|
|
||||||
'fetch_rss_missing_url' => 'The Fetch RSS node is missing a feed URL.',
|
|
||||||
'fetch_rss_request_failed' => 'The RSS feed request failed.',
|
|
||||||
'fetch_rss_malformed' => 'The RSS feed is malformed.',
|
|
||||||
'http_missing_url' => 'The HTTP request node is missing a URL.',
|
|
||||||
'http_request_exception' => 'The HTTP request threw an exception.',
|
|
||||||
'http_request_failed' => 'The HTTP request failed.',
|
|
||||||
'http_items_path_not_array' => 'The items path did not resolve to a list.',
|
|
||||||
'generate_image_format_required' => 'AI generate only creates images. Pick an image format (not video).',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
@ -6,8 +6,6 @@
|
||||||
|
|
||||||
'back' => 'Back',
|
'back' => 'Back',
|
||||||
|
|
||||||
'beta' => 'Beta',
|
|
||||||
|
|
||||||
'confirm_modal' => [
|
'confirm_modal' => [
|
||||||
'cannot_be_undone' => 'This cannot be undone.',
|
'cannot_be_undone' => 'This cannot be undone.',
|
||||||
'type' => 'Type',
|
'type' => 'Type',
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@
|
||||||
'others' => 'Others',
|
'others' => 'Others',
|
||||||
],
|
],
|
||||||
'analytics' => 'Analytics',
|
'analytics' => 'Analytics',
|
||||||
'automations' => 'Automations',
|
|
||||||
'onboarding' => 'Getting started',
|
'onboarding' => 'Getting started',
|
||||||
'onboarding_hint' => 'Finish setup',
|
'onboarding_hint' => 'Finish setup',
|
||||||
'posts' => [
|
'posts' => [
|
||||||
|
|
|
||||||
|
|
@ -1,402 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'mobile_notice' => 'El editor de automatizaciones funciona mejor en una pantalla más grande. Ábrelo en un ordenador para crear tu flujo de trabajo.',
|
|
||||||
'title' => 'Automatizaciones',
|
|
||||||
'default_name' => 'Nueva automatización',
|
|
||||||
|
|
||||||
'actions' => [
|
|
||||||
'new' => 'Nueva automatización',
|
|
||||||
'edit' => 'Editar',
|
|
||||||
'save' => 'Guardar',
|
|
||||||
'activate' => 'Activar',
|
|
||||||
'pause' => 'Pausar',
|
|
||||||
'delete' => 'Eliminar',
|
|
||||||
'retry' => 'Reintentar',
|
|
||||||
'guide' => 'Aprende cómo funciona',
|
|
||||||
],
|
|
||||||
|
|
||||||
'tabs' => [
|
|
||||||
'build' => 'Construir',
|
|
||||||
'variables' => 'Variables',
|
|
||||||
'test' => 'Probar',
|
|
||||||
],
|
|
||||||
|
|
||||||
'nav' => [
|
|
||||||
'workflow' => 'Workflow',
|
|
||||||
'invocations' => 'Invocaciones',
|
|
||||||
'metrics' => 'Métricas',
|
|
||||||
'settings' => 'Configuración',
|
|
||||||
],
|
|
||||||
|
|
||||||
'settings' => [
|
|
||||||
'general' => 'General',
|
|
||||||
'general_description' => 'Renombra esta automatización.',
|
|
||||||
'name_label' => 'Nombre',
|
|
||||||
'name_saved' => 'Automatización renombrada.',
|
|
||||||
'status_title' => 'Estado',
|
|
||||||
'status_description' => 'Actívala para que empiece a ejecutarse, o pausa para detenerla.',
|
|
||||||
'activated_at' => 'Activada el :date',
|
|
||||||
'paused_at' => 'Pausada el :date',
|
|
||||||
'created_at' => 'Creada el :date',
|
|
||||||
'danger_title' => 'Zona de peligro',
|
|
||||||
'danger_description' => 'Acciones irreversibles.',
|
|
||||||
'delete_title' => 'Eliminar esta automatización',
|
|
||||||
'delete_description' => 'Elimina permanentemente la automatización y su historial de ejecuciones.',
|
|
||||||
],
|
|
||||||
|
|
||||||
'status_run' => [
|
|
||||||
'pending' => 'Pendiente',
|
|
||||||
'running' => 'Ejecutando',
|
|
||||||
'waiting' => 'Esperando',
|
|
||||||
'completed' => 'Completado',
|
|
||||||
'failed' => 'Fallido',
|
|
||||||
'cancelled' => 'Cancelado',
|
|
||||||
],
|
|
||||||
|
|
||||||
'node_type' => [
|
|
||||||
'trigger' => 'Disparador',
|
|
||||||
'generate' => 'Generar contenido',
|
|
||||||
'delay' => 'Espera',
|
|
||||||
'condition' => 'Condición',
|
|
||||||
'publish' => 'Publicar',
|
|
||||||
'end' => 'Fin',
|
|
||||||
'fetch_rss' => 'Obtener RSS',
|
|
||||||
'http_request' => 'Petición HTTP',
|
|
||||||
],
|
|
||||||
|
|
||||||
'invocations' => [
|
|
||||||
'empty' => 'Aún no hay invocaciones.',
|
|
||||||
'refresh' => 'Actualizar',
|
|
||||||
'search_placeholder' => 'Buscar por ID de ejecución…',
|
|
||||||
'copied' => 'ID de ejecución copiado.',
|
|
||||||
'loading' => 'Cargando pasos…',
|
|
||||||
'no_steps' => 'Sin pasos registrados.',
|
|
||||||
'load_error' => 'No se pudieron cargar los pasos.',
|
|
||||||
'steps' => '{0}Sin pasos|{1}:count paso|[2,*]:count pasos',
|
|
||||||
'filter' => [
|
|
||||||
'all' => 'Todos los estados',
|
|
||||||
],
|
|
||||||
'columns' => [
|
|
||||||
'timestamp' => 'Fecha',
|
|
||||||
'run' => 'Ejecución',
|
|
||||||
'status' => 'Estado',
|
|
||||||
'message' => 'Último mensaje',
|
|
||||||
'duration' => 'Duración',
|
|
||||||
],
|
|
||||||
'summary' => [
|
|
||||||
'completed' => 'Workflow completado',
|
|
||||||
'failed' => 'Workflow fallido',
|
|
||||||
'running' => 'Workflow en ejecución',
|
|
||||||
'cancelled' => 'Workflow cancelado',
|
|
||||||
'pending' => 'Workflow pendiente',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'metrics' => [
|
|
||||||
'overview' => 'Resumen',
|
|
||||||
'runs_over_time' => 'Ejecuciones a lo largo del tiempo',
|
|
||||||
'posts_by_platform' => 'Posts por plataforma',
|
|
||||||
'no_posts' => 'No se publicaron posts en este período.',
|
|
||||||
'cards' => [
|
|
||||||
'runs' => 'Total de ejecuciones',
|
|
||||||
'completed' => 'Completadas',
|
|
||||||
'failed' => 'Fallidas',
|
|
||||||
'in_progress' => 'En progreso',
|
|
||||||
'success_rate' => 'Tasa de éxito',
|
|
||||||
'avg_duration' => 'Duración media',
|
|
||||||
'posts_created' => 'Posts creados',
|
|
||||||
],
|
|
||||||
'legend' => [
|
|
||||||
'started' => 'Iniciadas',
|
|
||||||
'completed' => 'Completadas',
|
|
||||||
'failed' => 'Fallidas',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'categories' => [
|
|
||||||
'sources' => 'Fuentes',
|
|
||||||
'content' => 'Contenido',
|
|
||||||
'flow' => 'Flujo',
|
|
||||||
'output' => 'Salida',
|
|
||||||
],
|
|
||||||
|
|
||||||
'variables' => [
|
|
||||||
'title' => 'Variables del workflow',
|
|
||||||
'hint' => 'Valores reutilizables referenciados en cualquier lugar con {{ variables.KEY }}. Almacenados cifrados.',
|
|
||||||
'empty' => 'Aún no hay variables.',
|
|
||||||
'key' => 'Clave',
|
|
||||||
'value' => 'Valor',
|
|
||||||
'key_placeholder' => 'API_KEY',
|
|
||||||
'value_placeholder' => 'Valor',
|
|
||||||
'add' => 'Nueva variable',
|
|
||||||
],
|
|
||||||
|
|
||||||
'expr' => [
|
|
||||||
'trigger_event' => 'Nombre del evento del disparador',
|
|
||||||
'trigger_fired_at' => 'Cuándo se disparó',
|
|
||||||
'trigger_post_id' => 'ID del post que disparó',
|
|
||||||
'trigger_post_content' => 'Contenido del post que disparó',
|
|
||||||
'trigger_post_status' => 'Estado del post que disparó',
|
|
||||||
'trigger_post_scheduled_at' => 'Cuándo está programado el post',
|
|
||||||
'trigger_post_published_at' => 'Cuándo se publicó el post',
|
|
||||||
'fetched_title' => 'Título del ítem obtenido',
|
|
||||||
'fetched_link' => 'Enlace del ítem obtenido',
|
|
||||||
'fetched_date' => 'Fecha de publicación del ítem obtenido',
|
|
||||||
'fetched_content' => 'Contenido completo del ítem obtenido',
|
|
||||||
'fetched_description' => 'Resumen del ítem obtenido',
|
|
||||||
'fetched_author' => 'Autor del ítem obtenido',
|
|
||||||
'fetched_image' => 'URL de la imagen del ítem obtenido',
|
|
||||||
'fetched_categories' => 'Categorías del ítem obtenido',
|
|
||||||
'fetched_enclosure' => 'Multimedia del ítem (audio/vídeo/archivo)',
|
|
||||||
'fetched_pubdate' => 'Fecha de publicación del ítem obtenido',
|
|
||||||
'fetched_http' => 'Ítem HTTP obtenido (añade un campo)',
|
|
||||||
'generated_content' => 'Contenido del post generado por IA',
|
|
||||||
'generated_post_url' => 'URL del post generado por IA',
|
|
||||||
'variable' => 'Variable del flujo',
|
|
||||||
'now' => 'Fecha y hora actuales',
|
|
||||||
],
|
|
||||||
|
|
||||||
'test' => [
|
|
||||||
'description' => 'Ejecuta la automatización de punta a punta usando un payload de disparo sintético. Útil para validar cada nodo sin esperar el cronograma o el feed real.',
|
|
||||||
'starting' => 'Iniciando ejecución de prueba…',
|
|
||||||
'in_progress' => 'En progreso',
|
|
||||||
'completed' => 'Completado',
|
|
||||||
'failed' => 'Fallido',
|
|
||||||
'waiting' => 'Esperando',
|
|
||||||
'close' => 'Cerrar',
|
|
||||||
'no_node_runs' => 'Esperando que el primer nodo comience…',
|
|
||||||
'node_input' => 'Entrada',
|
|
||||||
'node_output' => 'Salida',
|
|
||||||
'node_error' => 'Error',
|
|
||||||
'no_new_items' => 'Sin elementos nuevos — no se ejecutó nada después.',
|
|
||||||
'error_starting' => 'No se pudo iniciar la ejecución de prueba.',
|
|
||||||
'with_real_data' => 'Con datos reales',
|
|
||||||
'run' => 'Ejecutar prueba',
|
|
||||||
'idle_hint' => 'Pulsa Ejecutar prueba para correr la automatización de principio a fin.',
|
|
||||||
'real_data_hint' => 'Esta prueba publicará posts, avanzará marcadores de polling y disparará efectos secundarios externos.',
|
|
||||||
'dry_badge' => 'Prueba seca',
|
|
||||||
],
|
|
||||||
|
|
||||||
'status' => [
|
|
||||||
'draft' => 'Borrador',
|
|
||||||
'active' => 'Activa',
|
|
||||||
'paused' => 'Pausada',
|
|
||||||
],
|
|
||||||
|
|
||||||
'index' => [
|
|
||||||
'empty_title' => 'Aún no hay automatizaciones',
|
|
||||||
'empty_description' => 'Crea tu primera automatización para empezar a publicar en piloto automático.',
|
|
||||||
'columns' => [
|
|
||||||
'name' => 'Nombre',
|
|
||||||
'status' => 'Estado',
|
|
||||||
'created' => 'Creada',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'form' => [
|
|
||||||
'activate_error_fallback' => 'No se pudo activar la automatización.',
|
|
||||||
'pause_error_fallback' => 'No se pudo pausar la automatización.',
|
|
||||||
'save_error_fallback' => 'No se pudo guardar la automatización.',
|
|
||||||
'save_success' => 'Automatización guardada.',
|
|
||||||
'empty_canvas_title' => 'Empieza a construir tu automatización',
|
|
||||||
'empty_canvas_description' => 'Arrastra un nodo del panel izquierdo para empezar.',
|
|
||||||
'name_placeholder' => 'Automatización sin título',
|
|
||||||
],
|
|
||||||
|
|
||||||
'nodes' => [
|
|
||||||
'trigger' => 'Disparador',
|
|
||||||
'generate' => 'Generar',
|
|
||||||
'delay' => 'Retraso',
|
|
||||||
'condition' => 'Condición',
|
|
||||||
'publish' => 'Publicar',
|
|
||||||
'end' => 'Terminar',
|
|
||||||
'end_summary' => 'Termina la automatización aquí',
|
|
||||||
'fetch_rss' => 'Obtener RSS',
|
|
||||||
'http_request' => 'Petición HTTP',
|
|
||||||
'handles' => [
|
|
||||||
'items' => 'con elementos',
|
|
||||||
'no_items' => 'sin elementos',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'config' => [
|
|
||||||
'select_placeholder' => 'Selecciona…',
|
|
||||||
'invalid_json' => 'Esto aún no es un JSON válido.',
|
|
||||||
'expand_editor' => 'Expandir editor',
|
|
||||||
'minimize_editor' => 'Minimizar',
|
|
||||||
|
|
||||||
'trigger' => [
|
|
||||||
'type' => 'Tipo de disparador',
|
|
||||||
'types' => [
|
|
||||||
'schedule' => 'Programación',
|
|
||||||
'post_published' => 'Cuando un post se publica',
|
|
||||||
'post_scheduled' => 'Cuando un post se programa',
|
|
||||||
],
|
|
||||||
'post_published_hint' => 'Se ejecuta cada vez que un post en este workspace se publica. El post queda disponible en {{ trigger.post }} para los siguientes nodos.',
|
|
||||||
'post_scheduled_hint' => 'Se ejecuta cada vez que un post en este workspace se programa. El post queda disponible en {{ trigger.post }}.',
|
|
||||||
|
|
||||||
'schedule' => [
|
|
||||||
'field' => 'Intervalo de disparo',
|
|
||||||
'fields' => [
|
|
||||||
'minutes' => 'Minutos',
|
|
||||||
'hours' => 'Horas',
|
|
||||||
'days' => 'Días',
|
|
||||||
'weeks' => 'Semanas',
|
|
||||||
'months' => 'Meses',
|
|
||||||
],
|
|
||||||
'minutes_interval' => 'Minutos entre disparos',
|
|
||||||
'hours_interval' => 'Horas entre disparos',
|
|
||||||
'days_interval' => 'Días entre disparos',
|
|
||||||
'hour' => 'Disparar a la hora',
|
|
||||||
'minute' => 'Disparar al minuto',
|
|
||||||
'weekdays' => 'Disparar en días',
|
|
||||||
'day_of_month' => 'Día del mes',
|
|
||||||
'weekday_names' => [
|
|
||||||
'sun' => 'Dom',
|
|
||||||
'mon' => 'Lun',
|
|
||||||
'tue' => 'Mar',
|
|
||||||
'wed' => 'Mié',
|
|
||||||
'thu' => 'Jue',
|
|
||||||
'fri' => 'Vie',
|
|
||||||
'sat' => 'Sáb',
|
|
||||||
],
|
|
||||||
'summary' => [
|
|
||||||
'every_n_minutes' => 'Se ejecuta cada minuto|Se ejecuta cada :count minutos',
|
|
||||||
'every_n_hours' => 'Se ejecuta cada hora en el minuto :minute|Se ejecuta cada :count horas en el minuto :minute',
|
|
||||||
'every_n_days' => 'Se ejecuta cada día a las :time|Se ejecuta cada :count días a las :time',
|
|
||||||
'weekly' => 'Se ejecuta :days a las :time',
|
|
||||||
'monthly' => 'Se ejecuta el día :day de cada mes a las :time',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'generate' => [
|
|
||||||
'social_accounts' => 'Cuentas sociales',
|
|
||||||
'social_accounts_empty' => 'Sin cuentas sociales conectadas. Conecta una primero.',
|
|
||||||
'target_slide_count' => 'Diapositivas a generar',
|
|
||||||
'prompt_template' => 'Plantilla de prompt',
|
|
||||||
'prompt_template_hint' => 'Escribe {{ para insertar datos de pasos anteriores.',
|
|
||||||
'image_count' => 'Imágenes a generar',
|
|
||||||
'image_count_hint' => '0 = post solo texto (sin imagen). 1 = imagen única. 2+ = carrusel.',
|
|
||||||
'use_brand_voice' => 'Usar voz de marca',
|
|
||||||
'use_brand_voice_hint' => 'Aplica la descripción y la voz de tu marca. Desactiva para curaduría fiel de fuentes de terceros (noticias, RSS).',
|
|
||||||
'use_brand_visuals' => 'Usar visual de marca',
|
|
||||||
'use_brand_visuals_hint' => 'Guía las imágenes de IA con los colores e identidad de tu marca. Desactiva para imágenes neutrales, guiadas solo por el tema del post.',
|
|
||||||
'style' => 'Estilo',
|
|
||||||
'account_summary' => ':count cuenta · :format|:count cuentas · :format',
|
|
||||||
'formats' => [
|
|
||||||
'single' => 'único',
|
|
||||||
'carousel' => 'carrusel',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'delay' => [
|
|
||||||
'duration' => 'Duración',
|
|
||||||
'unit' => 'Unidad',
|
|
||||||
'units' => [
|
|
||||||
'minutes' => 'Minutos',
|
|
||||||
'hours' => 'Horas',
|
|
||||||
'days' => 'Días',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'condition' => [
|
|
||||||
'field' => 'Campo',
|
|
||||||
'operator' => 'Operador',
|
|
||||||
'operators' => [
|
|
||||||
'contains' => 'contiene',
|
|
||||||
'not_contains' => 'no contiene',
|
|
||||||
'equals' => 'es igual a',
|
|
||||||
'not_equals' => 'no es igual a',
|
|
||||||
'matches' => 'coincide (regex)',
|
|
||||||
'greater_than' => 'mayor que',
|
|
||||||
'less_than' => 'menor que',
|
|
||||||
],
|
|
||||||
'value' => 'Valor',
|
|
||||||
],
|
|
||||||
'publish' => [
|
|
||||||
'mode' => 'Modo',
|
|
||||||
'modes' => [
|
|
||||||
'now' => 'Publicar ahora',
|
|
||||||
'scheduled' => 'Programar',
|
|
||||||
'draft' => 'Guardar como borrador',
|
|
||||||
],
|
|
||||||
'scheduled_offset' => 'Diferencia desde el disparador (minutos)',
|
|
||||||
'offset_summary' => ':mode · +:offset min',
|
|
||||||
],
|
|
||||||
'end' => [
|
|
||||||
'reason' => 'Razón (opcional)',
|
|
||||||
'reason_placeholder' => 'p.ej. Filtrado por la condición',
|
|
||||||
],
|
|
||||||
'fetch_rss' => [
|
|
||||||
'feed_url' => 'URL del feed',
|
|
||||||
'feed_url_hint' => 'En la primera ejecución, el watermark se fija en "ahora" para no inundar los siguientes nodos con ítems históricos. Ejecuciones siguientes solo ven ítems nuevos.',
|
|
||||||
'inspect' => 'Inspeccionar feed',
|
|
||||||
'inspecting' => 'Inspeccionando…',
|
|
||||||
'inspect_hint' => 'Obtén una muestra para descubrir los campos disponibles para usar en los siguientes nodos.',
|
|
||||||
'inspect_error' => 'No se pudo leer este feed. Revisa la URL e inténtalo de nuevo.',
|
|
||||||
'discovered_fields' => 'Campos disponibles',
|
|
||||||
'discovered_empty' => 'No se encontraron campos en el último ítem.',
|
|
||||||
],
|
|
||||||
'http_request' => [
|
|
||||||
'url' => 'URL',
|
|
||||||
'method' => 'Método',
|
|
||||||
'auth_type' => 'Autenticación',
|
|
||||||
'auth' => [
|
|
||||||
'none' => 'Ninguna (pública)',
|
|
||||||
'bearer' => 'Bearer token',
|
|
||||||
'basic' => 'Basic auth',
|
|
||||||
'api_key' => 'Header de API key',
|
|
||||||
],
|
|
||||||
'bearer_token' => 'Bearer token',
|
|
||||||
'basic_username' => 'Usuario',
|
|
||||||
'basic_password' => 'Contraseña',
|
|
||||||
'api_key_header' => 'Nombre del header',
|
|
||||||
'api_key_value' => 'API key',
|
|
||||||
'body_template' => 'Plantilla del body (JSON)',
|
|
||||||
'headers' => 'Headers',
|
|
||||||
'header_name' => 'Nombre del header',
|
|
||||||
'header_value' => 'Valor',
|
|
||||||
'add_header' => 'Agregar header',
|
|
||||||
'polling_section' => 'Lista y deduplicación (opcional)',
|
|
||||||
'polling_hint' => 'Cuando la respuesta es una lista, cada ítem ejecuta el flujo por separado. Un objeto único se ejecuta una vez.',
|
|
||||||
'items_path' => 'Ruta de ítems',
|
|
||||||
'items_path_hint' => 'Deja vacío si la respuesta ya es un array. Usa una ruta con puntos (ej: data.items) para un array anidado, o * para un objeto con claves por id.',
|
|
||||||
'item_key_path' => 'Ruta de clave del ítem',
|
|
||||||
'item_key_path_hint' => 'Ruta JSON a un id único (ej: id). Los ítems ya vistos se omiten, así un feed sin fechas aún reenvía solo los nuevos.',
|
|
||||||
'item_date_path' => 'Ruta de fecha del ítem',
|
|
||||||
'item_date_path_hint' => 'Ruta JSON al timestamp del ítem (ej: published_at). Preferido sobre la ruta de clave cuando existe. La primera obtención registra el punto de partida y no reenvía nada, así un feed existente nunca inunda el primer día.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'delete' => [
|
|
||||||
'title' => 'Eliminar automatización',
|
|
||||||
'description' => '¿Estás seguro de que deseas eliminar esta automatización? Todas las ejecuciones y elementos del disparador también serán eliminados. Esta acción no se puede deshacer.',
|
|
||||||
'confirm' => 'Eliminar',
|
|
||||||
'cancel' => 'Cancelar',
|
|
||||||
],
|
|
||||||
|
|
||||||
'flash' => [
|
|
||||||
'deleted' => '¡Automatización eliminada correctamente!',
|
|
||||||
],
|
|
||||||
|
|
||||||
'errors' => [
|
|
||||||
'no_active_social_accounts' => 'No hay cuentas sociales activas configuradas para esta automatización.',
|
|
||||||
'must_have_one_trigger' => 'La automatización debe tener exactamente un nodo disparador.',
|
|
||||||
'trigger_must_be_connected' => 'El nodo disparador debe estar conectado a al menos un nodo.',
|
|
||||||
'graph_contains_cycle' => 'El grafo de la automatización contiene un ciclo.',
|
|
||||||
'only_failed_can_retry' => 'Solo se pueden reintentar ejecuciones fallidas.',
|
|
||||||
'no_generated_post' => 'No se encontró un post generado en la ejecución.',
|
|
||||||
'url_not_allowed' => 'La URL de la petición apunta a una dirección privada o inaccesible y fue bloqueada.',
|
|
||||||
'node_no_longer_exists' => 'El nodo :node_id ya no existe en la automatización.',
|
|
||||||
'no_trigger_connection' => 'Ningún nodo está conectado al nodo disparador.',
|
|
||||||
'fetch_rss_missing_url' => 'Al nodo Obtener RSS le falta la URL del feed.',
|
|
||||||
'fetch_rss_request_failed' => 'La solicitud del feed RSS falló.',
|
|
||||||
'fetch_rss_malformed' => 'El feed RSS está mal formado.',
|
|
||||||
'http_missing_url' => 'Al nodo de petición HTTP le falta la URL.',
|
|
||||||
'http_request_exception' => 'La petición HTTP lanzó una excepción.',
|
|
||||||
'http_request_failed' => 'La petición HTTP falló.',
|
|
||||||
'http_items_path_not_array' => 'El items path no resolvió a una lista.',
|
|
||||||
'generate_image_format_required' => 'La generación con IA solo crea imágenes. Elige un formato de imagen (no vídeo).',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
@ -6,8 +6,6 @@
|
||||||
|
|
||||||
'back' => 'Volver',
|
'back' => 'Volver',
|
||||||
|
|
||||||
'beta' => 'Beta',
|
|
||||||
|
|
||||||
'confirm_modal' => [
|
'confirm_modal' => [
|
||||||
'cannot_be_undone' => 'Esta acción no se puede deshacer.',
|
'cannot_be_undone' => 'Esta acción no se puede deshacer.',
|
||||||
'type' => 'Escribe',
|
'type' => 'Escribe',
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue