Compare commits
9 commits
cf2fa647f5
...
ec09711a6f
| Author | SHA1 | Date | |
|---|---|---|---|
| ec09711a6f | |||
| bf96489362 | |||
| 0bb10729de | |||
| 19cbea9273 | |||
| a448c54e73 | |||
| 675802e8e5 | |||
| 6784af2ff6 | |||
| 56db303b15 | |||
| 0b2fc5004b |
176 changed files with 1989 additions and 1437 deletions
113
.forgejo/workflows/ci.yml
Normal file
113
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['release/*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Set up PHP
|
||||
uses: https://github.com/shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: pdo_sqlite, mbstring, xml, dom
|
||||
coverage: pcov
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: https://data.forgejo.org/actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache
|
||||
key: composer-${{ hashFiles('composer.lock') }}
|
||||
restore-keys: composer-
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
|
||||
- name: Prepare environment
|
||||
run: cp .env.testing .env
|
||||
|
||||
- name: Lint
|
||||
run: vendor/bin/pint --test
|
||||
|
||||
- name: Static analysis
|
||||
run: vendor/bin/phpstan analyse
|
||||
|
||||
- name: Tests
|
||||
run: php artisan test --coverage-clover coverage.xml --coverage-text
|
||||
|
||||
- name: Parse coverage
|
||||
if: github.event_name == 'pull_request'
|
||||
id: coverage
|
||||
run: |
|
||||
COVERAGE=$(php -r '
|
||||
$xml = simplexml_load_file("coverage.xml");
|
||||
if ($xml === false || !isset($xml->project->metrics)) {
|
||||
echo "0";
|
||||
exit;
|
||||
}
|
||||
$metrics = $xml->project->metrics;
|
||||
$statements = (int) $metrics["statements"];
|
||||
$covered = (int) $metrics["coveredstatements"];
|
||||
echo $statements > 0 ? round(($covered / $statements) * 100, 2) : 0;
|
||||
')
|
||||
echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment coverage on PR
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
COVERAGE: ${{ steps.coverage.outputs.percentage }}
|
||||
REPO: ${{ github.repository }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
API_URL="${SERVER_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments"
|
||||
MARKER="<!-- ffr-ci-coverage-report -->"
|
||||
|
||||
BODY="${MARKER}
|
||||
## Code Coverage Report
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Line Coverage** | ${COVERAGE}% |
|
||||
|
||||
_Updated by CI — commit ${COMMIT_SHA}_"
|
||||
|
||||
# Find existing coverage comment
|
||||
EXISTING=$(curl -sf -H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${API_URL}?limit=50" | \
|
||||
php -r '
|
||||
$comments = json_decode(file_get_contents("php://stdin"), true);
|
||||
if (!is_array($comments)) exit;
|
||||
foreach ($comments as $c) {
|
||||
if (str_contains($c["body"], "<!-- ffr-ci-coverage-report -->")) {
|
||||
echo $c["id"];
|
||||
exit;
|
||||
}
|
||||
}
|
||||
' || true)
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
# Update existing comment
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
|
||||
"${SERVER_URL}/api/v1/repos/${REPO}/issues/comments/${EXISTING}" > /dev/null
|
||||
else
|
||||
# Create new comment
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
|
||||
"${API_URL}" > /dev/null
|
||||
fi
|
||||
|
|
@ -15,7 +15,7 @@ public function execute(string $name, string $provider, int $languageId, ?string
|
|||
|
||||
$url = config("feed.providers.{$provider}.languages.{$langCode}.url");
|
||||
|
||||
if (!$url) {
|
||||
if (! $url) {
|
||||
throw new InvalidArgumentException("Invalid provider and language combination: {$provider}/{$langCode}");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public function __construct(
|
|||
*/
|
||||
public function execute(string $instanceDomain, string $username, string $password, string $platform = 'lemmy'): PlatformAccount
|
||||
{
|
||||
$fullInstanceUrl = 'https://' . $instanceDomain;
|
||||
$fullInstanceUrl = 'https://'.$instanceDomain;
|
||||
|
||||
// Authenticate first — if this fails, no records are created
|
||||
$authResponse = $this->lemmyAuthService->authenticate($fullInstanceUrl, $username, $password);
|
||||
|
|
|
|||
|
|
@ -15,13 +15,13 @@ class FetchNewArticlesCommand extends Command
|
|||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!Setting::isArticleProcessingEnabled()) {
|
||||
if (! Setting::isArticleProcessingEnabled()) {
|
||||
$this->info('Article processing is disabled. Article discovery skipped.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (!Feed::where('is_active', true)->exists()) {
|
||||
if (! Feed::where('is_active', true)->exists()) {
|
||||
$this->info('No active feeds found. Article discovery skipped.');
|
||||
|
||||
return self::SUCCESS;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ public function canParse(string $url): bool;
|
|||
|
||||
/**
|
||||
* Extract article data from HTML
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function extractData(string $html): array;
|
||||
|
|
@ -19,4 +20,4 @@ public function extractData(string $html): array;
|
|||
* Get the source name for this parser
|
||||
*/
|
||||
public function getSourceName(): string;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ public function canParse(string $url): bool;
|
|||
|
||||
/**
|
||||
* Extract article URLs from homepage HTML
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function extractArticleUrls(string $html): array;
|
||||
|
|
@ -24,4 +25,4 @@ public function getHomepageUrl(): string;
|
|||
* Get the source name for this parser
|
||||
*/
|
||||
public function getSourceName(): string;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,4 +5,18 @@
|
|||
enum PlatformEnum: string
|
||||
{
|
||||
case LEMMY = 'lemmy';
|
||||
}
|
||||
|
||||
public function channelLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::LEMMY => 'Community',
|
||||
};
|
||||
}
|
||||
|
||||
public function channelLabelPlural(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::LEMMY => 'Communities',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
app/Events/ActionPerformed.php
Normal file
18
app/Events/ActionPerformed.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class ActionPerformed
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public string $message,
|
||||
public LogLevelEnum $level = LogLevelEnum::INFO,
|
||||
/** @var array<string, mixed> */
|
||||
public array $context = [],
|
||||
) {}
|
||||
}
|
||||
|
|
@ -12,6 +12,5 @@ class ExceptionLogged
|
|||
|
||||
public function __construct(
|
||||
public Log $log
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,5 @@ public function __construct(
|
|||
public string $message,
|
||||
/** @var array<string, mixed> */
|
||||
public array $context = []
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,5 @@ class NewArticleFetched
|
|||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(public Article $article)
|
||||
{
|
||||
}
|
||||
public function __construct(public Article $article) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,4 @@
|
|||
|
||||
use Exception;
|
||||
|
||||
class ChannelException extends Exception
|
||||
{
|
||||
}
|
||||
class ChannelException extends Exception {}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class PublishException extends Exception
|
|||
{
|
||||
public function __construct(
|
||||
private readonly Article $article,
|
||||
private readonly PlatformEnum|null $platform,
|
||||
private readonly ?PlatformEnum $platform,
|
||||
?Throwable $previous = null
|
||||
) {
|
||||
$message = "Failed to publish article #$article->id";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,4 @@
|
|||
|
||||
use Exception;
|
||||
|
||||
class RoutingException extends Exception
|
||||
{
|
||||
}
|
||||
class RoutingException extends Exception {}
|
||||
|
|
|
|||
|
|
@ -10,4 +10,4 @@ protected static function getFacadeAccessor()
|
|||
{
|
||||
return \App\Services\Log\LogSaver::class;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@
|
|||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Resources\ArticleResource;
|
||||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use App\Models\Article;
|
||||
use App\Models\Setting;
|
||||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class ArticlesController extends BaseController
|
||||
{
|
||||
|
|
@ -54,7 +53,7 @@ public function approve(Article $article): JsonResponse
|
|||
'Article approved and queued for publishing.'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to approve article: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to approve article: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +70,7 @@ public function reject(Article $article): JsonResponse
|
|||
'Article rejected.'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to reject article: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to reject article: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +87,7 @@ public function refresh(): JsonResponse
|
|||
'Article refresh started. New articles will appear shortly.'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to start article refresh: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to start article refresh: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
|
|
@ -24,7 +23,7 @@ public function login(Request $request): JsonResponse
|
|||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||
if (! $user || ! Hash::check($request->password, $user->password)) {
|
||||
return $this->sendError('Invalid credentials', [], 401);
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +41,7 @@ public function login(Request $request): JsonResponse
|
|||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Login failed: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Login failed: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +77,7 @@ public function register(Request $request): JsonResponse
|
|||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Registration failed: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Registration failed: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +91,7 @@ public function logout(Request $request): JsonResponse
|
|||
|
||||
return $this->sendResponse(null, 'Logged out successfully');
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Logout failed: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Logout failed: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,4 +108,4 @@ public function me(Request $request): JsonResponse
|
|||
],
|
||||
], 'User retrieved successfully');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ public function sendResponse(mixed $result, string $message = 'Success', int $co
|
|||
|
||||
/**
|
||||
* Error response method
|
||||
*
|
||||
* @param array<string, mixed> $errorMessages
|
||||
*/
|
||||
public function sendError(string $error, array $errorMessages = [], int $code = 400): JsonResponse
|
||||
{
|
||||
|
|
@ -31,7 +33,7 @@ public function sendError(string $error, array $errorMessages = [], int $code =
|
|||
'message' => $error,
|
||||
];
|
||||
|
||||
if (!empty($errorMessages)) {
|
||||
if (! empty($errorMessages)) {
|
||||
$response['errors'] = $errorMessages;
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +42,8 @@ public function sendError(string $error, array $errorMessages = [], int $code =
|
|||
|
||||
/**
|
||||
* Validation error response method
|
||||
*
|
||||
* @param array<string, mixed> $errors
|
||||
*/
|
||||
public function sendValidationError(array $errors): JsonResponse
|
||||
{
|
||||
|
|
@ -61,4 +65,4 @@ public function sendUnauthorized(string $message = 'Unauthorized'): JsonResponse
|
|||
{
|
||||
return $this->sendError($message, [], 401);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@
|
|||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Services\DashboardStatsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -40,8 +37,7 @@ public function stats(Request $request): JsonResponse
|
|||
'current_period' => $period,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
throw $e;
|
||||
return $this->sendError('Failed to fetch dashboard stats: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to fetch dashboard stats: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use InvalidArgumentException;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class FeedsController extends BaseController
|
||||
{
|
||||
|
|
@ -21,7 +21,7 @@ class FeedsController extends BaseController
|
|||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min($request->get('per_page', 15), 100);
|
||||
|
||||
|
||||
$feeds = Feed::with(['language'])
|
||||
->withCount('articles')
|
||||
->orderBy('is_active', 'desc')
|
||||
|
|
@ -37,7 +37,7 @@ public function index(Request $request): JsonResponse
|
|||
'total' => $feeds->total(),
|
||||
'from' => $feeds->firstItem(),
|
||||
'to' => $feeds->lastItem(),
|
||||
]
|
||||
],
|
||||
], 'Feeds retrieved successfully.');
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ public function store(StoreFeedRequest $request, CreateFeedAction $createFeedAct
|
|||
} catch (InvalidArgumentException $e) {
|
||||
return $this->sendError($e->getMessage(), [], 422);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to create feed: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to create feed: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ public function update(UpdateFeedRequest $request, Feed $feed): JsonResponse
|
|||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to update feed: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to update feed: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +114,7 @@ public function destroy(Feed $feed): JsonResponse
|
|||
'Feed deleted successfully!'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to delete feed: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to delete feed: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ public function destroy(Feed $feed): JsonResponse
|
|||
public function toggle(Feed $feed): JsonResponse
|
||||
{
|
||||
try {
|
||||
$newStatus = !$feed->is_active;
|
||||
$newStatus = ! $feed->is_active;
|
||||
$feed->update(['is_active' => $newStatus]);
|
||||
|
||||
$status = $newStatus ? 'activated' : 'deactivated';
|
||||
|
|
@ -134,7 +134,7 @@ public function toggle(Feed $feed): JsonResponse
|
|||
"Feed {$status} successfully!"
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to toggle feed status: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to toggle feed status: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ public function store(Request $request, Feed $feed, PlatformChannel $channel): J
|
|||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to create keyword: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to create keyword: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel, K
|
|||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to update keyword: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to update keyword: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ public function destroy(Feed $feed, PlatformChannel $channel, Keyword $keyword):
|
|||
'Keyword deleted successfully!'
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to delete keyword: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to delete keyword: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ public function toggle(Feed $feed, PlatformChannel $channel, Keyword $keyword):
|
|||
return $this->sendNotFound('Keyword not found for this route.');
|
||||
}
|
||||
|
||||
$newStatus = !$keyword->is_active;
|
||||
$newStatus = ! $keyword->is_active;
|
||||
$keyword->update(['is_active' => $newStatus]);
|
||||
|
||||
$status = $newStatus ? 'activated' : 'deactivated';
|
||||
|
|
@ -137,7 +137,7 @@ public function toggle(Feed $feed, PlatformChannel $channel, Keyword $keyword):
|
|||
"Keyword {$status} successfully!"
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to toggle keyword status: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to toggle keyword status: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ public function index(Request $request): JsonResponse
|
|||
],
|
||||
], 'Logs retrieved successfully.');
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to retrieve logs: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to retrieve logs: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,18 +31,18 @@ public function status(): JsonResponse
|
|||
// 1. They haven't completed or skipped onboarding AND
|
||||
// 2. They don't have all required components
|
||||
$hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute;
|
||||
$needsOnboarding = !$onboardingCompleted && !$onboardingSkipped && !$hasAllComponents;
|
||||
$needsOnboarding = ! $onboardingCompleted && ! $onboardingSkipped && ! $hasAllComponents;
|
||||
|
||||
// Determine current step
|
||||
$currentStep = null;
|
||||
if ($needsOnboarding) {
|
||||
if (!$hasPlatformAccount) {
|
||||
if (! $hasPlatformAccount) {
|
||||
$currentStep = 'platform';
|
||||
} elseif (!$hasFeed) {
|
||||
} elseif (! $hasFeed) {
|
||||
$currentStep = 'feed';
|
||||
} elseif (!$hasChannel) {
|
||||
} elseif (! $hasChannel) {
|
||||
$currentStep = 'channel';
|
||||
} elseif (!$hasRoute) {
|
||||
} elseif (! $hasRoute) {
|
||||
$currentStep = 'route';
|
||||
}
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ public function status(): JsonResponse
|
|||
'has_route' => $hasRoute,
|
||||
'onboarding_skipped' => $onboardingSkipped,
|
||||
'onboarding_completed' => $onboardingCompleted,
|
||||
'missing_components' => !$hasAllComponents && $onboardingCompleted,
|
||||
'missing_components' => ! $hasAllComponents && $onboardingCompleted,
|
||||
], 'Onboarding status retrieved successfully.');
|
||||
}
|
||||
|
||||
|
|
@ -84,8 +84,10 @@ public function options(): JsonResponse
|
|||
->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
|
||||
|
||||
// Get feed providers from config
|
||||
$feedProviders = collect(config('feed.providers', []))
|
||||
->filter(fn($provider) => $provider['is_active'])
|
||||
/** @var array<string, array<string, mixed>> $providers */
|
||||
$providers = config('feed.providers', []);
|
||||
$feedProviders = collect($providers)
|
||||
->filter(fn (array $provider) => $provider['is_active'])
|
||||
->values();
|
||||
|
||||
return $this->sendResponse([
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ public function store(StorePlatformAccountRequest $request, CreatePlatformAccoun
|
|||
if (str_contains($e->getMessage(), 'Rate limited by')) {
|
||||
return $this->sendError($e->getMessage(), [], 429);
|
||||
}
|
||||
|
||||
return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Unable to connect to the Lemmy instance. Please check the URL and try again.', [], 422);
|
||||
|
|
@ -97,7 +98,7 @@ public function update(Request $request, PlatformAccount $platformAccount): Json
|
|||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to update platform account: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to update platform account: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +115,7 @@ public function destroy(PlatformAccount $platformAccount): JsonResponse
|
|||
'Platform account deleted successfully!'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to delete platform account: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to delete platform account: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +132,7 @@ public function setActive(PlatformAccount $platformAccount): JsonResponse
|
|||
"Set {$platformAccount->username}@{$platformAccount->instance_url} as active for {$platformAccount->platform->value}!"
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to set platform account as active: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to set platform account as active: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
use App\Actions\CreateChannelAction;
|
||||
use App\Http\Requests\StorePlatformChannelRequest;
|
||||
use App\Http\Resources\PlatformChannelResource;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -54,7 +54,7 @@ public function store(StorePlatformChannelRequest $request, CreateChannelAction
|
|||
} catch (RuntimeException $e) {
|
||||
return $this->sendError($e->getMessage(), [], 422);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to create platform channel: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to create platform channel: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ public function update(Request $request, PlatformChannel $platformChannel): Json
|
|||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to update platform channel: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to update platform channel: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +108,7 @@ public function destroy(PlatformChannel $platformChannel): JsonResponse
|
|||
'Platform channel deleted successfully!'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to delete platform channel: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to delete platform channel: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ public function destroy(PlatformChannel $platformChannel): JsonResponse
|
|||
public function toggle(PlatformChannel $channel): JsonResponse
|
||||
{
|
||||
try {
|
||||
$newStatus = !$channel->is_active;
|
||||
$newStatus = ! $channel->is_active;
|
||||
$channel->update(['is_active' => $newStatus]);
|
||||
|
||||
$status = $newStatus ? 'activated' : 'deactivated';
|
||||
|
|
@ -128,7 +128,7 @@ public function toggle(PlatformChannel $channel): JsonResponse
|
|||
"Platform channel {$status} successfully!"
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to toggle platform channel status: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to toggle platform channel status: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +144,7 @@ public function attachAccount(PlatformChannel $channel, Request $request): JsonR
|
|||
'priority' => 'nullable|integer|min:1|max:100',
|
||||
]);
|
||||
|
||||
/** @var PlatformAccount $platformAccount */
|
||||
$platformAccount = PlatformAccount::findOrFail($validated['platform_account_id']);
|
||||
|
||||
// Check if account is already attached
|
||||
|
|
@ -165,7 +166,7 @@ public function attachAccount(PlatformChannel $channel, Request $request): JsonR
|
|||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to attach platform account: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to attach platform account: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +176,7 @@ public function attachAccount(PlatformChannel $channel, Request $request): JsonR
|
|||
public function detachAccount(PlatformChannel $channel, PlatformAccount $account): JsonResponse
|
||||
{
|
||||
try {
|
||||
if (!$channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) {
|
||||
if (! $channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) {
|
||||
return $this->sendError('Platform account is not attached to this channel.', [], 422);
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +187,7 @@ public function detachAccount(PlatformChannel $channel, PlatformAccount $account
|
|||
'Platform account detached from channel successfully!'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to detach platform account: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to detach platform account: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +202,7 @@ public function updateAccountRelation(PlatformChannel $channel, PlatformAccount
|
|||
'priority' => 'nullable|integer|min:1|max:100',
|
||||
]);
|
||||
|
||||
if (!$channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) {
|
||||
if (! $channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) {
|
||||
return $this->sendError('Platform account is not attached to this channel.', [], 422);
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +219,7 @@ public function updateAccountRelation(PlatformChannel $channel, PlatformAccount
|
|||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to update platform account relationship: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to update platform account relationship: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ public function store(StoreRouteRequest $request, CreateRouteAction $createRoute
|
|||
201
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to create routing configuration: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to create routing configuration: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,11 +62,11 @@ public function store(StoreRouteRequest $request, CreateRouteAction $createRoute
|
|||
public function show(Feed $feed, PlatformChannel $channel): JsonResponse
|
||||
{
|
||||
$route = $this->findRoute($feed, $channel);
|
||||
|
||||
if (!$route) {
|
||||
|
||||
if (! $route) {
|
||||
return $this->sendNotFound('Routing configuration not found.');
|
||||
}
|
||||
|
||||
|
||||
$route->load(['feed', 'platformChannel', 'keywords']);
|
||||
|
||||
return $this->sendResponse(
|
||||
|
|
@ -83,7 +83,7 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel):
|
|||
try {
|
||||
$route = $this->findRoute($feed, $channel);
|
||||
|
||||
if (!$route) {
|
||||
if (! $route) {
|
||||
return $this->sendNotFound('Routing configuration not found.');
|
||||
}
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel):
|
|||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to update routing configuration: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to update routing configuration: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ public function destroy(Feed $feed, PlatformChannel $channel): JsonResponse
|
|||
try {
|
||||
$route = $this->findRoute($feed, $channel);
|
||||
|
||||
if (!$route) {
|
||||
if (! $route) {
|
||||
return $this->sendNotFound('Routing configuration not found.');
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ public function destroy(Feed $feed, PlatformChannel $channel): JsonResponse
|
|||
'Routing configuration deleted successfully!'
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to delete routing configuration: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to delete routing configuration: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -140,11 +140,11 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
|
|||
try {
|
||||
$route = $this->findRoute($feed, $channel);
|
||||
|
||||
if (!$route) {
|
||||
if (! $route) {
|
||||
return $this->sendNotFound('Routing configuration not found.');
|
||||
}
|
||||
|
||||
$newStatus = !$route->is_active;
|
||||
$newStatus = ! $route->is_active;
|
||||
Route::where('feed_id', $feed->id)
|
||||
->where('platform_channel_id', $channel->id)
|
||||
->update(['is_active' => $newStatus]);
|
||||
|
|
@ -156,7 +156,7 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
|
|||
"Routing configuration {$status} successfully!"
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return $this->sendError('Failed to toggle routing configuration status: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to toggle routing configuration status: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -169,4 +169,4 @@ private function findRoute(Feed $feed, PlatformChannel $channel): ?Route
|
|||
->where('platform_channel_id', $channel->id)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ public function index(): JsonResponse
|
|||
|
||||
return $this->sendResponse($settings, 'Settings retrieved successfully.');
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to retrieve settings: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to retrieve settings: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ public function update(Request $request): JsonResponse
|
|||
} catch (ValidationException $e) {
|
||||
return $this->sendValidationError($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500);
|
||||
return $this->sendError('Failed to update settings: '.$e->getMessage(), [], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
|
|
@ -19,7 +20,9 @@ public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
|||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
/** @var MustVerifyEmail $user */
|
||||
$user = $request->user();
|
||||
event(new Verified($user));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
|
|
|
|||
|
|
@ -26,4 +26,4 @@ public function handle(Request $request, Closure $next): Response
|
|||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,4 +39,4 @@ public function share(Request $request): array
|
|||
//
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@ public function __construct(
|
|||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!$this->onboardingService->needsOnboarding()) {
|
||||
if (! $this->onboardingService->needsOnboarding()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ public function rules(): array
|
|||
'provider' => "required|in:{$providers}",
|
||||
'language_id' => 'required|exists:languages,id',
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean'
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ public function rules(): array
|
|||
{
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'url' => 'required|url|unique:feeds,url,' . ($this->route('feed') instanceof Feed ? (string)$this->route('feed')->id : (string)$this->route('feed')),
|
||||
'url' => 'required|url|unique:feeds,url,'.($this->route('feed') instanceof Feed ? (string) $this->route('feed')->id : (string) $this->route('feed')),
|
||||
'type' => 'required|in:website,rss',
|
||||
'language_id' => 'required|exists:languages,id',
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean'
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\ArticlePublication
|
||||
*/
|
||||
class ArticlePublicationResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
|
|
@ -17,10 +20,10 @@ public function toArray(Request $request): array
|
|||
return [
|
||||
'id' => $this->id,
|
||||
'article_id' => $this->article_id,
|
||||
'status' => $this->status,
|
||||
'published_at' => $this->published_at?->toISOString(),
|
||||
'platform' => $this->platform,
|
||||
'published_at' => $this->published_at->toISOString(),
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
'updated_at' => $this->updated_at->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@
|
|||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @mixin \App\Models\Article
|
||||
*/
|
||||
class ArticleResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -19,12 +22,8 @@ public function toArray(Request $request): array
|
|||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'is_valid' => $this->is_valid,
|
||||
'is_duplicate' => $this->is_duplicate,
|
||||
'approval_status' => $this->approval_status,
|
||||
'publish_status' => $this->publish_status,
|
||||
'approved_at' => $this->approved_at?->toISOString(),
|
||||
'approved_by' => $this->approved_by,
|
||||
'fetched_at' => $this->fetched_at?->toISOString(),
|
||||
'validated_at' => $this->validated_at?->toISOString(),
|
||||
'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null,
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\Feed
|
||||
*/
|
||||
class FeedResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
|
|
@ -26,9 +29,9 @@ public function toArray(Request $request): array
|
|||
'created_at' => $this->created_at->toISOString(),
|
||||
'updated_at' => $this->updated_at->toISOString(),
|
||||
'articles_count' => $this->when(
|
||||
$request->routeIs('api.feeds.*') && isset($this->articles_count),
|
||||
$request->routeIs('api.feeds.*') && isset($this->articles_count),
|
||||
$this->articles_count
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\PlatformAccount
|
||||
*/
|
||||
/**
|
||||
* @mixin \App\Models\PlatformAccount
|
||||
*/
|
||||
class PlatformAccountResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
|
|
@ -28,4 +34,4 @@ public function toArray(Request $request): array
|
|||
'channels' => PlatformChannelResource::collection($this->whenLoaded('channels')),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\PlatformChannel
|
||||
*/
|
||||
class PlatformChannelResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
|
|
@ -30,4 +33,4 @@ public function toArray(Request $request): array
|
|||
'routes' => RouteResource::collection($this->whenLoaded('routes')),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\PlatformInstance
|
||||
*/
|
||||
class PlatformInstanceResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
|
|
@ -24,4 +27,4 @@ public function toArray(Request $request): array
|
|||
'updated_at' => $this->updated_at->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Models\Route
|
||||
*/
|
||||
class RouteResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
|
|
@ -15,7 +18,6 @@ class RouteResource extends JsonResource
|
|||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'feed_id' => $this->feed_id,
|
||||
'platform_channel_id' => $this->platform_channel_id,
|
||||
'is_active' => $this->is_active,
|
||||
|
|
@ -35,4 +37,4 @@ public function toArray(Request $request): array
|
|||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void
|
|||
$logSaver->info('Starting feed article fetch', null, [
|
||||
'feed_id' => $this->feed->id,
|
||||
'feed_name' => $this->feed->name,
|
||||
'feed_url' => $this->feed->url
|
||||
'feed_url' => $this->feed->url,
|
||||
]);
|
||||
|
||||
$articles = $articleFetcher->getArticlesFromFeed($this->feed);
|
||||
|
|
@ -33,7 +33,7 @@ public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void
|
|||
$logSaver->info('Feed article fetch completed', null, [
|
||||
'feed_id' => $this->feed->id,
|
||||
'feed_name' => $this->feed->name,
|
||||
'articles_count' => $articles->count()
|
||||
'articles_count' => $articles->count(),
|
||||
]);
|
||||
|
||||
$this->feed->update(['last_fetched_at' => now()]);
|
||||
|
|
@ -42,7 +42,7 @@ public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void
|
|||
public static function dispatchForAllActiveFeeds(): void
|
||||
{
|
||||
$logSaver = app(LogSaver::class);
|
||||
|
||||
|
||||
Feed::where('is_active', true)
|
||||
->get()
|
||||
->each(function (Feed $feed, $index) use ($logSaver) {
|
||||
|
|
@ -56,7 +56,7 @@ public static function dispatchForAllActiveFeeds(): void
|
|||
$logSaver->info('Dispatched feed discovery job', null, [
|
||||
'feed_id' => $feed->id,
|
||||
'feed_name' => $feed->name,
|
||||
'delay_minutes' => $delayMinutes
|
||||
'delay_minutes' => $delayMinutes,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ public function __construct()
|
|||
|
||||
public function handle(LogSaver $logSaver): void
|
||||
{
|
||||
if (!Setting::isArticleProcessingEnabled()) {
|
||||
if (! Setting::isArticleProcessingEnabled()) {
|
||||
$logSaver->info('Article processing is disabled. Article discovery skipped.');
|
||||
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Exceptions\PublishException;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
|
|
@ -12,7 +14,7 @@
|
|||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class PublishNextArticleJob implements ShouldQueue, ShouldBeUnique
|
||||
class PublishNextArticleJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
|
|
@ -28,6 +30,7 @@ public function __construct()
|
|||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws PublishException
|
||||
*/
|
||||
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void
|
||||
|
|
@ -52,11 +55,11 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
|
|||
return;
|
||||
}
|
||||
|
||||
logger()->info('Publishing next article from scheduled job', [
|
||||
ActionPerformed::dispatch('Publishing next article from scheduled job', LogLevelEnum::INFO, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
'url' => $article->url,
|
||||
'created_at' => $article->created_at
|
||||
'created_at' => $article->created_at,
|
||||
]);
|
||||
|
||||
// Fetch article data
|
||||
|
|
@ -64,18 +67,18 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
|
|||
|
||||
try {
|
||||
$publishingService->publishToRoutedChannels($article, $extractedData);
|
||||
|
||||
logger()->info('Successfully published article', [
|
||||
|
||||
ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title
|
||||
'title' => $article->title,
|
||||
]);
|
||||
} catch (PublishException $e) {
|
||||
logger()->error('Failed to publish article', [
|
||||
ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
|
||||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SyncChannelPostsJob implements ShouldQueue, ShouldBeUnique
|
||||
class SyncChannelPostsJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ public function __construct(
|
|||
public static function dispatchForAllActiveChannels(): void
|
||||
{
|
||||
$logSaver = app(LogSaver::class);
|
||||
|
||||
|
||||
PlatformChannel::with(['platformInstance', 'platformAccounts'])
|
||||
->whereHas('platformInstance', fn ($query) => $query->where('platform', PlatformEnum::LEMMY))
|
||||
->whereHas('platformAccounts', fn ($query) => $query->where('platform_accounts.is_active', true))
|
||||
|
|
@ -78,7 +78,7 @@ private function syncLemmyChannelPosts(LogSaver $logSaver): void
|
|||
|
||||
} catch (Exception $e) {
|
||||
$logSaver->error('Failed to sync channel posts', $this->channel, [
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
|
|
@ -97,13 +97,13 @@ private function getAuthToken(LemmyApiService $api, PlatformAccount $account): s
|
|||
return $cachedToken;
|
||||
}
|
||||
|
||||
if (!$account->username || !$account->password) {
|
||||
if (! $account->username || ! $account->password) {
|
||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account');
|
||||
}
|
||||
|
||||
$token = $api->login($account->username, $account->password);
|
||||
|
||||
if (!$token) {
|
||||
if (! $token) {
|
||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account');
|
||||
}
|
||||
|
||||
|
|
|
|||
21
app/Listeners/LogActionListener.php
Normal file
21
app/Listeners/LogActionListener.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Exception;
|
||||
|
||||
class LogActionListener
|
||||
{
|
||||
public function __construct(private LogSaver $logSaver) {}
|
||||
|
||||
public function handle(ActionPerformed $event): void
|
||||
{
|
||||
try {
|
||||
$this->logSaver->log($event->level, $event->message, context: $event->context);
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to log action to database: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,14 +5,14 @@
|
|||
use App\Events\ExceptionLogged;
|
||||
use App\Events\ExceptionOccurred;
|
||||
use App\Models\Log;
|
||||
|
||||
class LogExceptionToDatabase
|
||||
{
|
||||
|
||||
public function handle(ExceptionOccurred $event): void
|
||||
{
|
||||
// Truncate the message to prevent database errors
|
||||
$message = strlen($event->message) > 255
|
||||
? substr($event->message, 0, 252) . '...'
|
||||
$message = strlen($event->message) > 255
|
||||
? substr($event->message, 0, 252).'...'
|
||||
: $event->message;
|
||||
|
||||
try {
|
||||
|
|
@ -24,15 +24,15 @@ public function handle(ExceptionOccurred $event): void
|
|||
'file' => $event->exception->getFile(),
|
||||
'line' => $event->exception->getLine(),
|
||||
'trace' => $event->exception->getTraceAsString(),
|
||||
...$event->context
|
||||
]
|
||||
...$event->context,
|
||||
],
|
||||
]);
|
||||
|
||||
ExceptionLogged::dispatch($log);
|
||||
} catch (\Exception $e) {
|
||||
// Prevent infinite recursion by not logging this exception
|
||||
// Optionally log to file or other non-database destination
|
||||
error_log("Failed to log exception to database: " . $e->getMessage());
|
||||
error_log('Failed to log exception to database: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Events\ArticleApproved;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
|
|
@ -40,14 +42,14 @@ public function handle(ArticleApproved $event): void
|
|||
if ($publications->isNotEmpty()) {
|
||||
$article->update(['publish_status' => 'published']);
|
||||
|
||||
logger()->info('Published approved article', [
|
||||
ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
} else {
|
||||
$article->update(['publish_status' => 'error']);
|
||||
|
||||
logger()->warning('No publications created for approved article', [
|
||||
ActionPerformed::dispatch('No publications created for approved article', LogLevelEnum::WARNING, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
]);
|
||||
|
|
@ -55,7 +57,7 @@ public function handle(ArticleApproved $event): void
|
|||
} catch (Exception $e) {
|
||||
$article->update(['publish_status' => 'error']);
|
||||
|
||||
logger()->error('Failed to publish approved article', [
|
||||
ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [
|
||||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Events\NewArticleFetched;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Article\ValidationService;
|
||||
|
|
@ -37,10 +39,11 @@ public function handle(NewArticleFetched $event): void
|
|||
try {
|
||||
$article = $this->validationService->validate($article);
|
||||
} catch (Exception $e) {
|
||||
logger()->error('Article validation failed', [
|
||||
ActionPerformed::dispatch('Article validation failed', LogLevelEnum::ERROR, [
|
||||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use App\Models\Article;
|
||||
use App\Models\Setting;
|
||||
use App\Jobs\ArticleDiscoveryJob;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ public function refresh(): void
|
|||
$this->dispatch('refresh-started');
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
{
|
||||
$articles = Article::with(['feed', 'articlePublication'])
|
||||
->orderBy('created_at', 'desc')
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class Channels extends Component
|
|||
public function toggle(int $channelId): void
|
||||
{
|
||||
$channel = PlatformChannel::findOrFail($channelId);
|
||||
$channel->is_active = !$channel->is_active;
|
||||
$channel->is_active = ! $channel->is_active;
|
||||
$channel->save();
|
||||
}
|
||||
|
||||
|
|
@ -29,13 +29,13 @@ public function closeAccountModal(): void
|
|||
|
||||
public function attachAccount(int $accountId): void
|
||||
{
|
||||
if (!$this->managingChannelId) {
|
||||
if (! $this->managingChannelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$channel = PlatformChannel::findOrFail($this->managingChannelId);
|
||||
|
||||
if (!$channel->platformAccounts()->where('platform_account_id', $accountId)->exists()) {
|
||||
if (! $channel->platformAccounts()->where('platform_account_id', $accountId)->exists()) {
|
||||
$channel->platformAccounts()->attach($accountId, [
|
||||
'is_active' => true,
|
||||
'priority' => 1,
|
||||
|
|
@ -51,7 +51,7 @@ public function detachAccount(int $channelId, int $accountId): void
|
|||
$channel->platformAccounts()->detach($accountId);
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
{
|
||||
$channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get();
|
||||
$allAccounts = PlatformAccount::where('is_active', true)->get();
|
||||
|
|
@ -61,7 +61,7 @@ public function render()
|
|||
: null;
|
||||
|
||||
$availableAccounts = $managingChannel
|
||||
? $allAccounts->filter(fn($account) => !$managingChannel->platformAccounts->contains('id', $account->id))
|
||||
? $allAccounts->filter(fn ($account) => ! $managingChannel->platformAccounts->contains('id', $account->id))
|
||||
: collect();
|
||||
|
||||
return view('livewire.channels', [
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public function setPeriod(string $period): void
|
|||
$this->period = $period;
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
{
|
||||
$service = app(DashboardStatsService::class);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ class Feeds extends Component
|
|||
public function toggle(int $feedId): void
|
||||
{
|
||||
$feed = Feed::findOrFail($feedId);
|
||||
$feed->is_active = !$feed->is_active;
|
||||
$feed->is_active = ! $feed->is_active;
|
||||
$feed->save();
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
{
|
||||
$feeds = Feed::orderBy('name')->get();
|
||||
|
||||
|
|
|
|||
|
|
@ -29,36 +29,54 @@ class Onboarding extends Component
|
|||
|
||||
// Platform form
|
||||
public string $instanceUrl = '';
|
||||
|
||||
public string $username = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
/** @var array<string, mixed>|null */
|
||||
public ?array $existingAccount = null;
|
||||
|
||||
// Feed form
|
||||
public string $feedName = '';
|
||||
|
||||
public string $feedProvider = 'vrt';
|
||||
|
||||
public ?int $feedLanguageId = null;
|
||||
|
||||
public string $feedDescription = '';
|
||||
|
||||
// Channel form
|
||||
public string $channelName = '';
|
||||
|
||||
public ?int $platformInstanceId = null;
|
||||
|
||||
public ?int $channelLanguageId = null;
|
||||
|
||||
public string $channelDescription = '';
|
||||
|
||||
// Route form
|
||||
public ?int $routeFeedId = null;
|
||||
|
||||
public ?int $routeChannelId = null;
|
||||
|
||||
public int $routePriority = 50;
|
||||
|
||||
// State
|
||||
/** @var array<string, string> */
|
||||
public array $formErrors = [];
|
||||
|
||||
public bool $isLoading = false;
|
||||
|
||||
#[\Livewire\Attributes\Locked]
|
||||
public ?int $previousChannelLanguageId = null;
|
||||
|
||||
protected CreatePlatformAccountAction $createPlatformAccountAction;
|
||||
|
||||
protected CreateFeedAction $createFeedAction;
|
||||
|
||||
protected CreateChannelAction $createChannelAction;
|
||||
|
||||
protected CreateRouteAction $createRouteAction;
|
||||
|
||||
public function boot(
|
||||
|
|
@ -188,7 +206,7 @@ public function createPlatformAccount(): void
|
|||
}
|
||||
} catch (Exception $e) {
|
||||
logger()->error('Lemmy platform account creation failed', [
|
||||
'instance_url' => 'https://' . $this->instanceUrl,
|
||||
'instance_url' => 'https://'.$this->instanceUrl,
|
||||
'username' => $this->username,
|
||||
'error' => $e->getMessage(),
|
||||
'class' => get_class($e),
|
||||
|
|
@ -319,17 +337,20 @@ public function completeOnboarding(): void
|
|||
/**
|
||||
* Get language codes that have at least one active provider.
|
||||
*/
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getAvailableLanguageCodes(): array
|
||||
{
|
||||
$providers = config('feed.providers', []);
|
||||
$languageCodes = [];
|
||||
|
||||
foreach ($providers as $provider) {
|
||||
if (!($provider['is_active'] ?? false)) {
|
||||
if (! ($provider['is_active'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
foreach (array_keys($provider['languages'] ?? []) as $code) {
|
||||
$languageCodes[$code] = true;
|
||||
$languageCodes[(string) $code] = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -339,14 +360,17 @@ public function getAvailableLanguageCodes(): array
|
|||
/**
|
||||
* Get providers available for the current channel language.
|
||||
*/
|
||||
/**
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
public function getProvidersForLanguage(): array
|
||||
{
|
||||
if (!$this->channelLanguageId) {
|
||||
if (! $this->channelLanguageId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$language = Language::find($this->channelLanguageId);
|
||||
if (!$language) {
|
||||
if (! $language) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -355,7 +379,7 @@ public function getProvidersForLanguage(): array
|
|||
$available = [];
|
||||
|
||||
foreach ($providers as $key => $provider) {
|
||||
if (!($provider['is_active'] ?? false)) {
|
||||
if (! ($provider['is_active'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
if (isset($provider['languages'][$langCode])) {
|
||||
|
|
@ -375,13 +399,14 @@ public function getProvidersForLanguage(): array
|
|||
*/
|
||||
public function getChannelLanguage(): ?Language
|
||||
{
|
||||
if (!$this->channelLanguageId) {
|
||||
if (! $this->channelLanguageId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Language::find($this->channelLanguageId);
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
{
|
||||
// For channel step: only show languages that have providers
|
||||
$availableCodes = $this->getAvailableLanguageCodes();
|
||||
|
|
|
|||
|
|
@ -11,12 +11,16 @@
|
|||
class Routes extends Component
|
||||
{
|
||||
public bool $showCreateModal = false;
|
||||
|
||||
public ?int $editingFeedId = null;
|
||||
|
||||
public ?int $editingChannelId = null;
|
||||
|
||||
// Create form
|
||||
public ?int $newFeedId = null;
|
||||
|
||||
public ?int $newChannelId = null;
|
||||
|
||||
public int $newPriority = 50;
|
||||
|
||||
// Edit form
|
||||
|
|
@ -24,6 +28,7 @@ class Routes extends Component
|
|||
|
||||
// Keyword management
|
||||
public string $newKeyword = '';
|
||||
|
||||
public bool $showKeywordInput = false;
|
||||
|
||||
public function openCreateModal(): void
|
||||
|
|
@ -53,6 +58,7 @@ public function createRoute(): void
|
|||
|
||||
if ($exists) {
|
||||
$this->addError('newFeedId', 'This route already exists.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +93,7 @@ public function closeEditModal(): void
|
|||
|
||||
public function updateRoute(): void
|
||||
{
|
||||
if (!$this->editingFeedId || !$this->editingChannelId) {
|
||||
if (! $this->editingFeedId || ! $this->editingChannelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +114,7 @@ public function toggle(int $feedId, int $channelId): void
|
|||
->where('platform_channel_id', $channelId)
|
||||
->firstOrFail();
|
||||
|
||||
$route->is_active = !$route->is_active;
|
||||
$route->is_active = ! $route->is_active;
|
||||
$route->save();
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +132,7 @@ public function delete(int $feedId, int $channelId): void
|
|||
|
||||
public function addKeyword(): void
|
||||
{
|
||||
if (!$this->editingFeedId || !$this->editingChannelId || empty(trim($this->newKeyword))) {
|
||||
if (! $this->editingFeedId || ! $this->editingChannelId || empty(trim($this->newKeyword))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -144,7 +150,7 @@ public function addKeyword(): void
|
|||
public function toggleKeyword(int $keywordId): void
|
||||
{
|
||||
$keyword = Keyword::findOrFail($keywordId);
|
||||
$keyword->is_active = !$keyword->is_active;
|
||||
$keyword->is_active = ! $keyword->is_active;
|
||||
$keyword->save();
|
||||
}
|
||||
|
||||
|
|
@ -153,22 +159,23 @@ public function deleteKeyword(int $keywordId): void
|
|||
Keyword::destroy($keywordId);
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
{
|
||||
$routes = Route::with(['feed', 'platformChannel'])
|
||||
$routes = Route::with(['feed', 'platformChannel.platformInstance'])
|
||||
->orderBy('priority', 'desc')
|
||||
->get();
|
||||
|
||||
// Batch load keywords for all routes to avoid N+1 queries
|
||||
$routeKeys = $routes->map(fn($r) => $r->feed_id . '-' . $r->platform_channel_id);
|
||||
$routeKeys = $routes->map(fn ($r) => $r->feed_id.'-'.$r->platform_channel_id);
|
||||
$allKeywords = Keyword::whereIn('feed_id', $routes->pluck('feed_id'))
|
||||
->whereIn('platform_channel_id', $routes->pluck('platform_channel_id'))
|
||||
->get()
|
||||
->groupBy(fn($k) => $k->feed_id . '-' . $k->platform_channel_id);
|
||||
->groupBy(fn ($k) => $k->feed_id.'-'.$k->platform_channel_id);
|
||||
|
||||
$routes = $routes->map(function ($route) use ($allKeywords) {
|
||||
$key = $route->feed_id . '-' . $route->platform_channel_id;
|
||||
$route->keywords = $allKeywords->get($key, collect());
|
||||
$key = $route->feed_id.'-'.$route->platform_channel_id;
|
||||
$route->setRelation('keywords', $allKeywords->get($key, collect()));
|
||||
|
||||
return $route;
|
||||
});
|
||||
|
||||
|
|
@ -179,7 +186,7 @@ public function render()
|
|||
$editingKeywords = collect();
|
||||
|
||||
if ($this->editingFeedId && $this->editingChannelId) {
|
||||
$editingRoute = Route::with(['feed', 'platformChannel'])
|
||||
$editingRoute = Route::with(['feed', 'platformChannel.platformInstance'])
|
||||
->where('feed_id', $this->editingFeedId)
|
||||
->where('platform_channel_id', $this->editingChannelId)
|
||||
->first();
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@
|
|||
class Settings extends Component
|
||||
{
|
||||
public bool $articleProcessingEnabled = true;
|
||||
|
||||
public bool $publishingApprovalsEnabled = false;
|
||||
|
||||
public int $articlePublishingInterval = 5;
|
||||
|
||||
public ?string $successMessage = null;
|
||||
|
||||
public ?string $errorMessage = null;
|
||||
|
||||
public function mount(): void
|
||||
|
|
@ -23,14 +26,14 @@ public function mount(): void
|
|||
|
||||
public function toggleArticleProcessing(): void
|
||||
{
|
||||
$this->articleProcessingEnabled = !$this->articleProcessingEnabled;
|
||||
$this->articleProcessingEnabled = ! $this->articleProcessingEnabled;
|
||||
Setting::setArticleProcessingEnabled($this->articleProcessingEnabled);
|
||||
$this->showSuccess();
|
||||
}
|
||||
|
||||
public function togglePublishingApprovals(): void
|
||||
{
|
||||
$this->publishingApprovalsEnabled = !$this->publishingApprovalsEnabled;
|
||||
$this->publishingApprovalsEnabled = ! $this->publishingApprovalsEnabled;
|
||||
Setting::setPublishingApprovalsEnabled($this->publishingApprovalsEnabled);
|
||||
$this->showSuccess();
|
||||
}
|
||||
|
|
@ -60,7 +63,7 @@ public function clearMessages(): void
|
|||
$this->errorMessage = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
{
|
||||
return view('livewire.settings')->layout('layouts.app');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,15 +15,20 @@
|
|||
* @method static firstOrCreate(array<string, mixed> $array)
|
||||
* @method static where(string $string, string $url)
|
||||
* @method static create(array<string, mixed> $array)
|
||||
* @property integer $id
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $feed_id
|
||||
* @property Feed $feed
|
||||
* @property string $url
|
||||
* @property string $title
|
||||
* @property string|null $description
|
||||
* @property string $approval_status
|
||||
* @property string $publish_status
|
||||
* @property bool|null $is_valid
|
||||
* @property Carbon|null $validated_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property ArticlePublication $articlePublication
|
||||
* @property ArticlePublication|null $articlePublication
|
||||
*/
|
||||
class Article extends Model
|
||||
{
|
||||
|
|
@ -79,7 +84,7 @@ public function isRejected(): bool
|
|||
return $this->approval_status === 'rejected';
|
||||
}
|
||||
|
||||
public function approve(string $approvedBy = null): void
|
||||
public function approve(?string $approvedBy = null): void
|
||||
{
|
||||
$this->update([
|
||||
'approval_status' => 'approved',
|
||||
|
|
@ -89,7 +94,7 @@ public function approve(string $approvedBy = null): void
|
|||
event(new ArticleApproved($this));
|
||||
}
|
||||
|
||||
public function reject(string $rejectedBy = null): void
|
||||
public function reject(?string $rejectedBy = null): void
|
||||
{
|
||||
$this->update([
|
||||
'approval_status' => 'rejected',
|
||||
|
|
@ -98,12 +103,12 @@ public function reject(string $rejectedBy = null): void
|
|||
|
||||
public function canBePublished(): bool
|
||||
{
|
||||
if (!$this->isValid()) {
|
||||
if (! $this->isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If approval system is disabled, auto-approve valid articles
|
||||
if (!\App\Models\Setting::isPublishingApprovalsEnabled()) {
|
||||
if (! \App\Models\Setting::isPublishingApprovalsEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,16 @@
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property integer $article_id
|
||||
* @property integer $platform_channel_id
|
||||
* @property integer $post_id
|
||||
* @property int $id
|
||||
* @property int $article_id
|
||||
* @property int $platform_channel_id
|
||||
* @property string $post_id
|
||||
* @property string $platform
|
||||
* @property string $published_by
|
||||
* @property array<string, mixed>|null $publication_data
|
||||
* @property \Illuminate\Support\Carbon $published_at
|
||||
* @property \Illuminate\Support\Carbon $created_at
|
||||
* @property \Illuminate\Support\Carbon $updated_at
|
||||
*
|
||||
* @method static create(array<string, mixed> $array)
|
||||
*/
|
||||
|
|
@ -18,7 +25,7 @@ class ArticlePublication extends Model
|
|||
{
|
||||
/** @use HasFactory<ArticlePublicationFactory> */
|
||||
use HasFactory;
|
||||
|
||||
|
||||
protected $fillable = [
|
||||
'article_id',
|
||||
'platform_channel_id',
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
* @property string $url
|
||||
* @property string $type
|
||||
* @property string $provider
|
||||
* @property int $language_id
|
||||
* @property int|null $language_id
|
||||
* @property Language|null $language
|
||||
* @property string $description
|
||||
* @property array<string, mixed> $settings
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
* @property Carbon|null $last_fetched_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*
|
||||
* @method static orderBy(string $string, string $string1)
|
||||
* @method static where(string $string, true $true)
|
||||
* @method static findOrFail(mixed $feed_id)
|
||||
|
|
@ -32,7 +33,9 @@ class Feed extends Model
|
|||
{
|
||||
/** @use HasFactory<FeedFactory> */
|
||||
use HasFactory;
|
||||
|
||||
private const RECENT_FETCH_THRESHOLD_HOURS = 2;
|
||||
|
||||
private const DAILY_FETCH_THRESHOLD_HOURS = 24;
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -44,13 +47,13 @@ class Feed extends Model
|
|||
'description',
|
||||
'settings',
|
||||
'is_active',
|
||||
'last_fetched_at'
|
||||
'last_fetched_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'settings' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'last_fetched_at' => 'datetime'
|
||||
'last_fetched_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getTypeDisplayAttribute(): string
|
||||
|
|
@ -64,11 +67,11 @@ public function getTypeDisplayAttribute(): string
|
|||
|
||||
public function getStatusAttribute(): string
|
||||
{
|
||||
if (!$this->is_active) {
|
||||
if (! $this->is_active) {
|
||||
return 'Inactive';
|
||||
}
|
||||
|
||||
if (!$this->last_fetched_at) {
|
||||
if (! $this->last_fetched_at) {
|
||||
return 'Never fetched';
|
||||
}
|
||||
|
||||
|
|
@ -79,12 +82,12 @@ public function getStatusAttribute(): string
|
|||
} elseif ($hoursAgo < self::DAILY_FETCH_THRESHOLD_HOURS) {
|
||||
return "Fetched {$hoursAgo}h ago";
|
||||
} else {
|
||||
return "Fetched " . $this->last_fetched_at->diffForHumans();
|
||||
return 'Fetched '.$this->last_fetched_at->diffForHumans();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<PlatformChannel, $this, Route>
|
||||
* @return BelongsToMany<PlatformChannel, $this>
|
||||
*/
|
||||
public function channels(): BelongsToMany
|
||||
{
|
||||
|
|
@ -94,7 +97,7 @@ public function channels(): BelongsToMany
|
|||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<PlatformChannel, $this, Route>
|
||||
* @return BelongsToMany<PlatformChannel, $this>
|
||||
*/
|
||||
public function activeChannels(): BelongsToMany
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\KeywordFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -20,17 +21,18 @@
|
|||
*/
|
||||
class Keyword extends Model
|
||||
{
|
||||
/** @use HasFactory<KeywordFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'feed_id',
|
||||
'platform_channel_id',
|
||||
'keyword',
|
||||
'is_active'
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean'
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -48,5 +50,4 @@ public function platformChannel(): BelongsTo
|
|||
{
|
||||
return $this->belongsTo(PlatformChannel::class);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,13 +15,13 @@ class Language extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'short_code',
|
||||
'name',
|
||||
'name',
|
||||
'native_name',
|
||||
'is_active'
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean'
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use Database\Factories\LogFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @method static create(array $array)
|
||||
* @method static create(array<string, mixed> $array)
|
||||
*
|
||||
* @property LogLevelEnum $level
|
||||
* @property string $message
|
||||
* @property array<string, mixed> $context
|
||||
|
|
@ -17,6 +19,7 @@
|
|||
*/
|
||||
class Log extends Model
|
||||
{
|
||||
/** @use HasFactory<LogFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'logs';
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PlatformEnum;
|
||||
use Database\Factories\PlatformAccountFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use App\Enums\PlatformEnum;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
|
|
@ -18,13 +18,14 @@
|
|||
* @property string $instance_url
|
||||
* @property string $username
|
||||
* @property string $password
|
||||
* @property string $settings
|
||||
* @property array<string, mixed> $settings
|
||||
* @property bool $is_active
|
||||
* @property Carbon $last_tested_at
|
||||
* @property Carbon|null $last_tested_at
|
||||
* @property string $status
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Collection<int, PlatformChannel> $activeChannels
|
||||
*
|
||||
* @method static where(string $string, PlatformEnum $platform)
|
||||
* @method static orderBy(string $string)
|
||||
* @method static create(array<string, mixed> $validated)
|
||||
|
|
@ -42,14 +43,14 @@ class PlatformAccount extends Model
|
|||
'settings',
|
||||
'is_active',
|
||||
'last_tested_at',
|
||||
'status'
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'platform' => PlatformEnum::class,
|
||||
'settings' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'last_tested_at' => 'datetime'
|
||||
'last_tested_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Encrypt password when storing
|
||||
|
|
@ -64,12 +65,12 @@ protected function password(): Attribute
|
|||
if (is_null($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Return empty string if value is empty
|
||||
if (empty($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($value);
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -82,18 +83,17 @@ protected function password(): Attribute
|
|||
if (is_null($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Store empty string as null
|
||||
if (empty($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return Crypt::encryptString($value);
|
||||
},
|
||||
)->withoutObjectCaching();
|
||||
}
|
||||
|
||||
|
||||
// Get the active accounts for a platform (returns collection)
|
||||
/**
|
||||
* @return Collection<int, PlatformAccount>
|
||||
|
|
|
|||
|
|
@ -10,15 +10,16 @@
|
|||
|
||||
/**
|
||||
* @method static findMany(mixed $channel_ids)
|
||||
* @method static create(array $array)
|
||||
* @property integer $id
|
||||
* @property integer $platform_instance_id
|
||||
* @method static create(array<string, mixed> $array)
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $platform_instance_id
|
||||
* @property PlatformInstance $platformInstance
|
||||
* @property integer $channel_id
|
||||
* @property int $channel_id
|
||||
* @property string $name
|
||||
* @property int $language_id
|
||||
* @property Language|null $language
|
||||
* @property boolean $is_active
|
||||
* @property bool $is_active
|
||||
*/
|
||||
class PlatformChannel extends Model
|
||||
{
|
||||
|
|
@ -34,11 +35,11 @@ class PlatformChannel extends Model
|
|||
'channel_id',
|
||||
'description',
|
||||
'language_id',
|
||||
'is_active'
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean'
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -70,11 +71,11 @@ public function activePlatformAccounts(): BelongsToMany
|
|||
public function getFullNameAttribute(): string
|
||||
{
|
||||
// For Lemmy, use /c/ prefix
|
||||
return $this->platformInstance->url . '/c/' . $this->name;
|
||||
return $this->platformInstance->url.'/c/'.$this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Feed, $this, Route>
|
||||
* @return BelongsToMany<Feed, $this>
|
||||
*/
|
||||
public function feeds(): BelongsToMany
|
||||
{
|
||||
|
|
@ -84,7 +85,7 @@ public function feeds(): BelongsToMany
|
|||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Feed, $this, Route>
|
||||
* @return BelongsToMany<Feed, $this>
|
||||
*/
|
||||
public function activeFeeds(): BelongsToMany
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@
|
|||
*/
|
||||
class PlatformChannelPost extends Model
|
||||
{
|
||||
/** @use HasFactory<\Illuminate\Database\Eloquent\Factories\Factory<PlatformChannelPost>> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'platform',
|
||||
'channel_id',
|
||||
|
|
@ -44,7 +46,7 @@ public static function urlExists(PlatformEnum $platform, string $channelId, stri
|
|||
|
||||
public static function duplicateExists(PlatformEnum $platform, string $channelId, ?string $url, ?string $title): bool
|
||||
{
|
||||
if (!$url && !$title) {
|
||||
if (! $url && ! $title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,28 +12,29 @@
|
|||
/**
|
||||
* @method static updateOrCreate(array<string, mixed> $array, $instanceData)
|
||||
* @method static where(string $string, mixed $operator)
|
||||
*
|
||||
* @property PlatformEnum $platform
|
||||
* @property string $url
|
||||
* @property string $name
|
||||
* @property string $description
|
||||
* @property boolean $is_active
|
||||
* @property bool $is_active
|
||||
*/
|
||||
class PlatformInstance extends Model
|
||||
{
|
||||
/** @use HasFactory<PlatformInstanceFactory> */
|
||||
use HasFactory;
|
||||
|
||||
|
||||
protected $fillable = [
|
||||
'platform',
|
||||
'url',
|
||||
'name',
|
||||
'description',
|
||||
'is_active'
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'platform' => PlatformEnum::class,
|
||||
'is_active' => 'boolean'
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
|
||||
use Database\Factories\RouteFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
|
|
@ -21,22 +21,23 @@ class Route extends Model
|
|||
{
|
||||
/** @use HasFactory<RouteFactory> */
|
||||
use HasFactory;
|
||||
|
||||
|
||||
protected $table = 'routes';
|
||||
|
||||
|
||||
// Laravel doesn't handle composite primary keys well, so we'll use regular queries
|
||||
protected $primaryKey = null;
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $fillable = [
|
||||
'feed_id',
|
||||
'platform_channel_id',
|
||||
'is_active',
|
||||
'priority'
|
||||
'priority',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean'
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,16 +2,18 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\SettingFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @method static updateOrCreate(string[] $array, array $array1)
|
||||
* @method static create(string[] $array)
|
||||
* @method static updateOrCreate(array<string, string> $array, array<string, mixed> $array1)
|
||||
* @method static create(array<string, string> $array)
|
||||
* @method static where(string $string, string $key)
|
||||
*/
|
||||
class Setting extends Model
|
||||
{
|
||||
/** @use HasFactory<SettingFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['key', 'value'];
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, HasApiTokens;
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@
|
|||
|
||||
namespace App\Modules\Lemmy;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class LemmyRequest
|
||||
{
|
||||
private string $instance;
|
||||
|
||||
private ?string $token;
|
||||
|
||||
private string $scheme = 'https';
|
||||
|
||||
public function __construct(string $instance, ?string $token = null)
|
||||
|
|
@ -45,11 +47,12 @@ public function withScheme(string $scheme): self
|
|||
if (in_array($scheme, ['http', 'https'], true)) {
|
||||
$this->scheme = $scheme;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public function get(string $endpoint, array $params = []): Response
|
||||
{
|
||||
|
|
@ -65,7 +68,7 @@ public function get(string $endpoint, array $params = []): Response
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function post(string $endpoint, array $data = []): Response
|
||||
{
|
||||
|
|
@ -83,6 +86,7 @@ public function post(string $endpoint, array $data = []): Response
|
|||
public function withToken(string $token): self
|
||||
{
|
||||
$this->token = $token;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ public function login(string $username, string $password): ?string
|
|||
'password' => $password,
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
if (! $response->successful()) {
|
||||
$responseBody = $response->body();
|
||||
logger()->error('Lemmy login failed', [
|
||||
'status' => $response->status(),
|
||||
|
|
@ -61,6 +61,7 @@ public function login(string $username, string $password): ?string
|
|||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return $data['jwt'] ?? null;
|
||||
} catch (Exception $e) {
|
||||
// Re-throw rate limit exceptions immediately
|
||||
|
|
@ -74,7 +75,7 @@ public function login(string $username, string $password): ?string
|
|||
continue;
|
||||
}
|
||||
// Connection failed - throw exception to distinguish from auth failure
|
||||
throw new Exception('Connection failed: ' . $e->getMessage());
|
||||
throw new Exception('Connection failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,11 +89,12 @@ public function getCommunityId(string $communityName, string $token): int
|
|||
$request = new LemmyRequest($this->instance, $token);
|
||||
$response = $request->get('community', ['name' => $communityName]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new Exception('Failed to fetch community: ' . $response->status());
|
||||
if (! $response->successful()) {
|
||||
throw new Exception('Failed to fetch community: '.$response->status());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return $data['community_view']['community']['id'] ?? throw new Exception('Community not found');
|
||||
} catch (Exception $e) {
|
||||
logger()->error('Community lookup failed', ['error' => $e->getMessage()]);
|
||||
|
|
@ -107,14 +109,15 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
|
|||
$response = $request->get('post/list', [
|
||||
'community_id' => $platformChannelId,
|
||||
'limit' => 50,
|
||||
'sort' => 'New'
|
||||
'sort' => 'New',
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
if (! $response->successful()) {
|
||||
logger()->warning('Failed to sync channel posts', [
|
||||
'status' => $response->status(),
|
||||
'platform_channel_id' => $platformChannelId
|
||||
'platform_channel_id' => $platformChannelId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -137,13 +140,13 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
|
|||
|
||||
logger()->info('Synced channel posts', [
|
||||
'platform_channel_id' => $platformChannelId,
|
||||
'posts_count' => count($posts)
|
||||
'posts_count' => count($posts),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
logger()->error('Exception while syncing channel posts', [
|
||||
'error' => $e->getMessage(),
|
||||
'platform_channel_id' => $platformChannelId
|
||||
'platform_channel_id' => $platformChannelId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -176,8 +179,8 @@ public function createPost(string $token, string $title, string $body, int $plat
|
|||
|
||||
$response = $request->post('post', $postData);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new Exception('Failed to create post: ' . $response->status() . ' - ' . $response->body());
|
||||
if (! $response->successful()) {
|
||||
throw new Exception('Failed to create post: '.$response->status().' - '.$response->body());
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
|
|
@ -196,19 +199,22 @@ public function getLanguages(): array
|
|||
$request = new LemmyRequest($this->instance);
|
||||
$response = $request->get('site');
|
||||
|
||||
if (!$response->successful()) {
|
||||
if (! $response->successful()) {
|
||||
logger()->warning('Failed to fetch site languages', [
|
||||
'status' => $response->status()
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return $data['all_languages'] ?? [];
|
||||
} catch (Exception $e) {
|
||||
logger()->error('Exception while fetching languages', [
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
class LemmyPublisher
|
||||
{
|
||||
private LemmyApiService $api;
|
||||
|
||||
private PlatformAccount $account;
|
||||
|
||||
public function __construct(PlatformAccount $account)
|
||||
|
|
@ -21,8 +22,9 @@ public function __construct(PlatformAccount $account)
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extractedData
|
||||
* @param array<string, mixed> $extractedData
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws PlatformAuthException
|
||||
* @throws Exception
|
||||
*/
|
||||
|
|
@ -37,6 +39,7 @@ public function publishToChannel(Article $article, array $extractedData, Platfor
|
|||
// If the cached token was stale, refresh and retry once
|
||||
if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) {
|
||||
$token = $authService->refreshToken($this->account);
|
||||
|
||||
return $this->createPost($token, $extractedData, $channel, $article);
|
||||
}
|
||||
throw $e;
|
||||
|
|
@ -44,7 +47,7 @@ public function publishToChannel(Article $article, array $extractedData, Platfor
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extractedData
|
||||
* @param array<string, mixed> $extractedData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article): array
|
||||
|
|
@ -65,5 +68,4 @@ private function createPost(string $token, array $extractedData, PlatformChannel
|
|||
$languageId
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
namespace App\Providers;
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Events\ExceptionOccurred;
|
||||
use App\Listeners\LogActionListener;
|
||||
use App\Listeners\LogExceptionToDatabase;
|
||||
use Error;
|
||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||
|
|
@ -14,12 +16,15 @@
|
|||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
}
|
||||
public function register(): void {}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Event::listen(
|
||||
ActionPerformed::class,
|
||||
LogActionListener::class,
|
||||
);
|
||||
|
||||
Event::listen(
|
||||
ExceptionOccurred::class,
|
||||
LogExceptionToDatabase::class,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Services\Http\HttpFetcher;
|
||||
use App\Services\Factories\ArticleParserFactory;
|
||||
use App\Services\Factories\HomepageParserFactory;
|
||||
use App\Services\Http\HttpFetcher;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Exception;
|
||||
use Illuminate\Support\Collection;
|
||||
|
|
@ -28,9 +28,9 @@ public function getArticlesFromFeed(Feed $feed): Collection
|
|||
return $this->getArticlesFromWebsiteFeed($feed);
|
||||
}
|
||||
|
||||
$this->logSaver->warning("Unsupported feed type", null, [
|
||||
$this->logSaver->warning('Unsupported feed type', null, [
|
||||
'feed_id' => $feed->id,
|
||||
'feed_type' => $feed->type
|
||||
'feed_type' => $feed->type,
|
||||
]);
|
||||
|
||||
return collect();
|
||||
|
|
@ -53,8 +53,8 @@ private function getArticlesFromRssFeed(Feed $feed): Collection
|
|||
libxml_use_internal_errors($previousUseErrors);
|
||||
}
|
||||
|
||||
if ($rss === false || !isset($rss->channel->item)) {
|
||||
$this->logSaver->warning("Failed to parse RSS feed XML", null, [
|
||||
if ($rss === false || ! isset($rss->channel->item)) {
|
||||
$this->logSaver->warning('Failed to parse RSS feed XML', null, [
|
||||
'feed_id' => $feed->id,
|
||||
'feed_url' => $feed->url,
|
||||
]);
|
||||
|
|
@ -72,7 +72,7 @@ private function getArticlesFromRssFeed(Feed $feed): Collection
|
|||
|
||||
return $articles;
|
||||
} catch (Exception $e) {
|
||||
$this->logSaver->error("Failed to fetch articles from RSS feed", null, [
|
||||
$this->logSaver->error('Failed to fetch articles from RSS feed', null, [
|
||||
'feed_id' => $feed->id,
|
||||
'feed_url' => $feed->url,
|
||||
'error' => $e->getMessage(),
|
||||
|
|
@ -92,9 +92,9 @@ private function getArticlesFromWebsiteFeed(Feed $feed): Collection
|
|||
$parser = HomepageParserFactory::getParserForFeed($feed);
|
||||
|
||||
if (! $parser) {
|
||||
$this->logSaver->warning("No parser available for feed URL", null, [
|
||||
$this->logSaver->warning('No parser available for feed URL', null, [
|
||||
'feed_id' => $feed->id,
|
||||
'feed_url' => $feed->url
|
||||
'feed_url' => $feed->url,
|
||||
]);
|
||||
|
||||
return collect();
|
||||
|
|
@ -107,10 +107,10 @@ private function getArticlesFromWebsiteFeed(Feed $feed): Collection
|
|||
->map(fn (string $url) => $this->saveArticle($url, $feed->id));
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->logSaver->error("Failed to fetch articles from website feed", null, [
|
||||
$this->logSaver->error('Failed to fetch articles from website feed', null, [
|
||||
'feed_id' => $feed->id,
|
||||
'feed_url' => $feed->url,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return collect();
|
||||
|
|
@ -130,7 +130,7 @@ public function fetchArticleData(Article $article): array
|
|||
} catch (Exception $e) {
|
||||
$this->logSaver->error('Exception while fetching article data', null, [
|
||||
'url' => $article->url,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
|
|
@ -156,7 +156,7 @@ private function saveArticle(string $url, ?int $feedId = null): Article
|
|||
|
||||
return $article;
|
||||
} catch (\Exception $e) {
|
||||
$this->logSaver->error("Failed to create article", null, [
|
||||
$this->logSaver->error('Failed to create article', null, [
|
||||
'url' => $url,
|
||||
'feed_id' => $feedId,
|
||||
'error' => $e->getMessage(),
|
||||
|
|
|
|||
|
|
@ -12,23 +12,23 @@ public function __construct(
|
|||
|
||||
public function validate(Article $article): Article
|
||||
{
|
||||
logger('Checking keywords for article: ' . $article->id);
|
||||
logger('Checking keywords for article: '.$article->id);
|
||||
|
||||
$articleData = $this->articleFetcher->fetchArticleData($article);
|
||||
|
||||
// Update article with fetched metadata (title, description)
|
||||
$updateData = [];
|
||||
|
||||
if (!empty($articleData)) {
|
||||
if (! empty($articleData)) {
|
||||
$updateData['title'] = $articleData['title'] ?? $article->title;
|
||||
$updateData['description'] = $articleData['description'] ?? $article->description;
|
||||
$updateData['content'] = $articleData['full_article'] ?? null;
|
||||
}
|
||||
|
||||
if (!isset($articleData['full_article']) || empty($articleData['full_article'])) {
|
||||
if (! isset($articleData['full_article']) || empty($articleData['full_article'])) {
|
||||
logger()->warning('Article data missing full_article content', [
|
||||
'article_id' => $article->id,
|
||||
'url' => $article->url
|
||||
'url' => $article->url,
|
||||
]);
|
||||
|
||||
$updateData['approval_status'] = 'rejected';
|
||||
|
|
@ -67,7 +67,7 @@ private function validateByKeywords(string $full_article): bool
|
|||
|
||||
// Common Belgian news topics
|
||||
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy',
|
||||
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police'
|
||||
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police',
|
||||
];
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
|
|
|
|||
|
|
@ -32,14 +32,14 @@ public function getToken(PlatformAccount $account): string
|
|||
public function refreshToken(PlatformAccount $account): string
|
||||
{
|
||||
if (! $account->username || ! $account->password || ! $account->instance_url) {
|
||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username);
|
||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: '.$account->username);
|
||||
}
|
||||
|
||||
$api = new LemmyApiService($account->instance_url);
|
||||
$token = $api->login($account->username, $account->password);
|
||||
|
||||
if (!$token) {
|
||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username);
|
||||
if (! $token) {
|
||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: '.$account->username);
|
||||
}
|
||||
|
||||
// Cache the token for future use
|
||||
|
|
@ -52,15 +52,19 @@ public function refreshToken(PlatformAccount $account): string
|
|||
|
||||
/**
|
||||
* Authenticate with Lemmy API and return user data with JWT
|
||||
*
|
||||
* @throws PlatformAuthException
|
||||
*/
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function authenticate(string $instanceUrl, string $username, string $password): array
|
||||
{
|
||||
try {
|
||||
$api = new LemmyApiService($instanceUrl);
|
||||
$token = $api->login($username, $password);
|
||||
|
||||
if (!$token) {
|
||||
if (! $token) {
|
||||
// Throw a clean exception that will be caught and handled by the controller
|
||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Invalid credentials');
|
||||
}
|
||||
|
|
@ -75,8 +79,8 @@ public function authenticate(string $instanceUrl, string $username, string $pass
|
|||
'id' => 0, // Would need API call to get actual user info
|
||||
'display_name' => null,
|
||||
'bio' => null,
|
||||
]
|
||||
]
|
||||
],
|
||||
],
|
||||
];
|
||||
} catch (PlatformAuthException $e) {
|
||||
// Re-throw PlatformAuthExceptions as-is to avoid nesting
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@
|
|||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardStatsService
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getStats(string $period = 'today'): array
|
||||
{
|
||||
$dateRange = $this->getDateRange($period);
|
||||
|
|
@ -73,6 +75,9 @@ private function getDateRange(string $period): ?array
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function getSystemStats(): array
|
||||
{
|
||||
$totalFeeds = Feed::query()->count();
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
|
||||
use App\Contracts\ArticleParserInterface;
|
||||
use App\Models\Feed;
|
||||
use App\Services\Parsers\VrtArticleParser;
|
||||
use App\Services\Parsers\BelgaArticleParser;
|
||||
use App\Services\Parsers\GuardianArticleParser;
|
||||
use App\Services\Parsers\VrtArticleParser;
|
||||
use Exception;
|
||||
|
||||
class ArticleParserFactory
|
||||
|
|
@ -26,7 +26,7 @@ class ArticleParserFactory
|
|||
public static function getParser(string $url): ArticleParserInterface
|
||||
{
|
||||
foreach (self::$parsers as $parserClass) {
|
||||
$parser = new $parserClass();
|
||||
$parser = new $parserClass;
|
||||
|
||||
if ($parser->canParse($url)) {
|
||||
return $parser;
|
||||
|
|
@ -38,21 +38,22 @@ public static function getParser(string $url): ArticleParserInterface
|
|||
|
||||
public static function getParserForFeed(Feed $feed, string $parserType = 'article'): ?ArticleParserInterface
|
||||
{
|
||||
if (!$feed->provider) {
|
||||
if (! $feed->provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$providerConfig = config("feed.providers.{$feed->provider}");
|
||||
if (!$providerConfig || !isset($providerConfig['parsers'][$parserType])) {
|
||||
if (! $providerConfig || ! isset($providerConfig['parsers'][$parserType])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var class-string<ArticleParserInterface> $parserClass */
|
||||
$parserClass = $providerConfig['parsers'][$parserType];
|
||||
if (!class_exists($parserClass)) {
|
||||
if (! class_exists($parserClass)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new $parserClass();
|
||||
return new $parserClass;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -60,18 +61,19 @@ public static function getParserForFeed(Feed $feed, string $parserType = 'articl
|
|||
*/
|
||||
public static function getSupportedSources(): array
|
||||
{
|
||||
return array_map(function($parserClass) {
|
||||
$parser = new $parserClass();
|
||||
return array_map(function ($parserClass) {
|
||||
$parser = new $parserClass;
|
||||
|
||||
return $parser->getSourceName();
|
||||
}, self::$parsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<ArticleParserInterface> $parserClass
|
||||
* @param class-string<ArticleParserInterface> $parserClass
|
||||
*/
|
||||
public static function registerParser(string $parserClass): void
|
||||
{
|
||||
if (!in_array($parserClass, self::$parsers)) {
|
||||
if (! in_array($parserClass, self::$parsers)) {
|
||||
self::$parsers[] = $parserClass;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,21 +9,22 @@ class HomepageParserFactory
|
|||
{
|
||||
public static function getParserForFeed(Feed $feed): ?HomepageParserInterface
|
||||
{
|
||||
if (!$feed->provider) {
|
||||
if (! $feed->provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$providerConfig = config("feed.providers.{$feed->provider}");
|
||||
if (!$providerConfig || !isset($providerConfig['parsers']['homepage'])) {
|
||||
if (! $providerConfig || ! isset($providerConfig['parsers']['homepage'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var class-string<HomepageParserInterface> $parserClass */
|
||||
$parserClass = $providerConfig['parsers']['homepage'];
|
||||
if (!class_exists($parserClass)) {
|
||||
if (! class_exists($parserClass)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$language = $feed->language?->short_code ?? 'en';
|
||||
$language = $feed->language->short_code ?? 'en';
|
||||
|
||||
return new $parserClass($language);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace App\Services\Http;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class HttpFetcher
|
||||
{
|
||||
|
|
@ -15,7 +15,7 @@ public static function fetchHtml(string $url): string
|
|||
try {
|
||||
$response = Http::get($url);
|
||||
|
||||
if (!$response->successful()) {
|
||||
if (! $response->successful()) {
|
||||
throw new Exception("Failed to fetch URL: {$url} - Status: {$response->status()}");
|
||||
}
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ public static function fetchHtml(string $url): string
|
|||
} catch (Exception $e) {
|
||||
logger()->error('HTTP fetch failed', [
|
||||
'url' => $url,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
|
|
@ -31,7 +31,7 @@ public static function fetchHtml(string $url): string
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
* @param array<int, string> $urls
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function fetchMultipleUrls(array $urls): array
|
||||
|
|
@ -44,24 +44,24 @@ public static function fetchMultipleUrls(array $urls): array
|
|||
});
|
||||
|
||||
return collect($responses)
|
||||
->filter(fn($response, $index) => isset($urls[$index]))
|
||||
->reject(fn($response, $index) => $response instanceof Exception)
|
||||
->filter(fn ($response, $index) => isset($urls[$index]))
|
||||
->reject(fn ($response, $index) => $response instanceof Exception)
|
||||
->map(function ($response, $index) use ($urls) {
|
||||
$url = $urls[$index];
|
||||
|
||||
/** @var \Illuminate\Http\Client\Response $response */
|
||||
try {
|
||||
if ($response->successful()) {
|
||||
return [
|
||||
'url' => $url,
|
||||
'html' => $response->body(),
|
||||
'success' => true
|
||||
'success' => true,
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'url' => $url,
|
||||
'html' => null,
|
||||
'success' => false,
|
||||
'status' => $response->status()
|
||||
'status' => $response->status(),
|
||||
];
|
||||
}
|
||||
} catch (Exception) {
|
||||
|
|
@ -69,11 +69,10 @@ public static function fetchMultipleUrls(array $urls): array
|
|||
'url' => $url,
|
||||
'html' => null,
|
||||
'success' => false,
|
||||
'error' => 'Exception occurred'
|
||||
'error' => 'Exception occurred',
|
||||
];
|
||||
}
|
||||
})
|
||||
->filter(fn($result) => $result !== null)
|
||||
->toArray();
|
||||
} catch (Exception $e) {
|
||||
logger()->error('Multiple URL fetch failed', ['error' => $e->getMessage()]);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
class LogSaver
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function info(string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||
{
|
||||
|
|
@ -17,7 +17,7 @@ public function info(string $message, ?PlatformChannel $channel = null, array $c
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function error(string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||
{
|
||||
|
|
@ -25,7 +25,7 @@ public function error(string $message, ?PlatformChannel $channel = null, array $
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function warning(string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||
{
|
||||
|
|
@ -33,7 +33,7 @@ public function warning(string $message, ?PlatformChannel $channel = null, array
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function debug(string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||
{
|
||||
|
|
@ -41,9 +41,9 @@ public function debug(string $message, ?PlatformChannel $channel = null, array $
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||
public function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||
{
|
||||
$logContext = $context;
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,6 @@ private function checkOnboardingStatus(): bool
|
|||
|
||||
$hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute;
|
||||
|
||||
return !$hasAllComponents;
|
||||
return ! $hasAllComponents;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,114 +10,114 @@ public static function extractTitle(string $html): ?string
|
|||
if (preg_match('/<h1[^>]*class="[^"]*prezly-slate-heading--heading-1[^"]*"[^>]*>([^<]+)<\/h1>/i', $html, $matches)) {
|
||||
return html_entity_decode(strip_tags($matches[1]), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
|
||||
// Try meta title
|
||||
if (preg_match('/<meta property="og:title" content="([^"]+)"/i', $html, $matches)) {
|
||||
return html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
|
||||
// Try any h1 tag
|
||||
if (preg_match('/<h1[^>]*>([^<]+)<\/h1>/i', $html, $matches)) {
|
||||
return html_entity_decode(strip_tags($matches[1]), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
|
||||
// Try title tag
|
||||
if (preg_match('/<title>([^<]+)<\/title>/i', $html, $matches)) {
|
||||
return html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public static function extractDescription(string $html): ?string
|
||||
{
|
||||
// Try meta description first
|
||||
if (preg_match('/<meta property="og:description" content="([^"]+)"/i', $html, $matches)) {
|
||||
return html_entity_decode($matches[1], ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
|
||||
// Try Belga-specific paragraph class
|
||||
if (preg_match('/<p[^>]*class="[^"]*styles_paragraph__[^"]*"[^>]*>([^<]+(?:<[^\/](?!p)[^>]*>[^<]*<\/[^>]*>[^<]*)*)<\/p>/i', $html, $matches)) {
|
||||
return html_entity_decode(strip_tags($matches[1]), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
|
||||
// Try to find first paragraph in article content
|
||||
if (preg_match('/<p[^>]*>([^<]+(?:<[^\/](?!p)[^>]*>[^<]*<\/[^>]*>[^<]*)*)<\/p>/i', $html, $matches)) {
|
||||
return html_entity_decode(strip_tags($matches[1]), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public static function extractFullArticle(string $html): ?string
|
||||
{
|
||||
// Remove scripts, styles, and other non-content elements
|
||||
$cleanHtml = preg_replace('/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/mi', '', $html);
|
||||
$cleanHtml = preg_replace('/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/mi', '', $cleanHtml);
|
||||
|
||||
|
||||
// Look for Belga-specific paragraph class
|
||||
if (preg_match_all('/<p[^>]*class="[^"]*styles_paragraph__[^"]*"[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches)) {
|
||||
$paragraphs = array_map(function($paragraph) {
|
||||
$paragraphs = array_map(function ($paragraph) {
|
||||
return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8');
|
||||
}, $matches[1]);
|
||||
|
||||
|
||||
// Filter out empty paragraphs and join with double newlines
|
||||
$fullText = implode("\n\n", array_filter($paragraphs, function($p) {
|
||||
$fullText = implode("\n\n", array_filter($paragraphs, function ($p) {
|
||||
return trim($p) !== '';
|
||||
}));
|
||||
|
||||
|
||||
return $fullText ?: null;
|
||||
}
|
||||
|
||||
|
||||
// Fallback: Try to extract from prezly-slate-document section
|
||||
if (preg_match('/<section[^>]*class="[^"]*prezly-slate-document[^"]*"[^>]*>(.*?)<\/section>/is', $cleanHtml, $sectionMatches)) {
|
||||
$sectionHtml = $sectionMatches[1];
|
||||
preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $sectionHtml, $matches);
|
||||
|
||||
if (!empty($matches[1])) {
|
||||
$paragraphs = array_map(function($paragraph) {
|
||||
|
||||
if (! empty($matches[1])) {
|
||||
$paragraphs = array_map(function ($paragraph) {
|
||||
return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8');
|
||||
}, $matches[1]);
|
||||
|
||||
|
||||
// Filter out empty paragraphs and join with double newlines
|
||||
$fullText = implode("\n\n", array_filter($paragraphs, function($p) {
|
||||
$fullText = implode("\n\n", array_filter($paragraphs, function ($p) {
|
||||
return trim($p) !== '';
|
||||
}));
|
||||
|
||||
|
||||
return $fullText ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Final fallback: Extract all paragraph content
|
||||
preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches);
|
||||
if (!empty($matches[1])) {
|
||||
$paragraphs = array_map(function($paragraph) {
|
||||
if (! empty($matches[1])) {
|
||||
$paragraphs = array_map(function ($paragraph) {
|
||||
return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8');
|
||||
}, $matches[1]);
|
||||
|
||||
|
||||
// Filter out empty paragraphs and join with double newlines
|
||||
$fullText = implode("\n\n", array_filter($paragraphs, function($p) {
|
||||
$fullText = implode("\n\n", array_filter($paragraphs, function ($p) {
|
||||
return trim($p) !== '';
|
||||
}));
|
||||
|
||||
|
||||
return $fullText ?: null;
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public static function extractThumbnail(string $html): ?string
|
||||
{
|
||||
// Try OpenGraph image first
|
||||
if (preg_match('/<meta property="og:image" content="([^"]+)"/i', $html, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
|
||||
// Try first image in article content
|
||||
if (preg_match('/<img[^>]+src="([^"]+)"/i', $html, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -133,4 +133,4 @@ public static function extractData(string $html): array
|
|||
'thumbnail' => self::extractThumbnail($html),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ public function getSourceName(): string
|
|||
{
|
||||
return 'Belga News Agency';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Parsers;
|
||||
|
||||
class BelgaHomepageParser
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function extractArticleUrls(string $html): array
|
||||
{
|
||||
// Find all relative article links (most articles use relative paths)
|
||||
preg_match_all('/<a[^>]+href="(\/[a-z0-9-]+)"/', $html, $matches);
|
||||
|
||||
// Blacklist of non-article paths
|
||||
$blacklistPaths = [
|
||||
'/',
|
||||
'/de',
|
||||
'/feed',
|
||||
'/search',
|
||||
'/category',
|
||||
'/about',
|
||||
'/contact',
|
||||
'/privacy',
|
||||
'/terms',
|
||||
];
|
||||
|
||||
$urls = collect($matches[1])
|
||||
->unique()
|
||||
->filter(function ($path) use ($blacklistPaths) {
|
||||
// Exclude exact matches and paths starting with blacklisted paths
|
||||
foreach ($blacklistPaths as $blacklistedPath) {
|
||||
if ($path === $blacklistedPath || str_starts_with($path, $blacklistedPath . '/')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
->map(function ($path) {
|
||||
// Convert relative paths to absolute URLs
|
||||
return 'https://www.belganewsagency.eu' . $path;
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return $urls;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Parsers;
|
||||
|
||||
use App\Contracts\HomepageParserInterface;
|
||||
|
||||
class BelgaHomepageParserAdapter implements HomepageParserInterface
|
||||
{
|
||||
public function __construct(
|
||||
private string $language = 'en',
|
||||
) {}
|
||||
|
||||
public function canParse(string $url): bool
|
||||
{
|
||||
return str_contains($url, 'belganewsagency.eu');
|
||||
}
|
||||
|
||||
public function extractArticleUrls(string $html): array
|
||||
{
|
||||
return BelgaHomepageParser::extractArticleUrls($html);
|
||||
}
|
||||
|
||||
public function getHomepageUrl(): string
|
||||
{
|
||||
return 'https://www.belganewsagency.eu/';
|
||||
}
|
||||
|
||||
public function getSourceName(): string
|
||||
{
|
||||
return 'Belga News Agency';
|
||||
}
|
||||
}
|
||||
|
|
@ -50,14 +50,14 @@ public static function extractFullArticle(string $html): ?string
|
|||
$sectionHtml = $sectionMatches[1];
|
||||
preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $sectionHtml, $matches);
|
||||
|
||||
if (!empty($matches[1])) {
|
||||
if (! empty($matches[1])) {
|
||||
return self::joinParagraphs($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: extract all paragraph content
|
||||
preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches);
|
||||
if (!empty($matches[1])) {
|
||||
if (! empty($matches[1])) {
|
||||
return self::joinParagraphs($matches[1]);
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ public static function extractData(string $html): array
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $paragraphs
|
||||
* @param array<int, string> $paragraphs
|
||||
*/
|
||||
private static function joinParagraphs(array $paragraphs): ?string
|
||||
{
|
||||
|
|
@ -107,4 +107,4 @@ private static function joinParagraphs(array $paragraphs): ?string
|
|||
|
||||
return $fullText ?: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ public function getSourceName(): string
|
|||
{
|
||||
return 'The Guardian';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,13 +48,13 @@ public static function extractFullArticle(string $html): ?string
|
|||
// Extract all paragraph content
|
||||
preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches);
|
||||
|
||||
if (!empty($matches[1])) {
|
||||
$paragraphs = array_map(function($paragraph) {
|
||||
if (! empty($matches[1])) {
|
||||
$paragraphs = array_map(function ($paragraph) {
|
||||
return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8');
|
||||
}, $matches[1]);
|
||||
|
||||
// Filter out empty paragraphs and join with double newlines
|
||||
$fullText = implode("\n\n", array_filter($paragraphs, function($p) {
|
||||
$fullText = implode("\n\n", array_filter($paragraphs, function ($p) {
|
||||
return trim($p) !== '';
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ public function getSourceName(): string
|
|||
{
|
||||
return 'VRT News';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ class VrtHomepageParser
|
|||
public static function extractArticleUrls(string $html, string $language = 'en'): array
|
||||
{
|
||||
$escapedLanguage = preg_quote($language, '/');
|
||||
preg_match_all('/href="(?:https:\/\/www\.vrt\.be)?(\/vrtnws\/' . $escapedLanguage . '\/\d{4}\/\d{2}\/\d{2}\/[^"]+)"/', $html, $matches);
|
||||
preg_match_all('/href="(?:https:\/\/www\.vrt\.be)?(\/vrtnws\/'.$escapedLanguage.'\/\d{4}\/\d{2}\/\d{2}\/[^"]+)"/', $html, $matches);
|
||||
|
||||
$urls = collect($matches[1])
|
||||
->unique()
|
||||
->map(fn ($path) => 'https://www.vrt.be' . $path)
|
||||
->map(fn ($path) => 'https://www.vrt.be'.$path)
|
||||
->toArray();
|
||||
|
||||
return $urls;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,4 +29,4 @@ public function getSourceName(): string
|
|||
{
|
||||
return 'VRT News';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,15 +12,13 @@
|
|||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use RuntimeException;
|
||||
|
||||
class ArticlePublishingService
|
||||
{
|
||||
public function __construct(private LogSaver $logSaver)
|
||||
{
|
||||
}
|
||||
public function __construct(private LogSaver $logSaver) {}
|
||||
|
||||
/**
|
||||
* Factory seam to create publisher instances (helps testing without network calls)
|
||||
*/
|
||||
|
|
@ -28,9 +26,11 @@ protected function makePublisher(mixed $account): LemmyPublisher
|
|||
{
|
||||
return new LemmyPublisher($account);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extractedData
|
||||
* @param array<string, mixed> $extractedData
|
||||
* @return Collection<int, ArticlePublication>
|
||||
*
|
||||
* @throws PublishException
|
||||
*/
|
||||
public function publishToRoutedChannels(Article $article, array $extractedData): Collection
|
||||
|
|
@ -60,7 +60,7 @@ public function publishToRoutedChannels(Article $article, array $extractedData):
|
|||
if (! $account) {
|
||||
$this->logSaver->warning('No active account for channel', $channel, [
|
||||
'article_id' => $article->id,
|
||||
'route_priority' => $route->priority
|
||||
'route_priority' => $route->priority,
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
|
@ -68,12 +68,13 @@ public function publishToRoutedChannels(Article $article, array $extractedData):
|
|||
|
||||
return $this->publishToChannel($article, $extractedData, $channel, $account);
|
||||
})
|
||||
->filter();
|
||||
->filter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route matches an article based on keywords
|
||||
* @param array<string, mixed> $extractedData
|
||||
*
|
||||
* @param array<string, mixed> $extractedData
|
||||
*/
|
||||
private function routeMatchesArticle(Route $route, array $extractedData): bool
|
||||
{
|
||||
|
|
@ -91,10 +92,10 @@ private function routeMatchesArticle(Route $route, array $extractedData): bool
|
|||
$articleContent = $extractedData['full_article'];
|
||||
}
|
||||
if (isset($extractedData['title'])) {
|
||||
$articleContent .= ' ' . $extractedData['title'];
|
||||
$articleContent .= ' '.$extractedData['title'];
|
||||
}
|
||||
if (isset($extractedData['description'])) {
|
||||
$articleContent .= ' ' . $extractedData['description'];
|
||||
$articleContent .= ' '.$extractedData['description'];
|
||||
}
|
||||
|
||||
// Check if any of the route's keywords match the article content
|
||||
|
|
@ -109,7 +110,7 @@ private function routeMatchesArticle(Route $route, array $extractedData): bool
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extractedData
|
||||
* @param array<string, mixed> $extractedData
|
||||
*/
|
||||
private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication
|
||||
{
|
||||
|
|
@ -145,14 +146,14 @@ private function publishToChannel(Article $article, array $extractedData, Platfo
|
|||
]);
|
||||
|
||||
$this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [
|
||||
'article_id' => $article->id
|
||||
'article_id' => $article->id,
|
||||
]);
|
||||
|
||||
return $publication;
|
||||
} catch (Exception $e) {
|
||||
$this->logSaver->warning('Failed to publish to channel', $channel, [
|
||||
'article_id' => $article->id,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
class RoutingValidationService
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, PlatformChannel> $channels
|
||||
* @param Collection<int, PlatformChannel> $channels
|
||||
*
|
||||
* @throws RoutingMismatchException
|
||||
*/
|
||||
public function validateLanguageCompatibility(Feed $feed, Collection $channels): void
|
||||
|
|
@ -29,4 +30,4 @@ public function validateLanguageCompatibility(Feed $feed, Collection $channels):
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Models\Route;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use App\Models\Setting;
|
||||
|
||||
class SystemStatusService
|
||||
|
|
@ -17,22 +17,22 @@ public function getSystemStatus(): array
|
|||
$reasons = [];
|
||||
$isEnabled = true;
|
||||
|
||||
if (!Setting::isArticleProcessingEnabled()) {
|
||||
if (! Setting::isArticleProcessingEnabled()) {
|
||||
$isEnabled = false;
|
||||
$reasons[] = 'Manually disabled by user';
|
||||
}
|
||||
|
||||
if (!Feed::where('is_active', true)->exists()) {
|
||||
if (! Feed::where('is_active', true)->exists()) {
|
||||
$isEnabled = false;
|
||||
$reasons[] = 'No active feeds configured';
|
||||
}
|
||||
|
||||
if (!PlatformChannel::where('is_active', true)->exists()) {
|
||||
if (! PlatformChannel::where('is_active', true)->exists()) {
|
||||
$isEnabled = false;
|
||||
$reasons[] = 'No active platform channels configured';
|
||||
}
|
||||
|
||||
if (!Route::where('is_active', true)->exists()) {
|
||||
if (! Route::where('is_active', true)->exists()) {
|
||||
$isEnabled = false;
|
||||
$reasons[] = 'No active feed-to-channel routes configured';
|
||||
}
|
||||
|
|
@ -49,4 +49,4 @@ public function canProcessArticles(): bool
|
|||
{
|
||||
return $this->getSystemStatus()['is_enabled'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-mockery": "^2.0",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
},
|
||||
"autoload": {
|
||||
|
|
|
|||
|
|
@ -33,13 +33,12 @@
|
|||
'code' => 'belga',
|
||||
'name' => 'Belga News Agency',
|
||||
'description' => 'Belgian national news agency',
|
||||
'type' => 'website',
|
||||
'type' => 'rss',
|
||||
'is_active' => true,
|
||||
'languages' => [
|
||||
'en' => ['url' => 'https://www.belganewsagency.eu/'],
|
||||
'en' => ['url' => 'https://www.belganewsagency.eu/feed'],
|
||||
],
|
||||
'parsers' => [
|
||||
'homepage' => \App\Services\Parsers\BelgaHomepageParserAdapter::class,
|
||||
'article' => \App\Services\Parsers\BelgaArticleParser::class,
|
||||
'article_page' => \App\Services\Parsers\BelgaArticlePageParser::class,
|
||||
],
|
||||
|
|
@ -74,4 +73,4 @@
|
|||
'max_articles_per_fetch' => 50,
|
||||
'article_retention_days' => 30,
|
||||
],
|
||||
];
|
||||
];
|
||||
|
|
|
|||
|
|
@ -61,4 +61,4 @@
|
|||
*/
|
||||
|
||||
'default' => 'en',
|
||||
];
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\PlatformChannel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
|
|
@ -32,4 +32,4 @@ public function recentlyPublished(): static
|
|||
'published_at' => $this->faker->dateTimeBetween('-1 day', 'now'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ public function belga(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'provider' => 'belga',
|
||||
'url' => 'https://www.belganewsagency.eu/',
|
||||
'url' => 'https://www.belganewsagency.eu/feed',
|
||||
'type' => 'rss',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,4 +48,4 @@ public function inactive(): static
|
|||
'is_active' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue