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:
Paulo Castellano 2026-09-05 11:03:23 -03:00 committed by GitHub
parent 82da3fd64b
commit d8149ef058
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
275 changed files with 95 additions and 24546 deletions

View file

@ -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." |
| ✨  **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. |
| ⚙️  **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. |
## Features
@ -47,7 +46,6 @@ ## Features
| **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. |
| **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. |
| **Signatures & labels** | Reusable hashtag and CTA blocks, color-coded post tags. |
| **Team collaboration** | Owner / Admin / Member roles, comments with @mentions on drafts. |

View file

@ -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);
}
}
}

View file

@ -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'),
],
];
}
}

View file

@ -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();
}
}

View file

@ -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,
];
}
}

View file

@ -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'));
}
}

View file

@ -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();
}
}

View file

@ -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'));
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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,
],
]);
}
}

View file

@ -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;
}
}
}

View file

@ -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));
}
}

View file

@ -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;
}
}
}

View file

@ -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),
]);
}
}

View file

@ -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);
}
}

View file

@ -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']);
}
}

View file

@ -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);
}
}

View file

@ -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(),
],
]);
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -26,8 +26,7 @@ class CreatePost
* `label_ids[]` are attached after creation so the same set of UUIDs
* works for REST, MCP, and web callers.
*
* `created_via` records which entry point created the post (web, mcp,
* api, or automation). Analytical only null when omitted.
* `created_via` records which entry point created the post (web, mcp, or api). Analytical only null when omitted.
*
* @param array{
* content?: ?string,

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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 ?? []));
}
}

View file

@ -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';
}

View file

@ -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';
}

View file

@ -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';
}

View file

@ -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';
}

View file

@ -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];
}
}

View file

@ -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';
}

View file

@ -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';
}

View file

@ -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';
}

View file

@ -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';
}

View file

@ -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';
}

View file

@ -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';
}

View file

@ -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';
}

View file

@ -9,5 +9,4 @@ enum CreatedVia: string
case Web = 'web';
case Mcp = 'mcp';
case Api = 'api';
case Automation = 'automation';
}

View file

@ -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';
}
}

View file

@ -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(),
]);
}
}

View file

@ -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 [];
}
}

View file

@ -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'],
];
}
}

View file

@ -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 [];
}
}

View file

@ -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'],
];
}
}

View file

@ -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 [];
}
}

View file

@ -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'],
];
}
}

View file

@ -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 => [],
};
}
}

View file

@ -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();
}
}

View file

@ -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,
];
}
}

View file

@ -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,
];
}
}

View file

@ -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;
}
}

View file

@ -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,
];
}
}

View file

@ -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')),
];
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}
}

View file

@ -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');
}
}

View file

@ -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' => []],
);
}
}

View file

@ -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);
}
}

View file

@ -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');
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -4,12 +4,10 @@
namespace App\Observers;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Enums\Post\Status as PostStatus;
use App\Events\OnboardingStatusUpdated;
use App\Events\PostCreated;
use App\Events\PostStatusChanged;
use App\Jobs\Automation\DispatchPostTriggerAutomationsJob;
use App\Models\Account;
use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;
@ -35,16 +33,6 @@ public function saved(Post $post): void
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);
DB::afterCommit(fn () => PostStatusChanged::dispatch($post, $previousStatus));

View file

@ -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);
}
}

View file

@ -8,11 +8,6 @@
use App\Models\AccessToken;
use App\Models\Account;
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\Media;
use App\Models\Notification;
@ -100,11 +95,6 @@ protected function configureMorphMap(): void
'accessToken' => AccessToken::class,
'account' => Account::class,
'aiUsageLog' => AiUsageLog::class,
'automation' => Automation::class,
'automationNodeRun' => AutomationNodeRun::class,
'automationNodeState' => AutomationNodeState::class,
'automationRun' => AutomationRun::class,
'automationTriggerItem' => AutomationTriggerItem::class,
'invite' => Invite::class,
'media' => Media::class,
'notification' => Notification::class,

View file

@ -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();
}
}
}

View file

@ -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;
}
}

View file

@ -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) ?: '';
}
}

View file

@ -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] ?? '';
}
}

View file

@ -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;
}
}

View file

@ -55,7 +55,6 @@
"posthog/posthog-php": "^4.1",
"predis/predis": "^3.3",
"sendkit/sendkit-laravel": "^1.1",
"simplepie/simplepie": "^1.9",
"socialiteproviders/facebook": "^4.1",
"socialiteproviders/instagram": "^5.1",
"socialiteproviders/linkedin": "^5.0",

83
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0a41ea1ff205782a01973b2ad02fd76c",
"content-hash": "ef65b49279941f3458f444863f0ce6e4",
"packages": [
{
"name": "aws/aws-crt-php",
@ -7244,87 +7244,6 @@
},
"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",
"version": "4.1.0",

View file

@ -256,21 +256,6 @@
'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' => [
'connection' => 'redis',
'queue' => ['webhooks'],
@ -307,12 +292,6 @@
'balanceCooldown' => 3,
],
'automations' => [
'maxProcesses' => 5,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
'webhooks' => [
'maxProcesses' => 3,
'balanceMaxShift' => 1,
@ -333,10 +312,6 @@
'maxProcesses' => 2,
],
'automations' => [
'maxProcesses' => 2,
],
'webhooks' => [
'maxProcesses' => 1,
],

View file

@ -72,7 +72,7 @@
|
| SafeHttpFetcher blocks requests to private/reserved IP ranges (SSRF
| 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.
|
*/
@ -145,9 +145,9 @@
| Outbound User-Agent
|--------------------------------------------------------------------------
|
| Branded User-Agent applied to outbound HTTP from automation nodes
| (http_request) and workspace webhooks so recipients know the request came from
| TryPost.it. Self-hosters can override it.
| Branded User-Agent applied to outbound HTTP from workspace webhooks so
| recipients know the request came from TryPost.it. Self-hosters can
| override it.
|
*/

View file

@ -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' => [],
]);
}
}

View file

@ -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(),
];
}
}

View file

@ -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' => [],
];
}
}

View file

@ -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(),
]);
}
}

View file

@ -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(),
];
}
}

View file

@ -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.
}
};

View file

@ -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' => 'توليد الذكاء الاصطناعي ينشئ صورًا فقط. اختر تنسيق صورة (وليس فيديو).',
],
];

View file

@ -6,8 +6,6 @@
'back' => 'رجوع',
'beta' => 'تجريبي',
'confirm_modal' => [
'cannot_be_undone' => 'لا يمكن التراجع عن هذا الإجراء.',
'type' => 'اكتب',

View file

@ -26,7 +26,6 @@
'others' => 'أخرى',
],
'analytics' => 'التحليلات',
'automations' => 'الأتمتة',
'onboarding' => 'البدء',
'onboarding_hint' => 'أكمل الإعداد',
'posts' => [

View file

@ -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).',
],
];

View file

@ -6,8 +6,6 @@
'back' => 'Zurück',
'beta' => 'Beta',
'confirm_modal' => [
'cannot_be_undone' => 'Dies kann nicht rückgängig gemacht werden.',
'type' => 'Gib',

View file

@ -26,7 +26,6 @@
'others' => 'Sonstiges',
],
'analytics' => 'Analytics',
'automations' => 'Automatisierungen',
'onboarding' => 'Erste Schritte',
'onboarding_hint' => 'Einrichtung abschließen',
'posts' => [

View file

@ -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 παράγει μόνο εικόνες. Επίλεξε μορφή εικόνας (όχι βίντεο).',
],
];

View file

@ -6,8 +6,6 @@
'back' => 'Πίσω',
'beta' => 'Βήτα',
'confirm_modal' => [
'cannot_be_undone' => 'Αυτό δεν μπορεί να αναιρεθεί.',
'type' => 'Πληκτρολογήστε',

View file

@ -26,7 +26,6 @@
'others' => 'Άλλα',
],
'analytics' => 'Στατιστικά',
'automations' => 'Αυτοματισμοί',
'onboarding' => 'Ξεκινώντας',
'onboarding_hint' => 'Ολοκλήρωση ρύθμισης',
'posts' => [

View file

@ -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 isnt 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).',
],
];

View file

@ -6,8 +6,6 @@
'back' => 'Back',
'beta' => 'Beta',
'confirm_modal' => [
'cannot_be_undone' => 'This cannot be undone.',
'type' => 'Type',

View file

@ -26,7 +26,6 @@
'others' => 'Others',
],
'analytics' => 'Analytics',
'automations' => 'Automations',
'onboarding' => 'Getting started',
'onboarding_hint' => 'Finish setup',
'posts' => [

View file

@ -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).',
],
];

View file

@ -6,8 +6,6 @@
'back' => 'Volver',
'beta' => 'Beta',
'confirm_modal' => [
'cannot_be_undone' => 'Esta acción no se puede deshacer.',
'type' => 'Escribe',

Some files were not shown because too many files have changed in this diff Show more