Merge main + finalize AI brand-colors toggle
Resolves the AiPostWizard conflict and completes the feature: - i18n parity: brand_colors_label + brand_colors_description in all 15 locales (was en/es/pt-BR only, which broke LocalizationParityTest). - Reworked the two-button toggle into a Switch with an explanatory description (matches the settings Switch/card pattern). - Only shown for templates that honor the flag: added appliesBrandVisuals() to the AiContentTemplate contract (ImageCard=true, tweet cards=false), exposed as applies_brand_visuals in the create-page DTO, and gated the toggle on it — so it no longer appears (as a no-op) for tweet-card styles. - Tests: TemplateContractTest covers appliesBrandVisuals for all templates.
This commit is contained in:
commit
2c2ab69e68
530 changed files with 41787 additions and 902 deletions
|
|
@ -191,8 +191,11 @@ STRIPE_SECRET=
|
|||
STRIPE_WEBHOOK_SECRET=
|
||||
# Set to false to allow generic signup trial without requiring a card.
|
||||
REQUIRE_CARD_FOR_TRIAL=true
|
||||
# Free trial length in days (Stripe Checkout shows one day fewer).
|
||||
# Free trial length in days, used only for the no-card generic trial above.
|
||||
CASHIER_TRIAL_DAYS=8
|
||||
# Stripe Coupon ID (amount_off, duration=once) so the first invoice at
|
||||
# checkout comes out to $1 instead of the full monthly price.
|
||||
STRIPE_FIRST_MONTH_COUPON_ID=
|
||||
|
||||
# Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder.
|
||||
STRIPE_WORKSPACE_MONTHLY=
|
||||
|
|
|
|||
65
.github/actions/setup-laravel/action.yml
vendored
Normal file
65
.github/actions/setup-laravel/action.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
name: 'Setup Laravel test environment'
|
||||
description: 'Set up PHP, Composer, optional Node, the app key, database, and Passport keys for a test job.'
|
||||
|
||||
inputs:
|
||||
node:
|
||||
description: 'Also set up Node.js and install npm dependencies.'
|
||||
default: 'false'
|
||||
db-port:
|
||||
description: 'Host port mapped to the Postgres service.'
|
||||
required: true
|
||||
redis-port:
|
||||
description: 'Host port mapped to the Redis service.'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, gd, redis
|
||||
coverage: none
|
||||
|
||||
- name: Setup Node.js
|
||||
if: inputs.node == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: vendor
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Prepare environment
|
||||
shell: bash
|
||||
run: cp .env.ci .env
|
||||
|
||||
- name: Install Composer dependencies
|
||||
shell: bash
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Install npm dependencies
|
||||
if: inputs.node == 'true'
|
||||
shell: bash
|
||||
run: npm ci
|
||||
|
||||
- name: Generate application key
|
||||
shell: bash
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Run migrations
|
||||
shell: bash
|
||||
env:
|
||||
DB_PORT: ${{ inputs.db-port }}
|
||||
REDIS_PORT: ${{ inputs.redis-port }}
|
||||
run: php artisan migrate --force
|
||||
|
||||
- name: Generate Passport keys
|
||||
shell: bash
|
||||
run: php artisan passport:keys --force
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
|
|
|||
6
.github/workflows/release-docker.yml
vendored
6
.github/workflows/release-docker.yml
vendored
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
platform: ${{ matrix.platform }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Docker metadata (labels)
|
||||
id: meta
|
||||
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
|
|
@ -92,7 +92,7 @@ jobs:
|
|||
packages: write
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
|
|
|
|||
132
.github/workflows/tests.yml
vendored
132
.github/workflows/tests.yml
vendored
|
|
@ -6,18 +6,19 @@ on:
|
|||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
env:
|
||||
DB_CONNECTION: pgsql
|
||||
DB_DATABASE: trypost_test
|
||||
DB_USERNAME: postgres
|
||||
DB_PASSWORD: password
|
||||
BROADCAST_CONNECTION: "null"
|
||||
CACHE_STORE: array
|
||||
QUEUE_CONNECTION: sync
|
||||
SESSION_DRIVER: array
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DB_CONNECTION: pgsql
|
||||
DB_DATABASE: trypost_test
|
||||
DB_USERNAME: postgres
|
||||
DB_PASSWORD: password
|
||||
BROADCAST_CONNECTION: "null"
|
||||
CACHE_STORE: array
|
||||
QUEUE_CONNECTION: sync
|
||||
SESSION_DRIVER: array
|
||||
|
||||
services:
|
||||
postgres:
|
||||
|
|
@ -46,54 +47,89 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
- name: Setup test environment
|
||||
uses: ./.github/actions/setup-laravel
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, gd, redis
|
||||
coverage: none
|
||||
db-port: ${{ job.services.postgres.ports['5432'] }}
|
||||
redis-port: ${{ job.services.redis.ports['6379'] }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Run backend tests
|
||||
env:
|
||||
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
|
||||
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
|
||||
run: php artisan test --compact --parallel
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: trypost_test
|
||||
ports:
|
||||
- 5432/tcp
|
||||
options: >-
|
||||
--health-cmd="pg_isready"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=3
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379/tcp
|
||||
options: >-
|
||||
--health-cmd="redis-cli ping"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup test environment
|
||||
uses: ./.github/actions/setup-laravel
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: vendor
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Prepare environment
|
||||
run: cp .env.ci .env
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate application key
|
||||
run: php artisan key:generate
|
||||
node: 'true'
|
||||
db-port: ${{ job.services.postgres.ports['5432'] }}
|
||||
redis-port: ${{ job.services.redis.ports['6379'] }}
|
||||
|
||||
- name: Build assets
|
||||
run: npm run build
|
||||
|
||||
- name: Run migrations
|
||||
run: php artisan migrate --force
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run browser tests
|
||||
env:
|
||||
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
|
||||
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
|
||||
run: php artisan test tests/Browser --compact --shard=${{ matrix.shard }}/2
|
||||
|
||||
- name: Generate Passport keys
|
||||
run: php artisan passport:keys --force
|
||||
- name: Upload Playwright artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-artifacts-${{ matrix.shard }}
|
||||
path: tests/Browser/Screenshots
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Run tests
|
||||
run: php artisan test --compact --parallel
|
||||
env:
|
||||
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
|
||||
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
|
||||
e2e-gate:
|
||||
if: always()
|
||||
needs: e2e
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require all e2e shards to pass
|
||||
run: '[ "${{ needs.e2e.result }}" = "success" ] || exit 1'
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
/.phpunit.cache
|
||||
/bootstrap/ssr
|
||||
/node_modules
|
||||
/tests/Browser/Screenshots
|
||||
/public/build
|
||||
/lang/php_*.json
|
||||
/public/hot
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ ## Frontend (Vue/TypeScript)
|
|||
|
||||
## Dialogs
|
||||
|
||||
- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col-reverse` on mobile and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
|
||||
- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col` on mobile (primary on top, cancel at the bottom) and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
|
||||
- Match sibling dialogs in the same feature area before inventing a new footer layout.
|
||||
|
||||
## AI agents (`app/Ai/Agents`)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
use App\Services\Automation\FeedParser;
|
||||
use App\Services\Brand\SafeHttpFetcher;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
|
|
@ -51,21 +50,19 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
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 {
|
||||
$this->safeHttp->guardAgainstSsrf($feedUrl);
|
||||
} catch (RuntimeException) {
|
||||
return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [
|
||||
'reason' => 'url_not_allowed',
|
||||
'url' => $feedUrl,
|
||||
$response = $this->safeHttp->get($feedUrl);
|
||||
} catch (RuntimeException $e) {
|
||||
return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$response = Http::timeout(10)->get($feedUrl);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), ['status' => $response->status()]);
|
||||
}
|
||||
|
||||
$items = $this->parser->parse($response->body());
|
||||
|
||||
if ($items === null) {
|
||||
|
|
|
|||
|
|
@ -364,7 +364,9 @@ private function buildRequest(array $config, array $context): PendingRequest
|
|||
$request = $request->withHeaders($headers);
|
||||
}
|
||||
|
||||
return $request->withUserAgent(config('trypost.user_agent'));
|
||||
return $request
|
||||
->withUserAgent(config('trypost.user_agent'))
|
||||
->withOptions($this->safeHttp->redirectGuardOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
try {
|
||||
$response = Http::withHeaders($headers)
|
||||
->withUserAgent(config('trypost.user_agent'))
|
||||
->withOptions(['allow_redirects' => false])
|
||||
->send($method, $url, ['json' => $payload]);
|
||||
} catch (Throwable $e) {
|
||||
return NodeRunResult::failed(__('automations.errors.webhook_request_failed'), [
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
namespace App\Actions\Billing;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Support\Billing\FirstMonthCheckoutDiscount;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
|
|
@ -12,8 +13,9 @@ class StartSubscriptionCheckout
|
|||
{
|
||||
/**
|
||||
* Create a Stripe Checkout session for the given price and return an Inertia
|
||||
* redirect to it. Quantity tracks the account's workspace count; a trial is
|
||||
* attached when the instance requires a card up front.
|
||||
* redirect to it. Quantity tracks the account's workspace count; the first
|
||||
* month's coupon is applied when the instance requires a card up front, so
|
||||
* the first invoice charges $1 instead of running a $0 trial authorization.
|
||||
*/
|
||||
public function redirect(Account $account, string $priceId, string $cancelUrl): Response
|
||||
{
|
||||
|
|
@ -23,12 +25,9 @@ public function redirect(Account $account, string $priceId, string $cancelUrl):
|
|||
]);
|
||||
|
||||
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
|
||||
->quantity(max(1, $account->workspaces()->count()))
|
||||
->allowPromotionCodes();
|
||||
->quantity(max(1, $account->workspaces()->count()));
|
||||
|
||||
if ((bool) config('trypost.billing.require_card_for_trial', true)) {
|
||||
$subscription->trialDays(config('cashier.trial_days'));
|
||||
}
|
||||
FirstMonthCheckoutDiscount::apply($subscription, $account);
|
||||
|
||||
$session = $subscription->checkout([
|
||||
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ public static function execute(User $user, array $data): Workspace
|
|||
'brand_color' => data_get($data, 'brand_color'),
|
||||
'background_color' => data_get($data, 'background_color'),
|
||||
'text_color' => data_get($data, 'text_color'),
|
||||
'content_language' => data_get($data, 'content_language'),
|
||||
'content_language' => data_get($data, 'content_language', app()->getLocale()),
|
||||
], static fn ($value): bool => $value !== null);
|
||||
|
||||
$workspace = DB::transaction(function () use ($user, $attributes): Workspace {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
namespace App\Ai\Agents;
|
||||
|
||||
use App\Enums\Workspace\BrandVoiceTrait;
|
||||
use App\Enums\Workspace\ContentLanguage;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Contracts\HasStructuredOutput;
|
||||
|
|
@ -20,6 +21,9 @@ public function instructions(): string
|
|||
return view('prompts.brand_analyzer', [
|
||||
'voice_groups' => BrandVoiceTrait::grouped(),
|
||||
'single_select_groups' => BrandVoiceTrait::singleSelectGroups(),
|
||||
'content_languages' => collect(ContentLanguage::values())
|
||||
->map(fn (string $code) => "`{$code}`")
|
||||
->implode(', '),
|
||||
])->render();
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +51,7 @@ public function schema(JsonSchema $schema): array
|
|||
->description('A concise 2-3 sentence brand description summarizing what the company does, who they serve, and what makes them unique. Written in the detected content language.')
|
||||
->required(),
|
||||
'language' => $schema->string()
|
||||
->enum(['en', 'pt-BR', 'es'])
|
||||
->enum(ContentLanguage::values())
|
||||
->description('The primary language of the content.')
|
||||
->required(),
|
||||
'brand_color' => $schema->string()
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ public function previewAsset(): string;
|
|||
/** Whether a social account must be selected for this template. */
|
||||
public function needsAccount(): bool;
|
||||
|
||||
/**
|
||||
* Whether this template's generated image honors the workspace brand
|
||||
* palette (so the "Brand colors" choice is meaningful). Tweet-card styles
|
||||
* are brand-colored by design and ignore the flag.
|
||||
*/
|
||||
public function appliesBrandVisuals(): bool;
|
||||
|
||||
/**
|
||||
* Content types (by value) this template can produce. Empty = all AI formats.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ public function needsAccount(): bool
|
|||
return $this->style()->needsAccount();
|
||||
}
|
||||
|
||||
public function appliesBrandVisuals(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function supportedFormats(): array
|
||||
{
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ public function needsAccount(): bool
|
|||
return $this->style()->needsAccount();
|
||||
}
|
||||
|
||||
public function appliesBrandVisuals(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function supportedFormats(): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ public function needsAccount(): bool
|
|||
return $this->style()->needsAccount();
|
||||
}
|
||||
|
||||
public function appliesBrandVisuals(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function supportedFormats(): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,17 +4,25 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Jobs\RefreshSocialToken;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RefreshExpiringTokens extends Command
|
||||
{
|
||||
protected $signature = 'social:refresh-expiring-tokens';
|
||||
|
||||
protected $description = 'Proactively refresh tokens expiring in the next 2 hours (or already expired)';
|
||||
protected $description = 'Proactively refresh social tokens before they expire';
|
||||
|
||||
/**
|
||||
* Rotating refresh_token platforms only need a short lead: verify() won't
|
||||
* rotate a still-valid token, so we catch them right before or after expiry.
|
||||
* Extension-model platforms (Instagram/Threads) can't be refreshed once
|
||||
* expired, so they get a much wider lead to survive queue backlog.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$count = 0;
|
||||
|
|
@ -22,7 +30,15 @@ public function handle(): void
|
|||
SocialAccount::query()
|
||||
->where('status', Status::Connected)
|
||||
->whereNotNull('token_expires_at')
|
||||
->where('token_expires_at', '<=', now()->addHours(2))
|
||||
->where(function (Builder $query) {
|
||||
$query->where(function (Builder $extension) {
|
||||
$extension->whereIn('platform', Platform::accessTokenExtendingPlatformValues())
|
||||
->where('token_expires_at', '<=', now()->addDay());
|
||||
})->orWhere(function (Builder $rotating) {
|
||||
$rotating->whereNotIn('platform', Platform::accessTokenExtendingPlatformValues())
|
||||
->where('token_expires_at', '<=', now()->addMinutes(30));
|
||||
});
|
||||
})
|
||||
->chunk(50, function ($accounts) use (&$count) {
|
||||
foreach ($accounts as $account) {
|
||||
RefreshSocialToken::dispatch($account);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Enums\Media\Source;
|
||||
use App\Enums\Media\Type;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class MediaItem
|
||||
{
|
||||
|
|
@ -59,6 +60,39 @@ public function height(): ?int
|
|||
return is_numeric($height) ? (int) $height : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-provided accessibility description for this image, when set.
|
||||
*/
|
||||
public function altText(): ?string
|
||||
{
|
||||
$alt = data_get($this->meta, 'alt_text');
|
||||
|
||||
if (! is_string($alt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$alt = trim($alt);
|
||||
|
||||
return $alt === '' ? null : $alt;
|
||||
}
|
||||
|
||||
/**
|
||||
* The alt text truncated to the given platform's cap, or null when no alt
|
||||
* text is set or the platform doesn't support it. Single place that applies
|
||||
* the per-platform cap so publishers don't each repeat the truncation.
|
||||
*/
|
||||
public function altTextFor(Platform $platform): ?string
|
||||
{
|
||||
$alt = $this->altText();
|
||||
$max = $platform->altTextMaxLength();
|
||||
|
||||
if ($alt === null || $max === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_substr($alt, 0, $max);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -127,6 +127,39 @@ public function maxImages(): int
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Character cap the platform's API accepts for image alt text (accessibility
|
||||
* description), or null when the platform has no alt-text field. X, LinkedIn,
|
||||
* Instagram, Pinterest, and Discord use documented API maxes. Facebook,
|
||||
* Threads, Mastodon, and Bluesky document no limit, so a defensive cap is
|
||||
* used instead. Single source of truth — publishers truncate to this value,
|
||||
* never a literal.
|
||||
*/
|
||||
public function altTextMaxLength(): ?int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Bluesky => 2000,
|
||||
self::X => 1000,
|
||||
self::Mastodon => 1500,
|
||||
self::LinkedIn, self::LinkedInPage => 4086,
|
||||
self::Facebook => 1000,
|
||||
self::Instagram, self::InstagramFacebook => 1000,
|
||||
self::Threads => 1000,
|
||||
self::Pinterest => 500,
|
||||
self::Discord => 1024,
|
||||
self::TikTok, self::YouTube, self::Telegram => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the platform's API accepts image alt text (accessibility
|
||||
* description) on published media.
|
||||
*/
|
||||
public function supportsAltText(): bool
|
||||
{
|
||||
return $this->altTextMaxLength() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard cap (in characters) the platform's API will accept. Going over this
|
||||
* means the post can't be published. Values are the documented API maxes:
|
||||
|
|
@ -261,6 +294,60 @@ public function requiresContent(): bool
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this platform refreshes by extending the access_token itself
|
||||
* (Instagram/Threads long-lived tokens) instead of exchanging a separate
|
||||
* refresh_token. Extension-model tokens cannot be refreshed once expired,
|
||||
* so they must be refreshed proactively while still valid — the opposite
|
||||
* of rotating refresh_token platforms, which we avoid refreshing until
|
||||
* they actually expire so we don't rotate a still-valid single-use token.
|
||||
*/
|
||||
public function extendsAccessTokenOnRefresh(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Instagram, self::Threads => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `platform` column values of the platforms that refresh by extending
|
||||
* their access token in place (Instagram and Threads — see
|
||||
* extendsAccessTokenOnRefresh), for use in database whereIn/whereNotIn
|
||||
* filters. Derived from extendsAccessTokenOnRefresh() so the two never drift.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function accessTokenExtendingPlatformValues(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
fn (self $platform): string => $platform->value,
|
||||
array_filter(self::cases(), fn (self $platform): bool => $platform->extendsAccessTokenOnRefresh()),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* The token lifetime, in seconds, to assume when the provider's OAuth
|
||||
* response omits expires_in. Each value is that network's own documented
|
||||
* default:
|
||||
*
|
||||
* - X: a 2-hour access token.
|
||||
* - Instagram / Threads: Meta's 60-day long-lived token.
|
||||
*
|
||||
* Networks that always return expires_in (LinkedIn, TikTok, YouTube,
|
||||
* Pinterest), whose refresh sets a fixed lifetime directly (Bluesky), or
|
||||
* whose tokens never expire (Facebook, Mastodon, Telegram, Discord) have no
|
||||
* fallback here and return null.
|
||||
*/
|
||||
public function defaultTokenTtlSeconds(): ?int
|
||||
{
|
||||
return match ($this) {
|
||||
self::X => 7200,
|
||||
self::Instagram, self::Threads => 5184000,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function queue(): string
|
||||
{
|
||||
return 'social-'.$this->value;
|
||||
|
|
|
|||
146
app/Enums/Workspace/ContentLanguage.php
Normal file
146
app/Enums/Workspace/ContentLanguage.php
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Workspace;
|
||||
|
||||
/**
|
||||
* The set of languages the app supports, and the single source of truth for it:
|
||||
* request validation, the brand analyzer's structured-output enum, homepage
|
||||
* language detection, the AI image prompt's language name, the content-language
|
||||
* picker options, and the right-to-left direction of the UI all derive from it.
|
||||
*
|
||||
* The string value is the language code stored on the workspace and passed
|
||||
* straight to the content prompts (`content_language`); the same codes also back
|
||||
* the application's UI locales, so `direction()` drives the document `dir` attribute.
|
||||
*/
|
||||
enum ContentLanguage: string
|
||||
{
|
||||
case English = 'en';
|
||||
case PortugueseBrazil = 'pt-BR';
|
||||
case Spanish = 'es';
|
||||
case French = 'fr';
|
||||
case German = 'de';
|
||||
case Italian = 'it';
|
||||
case Dutch = 'nl';
|
||||
case Polish = 'pl';
|
||||
case Greek = 'el';
|
||||
case Japanese = 'ja';
|
||||
case Korean = 'ko';
|
||||
case Chinese = 'zh';
|
||||
case Russian = 'ru';
|
||||
case Turkish = 'tr';
|
||||
case Arabic = 'ar';
|
||||
|
||||
public const DEFAULT = self::English;
|
||||
|
||||
/**
|
||||
* The language's own name, shown in the content-language picker.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::English => 'English',
|
||||
self::PortugueseBrazil => 'Português (Brasil)',
|
||||
self::Spanish => 'Español',
|
||||
self::French => 'Français',
|
||||
self::German => 'Deutsch',
|
||||
self::Italian => 'Italiano',
|
||||
self::Dutch => 'Nederlands',
|
||||
self::Polish => 'Polski',
|
||||
self::Greek => 'Ελληνικά',
|
||||
self::Japanese => '日本語',
|
||||
self::Korean => '한국어',
|
||||
self::Chinese => '中文',
|
||||
self::Russian => 'Русский',
|
||||
self::Turkish => 'Türkçe',
|
||||
self::Arabic => 'العربية',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The English name of the language, injected into AI image prompts so the
|
||||
* in-image text is rendered in the workspace's content language.
|
||||
*/
|
||||
public function englishName(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::English => 'English',
|
||||
self::PortugueseBrazil => 'Brazilian Portuguese',
|
||||
self::Spanish => 'Spanish',
|
||||
self::French => 'French',
|
||||
self::German => 'German',
|
||||
self::Italian => 'Italian',
|
||||
self::Dutch => 'Dutch',
|
||||
self::Polish => 'Polish',
|
||||
self::Greek => 'Greek',
|
||||
self::Japanese => 'Japanese',
|
||||
self::Korean => 'Korean',
|
||||
self::Chinese => 'Chinese',
|
||||
self::Russian => 'Russian',
|
||||
self::Turkish => 'Turkish',
|
||||
self::Arabic => 'Arabic',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The text direction (`ltr` or `rtl`) for the document root when this is the
|
||||
* active UI locale — only Arabic is written right to left.
|
||||
*/
|
||||
public function direction(): string
|
||||
{
|
||||
return $this === self::Arabic ? 'rtl' : 'ltr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a raw `<html lang>` value (e.g. "pt-PT", "zh-Hans") to a supported
|
||||
* language by matching its primary subtag, or null if none is supported.
|
||||
*/
|
||||
public static function fromHtmlLang(string $lang): ?self
|
||||
{
|
||||
$subtag = self::primarySubtag($lang);
|
||||
|
||||
if (strlen($subtag) < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (self::cases() as $language) {
|
||||
if (self::primarySubtag($language->value) === $subtag) {
|
||||
return $language;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lowercased primary subtag of a BCP 47 language tag ("pt-BR" => "pt").
|
||||
*/
|
||||
private static function primarySubtag(string $tag): string
|
||||
{
|
||||
return strtolower(explode('-', trim($tag), 2)[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(fn (self $language) => $language->value, self::cases());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{value: string, label: string, englishName: string}>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (self $language) => [
|
||||
'value' => $language->value,
|
||||
'label' => $language->label(),
|
||||
'englishName' => $language->englishName(),
|
||||
],
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use App\Services\Brand\SafeHttpFetcher;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -22,7 +22,7 @@ function uploadFromUrl(?string $url, string $directory = 'social-accounts'): ?st
|
|||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(10)->get($url);
|
||||
$response = app(SafeHttpFetcher::class)->guardedRequest($url)->timeout(10)->get($url);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning('uploadFromUrl: Failed to download', [
|
||||
|
|
|
|||
|
|
@ -9,16 +9,17 @@
|
|||
use App\Http\Requests\App\Asset\StoreChunkedAssetRequest;
|
||||
use App\Http\Resources\App\MediaResource;
|
||||
use App\Models\Media;
|
||||
use App\Services\Brand\SafeHttpFetcher;
|
||||
use App\Services\UnsplashService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
class AssetController extends Controller
|
||||
|
|
@ -115,7 +116,7 @@ public function storeChunked(StoreChunkedAssetRequest $request): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $unsplash): MediaResource
|
||||
public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $unsplash, SafeHttpFetcher $safeHttp): MediaResource
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
|
|
@ -128,7 +129,13 @@ public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService
|
|||
$unsplash->trackDownload($downloadLocation);
|
||||
}
|
||||
|
||||
$response = Http::timeout(30)->get(data_get($validated, 'url'));
|
||||
$url = (string) data_get($validated, 'url');
|
||||
|
||||
try {
|
||||
$response = $safeHttp->guardedRequest($url)->timeout(30)->get($url);
|
||||
} catch (RuntimeException) {
|
||||
abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL');
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL');
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
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;
|
||||
|
|
@ -77,7 +78,11 @@ public function show(Automation $automation): RedirectResponse
|
|||
{
|
||||
$this->authorize('view', $automation);
|
||||
|
||||
return redirect()->route('app.automations.workflow', $automation->id);
|
||||
// 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
|
||||
|
|
|
|||
25
app/Http/Controllers/App/LinkPreviewController.php
Normal file
25
app/Http/Controllers/App/LinkPreviewController.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\Post\LinkPreviewRequest;
|
||||
use App\Services\Social\LinkCard\LinkCardFetcher;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class LinkPreviewController extends Controller
|
||||
{
|
||||
public function __invoke(LinkPreviewRequest $request, LinkCardFetcher $fetcher): JsonResponse|Response
|
||||
{
|
||||
$card = $fetcher->fetch($request->validated('url'));
|
||||
|
||||
if ($card === null) {
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
return response()->json($card->toArray());
|
||||
}
|
||||
}
|
||||
|
|
@ -150,9 +150,15 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
'network' => $platform->network(),
|
||||
])->values();
|
||||
|
||||
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
|
||||
|
||||
return Inertia::render('onboarding/Connect', [
|
||||
'platforms' => $platforms,
|
||||
'accounts' => SocialAccountResource::collection($accounts)->resolve(),
|
||||
'plan' => [
|
||||
'name' => $plan->name,
|
||||
'interval' => 'monthly',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ public function create(Request $request): Response
|
|||
'preview' => $t->previewAsset(),
|
||||
'needs_account' => $t->needsAccount(),
|
||||
'supported_formats' => $t->supportedFormats(),
|
||||
'applies_brand_visuals' => $t->appliesBrandVisuals(),
|
||||
], $registry->all());
|
||||
|
||||
return Inertia::render('posts/Create', [
|
||||
|
|
|
|||
|
|
@ -9,23 +9,23 @@
|
|||
use App\Actions\Workspace\DeleteWorkspace;
|
||||
use App\Enums\Workspace\BrandFont;
|
||||
use App\Enums\Workspace\BrandVoiceTrait;
|
||||
use App\Enums\Workspace\ContentLanguage;
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
use App\Http\Requests\App\Workspace\AutofillBrandRequest;
|
||||
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
|
||||
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
|
||||
use App\Http\Resources\App\WorkspaceMemberResource;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Brand\LogoAttacher;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
use Throwable;
|
||||
|
||||
class WorkspaceController extends Controller
|
||||
{
|
||||
|
|
@ -67,8 +67,26 @@ public function index(Request $request): Response
|
|||
|
||||
public function create(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($request->user())) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
return Inertia::render('workspaces/Create', [
|
||||
'availableFonts' => BrandFont::values(),
|
||||
'availableImageStyles' => ImageStyle::values(),
|
||||
'availableVoiceTraits' => BrandVoiceTrait::grouped(),
|
||||
'availableContentLanguages' => ContentLanguage::options(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block creating a paid additional workspace without an active subscription.
|
||||
* Guards both the form (`create`) and the write (`store`) so a direct POST
|
||||
* can't bootstrap a second billable workspace — which would also inflate the
|
||||
* checkout quantity past the fixed first-month coupon.
|
||||
*/
|
||||
private function denyAdditionalWorkspaceWithoutSubscription(User $user): ?RedirectResponse
|
||||
{
|
||||
if (! config('trypost.self_hosted')
|
||||
&& $user->ownedWorkspacesCount() > 0
|
||||
&& ! $user->account?->hasActiveSubscription()) {
|
||||
|
|
@ -76,11 +94,7 @@ public function create(Request $request): Response|RedirectResponse
|
|||
->with('message', 'Subscribe to create more workspaces.');
|
||||
}
|
||||
|
||||
return Inertia::render('workspaces/Create', [
|
||||
'availableFonts' => BrandFont::values(),
|
||||
'availableImageStyles' => ImageStyle::values(),
|
||||
'availableVoiceTraits' => BrandVoiceTrait::grouped(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
public function autofillBrand(AutofillBrandRequest $request, AutofillBrand $autofill): JsonResponse
|
||||
|
|
@ -98,20 +112,16 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher
|
|||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($user)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$workspace = CreateWorkspace::execute($user, $validated);
|
||||
|
||||
if ($logoUrl = data_get($validated, 'logo_url')) {
|
||||
try {
|
||||
$logoAttacher->attach($workspace, $logoUrl);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Logo attach failed during workspace creation', [
|
||||
'workspace_id' => $workspace->id,
|
||||
'logo_url' => $logoUrl,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
$logoAttacher->attach($workspace, $logoUrl);
|
||||
}
|
||||
|
||||
return redirect()->route('app.accounts')
|
||||
|
|
@ -163,6 +173,7 @@ public function brandSettings(Request $request): Response|RedirectResponse
|
|||
'availableFonts' => BrandFont::values(),
|
||||
'availableImageStyles' => ImageStyle::values(),
|
||||
'availableVoiceTraits' => BrandVoiceTrait::grouped(),
|
||||
'availableContentLanguages' => ContentLanguage::options(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +206,7 @@ public function deleteLogo(Request $request): RedirectResponse
|
|||
return back()->with('flash.success', __('settings.flash.logo_deleted'));
|
||||
}
|
||||
|
||||
public function updateSettings(UpdateWorkspaceRequest $request): RedirectResponse
|
||||
public function updateSettings(UpdateWorkspaceRequest $request, LogoAttacher $logoAttacher): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$workspace = $user->currentWorkspace;
|
||||
|
|
@ -206,7 +217,16 @@ public function updateSettings(UpdateWorkspaceRequest $request): RedirectRespons
|
|||
|
||||
$this->authorize('update', $workspace);
|
||||
|
||||
$workspace->update($request->validated());
|
||||
$validated = $request->validated();
|
||||
|
||||
$logoUrl = data_get($validated, 'logo_url');
|
||||
unset($validated['logo_url']);
|
||||
|
||||
$workspace->update($validated);
|
||||
|
||||
if ($logoUrl) {
|
||||
$logoAttacher->attach($workspace, $logoUrl);
|
||||
}
|
||||
|
||||
return back()->with('flash.success', __('settings.flash.workspace_updated'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ public function callback(Request $request): InertiaResponse
|
|||
$avatarPath = $socialUser->getAvatar() ? uploadFromUrl($socialUser->getAvatar()) : null;
|
||||
|
||||
// Calculate token expiration (long-lived tokens last 60 days)
|
||||
$expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds
|
||||
$expiresIn = $socialUser->expiresIn ?? $this->platform->defaultTokenTtlSeconds();
|
||||
$tokenExpiresAt = now()->addSeconds($expiresIn);
|
||||
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Enums\SocialAccount\Status;
|
||||
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Social\TokenRedactor;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -90,7 +91,7 @@ public function callback(Request $request): InertiaResponse
|
|||
if ($tokenResponse->failed()) {
|
||||
Log::error('Threads token exchange failed', [
|
||||
'status' => $tokenResponse->status(),
|
||||
'body' => $tokenResponse->body(),
|
||||
'body' => TokenRedactor::redact($tokenResponse->body()),
|
||||
]);
|
||||
throw new \Exception('Failed to exchange token');
|
||||
}
|
||||
|
|
@ -106,15 +107,18 @@ public function callback(Request $request): InertiaResponse
|
|||
'access_token' => $shortLivedToken,
|
||||
]);
|
||||
|
||||
$longLivedToken = $shortLivedToken;
|
||||
$expiresIn = null;
|
||||
|
||||
if ($longLivedResponse->successful()) {
|
||||
$longLivedData = $longLivedResponse->json();
|
||||
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
|
||||
$expiresIn = $longLivedData['expires_in'] ?? null;
|
||||
if ($longLivedResponse->failed()) {
|
||||
Log::error('Threads long-lived token exchange failed', [
|
||||
'status' => $longLivedResponse->status(),
|
||||
'body' => TokenRedactor::redact($longLivedResponse->body()),
|
||||
]);
|
||||
throw new \Exception('Failed to exchange long-lived token');
|
||||
}
|
||||
|
||||
$longLivedData = $longLivedResponse->json();
|
||||
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
|
||||
$expiresIn = $longLivedData['expires_in'] ?? $this->platform->defaultTokenTtlSeconds();
|
||||
|
||||
// Fetch user profile
|
||||
$profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [
|
||||
'access_token' => $longLivedToken,
|
||||
|
|
@ -142,7 +146,7 @@ public function callback(Request $request): InertiaResponse
|
|||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $longLivedToken,
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
|
||||
'token_expires_at' => now()->addSeconds($expiresIn),
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@
|
|||
|
||||
namespace App\Http\Middleware\App;
|
||||
|
||||
use App\Enums\Workspace\ContentLanguage;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
|
|
@ -19,8 +21,11 @@ public function handle(Request $request, Closure $next): Response
|
|||
$available = config('languages.available');
|
||||
$locale = $request->cookie('locale');
|
||||
$isValid = $locale && array_key_exists($locale, $available);
|
||||
$activeLocale = $isValid ? $locale : config('languages.default');
|
||||
$language = ContentLanguage::tryFrom($activeLocale) ?? ContentLanguage::DEFAULT;
|
||||
|
||||
App::setLocale($isValid ? $locale : config('languages.default'));
|
||||
App::setLocale($activeLocale);
|
||||
View::share('htmlDir', $language->direction());
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Http\Requests\Api\Post;
|
||||
|
||||
use App\Support\PostMediaRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AttachMediaFromUrlRequest extends FormRequest
|
||||
|
|
@ -20,7 +21,8 @@ public function rules(): array
|
|||
{
|
||||
return [
|
||||
'urls' => ['required', 'array', 'min:1', 'max:10'],
|
||||
'urls.*' => ['url:http,https', 'active_url'],
|
||||
'urls.*.url' => ['required', 'url:http,https', 'active_url'],
|
||||
'urls.*.alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Http\Requests\App\Ai;
|
||||
|
||||
use App\Support\AiPromptRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GeneratePostContentRequest extends FormRequest
|
||||
|
|
@ -19,7 +20,7 @@ public function authorize(): bool
|
|||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'prompt' => ['required', 'string', 'max:2000'],
|
||||
'prompt' => ['required', 'string', 'max:'.AiPromptRules::PROMPT_MAX_LENGTH],
|
||||
'current_content' => ['nullable', 'string', 'max:10000'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Support\AiPromptRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
|
@ -33,7 +34,7 @@ public function rules(): array
|
|||
],
|
||||
'social_account_id' => ['nullable', 'uuid'],
|
||||
'image_count' => ['nullable', 'integer', 'min:0', 'max:10'],
|
||||
'prompt' => ['required', 'string', 'max:2000'],
|
||||
'prompt' => AiPromptRules::wizardPromptRule(),
|
||||
'date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)],
|
||||
'apply_brand_visuals' => ['sometimes', 'boolean'],
|
||||
|
|
|
|||
25
app/Http/Requests/App/Post/LinkPreviewRequest.php
Normal file
25
app/Http/Requests/App/Post/LinkPreviewRequest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Post;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LinkPreviewRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'url' => ['required', 'string', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Enums\Workspace\BrandFont;
|
||||
use App\Enums\Workspace\BrandVoiceTrait;
|
||||
use App\Enums\Workspace\ContentLanguage;
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -32,7 +33,7 @@ public function rules(): array
|
|||
'text_color' => $hex,
|
||||
'brand_font' => ['sometimes', 'string', Rule::in(BrandFont::values())],
|
||||
'image_style' => ['sometimes', 'string', Rule::in(ImageStyle::values())],
|
||||
'content_language' => ['nullable', 'string', 'in:en,pt-BR,es'],
|
||||
'content_language' => ['nullable', 'string', Rule::in(ContentLanguage::values())],
|
||||
'logo_url' => ['nullable', 'url', 'max:1024'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Enums\Workspace\BrandFont;
|
||||
use App\Enums\Workspace\BrandVoiceTrait;
|
||||
use App\Enums\Workspace\ContentLanguage;
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -32,7 +33,8 @@ public function rules(): array
|
|||
'text_color' => $hex,
|
||||
'brand_font' => ['sometimes', 'required', 'string', Rule::in(BrandFont::values())],
|
||||
'image_style' => ['sometimes', 'required', 'string', Rule::in(ImageStyle::values())],
|
||||
'content_language' => ['sometimes', 'string', 'in:en,pt-BR,es'],
|
||||
'content_language' => ['sometimes', 'string', Rule::in(ContentLanguage::values())],
|
||||
'logo_url' => ['nullable', 'url', 'max:1024'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -282,6 +282,10 @@ private function replaceMediaOnPost(
|
|||
throw new RuntimeException('Media item changed before regeneration completed.');
|
||||
}
|
||||
|
||||
if (($meta = data_get($items->get($currentIndex), 'meta')) !== null) {
|
||||
$newMediaItem['meta'] = $meta;
|
||||
}
|
||||
|
||||
$items->put($currentIndex, $newMediaItem);
|
||||
$fresh->update(['media' => $items->values()->all()]);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,14 @@ public function __construct(public SocialAccount $account) {}
|
|||
public function handle(ConnectionVerifier $verifier): void
|
||||
{
|
||||
try {
|
||||
$verifier->refreshToken($this->account);
|
||||
if ($this->account->platform->extendsAccessTokenOnRefresh()) {
|
||||
// Instagram/Threads extend the long-lived token itself and
|
||||
// can't be refreshed once expired, so extend it while it's
|
||||
// still valid instead of waiting for it to fail.
|
||||
$verifier->refreshToken($this->account);
|
||||
} else {
|
||||
$verifier->verify($this->account);
|
||||
}
|
||||
} catch (PlatformUnavailableException $e) {
|
||||
Log::warning('Token refresh skipped: platform unavailable', [
|
||||
'account_id' => $this->account->id,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\Media;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\PostMediaRules;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
|
|
@ -23,6 +24,7 @@ public function handle(Request $request): Response|ResponseFactory
|
|||
$validated = $request->validate([
|
||||
'post_id' => ['required', 'uuid'],
|
||||
'upload_token' => ['required', 'uuid'],
|
||||
'alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
|
||||
]);
|
||||
|
||||
$workspaceId = $request->user()->current_workspace_id;
|
||||
|
|
@ -48,14 +50,20 @@ public function handle(Request $request): Response|ResponseFactory
|
|||
return Response::error('No enabled platform on this post accepts this media type.');
|
||||
}
|
||||
|
||||
$post->appendMedia([[
|
||||
$item = [
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'type' => $media->type,
|
||||
'mime_type' => $media->mime_type,
|
||||
'original_filename' => $media->original_filename,
|
||||
]]);
|
||||
];
|
||||
|
||||
if (($alt = data_get($validated, 'alt')) !== null && $media->isImage()) {
|
||||
$item['meta'] = ['alt_text' => $alt];
|
||||
}
|
||||
|
||||
$post->appendMedia([$item]);
|
||||
|
||||
$post->refresh()->load(['postPlatforms.socialAccount', 'labels']);
|
||||
|
||||
|
|
@ -69,6 +77,7 @@ public function schema(JsonSchema $schema): array
|
|||
return [
|
||||
'post_id' => $schema->string()->required()->description('UUID of the post to attach the uploaded media to.'),
|
||||
'upload_token' => $schema->string()->required()->description('upload_token returned by RequestMediaUploadTool, after the user has POSTed the file to the upload_url.'),
|
||||
'alt' => $schema->string()->description('Optional accessibility alt text for the media (applies to images).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Http\Resources\Api\PostResource;
|
||||
use App\Models\Post;
|
||||
use App\Services\Post\MediaAttacher;
|
||||
use App\Support\PostMediaRules;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
|
|
@ -22,7 +23,8 @@ public function handle(Request $request): Response|ResponseFactory
|
|||
$validated = $request->validate([
|
||||
'post_id' => ['required', 'uuid'],
|
||||
'urls' => ['required', 'array', 'min:1', 'max:10'],
|
||||
'urls.*' => ['url:http,https', 'active_url'],
|
||||
'urls.*.url' => ['required', 'url:http,https', 'active_url'],
|
||||
'urls.*.alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
|
||||
]);
|
||||
|
||||
$post = Post::where('workspace_id', $request->user()->current_workspace_id)
|
||||
|
|
@ -51,9 +53,12 @@ public function schema(JsonSchema $schema): array
|
|||
return [
|
||||
'post_id' => $schema->string()->required()->description('UUID of the post to attach media to.'),
|
||||
'urls' => $schema->array()
|
||||
->items($schema->string())
|
||||
->items($schema->object(fn ($u) => [
|
||||
'url' => $u->string()->required()->description('Public HTTP/HTTPS URL of an image, video, or PDF.'),
|
||||
'alt' => $u->string()->description('Optional accessibility alt text for the image (ignored for video/PDF, which have no alt text).'),
|
||||
]))
|
||||
->required()
|
||||
->description('Public HTTP/HTTPS URLs of images, videos, or PDFs. Max 10 URLs per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'),
|
||||
->description('Media to attach. Max 10 per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,19 @@ protected function isTokenExpiringSoon(): Attribute
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the token should be refreshed before use. Rotating-refresh-token
|
||||
* platforms are only refreshed once actually expired, to avoid rotating a
|
||||
* still-valid single-use refresh_token; extension-model platforms
|
||||
* (Instagram/Threads) must be refreshed while still valid because their
|
||||
* token can't be extended once expired.
|
||||
*/
|
||||
public function needsProactiveTokenRefresh(): bool
|
||||
{
|
||||
return $this->is_token_expired
|
||||
|| ($this->platform->extendsAccessTokenOnRefresh() && $this->is_token_expiring_soon);
|
||||
}
|
||||
|
||||
protected function avatarUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Enums\Workspace\ContentLanguage;
|
||||
use App\Enums\Workspace\ImageStyle;
|
||||
use App\Support\HexColorName;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -89,11 +90,7 @@ public function generate(
|
|||
|
||||
private function languageName(string $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
'pt-BR' => 'Brazilian Portuguese',
|
||||
'es' => 'Spanish',
|
||||
default => 'English',
|
||||
};
|
||||
return (ContentLanguage::tryFrom($code) ?? ContentLanguage::DEFAULT)->englishName();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Services\Brand;
|
||||
|
||||
use App\Enums\Workspace\ContentLanguage;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Component\DomCrawler\UriResolver;
|
||||
|
||||
|
|
@ -291,14 +292,7 @@ private function extractLanguage(Crawler $crawler): ?string
|
|||
return null;
|
||||
}
|
||||
|
||||
$lower = strtolower($lang);
|
||||
|
||||
return match (true) {
|
||||
str_starts_with($lower, 'pt') => 'pt-BR',
|
||||
str_starts_with($lower, 'es') => 'es',
|
||||
str_starts_with($lower, 'en') => 'en',
|
||||
default => null,
|
||||
};
|
||||
return ContentLanguage::fromHtmlLang($lang)?->value;
|
||||
}
|
||||
|
||||
private function extractLogoUrl(Crawler $crawler, string $baseUrl): ?string
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class LogoAttacher
|
||||
{
|
||||
|
|
@ -77,6 +78,10 @@ public function attach(Workspace $workspace, string $logoUrl): bool
|
|||
);
|
||||
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Logo rejected — persistence error', ['url' => $logoUrl, 'error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
if (file_exists($tempPath)) {
|
||||
@unlink($tempPath);
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@
|
|||
namespace App\Services\Brand;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\DomCrawler\UriResolver;
|
||||
|
||||
/**
|
||||
* HTTP fetcher with SSRF protection, timeout, redirect cap and a branded user-agent.
|
||||
|
|
@ -36,13 +38,38 @@ public function get(string $url): Response
|
|||
{
|
||||
$this->guardAgainstSsrf($url);
|
||||
|
||||
try {
|
||||
$response = Http::timeout(self::TIMEOUT_SECONDS)
|
||||
->withUserAgent(self::USER_AGENT)
|
||||
->withOptions(['allow_redirects' => ['max' => self::MAX_REDIRECTS]])
|
||||
->get($url);
|
||||
} catch (ConnectionException $e) {
|
||||
throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => $e->getMessage()]));
|
||||
$currentUrl = $url;
|
||||
|
||||
// Redirects are followed manually (allow_redirects disabled) so that every
|
||||
// hop's Location target is re-validated against the SSRF guard before it is
|
||||
// ever requested. A public page could otherwise 302 to an internal host and
|
||||
// Guzzle's built-in redirect following would fetch it without re-checking.
|
||||
for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) {
|
||||
try {
|
||||
$response = Http::timeout(self::TIMEOUT_SECONDS)
|
||||
->withUserAgent(self::USER_AGENT)
|
||||
->withOptions(['allow_redirects' => false])
|
||||
->get($currentUrl);
|
||||
} catch (ConnectionException $e) {
|
||||
throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => $e->getMessage()]));
|
||||
}
|
||||
|
||||
if (! $response->redirect() || $hop >= self::MAX_REDIRECTS) {
|
||||
break;
|
||||
}
|
||||
|
||||
$location = $response->header('Location');
|
||||
|
||||
if ($location === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
$currentUrl = (string) UriResolver::resolve($location, $currentUrl);
|
||||
$this->guardAgainstSsrf($currentUrl);
|
||||
}
|
||||
|
||||
if ($response->redirect()) {
|
||||
throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => 'too many redirects']));
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
|
|
@ -65,6 +92,43 @@ public function tryGet(string $url): ?Response
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guzzle allow_redirects options that re-run the SSRF guard on every hop.
|
||||
* For callers that follow redirects on user-supplied URLs with methods/bodies
|
||||
* that SafeHttpFetcher::get() cannot express.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function redirectGuardOptions(int $max = self::MAX_REDIRECTS): array
|
||||
{
|
||||
return [
|
||||
'allow_redirects' => [
|
||||
'max' => $max,
|
||||
'strict' => true,
|
||||
'protocols' => ['http', 'https'],
|
||||
'on_redirect' => function ($request, $response, $uri): void {
|
||||
$this->guardAgainstSsrf((string) $uri);
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A PendingRequest with the SSRF guard applied to $url and redirect handling
|
||||
* pre-configured (per-hop re-guard when following, or no redirects). Callers
|
||||
* add their own timeout / sink / headers and dispatch to the SAME $url, so a
|
||||
* user-supplied URL can never be fetched without the guard and redirect
|
||||
* protection.
|
||||
*/
|
||||
public function guardedRequest(string $url, bool $followRedirects = true): PendingRequest
|
||||
{
|
||||
$this->guardAgainstSsrf($url);
|
||||
|
||||
return Http::withUserAgent(self::USER_AGENT)->withOptions(
|
||||
$followRedirects ? $this->redirectGuardOptions() : ['allow_redirects' => false],
|
||||
);
|
||||
}
|
||||
|
||||
public function guardAgainstSsrf(string $url): void
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
|
|
@ -80,6 +144,13 @@ public function guardAgainstSsrf(string $url): void
|
|||
throw new RuntimeException(__('workspaces.create.autofill_errors.missing_host'));
|
||||
}
|
||||
|
||||
// Self-hosted operators can opt into fetching their own internal
|
||||
// network. Only the private/reserved-IP rejection below is skipped;
|
||||
// the scheme and host checks above still always apply.
|
||||
if ((bool) config('trypost.security.allow_private_network')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ip = gethostbyname($host);
|
||||
|
||||
if ($ip === $host && filter_var($host, FILTER_VALIDATE_IP) === false) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class GiphyService
|
|||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = config('services.giphy.api_key', '');
|
||||
$this->apiKey = (string) config('services.giphy.api_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,11 +6,29 @@
|
|||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Intervention\Image\Direction;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
use Intervention\Image\ImageManager;
|
||||
use Intervention\Image\Interfaces\ImageInterface;
|
||||
use RuntimeException;
|
||||
|
||||
class MediaOptimizer
|
||||
{
|
||||
private const MAX_DECODE_MEMORY_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
private const FIT_BLUR_SIGMA = 55;
|
||||
|
||||
private const FIT_BLUR_GAMMA = 1.3;
|
||||
|
||||
private const FIT_QUALITY = 90;
|
||||
|
||||
private const FIT_GD_DOWNSCALE = 8;
|
||||
|
||||
private const FIT_GD_BLUR = 45;
|
||||
|
||||
private const FIT_GD_BRIGHTNESS = 12;
|
||||
|
||||
private ImageManager $manager;
|
||||
|
||||
public function __construct()
|
||||
|
|
@ -26,29 +44,18 @@ public function optimizeImage(string $filePath, Platform $platform): string
|
|||
{
|
||||
$config = $this->getImageConfig($platform);
|
||||
|
||||
// Check image dimensions to prevent GD memory overflow
|
||||
$imageInfo = @getimagesize($filePath);
|
||||
if ($imageInfo) {
|
||||
$width = $imageInfo[0];
|
||||
$height = $imageInfo[1];
|
||||
$channels = $imageInfo['channels'] ?? 4;
|
||||
$estimatedMemory = $width * $height * $channels * 1.5; // 1.5x safety margin
|
||||
if ($imageInfo !== false && $this->estimatedDecodeMemory($imageInfo) > self::MAX_DECODE_MEMORY_BYTES) {
|
||||
Log::warning('MediaOptimizer: Image too large for GD processing', [
|
||||
'width' => $imageInfo[0],
|
||||
'height' => $imageInfo[1],
|
||||
'platform' => $platform->value,
|
||||
]);
|
||||
|
||||
// If image would use more than 256MB of RAM, skip optimization and return as-is
|
||||
if ($estimatedMemory > 256 * 1024 * 1024) {
|
||||
Log::warning('MediaOptimizer: Image too large for GD processing', [
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'estimated_memory' => $estimatedMemory,
|
||||
'platform' => $platform->value,
|
||||
]);
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'media_opt_');
|
||||
copy($filePath, $tempFile);
|
||||
|
||||
// Copy original file to temp location and return
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'media_opt_');
|
||||
copy($filePath, $tempFile);
|
||||
|
||||
return $tempFile;
|
||||
}
|
||||
return $tempFile;
|
||||
}
|
||||
|
||||
$image = $this->manager->decodePath($filePath);
|
||||
|
|
@ -113,6 +120,8 @@ public function maxWidthForPlatform(Platform $platform): ?int
|
|||
*/
|
||||
public function cropToAspectRatio(string $filePath, float $ratio): string
|
||||
{
|
||||
$this->assertWithinMemoryBudget($filePath);
|
||||
|
||||
$image = $this->manager->decodePath($filePath);
|
||||
|
||||
$width = $image->width();
|
||||
|
|
@ -148,6 +157,131 @@ public function cropToAspectRatio(string $filePath, float $ratio): string
|
|||
return $tempFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit an image inside a width×height canvas without cropping: the image is
|
||||
* scaled to fit and centered, and the empty space is filled with a blurred,
|
||||
* slightly darkened copy of the image. When the image already matches the
|
||||
* canvas ratio it's just scaled down (no background). Returns a temp file.
|
||||
*/
|
||||
public function fitToCanvas(string $filePath, int $width, int $height): string
|
||||
{
|
||||
$this->assertWithinMemoryBudget($filePath);
|
||||
|
||||
$foreground = $this->manager->decodePath($filePath);
|
||||
$canvasRatio = $width / $height;
|
||||
$imageRatio = $foreground->width() / $foreground->height();
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'media_fit_');
|
||||
|
||||
try {
|
||||
if (abs($imageRatio - $canvasRatio) < 0.01) {
|
||||
$sized = $foreground->scaleDown($width, $height);
|
||||
file_put_contents($tempFile, (string) $sized->encodeUsingMediaType('image/jpeg', quality: self::FIT_QUALITY));
|
||||
|
||||
return $tempFile;
|
||||
}
|
||||
|
||||
$canvas = extension_loaded('imagick')
|
||||
? $this->fitOntoBlurredBackground($filePath, $width, $height)
|
||||
: $this->fitOntoBlurredBackgroundGd($filePath, $width, $height);
|
||||
|
||||
file_put_contents($tempFile, (string) $canvas->encodeUsingMediaType('image/jpeg', quality: self::FIT_QUALITY));
|
||||
|
||||
return $tempFile;
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($tempFile);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit the image onto the canvas over a soft, lightened blurred background
|
||||
* built from the image itself: the image is scaled to fill the width, heavily
|
||||
* gaussian-blurred so shapes dissolve into a colour wash, lightened, and the
|
||||
* top half is mirrored onto the bottom so the background reads symmetrically.
|
||||
* Imagick only — blurring at full width before stretching avoids the streaks
|
||||
* and posterisation the GD path is prone to.
|
||||
*/
|
||||
private function fitOntoBlurredBackground(string $filePath, int $width, int $height): ImageInterface
|
||||
{
|
||||
$manager = new ImageManager(new ImagickDriver);
|
||||
|
||||
$foreground = $manager->decodePath($filePath);
|
||||
$sourceWidth = $foreground->width();
|
||||
$sourceHeight = $foreground->height();
|
||||
$scale = min($width / $sourceWidth, $height / $sourceHeight);
|
||||
$foreground->resize(max(1, (int) round($sourceWidth * $scale)), max(1, (int) round($sourceHeight * $scale)));
|
||||
|
||||
$scaledHeight = max(1, (int) round($sourceHeight * ($width / $sourceWidth)));
|
||||
$topHalf = $manager->decodePath($filePath)->resize($width, $scaledHeight);
|
||||
$core = $topHalf->core()->native();
|
||||
$core->blurImage(0, self::FIT_BLUR_SIGMA);
|
||||
$core->gammaImage(self::FIT_BLUR_GAMMA);
|
||||
$topHalf->resize($width, intdiv($height, 2));
|
||||
|
||||
$canvas = $manager->createImage($width, $height)->fill('000000');
|
||||
$canvas->insert($topHalf, 0, 0, 'top-left');
|
||||
$topHalf->flip(Direction::VERTICAL);
|
||||
$canvas->insert($topHalf, 0, intdiv($height, 2), 'top-left');
|
||||
$canvas->insert($foreground, 0, 0, 'center');
|
||||
|
||||
return $canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* GD fallback for hosts without ext-imagick: a downscale→blur→upscale
|
||||
* background. Less refined than the Imagick path (no large smooth gaussian),
|
||||
* but artefact-free enough for a story background.
|
||||
*/
|
||||
private function fitOntoBlurredBackgroundGd(string $filePath, int $width, int $height): ImageInterface
|
||||
{
|
||||
$foreground = $this->manager->decodePath($filePath);
|
||||
$scale = min($width / $foreground->width(), $height / $foreground->height());
|
||||
$foreground->resize(max(1, (int) round($foreground->width() * $scale)), max(1, (int) round($foreground->height() * $scale)));
|
||||
|
||||
$canvas = $this->manager->decodePath($filePath)
|
||||
->cover($width, $height)
|
||||
->resize(intdiv($width, self::FIT_GD_DOWNSCALE), intdiv($height, self::FIT_GD_DOWNSCALE))
|
||||
->blur(self::FIT_GD_BLUR)
|
||||
->resize($width, $height)
|
||||
->brightness(self::FIT_GD_BRIGHTNESS);
|
||||
|
||||
$canvas->insert($foreground, 0, 0, 'center');
|
||||
|
||||
return $canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated GD memory (bytes) needed to decode an image, from its
|
||||
* getimagesize() metadata.
|
||||
*
|
||||
* @param array{0: int, 1: int, channels?: int} $imageInfo
|
||||
*/
|
||||
private function estimatedDecodeMemory(array $imageInfo): float
|
||||
{
|
||||
return $imageInfo[0] * $imageInfo[1] * ($imageInfo['channels'] ?? 4) * 1.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a source whose pixel dimensions would blow the GD memory budget,
|
||||
* before it is decoded — a small-byte, huge-dimension image would otherwise
|
||||
* exhaust memory with an uncatchable fatal. Transforms that can't fall back
|
||||
* to the original (crop, fit) call this; `optimizeImage` skips instead.
|
||||
*/
|
||||
private function assertWithinMemoryBudget(string $filePath): void
|
||||
{
|
||||
$imageInfo = @getimagesize($filePath);
|
||||
|
||||
if ($imageInfo === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->estimatedDecodeMemory($imageInfo) > self::MAX_DECODE_MEMORY_BYTES) {
|
||||
throw new RuntimeException("Image dimensions ({$imageInfo[0]}x{$imageInfo[1]}) exceed the safe processing budget.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{max_width: int, max_size: int, format: string, quality: int}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
use App\Models\Media;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use App\Services\Brand\SafeHttpFetcher;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
|
|
@ -24,8 +24,10 @@
|
|||
*/
|
||||
class MediaAttacher
|
||||
{
|
||||
public function __construct(private readonly SafeHttpFetcher $safeHttp) {}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
* @param array<int, array{url: string, alt?: ?string}> $urls
|
||||
* @return array{attached: array<int, array<string, mixed>>, failed: array<int, string>}
|
||||
*/
|
||||
public function attachFromUrls(Post $post, array $urls): array
|
||||
|
|
@ -33,10 +35,22 @@ public function attachFromUrls(Post $post, array $urls): array
|
|||
$attached = [];
|
||||
$failed = [];
|
||||
|
||||
foreach ($urls as $url) {
|
||||
($item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url)) === null
|
||||
? $failed[] = $url
|
||||
: $attached[] = $item;
|
||||
foreach ($urls as $entry) {
|
||||
$url = (string) data_get($entry, 'url', '');
|
||||
$item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url);
|
||||
|
||||
if ($item === null) {
|
||||
$failed[] = $url;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($alt = data_get($entry, 'alt')) !== null
|
||||
&& MediaType::classify(data_get($item, 'mime_type'), data_get($item, 'path')) === MediaType::Image) {
|
||||
$item['meta'] = ['alt_text' => $alt];
|
||||
}
|
||||
|
||||
$attached[] = $item;
|
||||
}
|
||||
|
||||
if ($attached !== []) {
|
||||
|
|
@ -77,6 +91,10 @@ public function resolveInlineMedia(Workspace $workspace, array $allowedTypes, ar
|
|||
continue;
|
||||
}
|
||||
|
||||
if (($meta = data_get($item, 'meta')) !== null) {
|
||||
$hosted['meta'] = $meta;
|
||||
}
|
||||
|
||||
$media[] = $hosted;
|
||||
$hostedIds[] = data_get($hosted, 'id');
|
||||
}
|
||||
|
|
@ -143,10 +161,10 @@ private function download(string $url): ?array
|
|||
$temp = tempnam(sys_get_temp_dir(), 'media_');
|
||||
|
||||
try {
|
||||
$response = Http::timeout(20)
|
||||
$response = $this->safeHttp->guardedRequest($url, followRedirects: false)
|
||||
->timeout(20)
|
||||
->sink($temp)
|
||||
->withOptions([
|
||||
'allow_redirects' => false,
|
||||
'progress' => static function ($total, $downloaded) use ($cap): void {
|
||||
if ($downloaded > $cap) {
|
||||
throw new RuntimeException('exceeded max bytes');
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$this->account = $postPlatform->socialAccount;
|
||||
$this->hasRetried = false;
|
||||
|
||||
if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) {
|
||||
if ($this->account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($this->account);
|
||||
}
|
||||
|
||||
|
|
@ -121,10 +121,14 @@ private function publishPost(?string $content, $media): array
|
|||
$payload = $this->basePayload($content);
|
||||
|
||||
if ($media->isNotEmpty()) {
|
||||
$mediaUrn = $this->uploadMedia($media->first());
|
||||
$item = $media->first();
|
||||
$mediaUrn = $this->uploadMedia($item);
|
||||
|
||||
if ($mediaUrn) {
|
||||
$payload['content'] = ['media' => ['id' => $mediaUrn]];
|
||||
$payload['content'] = ['media' => array_filter([
|
||||
'id' => $mediaUrn,
|
||||
'altText' => $item->isImage() ? $item->altTextFor($this->platform()) : null,
|
||||
], fn ($v) => $v !== null)];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,10 +147,10 @@ private function publishCarousel(?string $content, $media): array
|
|||
$imageUrn = $this->uploadImage($item);
|
||||
|
||||
if ($imageUrn) {
|
||||
$images[] = [
|
||||
$images[] = array_filter([
|
||||
'id' => $imageUrn,
|
||||
'altText' => $item->original_filename ?? 'Carousel image',
|
||||
];
|
||||
'altText' => $item->altTextFor($this->platform()),
|
||||
], fn ($v) => $v !== null);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ final class BlueskyLexicon
|
|||
|
||||
public const EMBED_VIDEO = 'app.bsky.embed.video';
|
||||
|
||||
public const EMBED_EXTERNAL = 'app.bsky.embed.external';
|
||||
|
||||
public const FACET_LINK = 'app.bsky.richtext.facet#link';
|
||||
|
||||
public const FACET_MENTION = 'app.bsky.richtext.facet#mention';
|
||||
|
|
|
|||
|
|
@ -9,13 +9,18 @@
|
|||
use App\Exceptions\Social\BlueskyPublishException;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Brand\SafeHttpFetcher;
|
||||
use App\Services\Media\MediaOptimizer;
|
||||
use App\Services\Social\Concerns\HasSocialHttpClient;
|
||||
use App\Services\Social\LinkCard\LinkCardFetcher;
|
||||
use App\Services\Social\LinkCard\LinkCardMetadata;
|
||||
use App\Support\UrlDetector;
|
||||
use Carbon\CarbonInterface;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class BlueskyPublisher
|
||||
|
|
@ -25,6 +30,9 @@ class BlueskyPublisher
|
|||
/** Seconds allowed for a remote media download (large videos need time). */
|
||||
private const DOWNLOAD_TIMEOUT = 600;
|
||||
|
||||
/** Short timeout for the card thumbnail download (attacker-influenceable og:image, not a large media upload). */
|
||||
private const THUMB_DOWNLOAD_TIMEOUT = 15;
|
||||
|
||||
/** Re-upload a transiently-failing transcode this many times before giving up. */
|
||||
private const VIDEO_UPLOAD_ATTEMPTS = 3;
|
||||
|
||||
|
|
@ -48,7 +56,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
|
||||
|
||||
// Refresh token if needed
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +71,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type);
|
||||
if ($blob) {
|
||||
$images[] = [
|
||||
'alt' => '',
|
||||
'alt' => $media->altTextFor(Platform::Bluesky) ?? '',
|
||||
'image' => $blob,
|
||||
];
|
||||
}
|
||||
|
|
@ -95,6 +103,13 @@ public function publish(PostPlatform $postPlatform): array
|
|||
}
|
||||
}
|
||||
|
||||
// No image or video embed, so a bare link can carry a preview card.
|
||||
// Bluesky does not hydrate cards server-side: the client must attach an
|
||||
// app.bsky.embed.external built from the page's OpenGraph metadata.
|
||||
if ($embed === null && $medias->isEmpty() && $content !== null) {
|
||||
$embed = $this->buildExternalEmbed($postPlatform->socialAccount, $service, $content);
|
||||
}
|
||||
|
||||
// Parse facets (links, mentions, hashtags) from text
|
||||
$text = $content ?? '';
|
||||
$facets = $this->parseFacets($text);
|
||||
|
|
@ -142,9 +157,63 @@ public function publish(PostPlatform $postPlatform): array
|
|||
];
|
||||
}
|
||||
|
||||
private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): ?array
|
||||
/**
|
||||
* Build an app.bsky.embed.external card for the first link in the text, or
|
||||
* null when there is no link, the scrape fails, or the page has no metadata.
|
||||
* The og:image is re-uploaded as the card thumb because Bluesky's `thumb`
|
||||
* only accepts a blob, never an external URL. Any failure degrades to null
|
||||
* so the post still publishes with just the link facet.
|
||||
*/
|
||||
private function buildExternalEmbed(SocialAccount $account, string $service, string $content): ?array
|
||||
{
|
||||
$tempFile = $this->downloadToTempFile($url, 'bsky_blob_');
|
||||
$card = app(LinkCardFetcher::class)->fetch($content);
|
||||
|
||||
if ($card === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$external = [
|
||||
'uri' => $card->uri,
|
||||
'title' => $card->title,
|
||||
'description' => $card->description,
|
||||
];
|
||||
|
||||
$thumb = $this->uploadCardThumb($account, $service, $card);
|
||||
|
||||
if ($thumb !== null) {
|
||||
$external['thumb'] = $thumb;
|
||||
}
|
||||
|
||||
return [
|
||||
'$type' => BlueskyLexicon::EMBED_EXTERNAL,
|
||||
'external' => $external,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the card's og:image and upload it as a blob for the thumb.
|
||||
* Returns null (card renders without a thumbnail) when there is no image or
|
||||
* the upload fails. A JPEG hint routes it through the image optimizer, which
|
||||
* re-encodes any static image and enforces Bluesky's 1MB blob limit.
|
||||
*/
|
||||
private function uploadCardThumb(SocialAccount $account, string $service, LinkCardMetadata $card): ?array
|
||||
{
|
||||
if ($card->imageUrl === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
app(SafeHttpFetcher::class)->guardAgainstSsrf($card->imageUrl);
|
||||
} catch (RuntimeException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->uploadBlob($account, $service, $card->imageUrl, 'image/jpeg', self::THUMB_DOWNLOAD_TIMEOUT, followRedirects: false);
|
||||
}
|
||||
|
||||
private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType, int $downloadTimeout = self::DOWNLOAD_TIMEOUT, bool $followRedirects = true): ?array
|
||||
{
|
||||
$tempFile = $this->downloadToTempFile($url, 'bsky_blob_', $downloadTimeout, $followRedirects);
|
||||
|
||||
if ($tempFile === null) {
|
||||
return null;
|
||||
|
|
@ -202,8 +271,15 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
|
|||
* Download a remote media file to a temp file. Returns the temp path, or
|
||||
* null (after cleaning up) if the temp file can't be created, the download
|
||||
* fails, or the downloaded file is empty.
|
||||
*
|
||||
* $followRedirects defaults to true for the media/video paths, which
|
||||
* download from our own storage/CDN URLs. The card thumb path passes
|
||||
* false because the source is an attacker-influenceable og:image that
|
||||
* was only guarded against SSRF on its original URL — a redirect on that
|
||||
* hop must not be followed without re-guarding, so it is simply not
|
||||
* followed at all (the thumb degrades to null instead).
|
||||
*/
|
||||
private function downloadToTempFile(string $url, string $prefix): ?string
|
||||
private function downloadToTempFile(string $url, string $prefix, int $timeoutSeconds = self::DOWNLOAD_TIMEOUT, bool $followRedirects = true): ?string
|
||||
{
|
||||
$tempFile = tempnam(sys_get_temp_dir(), $prefix);
|
||||
|
||||
|
|
@ -214,7 +290,13 @@ private function downloadToTempFile(string $url, string $prefix): ?string
|
|||
}
|
||||
|
||||
try {
|
||||
$response = Http::withOptions(['sink' => $tempFile])->timeout(self::DOWNLOAD_TIMEOUT)->get($url);
|
||||
$options = ['sink' => $tempFile];
|
||||
|
||||
if (! $followRedirects) {
|
||||
$options['allow_redirects'] = false;
|
||||
}
|
||||
|
||||
$response = Http::withOptions($options)->timeout($timeoutSeconds)->get($url);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new Exception('HTTP '.$response->status());
|
||||
|
|
@ -555,14 +637,14 @@ private function parseFacets(string $text): array
|
|||
|
||||
// Parse URLs
|
||||
preg_match_all(
|
||||
'/(https?:\/\/[^\s]+)/u',
|
||||
UrlDetector::URL_PATTERN,
|
||||
$text,
|
||||
$urlMatches,
|
||||
PREG_OFFSET_CAPTURE
|
||||
);
|
||||
|
||||
foreach ($urlMatches[0] as $match) {
|
||||
$url = $this->trimTrailingUrlPunctuation($match[0]);
|
||||
$url = UrlDetector::trimTrailingPunctuation($match[0]);
|
||||
$start = (int) $match[1];
|
||||
$end = $start + strlen($url);
|
||||
|
||||
|
|
@ -676,24 +758,6 @@ private function resolveHandleToDid(string $handle): ?string
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trailing sentence punctuation and an unmatched closing paren are almost
|
||||
* never part of a URL (e.g. "see https://x.com)."). Mirrors the official
|
||||
* atproto link tokenizer so the link facet doesn't over-extend past the URL.
|
||||
*/
|
||||
private function trimTrailingUrlPunctuation(string $url): string
|
||||
{
|
||||
if (preg_match('/[.,;:!?]$/', $url)) {
|
||||
$url = substr($url, 0, -1);
|
||||
}
|
||||
|
||||
if (str_ends_with($url, ')') && ! str_contains($url, '(')) {
|
||||
$url = substr($url, 0, -1);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
private function buildPostUrl(string $handle, string $postId): string
|
||||
{
|
||||
$webApp = (string) config('trypost.platforms.bluesky.web_app');
|
||||
|
|
|
|||
|
|
@ -37,14 +37,55 @@ protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRati
|
|||
throw $this->cropFailureException('Failed to download image for cropping');
|
||||
}
|
||||
|
||||
$cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio);
|
||||
try {
|
||||
$cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio);
|
||||
} catch (\Throwable) {
|
||||
throw $this->cropFailureException('Failed to process image for cropping');
|
||||
}
|
||||
|
||||
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
|
||||
Storage::put($path, file_get_contents($cropped));
|
||||
try {
|
||||
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
|
||||
Storage::put($path, file_get_contents($cropped));
|
||||
|
||||
@unlink($cropped);
|
||||
return Storage::url($path);
|
||||
} finally {
|
||||
@unlink($cropped);
|
||||
}
|
||||
} finally {
|
||||
@unlink($tempInput);
|
||||
}
|
||||
}
|
||||
|
||||
return Storage::url($path);
|
||||
/**
|
||||
* Fit the image inside a width×height canvas with a blurred-background
|
||||
* extension (no cropping), host it, and return a public URL. Used for
|
||||
* stories so an off-ratio image isn't clipped by the platform.
|
||||
*/
|
||||
protected function fitImageToCanvas(string $imageUrl, int $width, int $height): string
|
||||
{
|
||||
$tempInput = tempnam(sys_get_temp_dir(), 'fit_in_');
|
||||
|
||||
try {
|
||||
$download = Http::sink($tempInput)->timeout(120)->get($imageUrl);
|
||||
|
||||
if ($download->failed()) {
|
||||
throw $this->cropFailureException('Failed to download image for story fitting');
|
||||
}
|
||||
|
||||
try {
|
||||
$fitted = app(MediaOptimizer::class)->fitToCanvas($tempInput, $width, $height);
|
||||
} catch (\Throwable) {
|
||||
throw $this->cropFailureException('Failed to process image for story fitting');
|
||||
}
|
||||
|
||||
try {
|
||||
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
|
||||
Storage::put($path, file_get_contents($fitted));
|
||||
|
||||
return Storage::url($path);
|
||||
} finally {
|
||||
@unlink($fitted);
|
||||
}
|
||||
} finally {
|
||||
@unlink($tempInput);
|
||||
}
|
||||
|
|
@ -56,8 +97,8 @@ protected function aspectRatioToFloat(string $ratio): float
|
|||
}
|
||||
|
||||
/**
|
||||
* The platform-specific exception thrown when the source image cannot be
|
||||
* downloaded for cropping.
|
||||
* The platform-specific exception thrown when an image can't be prepared for
|
||||
* publishing — a download, crop, or story-fit failure.
|
||||
*/
|
||||
abstract protected function cropFailureException(string $message): SocialPublishException;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Social\Discord\DiscordClient;
|
||||
use App\Services\Social\Meta\GraphError;
|
||||
use App\Services\Social\Telegram\TelegramApi;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
|
@ -31,9 +32,7 @@ public function verify(SocialAccount $account): bool
|
|||
// refresh, so proactive refreshes during races cause false-positive
|
||||
// disconnects even though the access_token still works fine.
|
||||
if ($account->is_token_expired) {
|
||||
$this->refreshToken($account);
|
||||
|
||||
return $this->callVerifyEndpoint($account);
|
||||
return $this->refreshThenVerify($account);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -41,13 +40,40 @@ public function verify(SocialAccount $account): bool
|
|||
} catch (TokenExpiredException $e) {
|
||||
// Verify returned 401: the access_token is actually invalid.
|
||||
// Refresh and retry once with the new token.
|
||||
try {
|
||||
$this->refreshToken($account);
|
||||
} catch (TokenExpiredException) {
|
||||
throw $e;
|
||||
}
|
||||
return $this->refreshThenVerify($account, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the token, then verify with the new one.
|
||||
*
|
||||
* If either the refresh is rejected (4xx) or the verify that follows it hits
|
||||
* a token a concurrent refresh has already rotated — including the sub-commit
|
||||
* window where a lock-skipped refresh reloads a not-yet-persisted token —
|
||||
* reload and, when another process has since persisted a fresh access_token,
|
||||
* verify with that instead of giving up. X (and other providers that
|
||||
* single-use their refresh_token) would otherwise disconnect a still-usable
|
||||
* account whenever two refreshes race and one loses.
|
||||
*
|
||||
* @throws TokenExpiredException
|
||||
* @throws PlatformUnavailableException
|
||||
*/
|
||||
private function refreshThenVerify(SocialAccount $account, ?TokenExpiredException $original = null): bool
|
||||
{
|
||||
$accessTokenBeforeRefresh = $account->access_token;
|
||||
|
||||
try {
|
||||
$this->refreshToken($account);
|
||||
|
||||
return $this->callVerifyEndpoint($account);
|
||||
} catch (TokenExpiredException $e) {
|
||||
$account->refresh();
|
||||
|
||||
if ($account->access_token !== $accessTokenBeforeRefresh) {
|
||||
return $this->callVerifyEndpoint($account);
|
||||
}
|
||||
|
||||
throw $original ?? $e;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +181,7 @@ private function refreshXToken(SocialAccount $account): void
|
|||
$account->update([
|
||||
'access_token' => data_get($data, 'access_token'),
|
||||
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
|
||||
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)),
|
||||
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
|
||||
]);
|
||||
|
||||
$account->refresh();
|
||||
|
|
@ -288,10 +314,13 @@ private function refreshPinterestToken(SocialAccount $account): void
|
|||
private function refreshThreadsToken(SocialAccount $account): void
|
||||
{
|
||||
// Threads uses long-lived tokens that can be refreshed
|
||||
$response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
|
||||
'grant_type' => 'th_refresh_token',
|
||||
'access_token' => $account->access_token,
|
||||
]));
|
||||
$response = TokenRefreshClient::for(Platform::Threads)->send(
|
||||
fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
|
||||
'grant_type' => 'th_refresh_token',
|
||||
'access_token' => $account->access_token,
|
||||
]),
|
||||
GraphError::indicatesInvalidToken(...),
|
||||
);
|
||||
|
||||
$data = $response->json();
|
||||
$newToken = data_get($data, 'access_token');
|
||||
|
|
@ -299,7 +328,7 @@ private function refreshThreadsToken(SocialAccount $account): void
|
|||
$account->update([
|
||||
'access_token' => $newToken,
|
||||
'refresh_token' => $newToken,
|
||||
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
||||
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
|
||||
]);
|
||||
|
||||
$account->refresh();
|
||||
|
|
@ -307,10 +336,13 @@ private function refreshThreadsToken(SocialAccount $account): void
|
|||
|
||||
private function refreshInstagramToken(SocialAccount $account): void
|
||||
{
|
||||
$response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
|
||||
'grant_type' => 'ig_refresh_token',
|
||||
'access_token' => $account->access_token,
|
||||
]));
|
||||
$response = TokenRefreshClient::for(Platform::Instagram)->send(
|
||||
fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
|
||||
'grant_type' => 'ig_refresh_token',
|
||||
'access_token' => $account->access_token,
|
||||
]),
|
||||
GraphError::indicatesInvalidToken(...),
|
||||
);
|
||||
|
||||
$data = $response->json();
|
||||
$newToken = data_get($data, 'access_token');
|
||||
|
|
@ -318,7 +350,7 @@ private function refreshInstagramToken(SocialAccount $account): void
|
|||
$account->update([
|
||||
'access_token' => $newToken,
|
||||
'refresh_token' => $newToken,
|
||||
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
||||
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
|
||||
]);
|
||||
|
||||
$account->refresh();
|
||||
|
|
@ -385,13 +417,8 @@ private function verifyInstagram(SocialAccount $account): bool
|
|||
|
||||
$body = $response->json() ?? [];
|
||||
|
||||
if (isset($body['error'])) {
|
||||
$errorCode = $body['error']['code'] ?? null;
|
||||
$errorType = $body['error']['type'] ?? null;
|
||||
|
||||
if ($errorType === 'OAuthException' || $errorCode === 190) {
|
||||
throw new TokenExpiredException('Instagram access token is invalid or expired');
|
||||
}
|
||||
if (GraphError::indicatesInvalidToken($body)) {
|
||||
throw new TokenExpiredException('Instagram access token is invalid or expired');
|
||||
}
|
||||
|
||||
return $response->successful();
|
||||
|
|
@ -406,13 +433,8 @@ private function verifyFacebook(SocialAccount $account): bool
|
|||
|
||||
$body = $response->json() ?? [];
|
||||
|
||||
if (isset($body['error'])) {
|
||||
$errorCode = $body['error']['code'] ?? null;
|
||||
$errorType = $body['error']['type'] ?? null;
|
||||
|
||||
if ($errorType === 'OAuthException' || $errorCode === 190) {
|
||||
throw new TokenExpiredException('Facebook access token is invalid or expired');
|
||||
}
|
||||
if (GraphError::indicatesInvalidToken($body)) {
|
||||
throw new TokenExpiredException('Facebook access token is invalid or expired');
|
||||
}
|
||||
|
||||
return $response->successful();
|
||||
|
|
@ -427,13 +449,8 @@ private function verifyThreads(SocialAccount $account): bool
|
|||
|
||||
$body = $response->json() ?? [];
|
||||
|
||||
if (isset($body['error'])) {
|
||||
$errorCode = $body['error']['code'] ?? null;
|
||||
$errorType = $body['error']['type'] ?? null;
|
||||
|
||||
if ($errorType === 'OAuthException' || $errorCode === 190) {
|
||||
throw new TokenExpiredException('Threads access token is invalid or expired');
|
||||
}
|
||||
if (GraphError::indicatesInvalidToken($body)) {
|
||||
throw new TokenExpiredException('Threads access token is invalid or expired');
|
||||
}
|
||||
|
||||
return $response->successful();
|
||||
|
|
|
|||
|
|
@ -126,7 +126,16 @@ private function sendWithMedia(string $channelId, array $payload, Collection $me
|
|||
|
||||
$filename = $item->original_filename ?: (basename($item->path) ?: "media-{$index}");
|
||||
$request = $request->attach("files[{$index}]", file_get_contents($tempFile), $filename);
|
||||
$attachments[] = ['id' => $index, 'filename' => $filename];
|
||||
|
||||
$attachment = ['id' => $index, 'filename' => $filename];
|
||||
|
||||
$alt = $item->isImage() ? $item->altTextFor(Platform::Discord) : null;
|
||||
|
||||
if ($alt !== null) {
|
||||
$attachment['description'] = $alt;
|
||||
}
|
||||
|
||||
$attachments[] = $attachment;
|
||||
}
|
||||
|
||||
$payload['attachments'] = $attachments;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
namespace App\Services\Social;
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Exceptions\Social\ErrorCategory;
|
||||
use App\Exceptions\Social\FacebookPublishException;
|
||||
use App\Exceptions\Social\SocialPublishException;
|
||||
|
|
@ -134,6 +135,12 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st
|
|||
$payload['message'] = $content;
|
||||
}
|
||||
|
||||
$alt = $media->altTextFor(Platform::Facebook);
|
||||
|
||||
if ($alt !== null) {
|
||||
$payload['alt_text_custom'] = $alt;
|
||||
}
|
||||
|
||||
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
|
|
@ -163,11 +170,19 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
|
|||
continue;
|
||||
}
|
||||
|
||||
$uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
|
||||
$uploadPayload = [
|
||||
'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio),
|
||||
'published' => 'false',
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
];
|
||||
|
||||
$alt = $media->altTextFor(Platform::Facebook);
|
||||
|
||||
if ($alt !== null) {
|
||||
$uploadPayload['alt_text_custom'] = $alt;
|
||||
}
|
||||
|
||||
$uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $uploadPayload);
|
||||
|
||||
if ($uploadResponse->failed()) {
|
||||
Log::error('Facebook image upload failed', [
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
|
|||
{
|
||||
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
namespace App\Services\Social;
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Exceptions\Social\ErrorCategory;
|
||||
use App\Exceptions\Social\InstagramPublishException;
|
||||
use App\Exceptions\Social\SocialPublishException;
|
||||
|
|
@ -28,7 +29,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$account = $postPlatform->socialAccount;
|
||||
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -81,12 +82,20 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s
|
|||
{
|
||||
$imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio);
|
||||
|
||||
// Step 1: Create container
|
||||
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [
|
||||
$params = [
|
||||
'image_url' => $imageUrl,
|
||||
'caption' => $content,
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
];
|
||||
|
||||
$alt = $media->altTextFor(Platform::Instagram);
|
||||
|
||||
if ($alt !== null) {
|
||||
$params['alt_text'] = $alt;
|
||||
}
|
||||
|
||||
// Step 1: Create container
|
||||
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);
|
||||
|
||||
if ($containerResponse->failed()) {
|
||||
Log::error('Instagram container creation failed', [
|
||||
|
|
@ -158,7 +167,8 @@ private function publishStory(string $instagramId, string $accessToken, $media):
|
|||
if ($isVideo) {
|
||||
$params['video_url'] = $media->url;
|
||||
} else {
|
||||
$params['image_url'] = $media->url;
|
||||
$dimensions = ContentType::InstagramStory->aiImageDimensions();
|
||||
$params['image_url'] = $this->fitImageToCanvas($media->url, data_get($dimensions, 'width'), data_get($dimensions, 'height'));
|
||||
}
|
||||
|
||||
// Step 1: Create story container
|
||||
|
|
@ -206,6 +216,12 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
|
|||
$params['media_type'] = 'VIDEO';
|
||||
} else {
|
||||
$params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio);
|
||||
|
||||
$alt = $media->altTextFor(Platform::Instagram);
|
||||
|
||||
if ($alt !== null) {
|
||||
$params['alt_text'] = $alt;
|
||||
}
|
||||
}
|
||||
|
||||
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);
|
||||
|
|
|
|||
70
app/Services/Social/LinkCard/LinkCardFetcher.php
Normal file
70
app/Services/Social/LinkCard/LinkCardFetcher.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\LinkCard;
|
||||
|
||||
use App\Services\Brand\SafeHttpFetcher;
|
||||
use App\Support\UrlDetector;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Builds a link preview card (OpenGraph metadata) for the first URL in a piece
|
||||
* of text. Shared by BlueskyPublisher (publish-time embed) and the editor
|
||||
* preview endpoint. Returns null whenever a card cannot or should not be built,
|
||||
* so callers degrade gracefully.
|
||||
*/
|
||||
class LinkCardFetcher
|
||||
{
|
||||
/** Cards for a public URL are identical for everyone; cache briefly and globally. */
|
||||
private const int CACHE_MINUTES = 10;
|
||||
|
||||
public function __construct(
|
||||
private readonly SafeHttpFetcher $http = new SafeHttpFetcher,
|
||||
private readonly OpenGraphExtractor $extractor = new OpenGraphExtractor,
|
||||
) {}
|
||||
|
||||
public function fetch(string $text): ?LinkCardMetadata
|
||||
{
|
||||
$url = UrlDetector::firstUrl($text);
|
||||
|
||||
if ($url === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache a plain array, never the DTO: a rich object does not round-trip
|
||||
// cleanly through every cache driver (it comes back as an incomplete
|
||||
// class), whereas primitives always do.
|
||||
$data = Cache::remember(
|
||||
'link_card:'.sha1($url),
|
||||
now()->addMinutes(self::CACHE_MINUTES),
|
||||
fn (): ?array => $this->build($url)?->toArray(),
|
||||
);
|
||||
|
||||
return $data === null ? null : LinkCardMetadata::fromArray($data);
|
||||
}
|
||||
|
||||
private function build(string $url): ?LinkCardMetadata
|
||||
{
|
||||
$response = $this->http->tryGet($url);
|
||||
|
||||
if ($response === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = $this->extractor->extract($response->body(), $url);
|
||||
$title = data_get($meta, 'title');
|
||||
$description = data_get($meta, 'description');
|
||||
|
||||
if ($title === null && $description === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LinkCardMetadata(
|
||||
uri: $url,
|
||||
title: $title ?? '',
|
||||
description: $description ?? '',
|
||||
imageUrl: data_get($meta, 'image'),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
app/Services/Social/LinkCard/LinkCardMetadata.php
Normal file
61
app/Services/Social/LinkCard/LinkCardMetadata.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\LinkCard;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Uri;
|
||||
|
||||
final readonly class LinkCardMetadata
|
||||
{
|
||||
public function __construct(
|
||||
public string $uri,
|
||||
public string $title,
|
||||
public string $description,
|
||||
public ?string $imageUrl,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Rebuild the DTO from its cached array form (the inverse of toArray, minus
|
||||
* the derived `domain`, which toArray recomputes).
|
||||
*
|
||||
* @param array{uri?: string, title?: string, description?: string, image?: ?string} $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
uri: (string) data_get($data, 'uri', ''),
|
||||
title: (string) data_get($data, 'title', ''),
|
||||
description: (string) data_get($data, 'description', ''),
|
||||
imageUrl: data_get($data, 'image'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{uri: string, domain: string, title: string, description: string, image: ?string}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uri' => $this->uri,
|
||||
'domain' => $this->domain(),
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'image' => $this->imageUrl,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The bare display host for the card (e.g. "nyt.com" from
|
||||
* "https://www.nyt.com/x"), so the frontend renders it without parsing URLs.
|
||||
*/
|
||||
private function domain(): string
|
||||
{
|
||||
$host = Uri::of($this->uri)->host();
|
||||
|
||||
return $host === null || $host === ''
|
||||
? $this->uri
|
||||
: Str::chopStart($host, 'www.');
|
||||
}
|
||||
}
|
||||
75
app/Services/Social/LinkCard/OpenGraphExtractor.php
Normal file
75
app/Services/Social/LinkCard/OpenGraphExtractor.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\LinkCard;
|
||||
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Component\DomCrawler\UriResolver;
|
||||
|
||||
/**
|
||||
* Deterministic OpenGraph reader for link preview cards. Pulls og:title /
|
||||
* og:description / og:image with <title> and meta-description fallbacks.
|
||||
* Deliberately separate from HomepageMetaExtractor, which is brand-tuned and
|
||||
* intentionally excludes og:image.
|
||||
*/
|
||||
final class OpenGraphExtractor
|
||||
{
|
||||
/**
|
||||
* @return array{title: ?string, description: ?string, image: ?string}
|
||||
*/
|
||||
public function extract(string $html, string $baseUrl): array
|
||||
{
|
||||
$crawler = new Crawler($html, $baseUrl);
|
||||
|
||||
return [
|
||||
'title' => $this->title($crawler),
|
||||
'description' => $this->metaContent($crawler, 'property', 'og:description')
|
||||
?? $this->metaContent($crawler, 'name', 'description'),
|
||||
'image' => $this->image($crawler, $baseUrl),
|
||||
];
|
||||
}
|
||||
|
||||
private function title(Crawler $crawler): ?string
|
||||
{
|
||||
$ogTitle = $this->metaContent($crawler, 'property', 'og:title');
|
||||
|
||||
if ($ogTitle !== null) {
|
||||
return $ogTitle;
|
||||
}
|
||||
|
||||
$title = $crawler->filter('title')->first();
|
||||
|
||||
if ($title->count() === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = trim($title->text(''));
|
||||
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function image(Crawler $crawler, string $baseUrl): ?string
|
||||
{
|
||||
$image = $this->metaContent($crawler, 'property', 'og:image');
|
||||
|
||||
if ($image === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return UriResolver::resolve($image, $baseUrl);
|
||||
}
|
||||
|
||||
private function metaContent(Crawler $crawler, string $attr, string $value): ?string
|
||||
{
|
||||
$node = $crawler->filter(sprintf('meta[%s="%s"]', $attr, $value))->first();
|
||||
|
||||
if ($node->count() === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = trim((string) $node->attr('content', ''));
|
||||
|
||||
return $content === '' ? null : $content;
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
// Upload media first (max 4)
|
||||
foreach ($medias->take(4) as $media) {
|
||||
$mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename);
|
||||
$mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->isImage() ? $media->altTextFor(Platform::Mastodon) : null);
|
||||
if ($mediaId) {
|
||||
$mediaIds[] = $mediaId;
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
];
|
||||
}
|
||||
|
||||
private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename): ?string
|
||||
private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename, ?string $altText): ?string
|
||||
{
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'masto_media_');
|
||||
|
||||
|
|
@ -101,9 +101,14 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u
|
|||
|
||||
$stream = fopen($tempFile, 'r');
|
||||
|
||||
$response = $this->socialHttp()->withToken($account->access_token)
|
||||
->attach('file', $stream, $name)
|
||||
->post("{$instance}/api/v1/media");
|
||||
$request = $this->socialHttp()->withToken($account->access_token)
|
||||
->attach('file', $stream, $name);
|
||||
|
||||
if ($altText !== null) {
|
||||
$request = $request->attach('description', $altText);
|
||||
}
|
||||
|
||||
$response = $request->post("{$instance}/api/v1/media");
|
||||
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
|
|
|
|||
30
app/Services/Social/Meta/GraphError.php
Normal file
30
app/Services/Social/Meta/GraphError.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\Meta;
|
||||
|
||||
/**
|
||||
* Interprets Meta Graph API (Facebook / Instagram / Threads) error responses.
|
||||
*
|
||||
* Meta returns rate-limit and transient failures as an ordinary HTTP 4xx with
|
||||
* type "OAuthException" (e.g. code 4 / 17 "too many calls", code 1 / 2
|
||||
* "temporary problem"), so neither the HTTP status nor the error type can tell
|
||||
* a dead token from a throttle. Only error code 190 means the access token
|
||||
* itself is invalid or expired — the same signal the platform publish
|
||||
* exceptions already use to decide a disconnect.
|
||||
*/
|
||||
class GraphError
|
||||
{
|
||||
/**
|
||||
* Whether the given Meta Graph error body means the access token is
|
||||
* genuinely invalid or expired (code 190), as opposed to a rate-limit or
|
||||
* transient error that must not disconnect a still-valid token.
|
||||
*
|
||||
* @param array<string, mixed>|null $body
|
||||
*/
|
||||
public static function indicatesInvalidToken(?array $body): bool
|
||||
{
|
||||
return data_get($body, 'error.code') === 190;
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -119,8 +119,10 @@ private function publishImagePin(PostPlatform $postPlatform, ?string $content):
|
|||
$payload['link'] = data_get($postPlatform->meta, 'link');
|
||||
}
|
||||
|
||||
if (! empty(data_get($postPlatform->meta, 'alt_text'))) {
|
||||
$payload['alt_text'] = substr(data_get($postPlatform->meta, 'alt_text'), 0, 500);
|
||||
$alt = $postPlatform->post->mediaItems->first(fn ($m) => $m->isImage())?->altTextFor(Platform::Pinterest);
|
||||
|
||||
if ($alt !== null) {
|
||||
$payload['alt_text'] = $alt;
|
||||
}
|
||||
|
||||
$response = $this->socialHttp()->withToken($account->access_token)
|
||||
|
|
@ -407,7 +409,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
|
|||
*/
|
||||
public function getBoards(SocialAccount $account): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Services\Social;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Exceptions\Social\ErrorCategory;
|
||||
use App\Exceptions\Social\ThreadsPublishException;
|
||||
use App\Models\PostPlatform;
|
||||
|
|
@ -30,7 +31,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -100,12 +101,20 @@ private function publishTextPost(string $userId, string $accessToken, string $co
|
|||
private function publishImagePost(string $userId, string $accessToken, ?string $content, $media): array
|
||||
{
|
||||
// Step 1: Create container
|
||||
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [
|
||||
$params = [
|
||||
'media_type' => 'IMAGE',
|
||||
'image_url' => $media->url,
|
||||
'text' => $content,
|
||||
'access_token' => $accessToken,
|
||||
]);
|
||||
];
|
||||
|
||||
$alt = $media->altTextFor(Platform::Threads);
|
||||
|
||||
if ($alt !== null) {
|
||||
$params['alt_text'] = $alt;
|
||||
}
|
||||
|
||||
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params);
|
||||
|
||||
if ($containerResponse->failed()) {
|
||||
Log::error('Threads image container creation failed', [
|
||||
|
|
@ -184,6 +193,12 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c
|
|||
} else {
|
||||
$params['media_type'] = 'IMAGE';
|
||||
$params['image_url'] = $media->url;
|
||||
|
||||
$alt = $media->altTextFor(Platform::Threads);
|
||||
|
||||
if ($alt !== null) {
|
||||
$params['alt_text'] = $alt;
|
||||
}
|
||||
}
|
||||
|
||||
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ public function getMetrics(SocialAccount $account): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ public function fetch(SocialAccount $account): array
|
|||
*/
|
||||
private function fetchFresh(SocialAccount $account): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,13 +16,18 @@
|
|||
* Normalizes the failure modes of an OAuth token-refresh request:
|
||||
*
|
||||
* - ConnectionException (timeout / DNS / refused) → PlatformUnavailableException
|
||||
* - HTTP 5xx → PlatformUnavailableException
|
||||
* - HTTP 4xx → TokenExpiredException
|
||||
* - HTTP 5xx or 429 → PlatformUnavailableException
|
||||
* - other HTTP 4xx → TokenExpiredException
|
||||
*
|
||||
* Providers that return rate-limit or transient errors as an ordinary 4xx
|
||||
* (Meta: Instagram/Threads) can pass an `$isTokenInvalid` classifier to
|
||||
* `send()`; only a genuinely dead token then disconnects, while every other
|
||||
* 4xx is treated as transient (PlatformUnavailableException).
|
||||
*
|
||||
* Callers configure the actual HTTP call through the closure passed to
|
||||
* `send()`, so platform-specific quirks (form vs JSON body, auth headers,
|
||||
* basic auth, etc.) stay where they belong — in the per-platform refresh
|
||||
* method — while the failure semantics are uniform across providers.
|
||||
* method.
|
||||
*/
|
||||
class TokenRefreshClient
|
||||
{
|
||||
|
|
@ -35,11 +40,14 @@ public static function for(Platform $platform): self
|
|||
|
||||
/**
|
||||
* @param Closure():Response $request
|
||||
* @param (Closure(array<string, mixed>|null):bool)|null $isTokenInvalid Given the parsed
|
||||
* response body, returns whether a non-5xx/429 failure means the token itself is dead.
|
||||
* When it returns false, the failure is treated as transient (PlatformUnavailableException).
|
||||
*
|
||||
* @throws PlatformUnavailableException
|
||||
* @throws TokenExpiredException
|
||||
*/
|
||||
public function send(Closure $request): Response
|
||||
public function send(Closure $request, ?Closure $isTokenInvalid = null): Response
|
||||
{
|
||||
$name = $this->platform->label();
|
||||
|
||||
|
|
@ -62,6 +70,14 @@ public function send(Closure $request): Response
|
|||
]);
|
||||
|
||||
$body = $response->json();
|
||||
|
||||
if ($isTokenInvalid !== null && ! $isTokenInvalid($body)) {
|
||||
throw new PlatformUnavailableException(
|
||||
"{$name} API returned {$response->status()} during token refresh",
|
||||
$response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
$message = data_get($body, 'error_description')
|
||||
?? data_get($body, 'error.message')
|
||||
?? "Failed to refresh {$name} token";
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Services\Social;
|
||||
|
||||
use App\DataTransferObjects\MediaItem;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Exceptions\Social\ErrorCategory;
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class XPublisher
|
||||
{
|
||||
|
|
@ -37,8 +39,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
// Refresh token if expired or expiring soon
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +62,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$mediaId = data_get($uploadedMedia, 'data.id', data_get($uploadedMedia, 'media_id'));
|
||||
if ($mediaId) {
|
||||
$mediaIds[] = $mediaId;
|
||||
$this->uploadAltText($mediaId, $mediaItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,6 +109,41 @@ private function getHttpClient(): PendingRequest
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the image's accessibility description on X via the v2 media
|
||||
* metadata endpoint. Best-effort: only images carry alt text, and a failure
|
||||
* here never blocks the tweet — the media already uploaded and the post
|
||||
* should still go out without the description.
|
||||
*/
|
||||
private function uploadAltText(string $mediaId, MediaItem $mediaItem): void
|
||||
{
|
||||
if (! $mediaItem->isImage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$alt = $mediaItem->altTextFor(Platform::X);
|
||||
|
||||
if ($alt === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->getHttpClient()->post("{$this->baseUrl}/media/metadata", [
|
||||
'id' => $mediaId,
|
||||
'metadata' => [
|
||||
'alt_text' => [
|
||||
'text' => $alt,
|
||||
],
|
||||
],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('X alt text upload failed; posting the tweet without it', [
|
||||
'media_id' => $mediaId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function uploadMedia($mediaItem): ?array
|
||||
{
|
||||
$mimeType = $mediaItem->mime_type;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class UnsplashService
|
|||
|
||||
public function __construct()
|
||||
{
|
||||
$this->accessKey = config('services.unsplash.access_key', '');
|
||||
$this->accessKey = (string) config('services.unsplash.access_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
36
app/Support/AiPromptRules.php
Normal file
36
app/Support/AiPromptRules.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Single source of truth for the user-supplied AI generation prompt length,
|
||||
* shared by the create wizard (StartPostCreationRequest) and the editor's
|
||||
* content generation (GeneratePostContentRequest). The frontend counter in
|
||||
* resources/js/components/posts/create/AiPostWizard.vue mirrors these bounds.
|
||||
*/
|
||||
class AiPromptRules
|
||||
{
|
||||
/**
|
||||
* Minimum prompt length (characters) for the create wizard.
|
||||
*/
|
||||
public const PROMPT_MIN_LENGTH = 3;
|
||||
|
||||
/**
|
||||
* Maximum prompt length (characters); mirrored by the frontend counter.
|
||||
*/
|
||||
public const PROMPT_MAX_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* Validation rules for the create wizard's generation prompt. The editor's
|
||||
* content generation reuses only PROMPT_MAX_LENGTH — it has no minimum,
|
||||
* since it has no character counter to mirror one.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function wizardPromptRule(): array
|
||||
{
|
||||
return ['required', 'string', 'min:'.self::PROMPT_MIN_LENGTH, 'max:'.self::PROMPT_MAX_LENGTH];
|
||||
}
|
||||
}
|
||||
66
app/Support/Billing/FirstMonthCheckoutDiscount.php
Normal file
66
app/Support/Billing/FirstMonthCheckoutDiscount.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Billing;
|
||||
|
||||
use App\Models\Account;
|
||||
use Laravel\Cashier\SubscriptionBuilder;
|
||||
use RuntimeException;
|
||||
use Stripe\Subscription as StripeSubscription;
|
||||
|
||||
final class FirstMonthCheckoutDiscount
|
||||
{
|
||||
/**
|
||||
* Configure a subscription checkout to charge $1 for the first invoice via
|
||||
* a `duration: once` Stripe coupon, so a real charge validates the card
|
||||
* instead of a $0 trial authorization. Stripe rejects a Checkout Session
|
||||
* that sets both `discounts` and `allow_promotion_codes`, so checkouts that
|
||||
* don't qualify for the paid first month keep the promotion-code field.
|
||||
*
|
||||
* @throws RuntimeException when a qualifying checkout has the paid first
|
||||
* month enabled but no coupon configured — failing
|
||||
* loudly beats silently charging full price.
|
||||
*/
|
||||
public static function apply(SubscriptionBuilder $subscription, Account $account): SubscriptionBuilder
|
||||
{
|
||||
if (! self::qualifiesForPaidFirstMonth($account)) {
|
||||
return $subscription->allowPromotionCodes();
|
||||
}
|
||||
|
||||
$couponId = config('cashier.first_month_coupon_id');
|
||||
|
||||
if (! is_string($couponId) || $couponId === '') {
|
||||
throw new RuntimeException(
|
||||
'STRIPE_FIRST_MONTH_COUPON_ID must be set when REQUIRE_CARD_FOR_TRIAL is enabled, '
|
||||
.'otherwise checkout would charge the full price instead of the $1 first month.'
|
||||
);
|
||||
}
|
||||
|
||||
return $subscription->withCoupon($couponId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixed-amount first-month coupon only applies to a genuinely new
|
||||
* customer checking out a single workspace: the fixed `amount_off` is only
|
||||
* correct for a quantity of one, and the $1 offer is for first-time signups
|
||||
* — not a returning account re-subscribing with workspaces it kept from a
|
||||
* lapsed subscription. A subscription that never left `incomplete` never
|
||||
* became real, so a new customer retrying after a failed first attempt
|
||||
* still qualifies; any started subscription (even canceled) does not.
|
||||
*/
|
||||
private static function qualifiesForPaidFirstMonth(Account $account): bool
|
||||
{
|
||||
if (! (bool) config('trypost.billing.require_card_for_trial', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $account->workspaces()->count() === 1
|
||||
&& ! $account->subscriptions()
|
||||
->whereNotIn('stripe_status', [
|
||||
StripeSubscription::STATUS_INCOMPLETE,
|
||||
StripeSubscription::STATUS_INCOMPLETE_EXPIRED,
|
||||
])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
namespace App\Support;
|
||||
|
||||
use App\Enums\Media\Source;
|
||||
use Closure;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
|
|
@ -15,6 +16,12 @@
|
|||
*/
|
||||
class PostMediaRules
|
||||
{
|
||||
/**
|
||||
* Maximum stored length (characters) for a media item's alt text. Publishers
|
||||
* truncate further to each platform's own cap via Platform::altTextMaxLength().
|
||||
*/
|
||||
public const ALT_TEXT_MAX_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* @param bool $hosted true (web): items must already be hosted (id + path
|
||||
* required); false (API): a bare external `url` is
|
||||
|
|
@ -34,7 +41,23 @@ public static function rules(bool $hosted): array
|
|||
'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'media.*.size' => ['sometimes', 'nullable', 'integer'],
|
||||
'media.*.meta' => ['sometimes', 'nullable', 'array'],
|
||||
'media.*.meta' => ['sometimes', 'nullable', 'array', static function (string $attribute, mixed $value, Closure $fail): void {
|
||||
$altText = data_get($value, 'alt_text');
|
||||
|
||||
if ($altText === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($altText)) {
|
||||
$fail('validation.string')->translate(['attribute' => trans('posts.edit.alt_text.label')]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (mb_strlen($altText) > self::ALT_TEXT_MAX_LENGTH) {
|
||||
$fail('validation.max.string')->translate(['attribute' => trans('posts.edit.alt_text.label'), 'max' => self::ALT_TEXT_MAX_LENGTH]);
|
||||
}
|
||||
}],
|
||||
'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))],
|
||||
'media.*.source_meta' => ['sometimes', 'nullable', 'array'],
|
||||
];
|
||||
|
|
|
|||
38
app/Support/UrlDetector.php
Normal file
38
app/Support/UrlDetector.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class UrlDetector
|
||||
{
|
||||
/** PCRE matching bare http(s) URLs; shared with Bluesky link-facet parsing. */
|
||||
public const string URL_PATTERN = '/(https?:\/\/[^\s]+)/u';
|
||||
|
||||
public static function firstUrl(string $text): ?string
|
||||
{
|
||||
if (preg_match(self::URL_PATTERN, $text, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::trimTrailingPunctuation($matches[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trailing sentence punctuation and an unmatched closing paren are almost
|
||||
* never part of a URL (e.g. "see https://x.com)."). Mirrors the official
|
||||
* atproto link tokenizer.
|
||||
*/
|
||||
public static function trimTrailingPunctuation(string $url): string
|
||||
{
|
||||
if (preg_match('/[.,;:!?]$/', $url)) {
|
||||
$url = substr($url, 0, -1);
|
||||
}
|
||||
|
||||
if (str_ends_with($url, ')') && ! str_contains($url, '(')) {
|
||||
$url = substr($url, 0, -1);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
|
@ -74,6 +74,7 @@
|
|||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^4.4",
|
||||
"pestphp/pest-plugin-browser": "^4.3",
|
||||
"pestphp/pest-plugin-laravel": "^4.1"
|
||||
},
|
||||
"autoload": {
|
||||
|
|
@ -120,6 +121,10 @@
|
|||
"@test:lint",
|
||||
"@php artisan test"
|
||||
],
|
||||
"test:all": [
|
||||
"@test",
|
||||
"@php artisan test tests/Browser"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
|
|
|
|||
1573
composer.lock
generated
1573
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -137,4 +137,19 @@
|
|||
|
||||
'trial_days' => env('CASHIER_TRIAL_DAYS', 8),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Paid First Month Coupon
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Stripe Coupon ID applied at checkout so the first invoice comes out to
|
||||
| $1 instead of the full monthly price — a real charge validates the
|
||||
| card up front instead of a $0 trial authorization. Must be an
|
||||
| `amount_off` coupon with `duration: once`, so it discounts only the
|
||||
| first invoice and the full price bills automatically afterward.
|
||||
|
|
||||
*/
|
||||
|
||||
'first_month_coupon_id' => env('STRIPE_FIRST_MONTH_COUPON_ID'),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -19,6 +19,18 @@
|
|||
'en' => 'English',
|
||||
'es' => 'Español',
|
||||
'pt-BR' => 'Português',
|
||||
'fr' => 'Français',
|
||||
'de' => 'Deutsch',
|
||||
'it' => 'Italiano',
|
||||
'nl' => 'Nederlands',
|
||||
'pl' => 'Polski',
|
||||
'el' => 'Ελληνικά',
|
||||
'ja' => '日本語',
|
||||
'ko' => '한국어',
|
||||
'zh' => '中文',
|
||||
'ru' => 'Русский',
|
||||
'tr' => 'Türkçe',
|
||||
'ar' => 'العربية',
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -16,6 +16,22 @@
|
|||
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Security
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 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
|
||||
| opt in here. Leave disabled unless you understand the SSRF risk.
|
||||
|
|
||||
*/
|
||||
|
||||
'security' => [
|
||||
'allow_private_network' => (bool) env('TRYPOST_ALLOW_PRIVATE_NETWORK', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Billing
|
||||
|
|
|
|||
148
lang/ar/accounts.php
Normal file
148
lang/ar/accounts.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'الاتصالات',
|
||||
'page_title' => 'الحسابات الاجتماعية',
|
||||
'description' => 'نظرة عامة على جميع حساباتك الاجتماعية المتصلة',
|
||||
'connect_cta' => 'ربط',
|
||||
|
||||
'not_connected' => 'غير متصل',
|
||||
'connect' => 'ربط',
|
||||
'connection_lost' => 'انقطع الاتصال',
|
||||
'reconnect' => 'إعادة الربط',
|
||||
'reconnect_account' => 'إعادة ربط الحساب',
|
||||
'view_profile' => 'عرض الملف الشخصي',
|
||||
'disconnect' => 'فصل',
|
||||
|
||||
'descriptions' => [
|
||||
'linkedin' => 'اربط ملفك الشخصي أو صفحة شركتك على LinkedIn',
|
||||
'linkedin-page' => 'اربط صفحة شركة على LinkedIn',
|
||||
'x' => 'اربط حسابك على X (Twitter)',
|
||||
'tiktok' => 'اربط حسابك على TikTok',
|
||||
'youtube' => 'اربط قناة على YouTube',
|
||||
'facebook' => 'اربط صفحة على Facebook',
|
||||
'instagram' => 'اربط حساب Instagram احترافيًا',
|
||||
'instagram-facebook' => 'اربط Instagram عبر صفحة Facebook',
|
||||
'threads' => 'اربط حسابك على Threads',
|
||||
'pinterest' => 'اربط حسابك على Pinterest',
|
||||
'bluesky' => 'اربط حسابك على Bluesky',
|
||||
'mastodon' => 'اربط حسابك على Mastodon',
|
||||
'telegram' => 'اربط قناة أو مجموعة على Telegram',
|
||||
'discord' => 'اربط خادم Discord',
|
||||
],
|
||||
|
||||
'disconnect_modal' => [
|
||||
'title' => 'فصل الحساب',
|
||||
'description' => 'هل أنت متأكد من رغبتك في فصل هذا الحساب؟ يمكنك إعادة ربطه في أي وقت.',
|
||||
'confirm' => 'فصل',
|
||||
'cancel' => 'إلغاء',
|
||||
],
|
||||
|
||||
'bluesky' => [
|
||||
'title' => 'ربط Bluesky',
|
||||
'description' => 'أدخل بيانات اعتمادك للربط',
|
||||
'email' => 'البريد الإلكتروني',
|
||||
'email_placeholder' => 'yourhandle.bsky.social',
|
||||
'app_password' => 'كلمة مرور التطبيق',
|
||||
'app_password_placeholder' => 'xxxx-xxxx-xxxx-xxxx',
|
||||
'app_password_hint' => 'استخدم <strong>كلمة مرور التطبيق</strong> لمزيد من الأمان. أنشئ واحدة من <a href="https://bsky.app/settings/app-passwords" target="_blank" class="underline">bsky.app/settings</a>.',
|
||||
'submit' => 'ربط Bluesky',
|
||||
'submitting' => 'جارٍ الاتصال...',
|
||||
],
|
||||
|
||||
'mastodon' => [
|
||||
'title' => 'ربط Mastodon',
|
||||
'description' => 'أدخل خادم Mastodon الخاص بك',
|
||||
'instance_url' => 'رابط الخادم',
|
||||
'instance_placeholder' => 'https://mastodon.social',
|
||||
'instance_hint' => 'أدخل رابط خادم Mastodon الخاص بك (مثل mastodon.social، techhub.social)',
|
||||
'submit' => 'المتابعة عبر Mastodon',
|
||||
'submitting' => 'جارٍ الاتصال...',
|
||||
],
|
||||
|
||||
'telegram' => [
|
||||
'title' => 'ربط Telegram',
|
||||
'description' => 'اربط قناة أو مجموعة',
|
||||
'step_admin' => 'أضِف :bot كمشرف على قناتك أو مجموعتك على Telegram.',
|
||||
'step_command' => 'انشر هذا الأمر في القناة أو المجموعة:',
|
||||
'waiting' => 'في انتظار اتصال القناة…',
|
||||
'connected' => 'تم اتصال القناة!',
|
||||
'connected_toast' => 'تم ربط قناة Telegram بنجاح!',
|
||||
'copied_toast' => 'تم نسخ الأمر إلى الحافظة',
|
||||
'copy_tooltip' => 'نسخ الأمر',
|
||||
'expired' => 'انتهت صلاحية هذا الرمز. أنشئ رمزًا جديدًا للمحاولة مرة أخرى.',
|
||||
'new_code' => 'إنشاء رمز جديد',
|
||||
'retry' => 'إعادة المحاولة',
|
||||
'error_generic' => 'تعذر بدء الاتصال. يرجى المحاولة مرة أخرى.',
|
||||
'network_taken' => 'تحتوي مساحة العمل هذه بالفعل على قناة Telegram متصلة. افصلها أولًا.',
|
||||
],
|
||||
|
||||
'facebook' => [
|
||||
'title' => 'اختيار صفحة Facebook',
|
||||
'description' => 'اختر الصفحة التي تريد ربطها',
|
||||
'no_pages' => 'لم يتم العثور على صفحات',
|
||||
'no_pages_description' => 'أنت لست مشرفًا على أي صفحة Facebook.',
|
||||
'page_label' => 'صفحة Facebook',
|
||||
'view' => 'عرض',
|
||||
'choose' => 'اختيار',
|
||||
],
|
||||
|
||||
'instagram_facebook' => [
|
||||
'title' => 'اختيار حساب Instagram',
|
||||
'description' => 'اختر حساب Instagram الذي تريد ربطه',
|
||||
'no_pages' => 'لم يتم العثور على حسابات Instagram',
|
||||
'no_pages_description' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram للأعمال.',
|
||||
'view' => 'عرض',
|
||||
'choose' => 'اختيار',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'اختيار صفحة LinkedIn',
|
||||
'description' => 'اختر الصفحة التي تريد ربطها',
|
||||
'no_pages' => 'لم يتم العثور على صفحات',
|
||||
'no_pages_description' => 'أنت لست مشرفًا على أي صفحة LinkedIn.',
|
||||
'page_label' => 'صفحة LinkedIn',
|
||||
'select_title' => 'أين تريد النشر؟',
|
||||
'select_subtitle' => 'انشر باسمك أو اختر صفحة شركة تديرها.',
|
||||
'person_tag' => 'شخص',
|
||||
'organization_tag' => 'مؤسسة',
|
||||
'view' => 'عرض',
|
||||
'choose' => 'اختيار',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'disconnected' => 'تم فصل الحساب بنجاح!',
|
||||
'connected' => 'تم ربط الحساب بنجاح!',
|
||||
'session_expired' => 'انتهت الجلسة. يرجى المحاولة مرة أخرى.',
|
||||
'workspace_not_found' => 'لم يتم العثور على مساحة العمل.',
|
||||
'activated' => 'تم تفعيل الحساب!',
|
||||
'deactivated' => 'تم إلغاء تفعيل الحساب!',
|
||||
'already_connected' => 'هذه المنصة متصلة بالفعل.',
|
||||
'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.',
|
||||
],
|
||||
|
||||
'popup_callback' => [
|
||||
'title_success' => 'تم الاتصال',
|
||||
'title_error' => 'خطأ',
|
||||
'closing' => 'ستُغلق هذه النافذة تلقائيًا...',
|
||||
'manual_close' => 'يمكنك إغلاق هذه النافذة.',
|
||||
'popup_blocked' => 'تعذر فتح نافذة الاتصال. يرجى السماح بالنوافذ المنبثقة والمحاولة مرة أخرى.',
|
||||
'connected' => 'تم ربط الحساب!',
|
||||
'reconnected' => 'تمت إعادة ربط الحساب!',
|
||||
'error_connecting' => 'خطأ في ربط الحساب. يرجى المحاولة مرة أخرى.',
|
||||
'network_taken' => 'تحتوي مساحة العمل هذه بالفعل على حساب لهذه الشبكة. افصله أولًا.',
|
||||
'error_connecting_page' => 'خطأ في ربط الصفحة. يرجى المحاولة مرة أخرى.',
|
||||
'error_connecting_channel' => 'خطأ في ربط القناة. يرجى المحاولة مرة أخرى.',
|
||||
'session_expired' => 'انتهت الجلسة. يرجى المحاولة مرة أخرى.',
|
||||
'workspace_not_found' => 'لم يتم العثور على مساحة العمل.',
|
||||
'invalid_state' => 'حالة غير صالحة. يرجى المحاولة مرة أخرى.',
|
||||
'failed_to_authenticate' => 'فشلت المصادقة.',
|
||||
'failed_to_get_profile' => 'فشل جلب الملف الشخصي.',
|
||||
'page_not_found' => 'لم يتم العثور على الصفحة.',
|
||||
'channel_not_found' => 'لم يتم العثور على القناة.',
|
||||
'no_facebook_pages' => 'لم يتم العثور على صفحات Facebook. يجب أن تكون مشرفًا على صفحة واحدة على الأقل.',
|
||||
'no_facebook_instagram_pages' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram.',
|
||||
'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.',
|
||||
'not_linkedin_admin' => 'أنت لست مشرفًا على أي صفحة LinkedIn.',
|
||||
],
|
||||
];
|
||||
55
lang/ar/analytics.php
Normal file
55
lang/ar/analytics.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'no_accounts' => 'لا توجد حسابات متصلة تحتوي على تحليلات.',
|
||||
'no_accounts_match' => 'لا توجد حسابات مطابقة.',
|
||||
'search_account' => 'البحث عن حساب…',
|
||||
'select_account' => 'اختر حسابًا لعرض التحليلات.',
|
||||
'no_data' => 'لا تتوفر بيانات تحليلات.',
|
||||
|
||||
'metrics' => [
|
||||
'avg_view_duration' => 'متوسط مدة المشاهدة (ث)',
|
||||
'avg_view_percentage' => 'متوسط نسبة المشاهدة',
|
||||
'bookmarks' => 'الإشارات المرجعية',
|
||||
'clicks' => 'النقرات',
|
||||
'comments' => 'التعليقات',
|
||||
'custom_reaction' => 'مخصص',
|
||||
'engagement' => 'التفاعل',
|
||||
'favourites' => 'المفضلة',
|
||||
'followers' => 'المتابِعون',
|
||||
'following' => 'المتابَعون',
|
||||
'impressions' => 'مرات الظهور',
|
||||
'interactions' => 'التفاعلات',
|
||||
'likes' => 'الإعجابات',
|
||||
'members' => 'الأعضاء',
|
||||
'minutes_watched' => 'دقائق المشاهدة',
|
||||
'organic_followers' => 'المتابِعون العضويون',
|
||||
'outbound_clicks' => 'النقرات الصادرة',
|
||||
'page_followers' => 'متابِعو الصفحة',
|
||||
'page_reach' => 'وصول الصفحة',
|
||||
'page_views' => 'مشاهدات الصفحة',
|
||||
'paid_followers' => 'المتابِعون المدفوعون',
|
||||
'pin_click_rate' => 'معدل النقر على الدبوس',
|
||||
'pin_clicks' => 'نقرات الدبوس',
|
||||
'posts_engagement' => 'تفاعل المنشورات',
|
||||
'posts_reach' => 'وصول المنشورات',
|
||||
'quotes' => 'الاقتباسات',
|
||||
'reach' => 'الوصول',
|
||||
'reblogs' => 'إعادات التدوين',
|
||||
'recent_comments' => 'أحدث التعليقات',
|
||||
'recent_likes' => 'أحدث الإعجابات',
|
||||
'recent_shares' => 'أحدث المشاركات',
|
||||
'replies' => 'الردود',
|
||||
'reposts' => 'إعادات النشر',
|
||||
'retweets' => 'إعادات التغريد',
|
||||
'saves' => 'مرات الحفظ',
|
||||
'shares' => 'المشاركات',
|
||||
'subscribers' => 'المشتركون',
|
||||
'subscribers_gained' => 'المشتركون الجدد',
|
||||
'subscribers_lost' => 'المشتركون المفقودون',
|
||||
'total_likes' => 'إجمالي الإعجابات',
|
||||
'video_views' => 'مشاهدات الفيديو',
|
||||
'videos' => 'مقاطع الفيديو',
|
||||
'views' => 'المشاهدات',
|
||||
],
|
||||
];
|
||||
52
lang/ar/assets.php
Normal file
52
lang/ar/assets.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'الوسائط',
|
||||
|
||||
'tabs' => [
|
||||
'my_uploads' => 'ملفاتي المرفوعة',
|
||||
'stock_photos' => 'صور مجانية',
|
||||
'gifs' => 'صور GIF',
|
||||
],
|
||||
|
||||
'upload' => [
|
||||
'drag_drop' => 'اسحب ملفاتك وأفلتها هنا، أو انقر للاختيار',
|
||||
'formats' => 'JPEG، PNG، GIF، WebP، MP4، PDF',
|
||||
'uploading' => 'جارٍ الرفع...',
|
||||
'failed' => 'تعذر رفع :file. يرجى المحاولة مرة أخرى.',
|
||||
],
|
||||
|
||||
'empty' => [
|
||||
'title' => 'لا توجد وسائط بعد',
|
||||
'description' => 'ارفع الصور ومقاطع الفيديو لبناء مكتبة الوسائط الخاصة بك.',
|
||||
],
|
||||
|
||||
'save_to_assets' => 'حفظ في الوسائط',
|
||||
'saved' => 'تم الحفظ في وسائطك!',
|
||||
'create_post' => 'إنشاء منشور',
|
||||
'add_to_post' => 'إضافة إلى المنشور',
|
||||
'search_placeholder' => 'البحث في الوسائط...',
|
||||
|
||||
'delete' => [
|
||||
'title' => 'حذف الوسائط',
|
||||
'description' => 'هل أنت متأكد من رغبتك في حذف هذا العنصر؟ لا يمكن التراجع عن هذا الإجراء.',
|
||||
'confirm' => 'حذف',
|
||||
'cancel' => 'إلغاء',
|
||||
],
|
||||
|
||||
'unsplash' => [
|
||||
'search_placeholder' => 'البحث عن صور مجانية...',
|
||||
'no_results' => 'لم يتم العثور على صور',
|
||||
'no_results_description' => 'جرّب كلمة بحث مختلفة.',
|
||||
'trending' => 'الرائج على Unsplash',
|
||||
'start_searching' => 'ابحث عن صور مجانية من Unsplash',
|
||||
],
|
||||
|
||||
'giphy' => [
|
||||
'trending' => 'الرائج على Giphy',
|
||||
'search_placeholder' => 'البحث عن صور GIF...',
|
||||
'no_results' => 'لم يتم العثور على صور GIF',
|
||||
'no_results_description' => 'جرّب كلمة بحث مختلفة.',
|
||||
'powered_by' => 'مقدَّم من GIPHY',
|
||||
],
|
||||
];
|
||||
141
lang/ar/auth.php
Normal file
141
lang/ar/auth.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are used during authentication for various
|
||||
| messages that we need to display to the user. You are free to modify
|
||||
| these language lines according to your application's requirements.
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => 'بيانات الاعتماد هذه لا تطابق سجلاتنا.',
|
||||
'password' => 'كلمة المرور المُدخلة غير صحيحة.',
|
||||
'throttle' => 'عدد كبير جدًا من محاولات تسجيل الدخول. يرجى المحاولة مرة أخرى بعد :seconds ثانية.',
|
||||
|
||||
'flash' => [
|
||||
'welcome' => 'مرحبًا بك في TryPost!',
|
||||
'welcome_trial' => 'مرحبًا بك في TryPost! لقد بدأت فترتك التجريبية.',
|
||||
],
|
||||
|
||||
'legal' => 'بمتابعتك، فإنك توافق على <a href="https://trypost.it/terms" target="_blank">شروط الخدمة</a> و<a href="https://trypost.it/privacy" target="_blank">سياسة الخصوصية</a>.',
|
||||
|
||||
'slides' => [
|
||||
'calendar' => [
|
||||
'title' => 'تقويم مرئي',
|
||||
'description' => 'خطّط لمحتواك وجدوِله باستخدام تقويم بديهي يعتمد على السحب والإفلات عبر جميع حساباتك الاجتماعية.',
|
||||
],
|
||||
'scheduling' => [
|
||||
'title' => 'جدولة ذكية',
|
||||
'description' => 'جدوِل المنشورات عبر LinkedIn وX وInstagram وTikTok وYouTube والمزيد — كل ذلك من مكان واحد.',
|
||||
],
|
||||
'media' => [
|
||||
'title' => 'وسائط غنية',
|
||||
'description' => 'انشر الصور والعروض الدائرية والقصص ومقاطع الريلز. تحصل كل منصة على التنسيق المناسب تلقائيًا.',
|
||||
],
|
||||
'video' => [
|
||||
'title' => 'نشر الفيديو',
|
||||
'description' => 'ارفع مقاطع الفيديو مرة واحدة وانشرها على TikTok وYouTube Shorts وInstagram Reels وFacebook Reels.',
|
||||
],
|
||||
'team' => [
|
||||
'title' => 'مساحات عمل جماعية',
|
||||
'description' => 'ادعُ فريقك، وعيّن الأدوار، وأدِر عدة علامات تجارية من مساحات عمل منفصلة.',
|
||||
],
|
||||
'signatures' => [
|
||||
'title' => 'التوقيعات',
|
||||
'description' => 'احفظ توقيعات قابلة لإعادة الاستخدام (وسوم، روابط، خواتيم) وأضِفها إلى المنشورات بنقرة واحدة.',
|
||||
],
|
||||
],
|
||||
|
||||
'or_continue_with' => 'أو تابع باستخدام',
|
||||
'or_continue_with_email' => 'أو تابع باستخدام البريد الإلكتروني',
|
||||
'google_login' => 'تسجيل الدخول عبر Google',
|
||||
'google_signup' => 'التسجيل عبر Google',
|
||||
'github_login' => 'تسجيل الدخول عبر GitHub',
|
||||
'github_signup' => 'التسجيل عبر GitHub',
|
||||
'github_email_unavailable' => 'تعذر جلب بريدك الإلكتروني من GitHub. اجعل بريدك على GitHub عامًا أو امنح نطاق الوصول إلى البريد، ثم حاول مرة أخرى.',
|
||||
|
||||
'signup_success' => [
|
||||
'page_title' => 'مرحبًا',
|
||||
'title' => 'جارٍ إعداد حسابك',
|
||||
'description' => 'يستغرق هذا عادةً بضع ثوانٍ فقط...',
|
||||
],
|
||||
|
||||
'login' => [
|
||||
'title' => 'تسجيل الدخول إلى حسابك',
|
||||
'description' => 'أدخل بريدك الإلكتروني وكلمة المرور أدناه لتسجيل الدخول',
|
||||
'page_title' => 'تسجيل الدخول',
|
||||
'email' => 'البريد الإلكتروني',
|
||||
'password' => 'كلمة المرور',
|
||||
'forgot_password' => 'نسيت كلمة المرور؟',
|
||||
'remember_me' => 'تذكّرني',
|
||||
'submit' => 'تسجيل الدخول',
|
||||
'no_account' => 'ليس لديك حساب؟',
|
||||
'sign_up' => 'إنشاء حساب',
|
||||
],
|
||||
|
||||
'register' => [
|
||||
'title' => 'تقويمك الاجتماعي بالكامل، في مكان واحد',
|
||||
'description' => 'أنشئ حسابك وابدأ جدولة المنشورات عبر كل شبكة.',
|
||||
'page_title' => 'التسجيل',
|
||||
'signup_with_email' => 'التسجيل بالبريد الإلكتروني',
|
||||
'name' => 'الاسم',
|
||||
'name_placeholder' => 'الاسم الكامل',
|
||||
'email' => 'البريد الإلكتروني',
|
||||
'password' => 'كلمة المرور',
|
||||
'show_password' => 'إظهار كلمة المرور',
|
||||
'hide_password' => 'إخفاء كلمة المرور',
|
||||
'submit' => 'إنشاء حساب',
|
||||
'has_account' => 'هل لديك حساب بالفعل؟',
|
||||
'log_in' => 'تسجيل الدخول',
|
||||
],
|
||||
|
||||
'forgot_password' => [
|
||||
'title' => 'نسيت كلمة المرور',
|
||||
'description' => 'أدخل بريدك الإلكتروني لاستلام رابط إعادة تعيين كلمة المرور',
|
||||
'page_title' => 'نسيت كلمة المرور',
|
||||
'email' => 'البريد الإلكتروني',
|
||||
'submit' => 'إرسال رابط إعادة تعيين كلمة المرور',
|
||||
'return_to' => 'أو، العودة إلى',
|
||||
'log_in' => 'تسجيل الدخول',
|
||||
],
|
||||
|
||||
'reset_password' => [
|
||||
'title' => 'إعادة تعيين كلمة المرور',
|
||||
'description' => 'يرجى إدخال كلمة المرور الجديدة أدناه',
|
||||
'page_title' => 'إعادة تعيين كلمة المرور',
|
||||
'email' => 'البريد الإلكتروني',
|
||||
'password' => 'كلمة المرور',
|
||||
'confirm_password' => 'تأكيد كلمة المرور',
|
||||
'confirm_placeholder' => 'تأكيد كلمة المرور',
|
||||
'submit' => 'إعادة تعيين كلمة المرور',
|
||||
],
|
||||
|
||||
'verify_email' => [
|
||||
'title' => 'تأكيد البريد الإلكتروني',
|
||||
'description' => 'يرجى تأكيد بريدك الإلكتروني بالنقر على الرابط الذي أرسلناه إليك للتو.',
|
||||
'page_title' => 'تأكيد البريد الإلكتروني',
|
||||
'link_sent' => 'تم إرسال رابط تأكيد جديد إلى البريد الإلكتروني الذي قدّمته أثناء التسجيل.',
|
||||
'resend' => 'إعادة إرسال بريد التأكيد',
|
||||
'log_out' => 'تسجيل الخروج',
|
||||
],
|
||||
|
||||
'accept_invite' => [
|
||||
'page_title' => 'قبول الدعوة',
|
||||
'title' => 'لقد تمت دعوتك!',
|
||||
'description' => 'لقد تمت دعوتك للانضمام إلى مساحة العمل :workspace.',
|
||||
'workspace' => 'مساحة العمل',
|
||||
'your_role' => 'دورك',
|
||||
'email' => 'البريد الإلكتروني',
|
||||
'accept' => 'قبول الدعوة',
|
||||
'decline' => 'رفض الدعوة',
|
||||
'login_prompt' => 'سجّل الدخول أو أنشئ حسابًا لقبول هذه الدعوة.',
|
||||
'log_in' => 'تسجيل الدخول',
|
||||
'create_account' => 'إنشاء حساب',
|
||||
],
|
||||
|
||||
];
|
||||
412
lang/ar/automations.php
Normal file
412
lang/ar/automations.php
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
<?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' => 'نشر',
|
||||
'webhook' => 'Webhook',
|
||||
'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' => 'نشر',
|
||||
'webhook' => 'Webhook',
|
||||
'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 د',
|
||||
],
|
||||
'webhook' => [
|
||||
'url' => 'الرابط',
|
||||
'method' => 'الطريقة',
|
||||
'payload_template' => 'قالب الحمولة (JSON)',
|
||||
],
|
||||
'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' => 'لم يتم العثور على منشور مُنشأ في التشغيل.',
|
||||
'webhook_server_error' => 'خطأ في خادم Webhook.',
|
||||
'webhook_request_failed' => 'تعذر إكمال طلب Webhook.',
|
||||
'webhook_missing_url' => 'عقدة Webhook تفتقد إلى رابط.',
|
||||
'webhook_invalid_payload_json' => 'قالب الحمولة ليس JSON صالحًا.',
|
||||
'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' => 'لم يُفضِ مسار العناصر إلى قائمة.',
|
||||
],
|
||||
];
|
||||
76
lang/ar/billing.php
Normal file
76
lang/ar/billing.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'الفوترة',
|
||||
|
||||
'past_due_notice' => [
|
||||
'title' => 'دفعة متأخرة',
|
||||
'description' => 'حدّث طريقة الدفع للحفاظ على اشتراكك نشطًا.',
|
||||
'cta' => 'تحديث الدفع',
|
||||
],
|
||||
|
||||
'annual_banner' => [
|
||||
'title' => 'احصل على شهرين مجانًا',
|
||||
'description' => 'انتقل إلى الفوترة السنوية وادفع أقل كل شهر — الخطة نفسها، لا شيء آخر يتغير.',
|
||||
'cta' => 'الترقية إلى السنوية',
|
||||
],
|
||||
|
||||
'subscribe' => [
|
||||
'billed_monthly' => 'فوترة شهرية',
|
||||
'billed_yearly' => 'فوترة سنوية',
|
||||
'prices' => [
|
||||
'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'],
|
||||
],
|
||||
],
|
||||
|
||||
'plan' => [
|
||||
'title' => 'الخطة',
|
||||
'description' => 'إدارة خطة اشتراكك.',
|
||||
'label' => 'الخطة',
|
||||
'workspaces' => '{1}مساحة عمل واحدة|{2}مساحتا عمل|[3,10]:count مساحات عمل|[11,*]:count مساحة عمل',
|
||||
'per_workspace' => 'لكل مساحة عمل',
|
||||
'price' => 'السعر',
|
||||
'month' => 'شهر',
|
||||
'trial' => 'تجريبي',
|
||||
'active' => 'نشط',
|
||||
'past_due' => 'متأخر',
|
||||
'cancelling' => 'قيد الإلغاء',
|
||||
'trial_ends' => 'تنتهي الفترة التجريبية',
|
||||
],
|
||||
|
||||
'subscription' => [
|
||||
'title' => 'الاشتراك',
|
||||
'description' => 'إدارة طريقة الدفع وتفاصيل الفوترة والاشتراك.',
|
||||
'payment_method' => 'طريقة الدفع',
|
||||
'no_payment_method' => 'لا توجد طريقة دفع مسجّلة بعد.',
|
||||
'expires_on' => 'تنتهي في :month/:year',
|
||||
'manage_label' => 'الاشتراك',
|
||||
'manage_stripe' => 'الإدارة عبر Stripe',
|
||||
],
|
||||
|
||||
'invoices' => [
|
||||
'title' => 'الفواتير',
|
||||
'description' => 'نزّل فواتيرك السابقة.',
|
||||
'empty' => 'لم يتم العثور على فواتير',
|
||||
'paid' => 'مدفوعة',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'plan_changed' => 'أنت الآن على خطة :plan.',
|
||||
'switched_to_yearly' => 'أنت الآن على الفوترة السنوية.',
|
||||
'cannot_manage' => 'يمكن لمالك الحساب فقط إدارة الفوترة.',
|
||||
'credits_exhausted' => 'نفد رصيد الذكاء الاصطناعي — تم استخدام مخصصك الشهري البالغ :limit. رقِّ خطتك أو انتظر حتى الشهر المقبل.',
|
||||
'subscription_required' => 'يلزم وجود اشتراك نشط لاستخدام ميزات الذكاء الاصطناعي.',
|
||||
],
|
||||
|
||||
'processing' => [
|
||||
'page_title' => 'جارٍ المعالجة...',
|
||||
'title' => 'جارٍ معالجة اشتراكك',
|
||||
'description' => 'يرجى الانتظار بينما نُعِدّ حسابك. لن يستغرق هذا سوى لحظة.',
|
||||
'success_title' => 'كل شيء جاهز!',
|
||||
'success_description' => 'اشتراكك نشط. جارٍ إعادة توجيهك إلى مساحات عملك...',
|
||||
'cancelled_title' => 'تم إلغاء الدفع',
|
||||
'cancelled_description' => 'تم إلغاء عملية الدفع. لم تُجرَ أي رسوم.',
|
||||
'retry' => 'إعادة المحاولة',
|
||||
],
|
||||
];
|
||||
39
lang/ar/brands.php
Normal file
39
lang/ar/brands.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'new_brand' => 'علامة تجارية جديدة',
|
||||
'no_brands_yet' => 'لا توجد علامات تجارية بعد',
|
||||
'no_brands_description' => 'أنشئ علامات تجارية لتنظيم حساباتك الاجتماعية حسب العميل أو المشروع',
|
||||
'accounts_count' => ':count حساب',
|
||||
|
||||
'create' => [
|
||||
'title' => 'إنشاء علامة تجارية',
|
||||
'description' => 'امنح علامتك التجارية اسمًا لتجميع الحسابات الاجتماعية',
|
||||
'name' => 'اسم العلامة التجارية',
|
||||
'name_placeholder' => 'مثال: Acme Corp، شخصي',
|
||||
'submit' => 'إنشاء علامة تجارية',
|
||||
'submitting' => 'جارٍ الإنشاء...',
|
||||
],
|
||||
|
||||
'edit' => [
|
||||
'title' => 'تعديل العلامة التجارية',
|
||||
'description' => 'تحديث اسم هذه العلامة التجارية',
|
||||
'name' => 'اسم العلامة التجارية',
|
||||
'name_placeholder' => 'مثال: Acme Corp، شخصي',
|
||||
'submit' => 'حفظ التغييرات',
|
||||
'submitting' => 'جارٍ الحفظ...',
|
||||
],
|
||||
|
||||
'delete' => [
|
||||
'title' => 'حذف العلامة التجارية',
|
||||
'description' => 'هل أنت متأكد من رغبتك في حذف هذه العلامة التجارية؟ سيتم إلغاء تعيين الحسابات الاجتماعية دون حذفها.',
|
||||
'confirm' => 'حذف',
|
||||
'cancel' => 'إلغاء',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'created' => 'تم إنشاء العلامة التجارية بنجاح!',
|
||||
'updated' => 'تم تحديث العلامة التجارية بنجاح!',
|
||||
'deleted' => 'تم حذف العلامة التجارية بنجاح!',
|
||||
],
|
||||
];
|
||||
12
lang/ar/calendar.php
Normal file
12
lang/ar/calendar.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'التقويم',
|
||||
'today' => 'اليوم',
|
||||
'day' => 'يوم',
|
||||
'week' => 'أسبوع',
|
||||
'month' => 'شهر',
|
||||
'new_post' => 'منشور جديد',
|
||||
'no_content' => 'لا يوجد محتوى',
|
||||
'more' => '+:count المزيد',
|
||||
];
|
||||
18
lang/ar/comments.php
Normal file
18
lang/ar/comments.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'placeholder' => 'اكتب تعليقًا...',
|
||||
'reply_placeholder' => 'اكتب ردًا...',
|
||||
'reply' => 'رد',
|
||||
'edit' => 'تعديل',
|
||||
'delete' => 'حذف',
|
||||
'edited' => 'تم التعديل',
|
||||
'save' => 'حفظ',
|
||||
'cancel' => 'إلغاء',
|
||||
'send' => 'إرسال',
|
||||
'replying_to' => 'رد على :name',
|
||||
'empty' => 'لا توجد تعليقات بعد. ابدأ المحادثة.',
|
||||
'load_more' => 'تحميل التعليقات الأقدم',
|
||||
'today' => 'اليوم',
|
||||
'yesterday' => 'أمس',
|
||||
];
|
||||
67
lang/ar/common.php
Normal file
67
lang/ar/common.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'back' => 'رجوع',
|
||||
|
||||
'beta' => 'تجريبي',
|
||||
|
||||
'confirm_modal' => [
|
||||
'cannot_be_undone' => 'لا يمكن التراجع عن هذا الإجراء.',
|
||||
'type' => 'اكتب',
|
||||
'to_confirm' => 'للتأكيد.',
|
||||
'copy_to_clipboard' => 'نسخ إلى الحافظة',
|
||||
'delete_keyword' => 'حذف',
|
||||
],
|
||||
|
||||
'photo_upload' => [
|
||||
'upload' => 'رفع',
|
||||
'uploading' => 'جارٍ الرفع...',
|
||||
'remove' => 'إزالة الصورة',
|
||||
'hint' => 'موصى به: صورة مربعة، بحد أقصى 2 ميغابايت.',
|
||||
'crop_title' => 'قص الصورة',
|
||||
'crop_description' => 'حدّد المنطقة التي تريد الاحتفاظ بها.',
|
||||
'crop_hint' => 'اسحب للتحريك، أو زاوية لتغيير الحجم.',
|
||||
'crop_save' => 'حفظ',
|
||||
'crop_cancel' => 'إلغاء',
|
||||
'crop_error' => 'تعذّر تحميل هذه الصورة. جرّب ملفًا آخر.',
|
||||
],
|
||||
|
||||
'timezone' => [
|
||||
'select' => 'اختر المنطقة الزمنية',
|
||||
'search' => 'البحث عن منطقة زمنية...',
|
||||
'empty' => 'لم يتم العثور على منطقة زمنية',
|
||||
],
|
||||
|
||||
'date_picker' => [
|
||||
'select' => 'اختر التاريخ',
|
||||
],
|
||||
|
||||
'date_range_picker' => [
|
||||
'placeholder' => 'اختر نطاقًا زمنيًا',
|
||||
'today' => 'اليوم',
|
||||
'yesterday' => 'أمس',
|
||||
'last_7_days' => 'آخر 7 أيام',
|
||||
'last_30_days' => 'آخر 30 يومًا',
|
||||
'last_3_months' => 'آخر 3 أشهر',
|
||||
'last_6_months' => 'آخر 6 أشهر',
|
||||
'last_12_months' => 'آخر 12 شهرًا',
|
||||
'this_month' => 'هذا الشهر',
|
||||
'last_month' => 'الشهر الماضي',
|
||||
'year_to_date' => 'منذ بداية العام',
|
||||
'last_year' => 'العام الماضي',
|
||||
],
|
||||
|
||||
'cancel' => 'إلغاء',
|
||||
'clear' => 'مسح',
|
||||
'close' => 'إغلاق',
|
||||
'loading_more' => 'جارٍ تحميل المزيد...',
|
||||
|
||||
'actions' => [
|
||||
'copy' => 'نسخ',
|
||||
'copied' => 'تم النسخ',
|
||||
'copy_failed' => 'فشل النسخ إلى الحافظة',
|
||||
],
|
||||
];
|
||||
54
lang/ar/labels.php
Normal file
54
lang/ar/labels.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'التسميات',
|
||||
'description' => 'أنشئ تسميات لتنظيم منشوراتك وتصنيفها',
|
||||
'search' => 'البحث في التسميات...',
|
||||
'new_label' => 'تسمية جديدة',
|
||||
'no_labels_yet' => 'لا توجد تسميات بعد',
|
||||
'no_search_results' => 'لا توجد تسميات مطابقة لبحثك',
|
||||
'try_different_search' => 'جرّب كلمة مختلفة أو امسح البحث.',
|
||||
'create_first_label' => 'أنشئ أول تسمية لك',
|
||||
'table' => [
|
||||
'name' => 'الاسم',
|
||||
'created_at' => 'تاريخ الإنشاء',
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
'edit' => 'تعديل التسمية',
|
||||
'delete' => 'حذف التسمية',
|
||||
],
|
||||
|
||||
'create' => [
|
||||
'title' => 'إنشاء تسمية',
|
||||
'description' => 'امنح تسميتك اسمًا واختر لونًا',
|
||||
'name' => 'الاسم',
|
||||
'name_placeholder' => 'أدخل اسم التسمية...',
|
||||
'color' => 'اللون',
|
||||
'submit' => 'إنشاء تسمية',
|
||||
'submitting' => 'جارٍ الإنشاء...',
|
||||
],
|
||||
|
||||
'edit' => [
|
||||
'title' => 'تعديل التسمية',
|
||||
'description' => 'تحديث اسم ولون هذه التسمية',
|
||||
'name' => 'الاسم',
|
||||
'name_placeholder' => 'أدخل اسم التسمية...',
|
||||
'color' => 'اللون',
|
||||
'submit' => 'حفظ التغييرات',
|
||||
'submitting' => 'جارٍ الحفظ...',
|
||||
],
|
||||
|
||||
'delete' => [
|
||||
'title' => 'حذف التسمية',
|
||||
'description' => 'هل أنت متأكد من رغبتك في حذف هذه التسمية؟ لا يمكن التراجع عن هذا الإجراء.',
|
||||
'confirm' => 'حذف',
|
||||
'cancel' => 'إلغاء',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'created' => 'تم إنشاء التسمية بنجاح!',
|
||||
'updated' => 'تم تحديث التسمية بنجاح!',
|
||||
'deleted' => 'تم حذف التسمية بنجاح!',
|
||||
],
|
||||
];
|
||||
22
lang/ar/mail.php
Normal file
22
lang/ar/mail.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'mentioned' => [
|
||||
'subject' => 'أشار إليك :name على TryPost',
|
||||
'title' => 'أشار إليك :name',
|
||||
'intro' => 'أشار إليك :name في تعليق على منشور.',
|
||||
'cta' => 'عرض التعليق',
|
||||
],
|
||||
|
||||
'workspace_connections_disconnected' => [
|
||||
'subject' => '{1} حساب واحد يحتاج إلى إعادة الربط في :workspace|{2} حسابان يحتاجان إلى إعادة الربط في :workspace|[3,10] :count حسابات تحتاج إلى إعادة الربط في :workspace|[11,*] :count حسابًا يحتاج إلى إعادة الربط في :workspace',
|
||||
'title' => 'حسابات تحتاج إلى إعادة الربط',
|
||||
'intro' => 'تم فصل الحسابات الاجتماعية التالية في مساحة العمل <strong>:workspace</strong> وتحتاج إلى إعادة الربط:',
|
||||
'reasons_title' => 'قد يكون هذا قد حدث بسبب:',
|
||||
'reason_expired' => 'انتهاء صلاحية رموز الوصول',
|
||||
'reason_revoked' => 'قيامك بإلغاء وصول TryPost على المنصة',
|
||||
'reason_changed' => 'قيام المنصة بتغيير متطلبات المصادقة الخاصة بها',
|
||||
'reconnect_cta' => 'يرجى إعادة ربط هذه الحسابات لمواصلة جدولة المنشورات ونشرها.',
|
||||
'button' => 'إعادة ربط الحسابات',
|
||||
],
|
||||
];
|
||||
18
lang/ar/notifications.php
Normal file
18
lang/ar/notifications.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'post_ready' => [
|
||||
'title' => 'منشورك جاهز',
|
||||
'body' => 'أنهى الذكاء الاصطناعي عمله للتو. انقر للمراجعة والنشر.',
|
||||
],
|
||||
'account_disconnected' => [
|
||||
'title' => 'تم فصل حساب :platform',
|
||||
'body' => 'يحتاج :account إلى إعادة الربط',
|
||||
],
|
||||
'account_token_expired' => [
|
||||
'title' => 'يحتاج حساب :platform إلى إعادة الربط',
|
||||
'body' => 'انتهت جلسة :account — يرجى إعادة الربط لمواصلة النشر',
|
||||
],
|
||||
];
|
||||
41
lang/ar/onboarding.php
Normal file
41
lang/ar/onboarding.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'title' => 'مرحبًا بك في TryPost',
|
||||
'description' => 'أخبرنا بما يصفك أنت أو نشاطك التجاري على أفضل وجه حتى نتمكن من تخصيص تجربتك.',
|
||||
'continue' => 'متابعة',
|
||||
'personas' => [
|
||||
'creator' => 'صانع محتوى',
|
||||
'freelancer' => 'مستقل',
|
||||
'developer' => 'مطوّر',
|
||||
'startup' => 'شركة ناشئة',
|
||||
'agency' => 'وكالة',
|
||||
'small_business' => 'نشاط تجاري صغير',
|
||||
'marketer' => 'مسوّق',
|
||||
'online_store' => 'متجر إلكتروني',
|
||||
'other' => 'أخرى',
|
||||
],
|
||||
'goals_title' => 'ما هدفك من استخدام TryPost؟',
|
||||
'goals_description' => 'اختر كل ما يناسبك وسنقوم بإعداد TryPost من أجلك.',
|
||||
'goals' => [
|
||||
'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة',
|
||||
'ai_content' => 'إنشاء منشورات أسرع بالذكاء الاصطناعي',
|
||||
'plan_calendar' => 'التخطيط لمنشوراتي على التقويم',
|
||||
'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية',
|
||||
'grow_audience' => 'تنمية جمهوري وزيادة التفاعل',
|
||||
'drive_sales' => 'الحصول على المزيد من الزيارات والمبيعات',
|
||||
'manage_clients' => 'إدارة عدة علامات تجارية أو عملاء',
|
||||
'team_collaboration' => 'العمل مع فريقي',
|
||||
'automate_api' => 'أتمتة النشر عبر الواجهة البرمجية أو MCP أو الكود',
|
||||
'track_performance' => 'معرفة أداء منشوراتي',
|
||||
'just_exploring' => 'مجرد استكشاف في الوقت الحالي',
|
||||
'other' => 'شيء آخر',
|
||||
],
|
||||
'connect' => [
|
||||
'title' => 'اربط شبكتك الأولى',
|
||||
'description' => 'اربط حسابًا اجتماعيًا واحدًا على الأقل لبدء الجدولة. يمكنك إضافة المزيد في أي وقت.',
|
||||
'must_connect' => 'اربط شبكة واحدة على الأقل للمتابعة.',
|
||||
],
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue